const ATTACHED_FLAG = "svgDownloadAttached"
const SVG_NS = "http://www.w3.org/2000/svg"
const TABLER_DOWNLOAD_ICON = ``
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 = `\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 => {
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((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((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}${label}`
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("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)
})
}