diff --git a/markdown-svg-renderer.docs.md b/markdown-svg-renderer.docs.md index a35c4c34..2025a49a 100644 --- a/markdown-svg-renderer.docs.md +++ b/markdown-svg-renderer.docs.md @@ -1,3 +1,3 @@ -View and render markdown content with live preview. Paste markdown directly or load from a raw URL or GitHub Gist, with support for standard formatting, tables, code blocks, and SVG previews featuring tabbed display for rendered output, PNG/JPEG export, MP4 generation for animated SVGs, and source code viewing. Toggle between split editor and full-screen viewer modes. +View and render markdown content with live preview. Paste markdown directly or load from a raw URL or GitHub Gist, with support for standard formatting, tables, code blocks, and SVG previews featuring tabbed display for rendered output, PNG/JPEG/WebP export, MP4 generation for animated SVGs, and source code viewing. Toggle between split editor and full-screen viewer modes. \ No newline at end of file diff --git a/markdown-svg-renderer.html b/markdown-svg-renderer.html index 51a86e77..cdb49f10 100644 --- a/markdown-svg-renderer.html +++ b/markdown-svg-renderer.html @@ -499,6 +499,32 @@ "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; const FFMPEG_DOWNLOAD_MB = 31; +// Raster export formats offered as tabs on each SVG block. +const IMAGE_FORMATS = { + png: { mimeType: "image/png", label: "PNG", filename: "image.png" }, + jpeg: { mimeType: "image/jpeg", label: "JPEG", filename: "image.jpg" }, + webp: { mimeType: "image/webp", label: "WebP", filename: "image.webp" } +}; + +// canvas.toDataURL() silently falls back to PNG when the browser has no +// encoder for the requested format (older Safari cannot encode WebP), so +// probe a 1x1 canvas once and hide the WebP tab if it is unsupported. +let _webpSupported = null; +function browserCanEncodeWebp() { + if (_webpSupported === null) { + try { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + _webpSupported = canvas + .toDataURL("image/webp") + .startsWith("data:image/webp"); + } catch (err) { + _webpSupported = false; + } + } + return _webpSupported; +} + async function fetchAsBlobUrl(url, mimeType) { const res = await fetch(url); if (!res.ok) throw new Error(`Fetch of ${url} returned ${res.status}`); @@ -598,6 +624,20 @@ const shadow = this.attachShadow({ mode: "open" }); this._animation = detectSvgAnimation(code); + const webpTab = browserCanEncodeWebp() + ? '' + : ""; + const webpPanel = browserCanEncodeWebp() + ? `
+
Rendering WebP…
+ + +
` + : ""; const mp4Tab = this._animation ? '' : ""; @@ -771,6 +811,7 @@ + ${webpTab} ${mp4Tab} @@ -798,6 +839,7 @@ + ${webpPanel} ${mp4Panel}
`; @@ -815,7 +857,7 @@ panels.forEach((p) => p.classList.toggle("active", p.dataset.panel === tab) ); - if (tab === "png" || tab === "jpeg") this.renderImage(tab); + if (IMAGE_FORMATS[tab]) this.renderImage(tab); if (tab === "mp4") this.prefetchFfmpeg(); }); }); @@ -1007,7 +1049,7 @@ } } - // Rasterize the SVG to PNG/JPEG at the size the SVG preview is displayed, + // Rasterize the SVG to PNG/JPEG/WebP at the size the SVG preview is displayed, // by drawing it onto a canvas via a blob URL (same approach as // svg-render.html). Runs lazily on first visit to the tab. renderImage(format) { @@ -1017,6 +1059,7 @@ const shadow = this.shadowRoot; const code = this.getAttribute("data-svg") || ""; + const { mimeType, label, filename } = IMAGE_FORMATS[format]; const panel = shadow.querySelector(`.panel[data-panel="${format}"]`); const status = panel.querySelector(".image-status"); const imageWrap = panel.querySelector(".image-wrap"); @@ -1025,7 +1068,7 @@ const downloadBtn = actions.querySelector("button"); status.hidden = false; - status.textContent = `Rendering ${format.toUpperCase()}…`; + status.textContent = `Rendering ${label}…`; status.classList.remove("error"); const fail = (message) => { @@ -1041,7 +1084,6 @@ const width = Math.max(1, Math.round(this.clientWidth)); const height = Math.max(1, Math.round((width * ratioH) / ratioW)); - const mimeType = format === "png" ? "image/png" : "image/jpeg"; const svgBlob = new Blob([code], { type: "image/svg+xml;charset=utf-8" }); const svgUrl = URL.createObjectURL(svgBlob); @@ -1060,7 +1102,13 @@ try { dataUrl = canvas.toDataURL(mimeType, 0.9); } catch (err) { - fail(`Could not render ${format.toUpperCase()}: ${err.message}`); + fail(`Could not render ${label}: ${err.message}`); + return; + } + // canvas.toDataURL() silently falls back to PNG when the browser cannot + // encode the requested format (older Safari has no WebP encoder). + if (!dataUrl.startsWith(`data:${mimeType}`)) { + fail(`This browser cannot encode ${label} images.`); return; } @@ -1073,18 +1121,18 @@ const padding = (base64.match(/=+$/) || [""])[0].length; const bytes = (base64.length * 3) / 4 - padding; const sizeKB = (bytes / 1024).toFixed(2); - downloadBtn.textContent = `Download ${format.toUpperCase()} (${sizeKB} KB)`; + downloadBtn.textContent = `Download ${label} (${sizeKB} KB)`; downloadBtn.onclick = () => { const link = document.createElement("a"); link.href = dataUrl; - link.download = format === "png" ? "image.png" : "image.jpg"; + link.download = filename; link.click(); }; }; img.onerror = () => { URL.revokeObjectURL(svgUrl); - fail(`Could not render this SVG as ${format.toUpperCase()}.`); + fail(`Could not render this SVG as ${label}.`); }; img.src = svgUrl; } diff --git a/tests/test_markdown_svg_renderer.py b/tests/test_markdown_svg_renderer.py index f09474e2..f906f9c6 100644 --- a/tests/test_markdown_svg_renderer.py +++ b/tests/test_markdown_svg_renderer.py @@ -185,3 +185,47 @@ def test_generate_mp4(page: Page, unused_port_server): assert b"moov" in data assert b"avc1" in data + + +def test_webp_tab_renders_and_offers_download(page: Page, unused_port_server): + unused_port_server.start(root) + page.goto( + f"http://127.0.0.1:{unused_port_server.port}/markdown-svg-renderer.html" + ) + block = fill_svg_block(page, STATIC_SVG) + block.locator('button[data-tab="webp"]').click() + panel = block.locator('.panel[data-panel="webp"]') + img = panel.locator("img") + expect(img).to_be_visible() + assert img.get_attribute("src").startswith("data:image/webp;base64,") + download_btn = panel.locator(".image-actions button") + expect(download_btn).to_be_visible() + expect(download_btn).to_contain_text("Download WebP (") + with page.expect_download() as download_info: + download_btn.click() + assert download_info.value.suggested_filename == "image.webp" + + +def test_webp_tab_hidden_when_browser_cannot_encode_webp( + page: Page, unused_port_server +): + unused_port_server.start(root) + # Simulate a browser without a WebP encoder: toDataURL("image/webp") + # falls back to PNG output, as older Safari does. + page.add_init_script( + """ + const original = HTMLCanvasElement.prototype.toDataURL; + HTMLCanvasElement.prototype.toDataURL = function (type, ...rest) { + if (type === "image/webp") type = "image/png"; + return original.call(this, type, ...rest); + }; + """ + ) + page.goto( + f"http://127.0.0.1:{unused_port_server.port}/markdown-svg-renderer.html" + ) + block = fill_svg_block(page, STATIC_SVG) + expect(block.locator('button[data-tab="png"]')).to_be_visible() + expect(block.locator('button[data-tab="jpeg"]')).to_be_visible() + assert block.locator('button[data-tab="webp"]').count() == 0 + assert block.locator('.panel[data-panel="webp"]').count() == 0