Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion markdown-svg-renderer.docs.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- Generated from commit: 4fbd2f2ec2bbe13448ee2e116af8c92264140de2 -->
64 changes: 56 additions & 8 deletions markdown-svg-renderer.html
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -598,6 +624,20 @@
const shadow = this.attachShadow({ mode: "open" });

this._animation = detectSvgAnimation(code);
const webpTab = browserCanEncodeWebp()
? '<button data-tab="webp">WebP</button>'
: "";
const webpPanel = browserCanEncodeWebp()
? `<div class="panel" data-panel="webp">
<div class="image-status">Rendering WebP…</div>
<div class="image-wrap" hidden>
<img alt="SVG rendered as WebP">
</div>
<div class="image-actions" hidden>
<button type="button">Download WebP</button>
</div>
</div>`
: "";
const mp4Tab = this._animation
? '<button data-tab="mp4">MP4</button>'
: "";
Expand Down Expand Up @@ -771,6 +811,7 @@
<button class="active" data-tab="render">Rendered</button>
<button data-tab="png">PNG</button>
<button data-tab="jpeg">JPEG</button>
${webpTab}
${mp4Tab}
<button data-tab="code">Code</button>
</div>
Expand Down Expand Up @@ -798,6 +839,7 @@
<button type="button">Download JPEG</button>
</div>
</div>
${webpPanel}
${mp4Panel}
<div class="panel" data-panel="code"><pre></pre></div>
`;
Expand All @@ -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();
});
});
Expand Down Expand Up @@ -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) {
Expand All @@ -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");
Expand All @@ -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) => {
Expand All @@ -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);

Expand All @@ -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;
}

Expand All @@ -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;
}
Expand Down
44 changes: 44 additions & 0 deletions tests/test_markdown_svg_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading