feat(diagram-render): offline mermaid+excalidraw render bundle for browse

Single self-contained page (dist/diagram-render.html, 9.2MB, committed per
eng-review D2) exposing __renderMermaid / __mermaidToExcalidraw /
__excalidrawToSvg / __rasterize / __probeImage through browse load-html +
js --out. Render contract per D3: securityLevel strict, per-fence ids,
print-css font lock, htmlLabels off (canvas-taint-safe). Deterministic
build (same sha twice); drift test pins dist == BUILD_INFO == package.json
pins and rebuild-reproducibility when toolchain matches. Spike-proven
offline: flowchart + sequence SVG, editable .excalidraw scene, 300dpi PNG.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-11 23:26:28 -07:00
parent c07e1ffcfb
commit a311cb1ec5
11 changed files with 6192 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
/**
* diagram-render bundle entry.
*
* Built into a single self-contained HTML page (dist/diagram-render.html) that
* make-pdf and /diagram load into a browse daemon tab via `load-html`. Every
* capability is exposed as a window.__* function and driven through `browse js`;
* binary results return as data URLs that `js --out` decodes to bytes on disk.
*
* page lifecycle (one tab per make-pdf run, reused across fences):
* load-html dist copy ─▶ poll #status == "ready" ─▶ N × __renderMermaid/
* __excalidrawToSvg/__rasterize ─▶ close tab (orchestrator finally)
* render error ─▶ caller reloads the page before the next fence
* (reset contract: no poisoned mermaid global survives, eng-review D6.2)
*
* Render contract (eng-review D3):
* - securityLevel "strict": no click callbacks, no HTML label injection in
* this tab. The make-pdf sanitizer is the second defense layer downstream.
* - Callers pass a unique id per fence (mermaid-fence-<n>); mermaid bakes it
* into every internal SVG id, so two diagrams inlined into one document
* can't collide on gradients/markers.
* - Font stacks mirror make-pdf/src/print-css.ts so text measured here lays
* out identically in the printed document.
* - htmlLabels false: foreignObject labels taint canvases (blocks toDataURL
* rasterization) and break when the SVG is inlined into another document.
*/
import mermaid from "mermaid";
import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
import { convertToExcalidrawElements, exportToSvg } from "@excalidraw/excalidraw";
declare global {
interface Window {
__bundleInfo: { name: string; deps: Record<string, string> };
__renderMermaid: (id: string, text: string) => Promise<string>;
__mermaidToExcalidraw: (text: string) => Promise<string>;
__excalidrawToSvg: (sceneJson: string) => Promise<string>;
__rasterize: (svgText: string, targetWidthPx: number) => Promise<string>;
__mountForScreenshot: (svgText: string, targetWidthPx: number) => string;
__probeImage: (src: string) => Promise<string>;
EXCALIDRAW_ASSET_PATH?: string;
__errors: string[];
}
}
// Excalidraw's font registry builds URLs from this against the document base.
// The host must be absolute and never resolves — the page is offline by design;
// exportToSvg embeds the bundled Excalifont glyphs without fetching.
window.EXCALIDRAW_ASSET_PATH = "https://gstack-render.localhost/excalidraw-assets/";
// Font stacks must match make-pdf/src/print-css.ts (sans + CJK + emoji) so
// mermaid's text measurement in this tab matches the print document's layout.
const PRINT_SANS =
'Helvetica, "Liberation Sans", Arial, "Hiragino Kaku Gothic ProN", ' +
'"Noto Sans CJK JP", "Microsoft YaHei", "Apple Color Emoji", ' +
'"Segoe UI Emoji", "Noto Color Emoji", sans-serif';
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: "neutral",
fontFamily: PRINT_SANS,
htmlLabels: false,
flowchart: { htmlLabels: false },
});
window.__renderMermaid = async (id: string, text: string): Promise<string> => {
if (!/^[A-Za-z][\w-]*$/.test(id)) throw new Error(`invalid mermaid render id: ${id}`);
const { svg } = await mermaid.render(id, text);
return svg;
};
window.__mermaidToExcalidraw = async (text: string): Promise<string> => {
const { elements, files } = await parseMermaidToExcalidraw(text);
const converted = convertToExcalidrawElements(elements);
const scene = {
type: "excalidraw",
version: 2,
source: "gstack-diagram-render",
elements: converted,
appState: { viewBackgroundColor: "#ffffff" },
files: files ?? {},
};
return JSON.stringify(scene);
};
window.__excalidrawToSvg = async (sceneJson: string): Promise<string> => {
const scene = JSON.parse(sceneJson);
if (!Array.isArray(scene.elements)) throw new Error("excalidraw scene has no elements array");
const svg = await exportToSvg({
elements: scene.elements,
appState: { ...(scene.appState ?? {}), exportBackground: true },
files: scene.files ?? null,
exportPadding: 16,
});
return new XMLSerializer().serializeToString(svg);
};
/**
* SVG → PNG data URL at an explicit pixel width. Callers own the DPI math:
* targetWidthPx = placed physical width (in) × 300dpi (eng-review D6.5) —
* the bundle never guesses a viewport.
*/
window.__rasterize = async (svgText: string, targetWidthPx: number): Promise<string> => {
if (!(targetWidthPx > 0 && targetWidthPx <= 10000)) {
throw new Error(`targetWidthPx out of range: ${targetWidthPx}`);
}
const blob = new Blob([svgText], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
try {
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("SVG image decode failed (malformed SVG or foreignObject content)"));
img.src = url;
});
const naturalW = img.naturalWidth || 800;
const naturalH = img.naturalHeight || 600;
const scale = targetWidthPx / naturalW;
const canvas = document.createElement("canvas");
canvas.width = Math.round(naturalW * scale);
canvas.height = Math.round(naturalH * scale);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2d canvas context unavailable");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Throws on tainted canvas — callers fall back to __mountForScreenshot +
// `browse screenshot --selector "#raster-stage"`.
return canvas.toDataURL("image/png");
} finally {
URL.revokeObjectURL(url);
}
};
/**
* Fallback rasterization stage: mount the SVG in the DOM so the caller can
* take an element screenshot (no canvas, no taint rules). Returns a marker
* string; the artifact is the screenshot, not the return value.
*/
window.__mountForScreenshot = (svgText: string, targetWidthPx: number): string => {
document.getElementById("raster-stage")?.remove();
const stage = document.createElement("div");
stage.id = "raster-stage";
stage.style.cssText = `display:inline-block;background:#fff;width:${targetWidthPx}px`;
stage.innerHTML = svgText;
const svg = stage.querySelector("svg");
if (svg) {
svg.setAttribute("width", String(targetWidthPx));
svg.removeAttribute("height");
svg.style.height = "auto";
}
document.body.appendChild(stage);
return `mounted:${targetWidthPx}`;
};
/** Probe intrinsic dimensions of an image (data URI or URL). Returns JSON. */
window.__probeImage = async (src: string): Promise<string> => {
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("image decode failed"));
img.src = src;
});
return JSON.stringify({ width: img.naturalWidth, height: img.naturalHeight });
};
// __BUNDLE_INFO__ is replaced at build time with the pinned dependency map.
window.__bundleInfo = { name: "gstack-diagram-render", deps: __BUNDLE_INFO_DEPS__ };
// Readiness signal: pollable text beats a bare invisible div (Playwright's
// visibility-based `wait` never fires on an empty element).
const status = document.getElementById("status");
if (status) status.textContent = "ready";
const done = document.createElement("div");
done.id = "done";
done.textContent = "ready";
done.style.cssText = "position:absolute;left:-9999px";
document.body.appendChild(done);
declare const __BUNDLE_INFO_DEPS__: Record<string, string>;