From 952417621e1fcdfb19ce7ec8ef45aecff08de08a Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 16 May 2026 12:29:10 +0200 Subject: [PATCH] fix(markdown): embed used @font-face rules in exported SVGs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/utils/svgDownload.ts | 60 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/utils/svgDownload.ts b/src/utils/svgDownload.ts index fe12f34..b8c68bc 100644 --- a/src/utils/svgDownload.ts +++ b/src/utils/svgDownload.ts @@ -3,6 +3,55 @@ const SVG_NS = "http://www.w3.org/2000/svg" const TABLER_DOWNLOAD_ICON = `` +const stripQuotes = (s: string): string => s.trim().replace(/^["']|["']$/g, "") + +const collectFontFamilies = (svg: SVGSVGElement): Set => { + const families = new Set() + 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("[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 => { + 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 viewBox = svg.viewBox?.baseVal if (viewBox && viewBox.width > 0 && viewBox.height > 0) { @@ -36,6 +85,14 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => { bg.setAttribute("fill", "#ffffff") 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) } @@ -67,6 +124,9 @@ const downloadAsPng = async ( const svgUrl = URL.createObjectURL(svgBlob) try { + if (document.fonts?.ready) { + await document.fonts.ready + } const img = new Image() img.crossOrigin = "anonymous" await new Promise((resolve, reject) => {