fix(markdown): embed used @font-face rules in exported SVGs

The exported SVG carries font-family="cmr7"/"cmsy7"/… attributes but
no @font-face, so standalone viewers fall back to system fonts. That
scatters TikZ glyphs (precomputed x positions assume Computer Modern
metrics) and turns cmsy7 minus signs into ¡ (custom symbol encoding).
Inline only the rules for font-families the SVG actually uses, and
await document.fonts.ready before rasterizing to PNG.
This commit is contained in:
Julien Calixte
2026-05-16 12:29:10 +02:00
parent 9d1b6552cb
commit 952417621e

View File

@@ -3,6 +3,55 @@ 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 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 stripQuotes = (s: string): string => s.trim().replace(/^["']|["']$/g, "")
const collectFontFamilies = (svg: SVGSVGElement): Set<string> => {
const families = new Set<string>()
const collect = (raw: string | null) => {
if (!raw) return
raw.split(",").forEach((part) => {
const cleaned = stripQuotes(part)
if (cleaned) families.add(cleaned)
})
}
collect(svg.getAttribute("font-family"))
svg.querySelectorAll<Element>("[font-family]").forEach((el) =>
collect(el.getAttribute("font-family"))
)
return families
}
const isFontFaceRule = (rule: CSSRule): boolean => {
if (typeof CSSFontFaceRule !== "undefined" && rule instanceof CSSFontFaceRule)
return true
return rule.cssText.trimStart().startsWith("@font-face")
}
const collectFontFaceCss = (families: Set<string>): string => {
if (families.size === 0) return ""
const parts: string[] = []
for (const sheet of Array.from(document.styleSheets)) {
let rules: CSSRuleList | null = null
try {
rules = sheet.cssRules
} catch {
continue
}
if (!rules) continue
for (const rule of Array.from(rules)) {
if (!isFontFaceRule(rule)) continue
const styleRule = rule as CSSFontFaceRule
const family = stripQuotes(
styleRule.style?.getPropertyValue("font-family") ?? ""
)
if (family && families.has(family)) {
parts.push(rule.cssText)
}
}
}
return parts.join("\n")
}
const getSvgPixelSize = (svg: SVGSVGElement): { width: number; height: number } => { const getSvgPixelSize = (svg: SVGSVGElement): { width: number; height: number } => {
const viewBox = svg.viewBox?.baseVal const viewBox = svg.viewBox?.baseVal
if (viewBox && viewBox.width > 0 && viewBox.height > 0) { if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
@@ -36,6 +85,14 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => {
bg.setAttribute("fill", "#ffffff") bg.setAttribute("fill", "#ffffff")
clone.insertBefore(bg, clone.firstChild) clone.insertBefore(bg, clone.firstChild)
const fontFaceCss = collectFontFaceCss(collectFontFamilies(svg))
if (fontFaceCss) {
const style = document.createElementNS(SVG_NS, "style")
style.setAttribute("type", "text/css")
style.textContent = fontFaceCss
clone.insertBefore(style, clone.firstChild)
}
return new XMLSerializer().serializeToString(clone) return new XMLSerializer().serializeToString(clone)
} }
@@ -67,6 +124,9 @@ const downloadAsPng = async (
const svgUrl = URL.createObjectURL(svgBlob) const svgUrl = URL.createObjectURL(svgBlob)
try { try {
if (document.fonts?.ready) {
await document.fonts.ready
}
const img = new Image() const img = new Image()
img.crossOrigin = "anonymous" img.crossOrigin = "anonymous"
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {