From c114da7f8050353c429bb5554ed3b837058c510e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 00:19:10 +0000 Subject: [PATCH 1/2] Fit expanded Mermaid diagrams to the viewer at 100% The SVG sat in a shrink-to-fit box with Mermaid's inline max-width, so 100% meant the diagram's natural (small) size. Stretch the SVG to fill the viewer and lift the inline max-width so its viewBox scales the drawing to fit. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Y8zZi9uphvmprNJWq9UTWm --- src/features/markdown/render/diagram/PanZoom.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/features/markdown/render/diagram/PanZoom.tsx b/src/features/markdown/render/diagram/PanZoom.tsx index 1de79bf..a9d7fe0 100644 --- a/src/features/markdown/render/diagram/PanZoom.tsx +++ b/src/features/markdown/render/diagram/PanZoom.tsx @@ -5,7 +5,10 @@ type Transform = { x: number; y: number; scale: number } const IDENTITY: Transform = { x: 0, y: 0, scale: 1 } const clamp = (value: number) => Math.min(8, Math.max(0.2, value)) -/** Drag to pan, wheel/pinch to zoom around the pointer. */ +/** + * Drag to pan, wheel/pinch to zoom around the pointer. 100% is "fit": the SVG is + * stretched to the viewer and its viewBox scales the drawing to fill it. + */ export function PanZoom({ svg }: { svg: string }) { const [transform, setTransform] = useState(IDENTITY) const drag = useRef<{ x: number; y: number; origin: Transform } | null>(null) @@ -48,7 +51,7 @@ export function PanZoom({ svg }: { svg: string }) {
setTransform(IDENTITY)} >
Date: Mon, 12 Oct 2026 00:21:33 +0000 Subject: [PATCH 2/2] Download Mermaid diagrams as PNG as well as SVG The diagram's download button opens a menu with Image (.png) and Vector (.svg). The PNG is rendered at 2x on the diagram's page color, with Mermaid labels as SVG text (foreignObject taints the canvas in Safari) and Hanken Grotesk embedded so the text matches the preview. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Y8zZi9uphvmprNJWq9UTWm --- .../markdown/render/diagram/Diagram.tsx | 58 ++++++++++++---- .../markdown/render/diagram/mermaid.ts | 17 ++++- src/features/markdown/render/diagram/png.ts | 68 +++++++++++++++++++ 3 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 src/features/markdown/render/diagram/png.ts diff --git a/src/features/markdown/render/diagram/Diagram.tsx b/src/features/markdown/render/diagram/Diagram.tsx index a0a2b45..4371dd2 100644 --- a/src/features/markdown/render/diagram/Diagram.tsx +++ b/src/features/markdown/render/diagram/Diagram.tsx @@ -1,14 +1,22 @@ import { Dialog as DialogPrimitive } from '@base-ui/react/dialog' -import { Download, Maximize2, TriangleAlert, X } from 'lucide-react' +import { Download, FileCode2, FileImage, Maximize2, TriangleAlert, X } from 'lucide-react' import { use, useEffect, useState } from 'react' +import { toast } from 'sonner' import { useTheme } from '@/features/theme/ThemeProvider' import { downloadFile } from '@/lib/download' import { CopyButton } from '@/ui/copy-button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/ui/dropdown-menu' import { DocumentContext } from '../context' -import { renderMermaid } from './mermaid' +import { diagramBackground, renderMermaid } from './mermaid' import { PanZoom } from './PanZoom' +import { svgToPng } from './png' type DiagramState = | { status: 'loading'; svg: string | null } @@ -47,6 +55,15 @@ export function Diagram({ code, line }: { code: string; line?: number }) { const svg = state.svg + async function downloadPng() { + try { + const flat = await renderMermaid(code, resolved, look, { htmlLabels: false }) + downloadFile('diagram.png', await svgToPng(flat, diagramBackground(resolved))) + } catch { + toast.error('This diagram could not be exported as PNG.') + } + } + return (
@@ -67,16 +84,33 @@ export function Diagram({ code, line }: { code: string; line?: number }) { > - + + + } + > + + + + void downloadPng()}> + + Image (.png) + + svg && downloadFile('diagram.svg', svg, 'image/svg+xml')} + > + + Vector (.svg) + + +
diff --git a/src/features/markdown/render/diagram/mermaid.ts b/src/features/markdown/render/diagram/mermaid.ts index f05eac6..c07d972 100644 --- a/src/features/markdown/render/diagram/mermaid.ts +++ b/src/features/markdown/render/diagram/mermaid.ts @@ -69,8 +69,20 @@ function enqueue(task: () => Promise) { return run } -export function renderMermaid(code: string, theme: ResolvedTheme, look: DiagramLook) { - const key = `${theme}|${look}|${code}` +/** The page color behind a diagram, for exports that need an opaque background. */ +export const diagramBackground = (theme: ResolvedTheme) => PALETTES[theme].background + +/** + * Renders a diagram to an SVG string. `htmlLabels: false` draws labels as SVG text + * instead of `foreignObject` HTML, which images and canvases can rasterize safely. + */ +export function renderMermaid( + code: string, + theme: ResolvedTheme, + look: DiagramLook, + { htmlLabels = true } = {}, +) { + const key = `${theme}|${look}|${htmlLabels}|${code}` const cached = cache.get(key) if (cached) return cached @@ -84,6 +96,7 @@ export function renderMermaid(code: string, theme: ResolvedTheme, look: DiagramL handDrawnSeed: 7, fontFamily: 'Hanken Grotesk Variable, ui-sans-serif, sans-serif', themeVariables: { ...PALETTES[theme], fontSize: '15px' }, + htmlLabels, flowchart: { curve: 'basis', padding: 14 }, suppressErrorRendering: true, }) diff --git a/src/features/markdown/render/diagram/png.ts b/src/features/markdown/render/diagram/png.ts new file mode 100644 index 0000000..51cf309 --- /dev/null +++ b/src/features/markdown/render/diagram/png.ts @@ -0,0 +1,68 @@ +/** + * Rasterizes a Mermaid SVG to PNG. The SVG is drawn into a canvas as an image, so + * it can't reach the page's fonts: Hanken Grotesk is embedded as a data URL, and + * the diagram should be rendered without HTML labels (`foreignObject` taints the + * canvas in Safari). + */ +import hankenGroteskWoff2 from '@fontsource-variable/hanken-grotesk/files/hanken-grotesk-latin-wght-normal.woff2?url' + +let fontCss: Promise | null = null + +function embeddedFontCss() { + fontCss ??= fetch(hankenGroteskWoff2) + .then((response) => response.blob()) + .then((blob) => blobToDataUrl(blob)) + .then( + (url) => + `@font-face{font-family:'Hanken Grotesk Variable';font-weight:100 900;src:url(${url}) format('woff2')}`, + ) + .catch(() => '') + return fontCss +} + +function blobToDataUrl(blob: Blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.addEventListener('load', () => resolve(reader.result as string)) + reader.addEventListener('error', () => reject(reader.error)) + reader.readAsDataURL(blob) + }) +} + +export async function svgToPng(svg: string, background: string, scale = 2) { + const doc = new DOMParser().parseFromString(svg, 'image/svg+xml') + const root = doc.documentElement + const [, , width = 0, height = 0] = (root.getAttribute('viewBox') ?? '') + .split(/[\s,]+/) + .map(Number) + if (!width || !height) throw new Error('This diagram has no size to export.') + + // Mermaid sizes the SVG with `width="100%"` and an inline max-width; give it real pixels. + root.setAttribute('width', String(width)) + root.setAttribute('height', String(height)) + root.style.removeProperty('max-width') + const style = doc.createElementNS('http://www.w3.org/2000/svg', 'style') + style.textContent = await embeddedFontCss() + root.prepend(style) + + const source = new XMLSerializer().serializeToString(root) + const image = new Image() + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}` + await image.decode() + + const canvas = document.createElement('canvas') + canvas.width = Math.ceil(width * scale) + canvas.height = Math.ceil(height * scale) + const context = canvas.getContext('2d') + if (!context) throw new Error('Could not create an image.') + context.fillStyle = background + context.fillRect(0, 0, canvas.width, canvas.height) + context.drawImage(image, 0, 0, canvas.width, canvas.height) + + return new Promise((resolve, reject) => + canvas.toBlob( + (blob) => (blob ? resolve(blob) : reject(new Error('Could not create an image.'))), + 'image/png', + ), + ) +}