From 89c35352cc0bc42f0dd18626ec418f6090778923 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 12 Jun 2026 00:06:45 -0700 Subject: [PATCH] feat(make-pdf): width directives + conservative auto-landscape via CSS named pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `![a](x.png){width=full||}` and `{page=landscape|portrait}` suffixes translate to data-gstack-* attrs in render() (before the sanitizer, which keeps data- attributes; unrecognized brace groups stay visible text). Default width rule needs no code: intrinsic CSS-px capped at the content box, never upscaled — figure img max-width owns it. Auto-landscape promotes a block to `@page wide { size: landscape }` only when aspect >= 1.8 AND intrinsic width > 2.5x the content box (~1600px on letter) AND diagram provenance (rendered fences) or a whole-word alt token (diagram|architecture|flowchart|chart|graph) for plain images. {page=...} forces or vetoes; fence info strings accept page=... too. preferCSSPageSize is passed to Chromium only when a promotion exists, so every other document prints exactly as before. False negatives are cheap; false positives feel broken (eng-review P4, Codex challenge accepted). Co-Authored-By: Claude Fable 5 --- make-pdf/src/diagram-prepass.ts | 17 ++- make-pdf/src/image-policy.ts | 219 ++++++++++++++++++++++++++++++++ make-pdf/src/orchestrator.ts | 14 +- make-pdf/src/print-css.ts | 16 +++ make-pdf/src/render.ts | 8 +- 5 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 make-pdf/src/image-policy.ts diff --git a/make-pdf/src/diagram-prepass.ts b/make-pdf/src/diagram-prepass.ts index f0e2a2c4b..db83e673a 100644 --- a/make-pdf/src/diagram-prepass.ts +++ b/make-pdf/src/diagram-prepass.ts @@ -42,6 +42,8 @@ export interface DiagramFence { source: string; /** Optional title="..." from the fence info string (a11y label, D6.4). */ title?: string; + /** Optional page=landscape|portrait fence directive (image-policy override). */ + page?: "landscape" | "portrait"; /** render=false → leave as a plain code block (escape hatch, D6.3). */ render: boolean; /** Placeholder token substituted into the markdown. */ @@ -119,6 +121,7 @@ export function extractDiagramFences(markdown: string): FenceExtraction { lang: info.lang, source: openFence.body.join("\n"), title: info.title, + page: info.page, render: true, token, ordinal, @@ -181,13 +184,18 @@ function matchFenceLine(line: string): { char: string; len: number; info: string 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 } { +/** Parse a fence info string: `mermaid`, `mermaid render=false`, + * `mermaid title="Auth flow"`, `mermaid page=landscape`. */ +export function parseInfoString(info: string): { + lang: string; render: boolean; title?: string; page?: "landscape" | "portrait"; +} { 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 }; + const pageRaw = info.match(/\bpage\s*=\s*(landscape|portrait)\b/i)?.[1]?.toLowerCase(); + const page = pageRaw === "landscape" || pageRaw === "portrait" ? pageRaw : undefined; + return { lang, render, title, page }; } // ─── Slot substitution (pure) ───────────────────────────────────────── @@ -233,8 +241,9 @@ export function buildDiagramFigure(fence: DiagramFence, svg: string): string { const captioned = fence.title ? `\n
${escapeHtml(fence.title)}
` : ""; + const pageAttr = fence.page ? ` data-gstack-page="${fence.page}"` : ""; return [ - `