Files
remanso/src/utils/svgDownload.ts
Julien Calixte 9d1b6552cb feat(markdown): add hover download buttons on rendered SVGs
Attach SVG and PNG (×2) download buttons to every rendered SVG (TikZ,
Mermaid, …) once post-render finishes. Exports always use the original
colors with a white background baked in, regardless of theme.
2026-05-16 12:17:04 +02:00

147 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const ATTACHED_FLAG = "svgDownloadAttached"
const SVG_NS = "http://www.w3.org/2000/svg"
const TABLER_DOWNLOAD_ICON = `<svg xmlns="${SVG_NS}" class="icon icon-tabler icon-tabler-download" width="14" height="14" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"/><path d="M7 11l5 5l5 -5"/><path d="M12 4l0 12"/></svg>`
const getSvgPixelSize = (svg: SVGSVGElement): { width: number; height: number } => {
const viewBox = svg.viewBox?.baseVal
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
return { width: viewBox.width, height: viewBox.height }
}
const rect = svg.getBoundingClientRect()
if (rect.width > 0 && rect.height > 0) {
return { width: rect.width, height: rect.height }
}
return { width: 800, height: 600 }
}
const buildExportableSvgString = (svg: SVGSVGElement): string => {
const clone = svg.cloneNode(true) as SVGSVGElement
if (!clone.getAttribute("xmlns")) clone.setAttribute("xmlns", SVG_NS)
if (!clone.getAttribute("xmlns:xlink"))
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink")
const { width, height } = getSvgPixelSize(svg)
if (!clone.getAttribute("viewBox")) {
clone.setAttribute("viewBox", `0 0 ${width} ${height}`)
}
clone.setAttribute("width", String(width))
clone.setAttribute("height", String(height))
const bg = document.createElementNS(SVG_NS, "rect")
bg.setAttribute("x", "0")
bg.setAttribute("y", "0")
bg.setAttribute("width", "100%")
bg.setAttribute("height", "100%")
bg.setAttribute("fill", "#ffffff")
clone.insertBefore(bg, clone.firstChild)
return new XMLSerializer().serializeToString(clone)
}
const triggerBlobDownload = (blob: Blob, filename: string): void => {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
const downloadAsSvg = (svg: SVGSVGElement, filename: string): void => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n${buildExportableSvgString(svg)}`
const blob = new Blob([xml], { type: "image/svg+xml;charset=utf-8" })
triggerBlobDownload(blob, filename)
}
const downloadAsPng = async (
svg: SVGSVGElement,
filename: string,
scale = 2
): Promise<void> => {
const { width, height } = getSvgPixelSize(svg)
const svgString = buildExportableSvgString(svg)
const svgBlob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" })
const svgUrl = URL.createObjectURL(svgBlob)
try {
const img = new Image()
img.crossOrigin = "anonymous"
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error("Failed to load SVG for PNG export"))
img.src = svgUrl
})
const canvas = document.createElement("canvas")
canvas.width = Math.max(1, Math.round(width * scale))
canvas.height = Math.max(1, Math.round(height * scale))
const ctx = canvas.getContext("2d")
if (!ctx) throw new Error("Canvas 2D context unavailable")
ctx.fillStyle = "#ffffff"
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
const pngBlob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png")
)
if (!pngBlob) throw new Error("Failed to encode PNG")
triggerBlobDownload(pngBlob, filename)
} finally {
URL.revokeObjectURL(svgUrl)
}
}
const makeButton = (label: string, title: string, onClick: () => void): HTMLButtonElement => {
const btn = document.createElement("button")
btn.type = "button"
btn.className = "btn btn-xs btn-neutral svg-download-button"
btn.title = title
btn.setAttribute("aria-label", title)
btn.innerHTML = `${TABLER_DOWNLOAD_ICON}<span>${label}</span>`
btn.addEventListener("click", (e) => {
e.preventDefault()
e.stopPropagation()
onClick()
})
return btn
}
export const attachSvgDownloads = (scope: ParentNode | null | undefined): void => {
if (!scope) return
const svgs = scope.querySelectorAll<SVGSVGElement>("svg")
let index = 0
svgs.forEach((svg) => {
if (svg.closest(".svg-download-button")) return
if (svg.closest(".svg-download-host")) return
const host = document.createElement("span")
host.className = "svg-download-host"
if (svg.dataset) host.dataset[ATTACHED_FLAG] = "true"
const parent = svg.parentNode
if (!parent) return
parent.insertBefore(host, svg)
host.appendChild(svg)
const toolbar = document.createElement("div")
toolbar.className = "svg-download-buttons"
const n = index++
const baseName = `diagram-${n + 1}`
toolbar.appendChild(
makeButton("SVG", "Download SVG", () =>
downloadAsSvg(svg, `${baseName}.svg`)
)
)
toolbar.appendChild(
makeButton("PNG ×2", "Download PNG (2×)", () => {
void downloadAsPng(svg, `${baseName}.png`, 2)
})
)
host.appendChild(toolbar)
})
}