diff --git a/make-pdf/src/browseClient.ts b/make-pdf/src/browseClient.ts
index 63cec7755..9c70c716c 100644
--- a/make-pdf/src/browseClient.ts
+++ b/make-pdf/src/browseClient.ts
@@ -268,6 +268,17 @@ export function loadHtml(opts: LoadHtmlOptions): void {
}
}
+/**
+ * Load an HTML file (already under browse's safe dirs, e.g. /tmp) into a tab
+ * by path. Cheaper than loadHtml for large pages — no JSON payload round-trip;
+ * browse reads the file directly (diagram-render bundle is ~9MB).
+ */
+export function loadHtmlFile(opts: { file: string; tabId: number; waitUntil?: "load" | "domcontentloaded" | "networkidle" }): void {
+ const args = ["load-html", opts.file, "--tab-id", String(opts.tabId)];
+ if (opts.waitUntil) args.push("--wait-until", opts.waitUntil);
+ runBrowse(args);
+}
+
/**
* Evaluate a JS expression in a tab. Returns the serialized result as string.
*/
diff --git a/make-pdf/src/cli.ts b/make-pdf/src/cli.ts
index 62a3b948e..f8e1fe52e 100644
--- a/make-pdf/src/cli.ts
+++ b/make-pdf/src/cli.ts
@@ -86,6 +86,12 @@ function printUsage(): void {
lines.push(" --quiet Suppress progress on stderr.");
lines.push(" --verbose Per-stage timings on stderr.");
lines.push("");
+ lines.push("Diagrams & images:");
+ lines.push(" ```mermaid / ```excalidraw fences render as vector diagrams.");
+ lines.push(" Add render=false to a fence info string to keep it as a code block.");
+ lines.push(" Local images are inlined; oversized rasters downscale to print resolution.");
+ lines.push(" --strict Missing/remote images fail the run (CI mode).");
+ lines.push("");
lines.push("Network:");
lines.push(" --allow-network Load external images (off by default).");
lines.push("");
@@ -136,6 +142,7 @@ function generateOptionsFromFlags(parsed: ParsedArgs): GenerateOptions {
quiet: f.quiet === true,
verbose: f.verbose === true,
allowNetwork: f["allow-network"] === true,
+ strict: f.strict === true,
title: typeof f.title === "string" ? f.title : undefined,
author: typeof f.author === "string" ? f.author : undefined,
date: typeof f.date === "string" ? f.date : undefined,
diff --git a/make-pdf/src/diagram-prepass.ts b/make-pdf/src/diagram-prepass.ts
new file mode 100644
index 000000000..f0e2a2c4b
--- /dev/null
+++ b/make-pdf/src/diagram-prepass.ts
@@ -0,0 +1,610 @@
+/**
+ * Diagram + image pre-pass. Runs between "read markdown" and render() in the
+ * orchestrator, and owns everything that needs the diagram-render bundle.
+ *
+ * markdown ─▶ extractDiagramFences() ──▶ render() (marked+sanitize+smarty)
+ * │ fences → placeholder tokens │
+ * │ ▼
+ * └─▶ renderFenceSlots() ───────────▶ substituteSlots(html, slots)
+ * one browse render tab/run │
+ * error ⇒ diagnostic block + page reload ▼
+ * inlineLocalImages(html)
+ * data URIs, probe dims from bytes,
+ * downscale >2x content box @300dpi,
+ * remote warn / missing placeholder /
+ * --strict hard-fail
+ *
+ * Placeholders survive marked, the sanitizer, and smartypants because they are
+ * plain hyphenated lowercase tokens with no quotes or HTML. Slot HTML is run
+ * through the same sanitizer as user content before substitution (the bundle
+ * renders with securityLevel strict — the sanitizer is the second layer).
+ *
+ * Reset contract (eng-review D6.2): each fence renders with a fresh
+ * mermaid.render id; after ANY render error the bundle page is reloaded before
+ * the next fence so a poisoned global can't corrupt diagram N+1.
+ */
+
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import * as crypto from "node:crypto";
+
+import * as browseClient from "./browseClient";
+import { sanitizeUntrustedHtml } from "./render";
+import { imageDims } from "./image-size";
+
+// ─── Types ────────────────────────────────────────────────────────────
+
+export interface DiagramFence {
+ /** "mermaid" | "excalidraw" */
+ lang: string;
+ /** Fence body (the diagram source). */
+ source: string;
+ /** Optional title="..." from the fence info string (a11y label, D6.4). */
+ title?: string;
+ /** render=false → leave as a plain code block (escape hatch, D6.3). */
+ render: boolean;
+ /** Placeholder token substituted into the markdown. */
+ token: string;
+ /** 1-based ordinal among rendered fences (unique ids, aria fallback). */
+ ordinal: number;
+}
+
+export interface FenceExtraction {
+ markdown: string;
+ fences: DiagramFence[];
+}
+
+export interface PrepassWarnings {
+ warn: (msg: string) => void;
+}
+
+export interface PrepassImageOptions {
+ /** Directory of the source markdown — relative image paths resolve here. */
+ inputDir: string;
+ /** Hard-fail on missing/remote images instead of warn (D6.1). */
+ strict: boolean;
+ /** Remote images are left untouched when network is explicitly allowed. */
+ allowNetwork: boolean;
+ /** Physical content-box width in inches (page width minus margins). */
+ contentWidthIn: number;
+ warn: (msg: string) => void;
+ /** Lazily provides a ready bundle tab (only opened when needed). */
+ getTab: () => RenderTab | null;
+}
+
+/** Print-resolution policy (eng-review D4): downscale rasters wider than
+ * 2 × contentWidth × 300dpi down to contentWidth × 300dpi. */
+const PRINT_DPI = 300;
+const DOWNSCALE_FACTOR = 2;
+
+export class StrictModeError extends Error {
+ constructor(msg: string) {
+ super(msg);
+ this.name = "StrictModeError";
+ }
+}
+
+// ─── Fence extraction (pure) ──────────────────────────────────────────
+
+const DIAGRAM_LANGS = new Set(["mermaid", "excalidraw"]);
+
+/**
+ * Extract top-level ```mermaid / ```excalidraw fences, replacing each with a
+ * unique placeholder token paragraph. Backtick and tilde fences, any length
+ * >= 3; closers must be at least as long as the opener (CommonMark). Fences
+ * with `render=false` in the info string are left untouched.
+ */
+export function extractDiagramFences(markdown: string): FenceExtraction {
+ const lines = markdown.split("\n");
+ const out: string[] = [];
+ const fences: DiagramFence[] = [];
+ const runId = crypto.randomBytes(4).toString("hex");
+
+ let i = 0;
+ let openFence: { char: string; len: number; info: string; body: string[] } | null = null;
+ let ordinal = 0;
+
+ while (i < lines.length) {
+ const line = lines[i];
+
+ if (openFence) {
+ const close = matchFenceLine(line);
+ if (close && close.char === openFence.char && close.len >= openFence.len && close.info === "") {
+ const info = parseInfoString(openFence.info);
+ if (DIAGRAM_LANGS.has(info.lang) && info.render) {
+ ordinal++;
+ const token = `gstack-diagram-slot-${runId}-${ordinal}`;
+ fences.push({
+ lang: info.lang,
+ source: openFence.body.join("\n"),
+ title: info.title,
+ render: true,
+ token,
+ ordinal,
+ });
+ out.push("", token, "");
+ } else {
+ // Not a diagram fence (or render=false): replay verbatim, but strip
+ // the render=false flag so it never leaks into highlighted output.
+ const infoOut = info.render ? openFence.info : info.lang;
+ out.push(`${openFence.char.repeat(openFence.len)}${infoOut}`);
+ out.push(...openFence.body);
+ out.push(line);
+ }
+ openFence = null;
+ i++;
+ continue;
+ }
+ openFence.body.push(line);
+ i++;
+ continue;
+ }
+
+ const open = matchFenceLine(line);
+ if (open && open.info !== "") {
+ openFence = { char: open.char, len: open.len, info: open.info, body: [] };
+ i++;
+ continue;
+ }
+ if (open) {
+ // Anonymous fence (plain code block) — copy through to its closer so a
+ // ```mermaid example INSIDE a plain fence is never extracted.
+ out.push(line);
+ i++;
+ while (i < lines.length) {
+ const l = lines[i];
+ const close = matchFenceLine(l);
+ out.push(l);
+ i++;
+ if (close && close.char === open.char && close.len >= open.len && close.info === "") break;
+ }
+ continue;
+ }
+
+ out.push(line);
+ i++;
+ }
+
+ // Unclosed fence at EOF: replay verbatim (CommonMark treats it as code to EOF).
+ if (openFence) {
+ out.push(`${openFence.char.repeat(openFence.len)}${openFence.info}`);
+ out.push(...openFence.body);
+ }
+
+ return { markdown: out.join("\n"), fences };
+}
+
+function matchFenceLine(line: string): { char: string; len: number; info: string } | null {
+ const m = line.match(/^ {0,3}(`{3,}|~{3,})\s*(.*)$/);
+ if (!m) return null;
+ return { char: m[1][0], len: m[1].length, info: m[2].trim() };
+}
+
+/** Parse a fence info string: `mermaid`, `mermaid render=false`, `mermaid title="Auth flow"`. */
+export function parseInfoString(info: string): { lang: string; render: boolean; title?: string } {
+ const lang = (info.match(/^\S+/)?.[0] ?? "").toLowerCase();
+ const render = !/\brender\s*=\s*false\b/i.test(info);
+ const title = info.match(/\btitle\s*=\s*"([^"]*)"/i)?.[1]
+ ?? info.match(/\btitle\s*=\s*'([^']*)'/i)?.[1];
+ return { lang, render, title };
+}
+
+// ─── Slot substitution (pure) ─────────────────────────────────────────
+
+/**
+ * Replace placeholder tokens in rendered HTML with their final slot HTML.
+ * marked wraps the bare token line in
…
; replace the wrapper too so
+ * the figure isn't nested inside a paragraph.
+ */
+export function substituteSlots(html: string, slots: Map): string {
+ let s = html;
+ for (const [token, slotHtml] of slots) {
+ const wrapped = new RegExp(`
\\s*${token}\\s*
`, "g");
+ if (wrapped.test(s)) {
+ s = s.replace(new RegExp(`
\\s*${token}\\s*
`, "g"), slotHtml);
+ } else {
+ s = s.split(token).join(slotHtml);
+ }
+ }
+ return s;
+}
+
+/**
+ * Visible diagnostic block for a failed fence render — never silent raw code
+ * (eng-review: explicit error blocks). Sanitizer-safe: all dynamic content is
+ * HTML-escaped.
+ */
+export function buildDiagnosticBlock(fence: DiagramFence, errorMessage: string): string {
+ const excerpt = fence.source.split("\n").slice(0, 8).join("\n");
+ const truncated = fence.source.split("\n").length > 8 ? "\n…" : "";
+ return [
+ ``,
+ `Diagram failed to render (${escapeHtml(fence.lang)})`,
+ `