Skill Tracer
Skills › gstack › make-pdf › diagram-prepass.ts

diagram-prepass.ts

834 lines · make-pdf/src/diagram-prepass.ts at 3f79d81

1/**
2 * Diagram + image pre-pass. Runs between "read markdown" and render() in the
3 * orchestrator, and owns everything that needs the diagram-render bundle.
4 *
5 * markdown ─▶ extractDiagramFences() ──▶ render() (marked+sanitize+smarty)
6 * │ fences → placeholder tokens │
7 * │ ▼
8 * └─▶ renderFenceSlots() ───────────▶ substituteSlots(html, slots)
9 * one browser render per batch │
10 * error ⇒ diagnostic block ▼
11 * inlineLocalImages(html)
12 * data URIs, probe dims from bytes,
13 * downscale >2x content box @300dpi,
14 * remote warn / missing placeholder /
15 * --strict hard-fail
16 *
17 * Placeholders survive marked, the sanitizer, and smartypants because they are
18 * plain hyphenated lowercase tokens with no quotes or HTML. Slot HTML is run
19 * through the same sanitizer as user content before substitution (the bundle
20 * renders with securityLevel strict — the sanitizer is the second layer).
21 *
22 * Bundle calls are batched: one render per batch (Aside runs one script per `aside repl` process) and
23 * nothing survives it, so every consumer collects its calls, runs them in one
24 * script (`BundleRun`), and substitutes the results. A failed call is data
25 * (diagnostic block / warning), never an abort. Each PDF run gets a fresh
26 * bundle page and each fence a fresh mermaid.render id (eng-review D6.2).
27 */
29import * as fs from "node:fs";
30import * as os from "node:os";
31import * as path from "node:path";
32import * as crypto from "node:crypto";
33import { fileURLToPath } from "node:url";
35import { render as renderHtml, renderTmpDir } from "../../lib/aside-render";
36import { escapeHtml, sanitizeUntrustedHtml } from "./render";
37import { imageDims } from "./image-size";
39// ─── Types ────────────────────────────────────────────────────────────
41export interface DiagramFence {
42 /** "mermaid" | "excalidraw" */
43 lang: string;
44 /** Fence body (the diagram source). */
45 source: string;
46 /** Optional title="..." from the fence info string (a11y label, D6.4). */
47 title?: string;
48 /** Optional page=landscape|portrait fence directive (image-policy override). */
49 page?: "landscape" | "portrait";
50 /** render=false → leave as a plain code block (escape hatch, D6.3). */
51 render: boolean;
52 /** Placeholder token substituted into the markdown. */
53 token: string;
54 /** 1-based ordinal among rendered fences (unique ids, aria fallback). */
55 ordinal: number;
58export interface FenceExtraction {
59 markdown: string;
60 fences: DiagramFence[];
63export interface PrepassWarnings {
64 warn: (msg: string) => void;
67export interface PrepassImageOptions {
68 /** Directory of the source markdown — relative image paths resolve here. */
69 inputDir: string;
70 /** Hard-fail on missing/remote images instead of warn (D6.1). */
71 strict: boolean;
72 /** Remote images are left untouched when network is explicitly allowed. */
73 allowNetwork: boolean;
74 /** Physical content-box width in inches (page width minus margins). */
75 contentWidthIn: number;
76 warn: (msg: string) => void;
77 /** Bundle runner for print-resolution downscaling; null = inline at full size. */
78 run: BundleRun | null;
81/** Print-resolution policy (eng-review D4): downscale rasters wider than
82 * 2 × contentWidth × 300dpi down to contentWidth × 300dpi. */
83const PRINT_DPI = 300;
84const DOWNSCALE_FACTOR = 2;
85/** Per-image read ceiling — bounds memory before any policy runs. */
86const MAX_IMAGE_BYTES = 64 * 1024 * 1024;
88export class StrictModeError extends Error {
89 constructor(msg: string) {
90 super(msg);
91 this.name = "StrictModeError";
92 }
95// ─── Fence extraction (pure) ──────────────────────────────────────────
97const DIAGRAM_LANGS = new Set(["mermaid", "excalidraw"]);
99/**
100 * Extract column-0 ```mermaid / ```excalidraw fences, replacing each with a
101 * unique placeholder token paragraph. Backtick and tilde fences, any length
102 * >= 3; closers must be at least as long as the opener (CommonMark). Fences
103 * with `render=false` are left untouched.
104 *
105 * Two deliberate conservatisms (red-team finding — the original version
106 * reconstructed fences at column 0 and restructured lists):
107 * - Non-diagram fences replay as their ORIGINAL raw lines, byte-for-byte
108 * (only a render=false flag is removed, in place, preserving indent).
109 * - INDENTED diagram fences (inside lists/quotes) are NOT extracted — a
110 * column-0 placeholder would split the list. They replay verbatim as code.
111 */
112export function extractDiagramFences(markdown: string): FenceExtraction {
113 const lines = markdown.split("\n");
114 const out: string[] = [];
115 const fences: DiagramFence[] = [];
116 const runId = crypto.randomBytes(4).toString("hex");
118 let i = 0;
119 let openFence: {
120 char: string; len: number; indent: number; info: string;
121 rawOpener: string; body: string[];
122 } | null = null;
123 let ordinal = 0;
125 while (i < lines.length) {
126 const line = lines[i];
128 if (openFence) {
129 const close = matchFenceLine(line);
130 if (close && close.char === openFence.char && close.len >= openFence.len && close.info === "") {
131 const info = parseInfoString(openFence.info);
132 if (DIAGRAM_LANGS.has(info.lang) && info.render && openFence.indent === 0) {
133 ordinal++;
134 const token = `gstack-diagram-slot-${runId}-${ordinal}`;
135 fences.push({
136 lang: info.lang,
137 source: openFence.body.join("\n"),
138 title: info.title,
139 page: info.page,
140 render: true,
141 token,
142 ordinal,
143 });
144 out.push("", token, "");
145 } else {
146 // Not extracted (other language, render=false, or indented): replay
147 // the ORIGINAL lines verbatim; only strip a render=false flag.
148 out.push(stripRenderFalse(openFence.rawOpener));
149 out.push(...openFence.body);
150 out.push(line);
151 }
152 openFence = null;
153 i++;
154 continue;
155 }
156 openFence.body.push(line);
157 i++;
158 continue;
159 }
161 const open = matchFenceLine(line);
162 if (open && open.info !== "") {
163 openFence = { ...open, rawOpener: line, body: [] };
164 i++;
165 continue;
166 }
167 if (open) {
168 // Anonymous fence (plain code block) — copy through to its closer so a
169 // ```mermaid example INSIDE a plain fence is never extracted.
170 out.push(line);
171 i++;
172 while (i < lines.length) {
173 const l = lines[i];
174 const close = matchFenceLine(l);
175 out.push(l);
176 i++;
177 if (close && close.char === open.char && close.len >= open.len && close.info === "") break;
178 }
179 continue;
180 }
182 out.push(line);
183 i++;
184 }
186 // Unclosed fence at EOF: replay verbatim (CommonMark treats it as code to EOF).
187 if (openFence) {
188 out.push(openFence.rawOpener);
189 out.push(...openFence.body);
190 }
192 return { markdown: out.join("\n"), fences };
195function matchFenceLine(line: string): { char: string; len: number; indent: number; info: string } | null {
196 const m = line.match(/^( {0,3})(`{3,}|~{3,})\s*(.*)$/);
197 if (!m) return null;
198 return { indent: m[1].length, char: m[2][0], len: m[2].length, info: m[3].trim() };
201/** Remove a render=false flag from a raw opener line, preserving everything else. */
202function stripRenderFalse(rawOpener: string): string {
203 return rawOpener.replace(/\s*\brender\s*=\s*false\b/i, "");
206/** Parse a fence info string: `mermaid`, `mermaid render=false`,
207 * `mermaid title="Auth flow"`, `mermaid page=landscape`. */
208export function parseInfoString(info: string): {
209 lang: string; render: boolean; title?: string; page?: "landscape" | "portrait";
210} {
211 const lang = (info.match(/^\S+/)?.[0] ?? "").toLowerCase();
212 const render = !/\brender\s*=\s*false\b/i.test(info);
213 const title = info.match(/\btitle\s*=\s*"([^"]*)"/i)?.[1]
214 ?? info.match(/\btitle\s*=\s*'([^']*)'/i)?.[1];
215 const pageRaw = info.match(/\bpage\s*=\s*(landscape|portrait)\b/i)?.[1]?.toLowerCase();
216 const page = pageRaw === "landscape" || pageRaw === "portrait" ? pageRaw : undefined;
217 return { lang, render, title, page };
220// ─── Slot substitution (pure) ─────────────────────────────────────────
222/**
223 * Replace placeholder tokens in rendered HTML with their final slot HTML.
224 * marked wraps the bare token line in <p>…</p>; replace the wrapper too so
225 * the figure isn't nested inside a paragraph.
226 */
227export function substituteSlots(html: string, slots: Map<string, string>): string {
228 let s = html;
229 for (const [token, slotHtml] of slots) {
230 // Function replacement is load-bearing: slot HTML carries user/LLM-authored
231 // diagram label text, and string-form replace() expands $&, $', $` patterns
232 // inside it — a label containing "$'" would duplicate the document tail.
233 const wrapped = new RegExp(`<p>\\s*${token}\\s*</p>`, "g");
234 const replaced = s.replace(wrapped, () => slotHtml);
235 s = replaced !== s ? replaced : s.split(token).join(slotHtml);
236 }
237 return s;
240/**
241 * Visible diagnostic block for a failed fence render — never silent raw code
242 * (eng-review: explicit error blocks). Sanitizer-safe: all dynamic content is
243 * HTML-escaped.
244 */
245export function buildDiagnosticBlock(fence: DiagramFence, errorMessage: string): string {
246 const excerpt = fence.source.split("\n").slice(0, 8).join("\n");
247 const truncated = fence.source.split("\n").length > 8 ? "\n…" : "";
248 return [
249 `<figure class="diagram diagram-error" role="img" aria-label="${escapeHtml(diagramLabel(fence))} (failed to render)">`,
250 `<figcaption class="diagram-error-title">Diagram failed to render (${escapeHtml(fence.lang)})</figcaption>`,
251 `<pre class="diagram-error-detail">${escapeHtml(errorMessage.trim())}\n\n${escapeHtml(excerpt + truncated)}</pre>`,
252 `</figure>`,
253 ].join("\n");
256/**
257 * Wrap a rendered SVG in an accessible figure (D6.4). The raw fence source is
258 * preserved base64-encoded in a data attribute — an HTML comment would need
259 * `--` escaping, which corrupts every mermaid arrow (`-->`) and breaks
260 * round-trip recovery.
261 */
262export function buildDiagramFigure(fence: DiagramFence, svg: string): string {
263 const label = diagramLabel(fence);
264 const cleanSvg = sanitizeUntrustedHtml(svg);
265 const captioned = fence.title
266 ? `\n<figcaption class="diagram-caption">${escapeHtml(fence.title)}</figcaption>`
267 : "";
268 const pageAttr = fence.page ? ` data-gstack-page="${fence.page}"` : "";
269 const sourceB64 = Buffer.from(fence.source, "utf8").toString("base64");
270 return [
271 `<figure class="diagram" role="img" aria-label="${escapeHtml(label)}"${pageAttr}` +
272 ` data-gstack-lang="${escapeHtml(fence.lang)}" data-gstack-source="${sourceB64}">`,
273 cleanSvg,
274 captioned,
275 `</figure>`,
276 ].join("\n");
279/** Recover the original fence source from a rendered figure (round-trip). */
280export function decodeFigureSource(figureHtml: string): string | null {
281 const m = figureHtml.match(/\bdata-gstack-source="([A-Za-z0-9+/=]*)"/);
282 if (!m) return null;
283 try {
284 return Buffer.from(m[1], "base64").toString("utf8");
285 } catch {
286 return null;
287 }
290function diagramLabel(fence: DiagramFence): string {
291 return fence.title ?? `diagram ${fence.ordinal}`;
294// ─── Bundle runner (diagram-render page, driven through Aside or gstack's browser) ────────
296export type BundleCall = { fn: string; args: unknown[] };
297export type BundleResult =
298 | { ok: true; value: string }
299 | { ok: false; error: string };
300/** Run bundle calls in order. Every call gets a result; failures are data. */
301export type BundleRun = (calls: BundleCall[]) => Promise<BundleResult[]>;
303const READY_TIMEOUT_MS = 20_000;
304/** Aside caps a script at 120s (the browse path shares the budget); ~40 mermaid renders fit with room to spare. */
305const CALLS_PER_SCRIPT = 40;
307/**
308 * Build the runner. The bundle path resolves lazily on the first call so an
309 * image-only document never touches it; a missing bundle fails every call
310 * with the resolver's message instead of throwing.
311 */
312export function bundleRunner(opts: { bundlePath?: string; render?: typeof renderHtml } = {}): BundleRun {
313 const render = opts.render ?? renderHtml;
314 let bundlePath = opts.bundlePath;
315 return async (calls) => {
316 const results: BundleResult[] = [];
317 try {
318 if (calls.length > 0) bundlePath ??= resolveBundlePath();
319 for (let i = 0; i < calls.length; i += CALLS_PER_SCRIPT) {
320 results.push(...await runScript(bundlePath!, calls.slice(i, i + CALLS_PER_SCRIPT), render));
321 }
322 } catch (err: any) {
323 // Unresolvable/unreadable bundle, staging failure: fail what's left as data.
324 const error = firstLine(err?.message ?? String(err));
325 while (results.length < calls.length) results.push({ ok: false, error });
326 }
327 return results;
328 };
331/**
332 * One render (an Aside script, or a browse tab): open the bundle, wait for #done, evaluate one expression
333 * per call, write each result to a file and read them back. The bundle copy
334 * and one JSON args file per call sit in a private dir served over loopback,
335 * so multi-MB payloads (data URIs, scene JSON) never ride argv; results are
336 * files too (never inline stdout) so SVG/PNG text survives intact.
337 */
338async function runScript(
339 bundlePath: string,
340 calls: BundleCall[],
341 render: typeof renderHtml,
342): Promise<BundleResult[]> {
343 const dir = fs.mkdtempSync(path.join(renderTmpDir(), "make-pdf-diagrams-"));
344 try {
345 const bundle = path.join(dir, "diagram-render.html");
346 fs.copyFileSync(bundlePath, bundle, fs.constants.COPYFILE_FICLONE);
347 const steps = calls.map((call, i) => {
348 fs.writeFileSync(path.join(dir, `call-${i}.json`), JSON.stringify(call.args));
349 // try/catch INSIDE the expression: a throwing fence returns an ERR
350 // marker and the script keeps going for the other fences. The URL is
351 // resolved against location.href, not the document base: the bundle
352 // sets <base href="https://gstack-render.localhost/"> for excalidraw.
353 const expression =
354 `(async () => { try { const a = await (await fetch(new URL("call-${i}.json", location.href).href)).json(); ` +
355 `return "OK:" + await window[${JSON.stringify(call.fn)}](...a); } ` +
356 `catch (e) { return "ERR:" + String((e && e.message) || e); } })()`;
357 return { kind: "eval" as const, expression, out: path.join(dir, `result-${i}.txt`) };
358 });
359 const r = await render({
360 file: bundle,
361 serveRoot: dir,
362 waitFor: { selector: "#done", timeoutMs: READY_TIMEOUT_MS },
363 steps,
364 });
365 if (!r.ok) {
366 const error = `diagram renderer: ${firstLine(r.error ?? "unknown error")}`;
367 return calls.map(() => ({ ok: false, error }));
368 }
369 return calls.map((_, i) => {
370 const text = fs.readFileSync(path.join(dir, `result-${i}.txt`), "utf8");
371 if (text.startsWith("OK:")) return { ok: true, value: text.slice(3) };
372 if (text.startsWith("ERR:")) return { ok: false, error: text.slice(4) };
373 return { ok: false, error: `unexpected bundle result: ${text.slice(0, 200)}` };
374 });
375 } finally {
376 fs.rmSync(dir, { recursive: true, force: true });
377 }
380/** Resolve dist/diagram-render.html: env override → repo-relative (dev) → global install. */
381export function resolveBundlePath(env: NodeJS.ProcessEnv = process.env): string {
382 const candidates = [
383 env.GSTACK_DIAGRAM_BUNDLE,
384 // dev: make-pdf/src/* → repo root lib/. (In a compiled binary this is the
385 // virtual /$bunfs/root and simply never exists — harmless.)
386 path.resolve(import.meta.dir, "../../lib/diagram-render/dist/diagram-render.html"),
387 // compiled binary at <root>/make-pdf/dist/pdf → <root>/lib/… — same shape
388 // in the repo and in the ~/.claude/skills/gstack global install. argv[0]
389 // is the literal string "bun" in compiled binaries; execPath is real.
390 path.resolve(path.dirname(process.execPath), "../../lib/diagram-render/dist/diagram-render.html"),
391 path.join(os.homedir(), ".claude/skills/gstack/lib/diagram-render/dist/diagram-render.html"),
392 ].filter((p): p is string => !!p);
393 for (const p of candidates) {
394 if (fs.existsSync(p)) return p;
395 }
396 throw new Error(
397 "diagram-render bundle not found. Tried:\n" +
398 candidates.map((c) => ` - ${c}`).join("\n") +
399 "\nRun `bun run build:diagram-render` (repo) or re-run ./setup (install).",
400 );
403// ─── Fence rendering ──────────────────────────────────────────────────
405/**
406 * Render every extracted fence to its slot HTML in one batch. A failed fence
407 * yields a visible diagnostic block; the others still render.
408 */
409export async function renderFenceSlots(
410 fences: DiagramFence[],
411 run: BundleRun,
412 warn: (msg: string) => void,
413): Promise<Map<string, string>> {
414 const slots = new Map<string, string>();
415 const fail = (fence: DiagramFence, msg: string) => {
416 warn(`diagram ${fence.ordinal} (${fence.lang}) failed to render: ${firstLine(msg)}`);
417 slots.set(fence.token, buildDiagnosticBlock(fence, msg));
418 };
419 const todo: DiagramFence[] = [];
420 for (const fence of fences) {
421 if (fence.lang !== "mermaid") {
422 try {
423 JSON.parse(fence.source); // fail fast with a JSON diagnostic, not a bundle stack
424 } catch (err: any) {
425 fail(fence, err?.message ?? String(err));
426 continue;
427 }
428 }
429 todo.push(fence);
430 }
431 const results = await run(todo.map((f) => f.lang === "mermaid"
432 ? { fn: "__renderMermaid", args: [`mermaid-fence-${f.ordinal}`, f.source] }
433 : { fn: "__excalidrawToSvg", args: [f.source] }));
434 todo.forEach((fence, i) => {
435 const r = results[i];
436 if (r.ok) slots.set(fence.token, buildDiagramFigure(fence, r.value));
437 else fail(fence, r.error);
438 });
439 return slots;
442// ─── DOCX rasterization (eng-review D6.5, P8) ─────────────────────────
444/**
445 * Replace inline diagram SVGs (and svg data-URI images) with PNG <img> tags
446 * for the DOCX export — Word's SVG support is unreliable, so the content-
447 * fidelity contract embeds rasters at 300dpi of the placed width (the
448 * content box). Diagnostic blocks keep their text form. Two passes: swap
449 * each target for a token while collecting its bundle call, run the batch,
450 * substitute results.
451 */
452export async function rasterizeDiagramFigures(
453 html: string,
454 run: BundleRun,
455 contentWidthIn: number,
456 warn: (msg: string) => void,
457): Promise<string> {
458 const targetPx = Math.round(contentWidthIn * PRINT_DPI);
459 const runId = crypto.randomBytes(4).toString("hex");
460 const calls: BundleCall[] = [];
461 const pending: Array<{ token: string; onOk: (png: string) => string; onErr: (reason: string) => string }> = [];
462 const enqueue = (svgText: string, onOk: (png: string) => string, onErr: (reason: string) => string): string => {
463 const token = `gstack-raster-slot-${runId}-${calls.length}`;
464 calls.push({ fn: "__rasterize", args: [svgText, targetPx] });
465 pending.push({ token, onOk, onErr });
466 return token;
467 };
469 // 1. Rendered diagram figures → <img> with the figure's aria-label as alt.
470 let out = html.replace(
471 /<figure class="diagram"[^>]*>[\s\S]*?<\/figure>/gi,
472 (figure) => {
473 const svgMatch = figure.match(/<svg\b[\s\S]*<\/svg>/i);
474 if (!svgMatch) return figure;
475 const label = figure.match(/\baria-label\s*=\s*"([^"]*)"/i)?.[1] ?? "diagram";
476 return enqueue(
477 svgMatch[0],
478 (png) => `<p><img src="${png}" alt="${label}"></p>`,
479 (reason) => {
480 warn(`docx: diagram rasterization failed (${reason}); embedding source text instead`);
481 // The converter drops <figure>/<svg> entirely, so returning the figure
482 // would make the diagram vanish without a trace — the exact invisible
483 // failure the diagnostic contract forbids. Surface the source.
484 const source = decodeFigureSource(figure) ?? "(source unavailable)";
485 return [
486 `<p><strong>Diagram could not be rasterized for DOCX (${escapeHtml(reason)}) — source:</strong></p>`,
487 `<pre>${escapeHtml(source)}</pre>`,
488 ].join("\n");
489 },
490 );
491 },
492 );
494 // 2. SVG data-URI images (inlined .svg files) → PNG.
495 out = out.replace(/<img\b[^>]*>/gi, (tag) => {
496 const m = tag.match(SRC_RE);
497 const src = m?.[2] ?? m?.[3] ?? "";
498 if (!src.startsWith("data:image/svg+xml")) return tag;
499 const svgText = Buffer.from(src.slice(src.indexOf(",") + 1), "base64").toString("utf8");
500 return enqueue(
501 svgText,
502 // Function replacement: data URIs can contain $-patterns.
503 (png) => tag.replace(SRC_RE, () => `src="${png}"`),
504 (reason) => {
505 warn(`docx: svg image rasterization failed (${reason})`);
506 return tag;
507 },
508 );
509 });
511 if (calls.length === 0) return out;
512 const results = await run(calls);
513 pending.forEach((p, i) => {
514 const r = results[i];
515 // split/join, not replace(): the replacement carries user content.
516 out = out.split(p.token).join(r.ok ? p.onOk(r.value) : p.onErr(firstLine(r.error)));
517 });
518 return out;
521/**
522 * Diagnostic figures → plain <p>/<pre> for the DOCX converter, which drops
523 * <figure> elements it can't map. An invisible error is the one thing the
524 * diagnostic contract forbids. Pure — no render tab needed.
525 */
526export function convertDiagnosticsForDocx(html: string): string {
527 return html.replace(
528 /<figure class="diagram diagram-error"[^>]*>([\s\S]*?)<\/figure>/gi,
529 (_full, body: string) => {
530 const title = body.match(/<figcaption[^>]*>([\s\S]*?)<\/figcaption>/i)?.[1] ?? "Diagram failed to render";
531 const detail = body.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i)?.[1] ?? "";
532 return `<p><strong>${title}</strong></p>\n<pre>${detail}</pre>`;
533 },
534 );
537// ─── Image inlining (eng-review D1 + D4 + D6.1) ───────────────────────
539const IMG_TAG_RE = /<img\b[^>]*>/gi;
540const SRC_RE = /\bsrc\s*=\s*("([^"]*)"|'([^']*)')/i;
542/**
543 * Inline every local <img> as a data URI, probe intrinsic dimensions from the
544 * bytes, and annotate the tag with data-gstack-px-width/-height for the width
545 * policy. Oversized rasters are downscaled to print resolution via the bundle
546 * tab. Missing files become visible placeholders (or throw under --strict);
547 * remote URLs warn (offline posture) unless --allow-network.
548 */
549export async function inlineLocalImages(html: string, opts: PrepassImageOptions): Promise<string> {
550 const maxPx = Math.round(opts.contentWidthIn * PRINT_DPI * DOWNSCALE_FACTOR);
551 const targetPx = Math.round(opts.contentWidthIn * PRINT_DPI);
552 // An image referenced N times is read/probed/downscaled once; the same data
553 // URI string is reused (also dedupes memory until the final join).
554 const memo = new Map<string, { dataUri: string; attrs: string }>();
555 // Oversized rasters get a token src in this pass and their downscaled bytes
556 // in the second — one bundle batch for the whole document.
557 const runId = crypto.randomBytes(4).toString("hex");
558 const downscales: Array<{
559 token: string; src: string; name: string; buf: Buffer; mime: string;
560 dims: { width: number; height: number };
561 }> = [];
563 const out = html.replace(IMG_TAG_RE, (tag) => {
564 const srcMatch = tag.match(SRC_RE);
565 if (!srcMatch) return tag;
566 const src = srcMatch[2] ?? srcMatch[3] ?? "";
568 if (src.startsWith("data:")) return annotateFromDataUri(tag, src);
570 // Windows drive-letter paths (C:/x.png, C:\x.png) look like single-letter
571 // URL schemes — they are local paths, not URLs.
572 const isDrivePath = /^[a-zA-Z]:[\\/]/.test(src);
574 if (!isDrivePath && /^[a-z][a-z0-9+.-]*:/i.test(src)) {
575 // Absolute URL with a scheme (http, https, file, …)
576 if (opts.allowNetwork && /^https?:/i.test(src)) return tag;
577 if (/^https?:/i.test(src)) {
578 const msg = `remote image blocked (offline posture): ${src}`;
579 if (opts.strict) throw new StrictModeError(msg + " — re-run without --strict or pass --allow-network");
580 opts.warn(msg);
581 // Leaving the tag would make Chromium fetch it at print time anyway —
582 // the warn would be a lie. Replace with a visible placeholder.
583 return buildBlockedRemotePlaceholder(src);
584 }
585 // file:// and friends fall through to the local path branch
586 if (!src.startsWith("file:")) return tag;
587 }
589 // decodeURIComponent throws on malformed escapes (foo%zz.png) — a broken
590 // URL must degrade to the missing-image path, not crash the run.
591 let decodedSrc = src;
592 try {
593 decodedSrc = decodeURIComponent(src);
594 } catch { /* keep raw src */ }
596 const filePath = src.startsWith("file:")
597 ? fileURLToPath(src)
598 : isDrivePath
599 ? path.resolve(src)
600 : path.resolve(opts.inputDir, decodedSrc);
602 const cached = memo.get(filePath);
603 if (cached !== undefined) return rewriteImgTag(tag, cached);
605 if (!fs.existsSync(filePath)) {
606 const msg = `image not found: ${src} (resolved to ${filePath})`;
607 if (opts.strict) throw new StrictModeError(msg);
608 opts.warn(msg);
609 return buildMissingImagePlaceholder(src);
610 }
612 // Out-of-tree reads are legal (local CLI semantics — like pandoc) but
613 // never silent: an agent PDF-ing untrusted markdown should not quietly
614 // embed ~/.ssh/config into a shareable document. --strict makes it fatal.
615 // Compare REAL paths — a symlink inside the input dir pointing outside
616 // would otherwise pass a string-prefix check (Codex adversarial finding).
617 // Runs after the existence check: realpath of a missing file can't
618 // resolve, and on macOS /var vs /private/var would false-positive.
619 const inputRoot = safeRealpath(path.resolve(opts.inputDir)) + path.sep;
620 const realFilePath = safeRealpath(filePath);
621 if (!realFilePath.startsWith(inputRoot)) {
622 const msg = `image resolves OUTSIDE the input directory: ${src} → ${realFilePath}`;
623 if (opts.strict) throw new StrictModeError(msg + " — move it under the markdown's directory or drop --strict");
624 opts.warn(msg);
625 }
627 // Bound the read BEFORE reading: a markdown image pointing at a special
628 // file (fifo, device) would hang readFileSync, and a multi-GB file would
629 // exhaust memory before any policy ran.
630 let stat: fs.Stats;
631 try {
632 stat = fs.statSync(filePath);
633 } catch {
634 opts.warn(`image unreadable: ${src}`);
635 return buildMissingImagePlaceholder(src);
636 }
637 if (!stat.isFile()) {
638 const msg = `image is not a regular file: ${src}`;
639 if (opts.strict) throw new StrictModeError(msg);
640 opts.warn(msg);
641 return buildMissingImagePlaceholder(src);
642 }
643 if (stat.size > MAX_IMAGE_BYTES) {
644 const msg = `image exceeds ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB cap: ${src} (${Math.round(stat.size / 1024 / 1024)}MB)`;
645 if (opts.strict) throw new StrictModeError(msg);
646 opts.warn(msg);
647 return buildMissingImagePlaceholder(src);
648 }
650 const buf = fs.readFileSync(filePath);
651 const dims = imageDims(buf);
652 const mime = dims?.mime ?? mimeFromExtension(filePath);
654 // Print-resolution normalization (D4): rasters only — SVG scales free.
655 if (dims && mime !== "image/svg+xml" && dims.width > maxPx && opts.run) {
656 const token = `gstack-downscale-slot-${runId}-${downscales.length}`;
657 downscales.push({ token, src, name: path.basename(filePath), buf, mime, dims });
658 memo.set(filePath, { dataUri: token, attrs: "" });
659 return rewriteImgTag(tag, memo.get(filePath)!);
660 }
662 memo.set(filePath, inlineEntry(buf, mime, dims));
663 return rewriteImgTag(tag, memo.get(filePath)!);
664 });
666 if (downscales.length === 0) return out;
667 const results = await opts.run!(downscales.map((d) => ({
668 fn: "__downscaleRaster",
669 args: [`data:${d.mime};base64,${d.buf.toString("base64")}`, targetPx, d.mime],
670 })));
671 const byToken = new Map<string, { dataUri: string; attrs: string }>();
672 downscales.forEach((d, i) => {
673 const r = results[i];
674 if (r.ok) {
675 opts.warn(
676 `downscaled ${d.name} ${d.dims.width}px → ${targetPx}px ` +
677 `(print is ${PRINT_DPI}dpi; original exceeds ${maxPx}px content-box ceiling)`,
678 );
679 const height = Math.round((d.dims.height * targetPx) / d.dims.width);
680 byToken.set(d.token, { dataUri: r.value, attrs: dimAttrs({ width: targetPx, height }) });
681 } else {
682 opts.warn(`downscale failed for ${d.src}, inlining at full size: ${firstLine(r.error)}`);
683 byToken.set(d.token, inlineEntry(d.buf, d.mime, d.dims));
684 }
685 });
686 return out.replace(IMG_TAG_RE, (tag) => {
687 const entry = byToken.get(tag.match(SRC_RE)?.[2] ?? "");
688 return entry ? rewriteImgTag(tag, entry) : tag;
689 });
692function inlineEntry(buf: Buffer, mime: string, dims: { width: number; height: number } | null): { dataUri: string; attrs: string } {
693 return { dataUri: `data:${mime};base64,${buf.toString("base64")}`, attrs: dims ? dimAttrs(dims) : "" };
696function dimAttrs(dims: { width: number; height: number }): string {
697 return ` data-gstack-px-width="${Math.round(dims.width)}" data-gstack-px-height="${Math.round(dims.height)}"`;
700/** Apply a memoized inline result to an img tag. */
701function rewriteImgTag(tag: string, entry: { dataUri: string; attrs: string }): string {
702 // Function replacement: data URIs are user-content-derived; string-form
703 // replace() would expand $-patterns inside them.
704 let out = tag.replace(SRC_RE, () => `src="${entry.dataUri}"`);
705 if (entry.attrs) out = out.replace(/^<img\b/i, () => `<img${entry.attrs}`);
706 return out;
709function annotateFromDataUri(tag: string, src: string): string {
710 try {
711 const b64 = src.slice(src.indexOf(",") + 1);
712 const head = Buffer.from(b64.slice(0, 8192), "base64");
713 const dims = imageDims(head);
714 if (!dims) return tag;
715 return tag.replace(
716 /^<img\b/i,
717 `<img data-gstack-px-width="${Math.round(dims.width)}" data-gstack-px-height="${Math.round(dims.height)}"`,
718 );
719 } catch {
720 return tag;
721 }
724function buildMissingImagePlaceholder(src: string): string {
725 return (
726 `<span class="image-missing" role="img" aria-label="missing image">` +
727 `[missing image: ${escapeHtml(src)}]</span>`
728 );
731function buildBlockedRemotePlaceholder(src: string): string {
732 return (
733 `<span class="image-missing" role="img" aria-label="remote image blocked">` +
734 `[remote image blocked (use --allow-network): ${escapeHtml(src)}]</span>`
735 );
738/** realpath that degrades to the input path when resolution fails. */
739function safeRealpath(p: string): string {
740 try {
741 return fs.realpathSync(p);
742 } catch {
743 return p;
744 }
747function mimeFromExtension(p: string): string {
748 switch (path.extname(p).toLowerCase()) {
749 case ".png": return "image/png";
750 case ".jpg":
751 case ".jpeg": return "image/jpeg";
752 case ".gif": return "image/gif";
753 case ".webp": return "image/webp";
754 case ".svg": return "image/svg+xml";
755 default: return "application/octet-stream";
756 }
759// ─── Content-box math ─────────────────────────────────────────────────
761const PAGE_WIDTHS_IN: Record<string, number> = {
762 letter: 8.5,
763 a4: 8.27,
764 legal: 8.5,
765 tabloid: 11,
766};
768/** Parse a CSS dimension ("1in" | "72pt" | "25mm" | "2.54cm") to inches. */
769export function dimToInches(dim: string | undefined, fallbackIn: number): number {
770 if (!dim) return fallbackIn;
771 const m = dim.trim().match(/^([0-9.]+)\s*(in|pt|cm|mm|px)?$/i);
772 if (!m) return fallbackIn;
773 const v = parseFloat(m[1]);
774 switch ((m[2] ?? "in").toLowerCase()) {
775 case "in": return v;
776 case "pt": return v / 72;
777 case "cm": return v / 2.54;
778 case "mm": return v / 25.4;
779 case "px": return v / 96;
780 default: return fallbackIn;
781 }
784export function contentWidthInches(opts: {
785 pageSize?: string;
786 margins?: string;
787 marginLeft?: string;
788 marginRight?: string;
789}): number {
790 const pageW = PAGE_WIDTHS_IN[opts.pageSize ?? "letter"] ?? 8.5;
791 const left = dimToInches(opts.marginLeft ?? opts.margins, 1);
792 const right = dimToInches(opts.marginRight ?? opts.margins, 1);
793 return Math.max(1, pageW - left - right);
796const PAGE_HEIGHTS_IN: Record<string, number> = {
797 letter: 11,
798 a4: 11.69,
799 legal: 14,
800 tabloid: 17,
801};
803/**
804 * Content box of the rotated (landscape) named page: portrait page HEIGHT
805 * becomes the landscape width; portrait WIDTH becomes the landscape height.
806 * Used by image-policy to vertically center promoted blocks.
807 */
808export function landscapeContentBox(opts: {
809 pageSize?: string;
810 margins?: string;
811 marginLeft?: string;
812 marginRight?: string;
813 marginTop?: string;
814 marginBottom?: string;
815}): { contentWIn: number; contentHIn: number } {
816 const size = opts.pageSize ?? "letter";
817 const pageH = PAGE_HEIGHTS_IN[size] ?? 11;
818 const pageW = PAGE_WIDTHS_IN[size] ?? 8.5;
819 const left = dimToInches(opts.marginLeft ?? opts.margins, 1);
820 const right = dimToInches(opts.marginRight ?? opts.margins, 1);
821 const top = dimToInches(opts.marginTop ?? opts.margins, 1);
822 const bottom = dimToInches(opts.marginBottom ?? opts.margins, 1);
823 return {
824 contentWIn: Math.max(1, pageH - left - right),
825 contentHIn: Math.max(1, pageW - top - bottom),
826 };
829// ─── tiny helpers ─────────────────────────────────────────────────────
830// escapeHtml is imported from ./render — single definition, no drift.
832function firstLine(s: string): string {
833 return s.split("\n")[0].slice(0, 200);