Skill Tracer
Skills › gstack › make-pdf › image-policy.ts

image-policy.ts

236 lines · make-pdf/src/image-policy.ts at 3f79d81

1/**
2 * Image width policy + conservative auto-landscape (eng-review P4, D4 spec).
3 *
4 * Two pure passes over rendered HTML:
5 *
6 * 1. applyImageDirectives — runs inside render() right after marked, before
7 * the sanitizer. Translates the markdown-adjacent directive suffix
8 * `![alt](x.png){width=50%}` / `{page=landscape}` into data-gstack-*
9 * attributes (the sanitizer keeps data- attributes; the brace text is
10 * consumed so it never reaches smartypants or the page).
11 *
12 * 2. applyImagePolicy — runs in the orchestrator after image inlining (which
13 * annotates data-gstack-px-width/-height from real bytes). Applies the
14 * width rule and decides landscape promotion:
15 *
16 * WIDTH RULE: render at intrinsic CSS-px width, capped at the content box,
17 * never upscaled — that is exactly `figure img { max-width: 100% }` doing
18 * its job, so the default needs no inline style. Directives opt into more:
19 * width=full stretches to the content box; <pct>/<dim> set explicit width.
20 *
21 * LANDSCAPE (conservative, false negatives are cheap):
22 * promote only when ALL hold —
23 * aspect ratio ≥ 1.8
24 * AND intrinsic CSS-px width > SHRINK_LIMIT × content box
25 * (content shrunk below ~40% of natural size = unreadable)
26 * AND diagram provenance (rendered fence) or an alt-text token from
27 * ALT_HINT_TOKENS (plain images)
28 * `{page=landscape}` forces, `{page=portrait}` vetoes — both skip the
29 * heuristics entirely.
30 *
31 * Promotion wraps the block in <div class="page-wide"> whose CSS named
32 * page (`@page wide { size: <size> landscape }`, print-css.ts) rotates
33 * just that page. Chromium only honors CSS page sizes when the print call
34 * passes preferCSSPageSize — the orchestrator sets it when hasLandscape.
35 */
37import { svgTagDims } from "./image-size";
39export interface ImagePolicyOptions {
40 /** Physical content-box width in inches (page width minus margins). */
41 contentWidthIn: number;
42 /**
43 * Landscape named-page content box (inches). Used to vertically center a
44 * promoted block via a computed inline margin-top — CSS flex/min-height
45 * centering fragments into phantom landscape pages in Chromium, so the
46 * margin is computed here from the block's known aspect ratio instead.
47 */
48 landscape: { contentWIn: number; contentHIn: number };
49 warn: (msg: string) => void;
52export interface ImagePolicyResult {
53 html: string;
54 /** True when at least one block was promoted to the landscape named page. */
55 hasLandscape: boolean;
58/** Aspect ratio floor for auto-promotion. */
59const MIN_ASPECT = 1.8;
60/**
61 * Auto-promote only when the intrinsic CSS-px width exceeds this multiple of
62 * the content box (in CSS px @96dpi). 2.5 ≈ the plan's ~1600px threshold on a
63 * 6.5in letter box; calibrated against fixtures (design doc Open Question 4).
64 */
65const SHRINK_LIMIT = 2.5;
66/** Alt-text tokens that mark a plain image as diagram-like (case-insensitive). */
67const ALT_HINT_TOKENS = ["diagram", "architecture", "flowchart", "chart", "graph"];
69// ─── Pass 1: directive suffixes ───────────────────────────────────────
71const IMG_WITH_SUFFIX_RE = /(<img\b[^>]*>)\s*\{([^{}<>\n]{1,120})\}/gi;
73/**
74 * Consume `{...}` directive suffixes adjacent to <img> tags. Unrecognized
75 * brace groups are left untouched (someone's literal prose).
76 */
77export function applyImageDirectives(html: string): string {
78 return html.replace(IMG_WITH_SUFFIX_RE, (full, imgTag: string, body: string) => {
79 const parsed = parseDirectives(body);
80 if (!parsed) return full;
81 let tag = imgTag;
82 if (parsed.width) tag = addAttr(tag, "data-gstack-width", parsed.width);
83 if (parsed.page) tag = addAttr(tag, "data-gstack-page", parsed.page);
84 return tag;
85 });
88export function parseDirectives(body: string): { width?: string; page?: string } | null {
89 let width: string | undefined;
90 let page: string | undefined;
91 let recognized = false;
92 for (const part of body.trim().split(/\s+/)) {
93 const m = part.match(/^(width|page)=(.+)$/i);
94 if (!m) return null; // any unknown token ⇒ not a directive group
95 const key = m[1].toLowerCase();
96 const value = m[2].toLowerCase();
97 if (key === "width" && /^(full|\d{1,3}%|[0-9.]+(in|cm|mm|pt|px))$/.test(value)) {
98 width = value;
99 recognized = true;
100 } else if (key === "page" && /^(landscape|portrait)$/.test(value)) {
101 page = value;
102 recognized = true;
103 } else {
104 return null; // recognized key, malformed value ⇒ leave visible, not silent
105 }
106 }
107 return recognized ? { width, page } : null;
110function addAttr(imgTag: string, name: string, value: string): string {
111 return imgTag.replace(/^<img\b/i, `<img ${name}="${value}"`);
114// ─── Pass 2: width styles + landscape promotion ───────────────────────
116export function applyImagePolicy(html: string, opts: ImagePolicyOptions): ImagePolicyResult {
117 let hasLandscape = false;
118 const boxCssPx = opts.contentWidthIn * 96;
119 const widthThresholdPx = boxCssPx * SHRINK_LIMIT;
121 // 2a. width directives → inline styles on the img.
122 let out = html.replace(/<img\b[^>]*>/gi, (tag) => {
123 const width = attrValue(tag, "data-gstack-width");
124 if (!width) return tag;
125 const css = width === "full" ? "100%" : width;
126 return mergeStyle(tag, `width: ${css}; height: auto;`);
127 });
129 // 2b. landscape promotion — standalone images (markdown images render as
130 // <p><img …></p>; promote by swapping the paragraph for the wide wrapper).
131 out = out.replace(/<p>\s*(<img\b[^>]*>)\s*<\/p>/gi, (full, tag: string) => {
132 const decision = decideImagePromotion(tag, widthThresholdPx);
133 if (!decision.promote) return full;
134 hasLandscape = true;
135 opts.warn(`promoting image to a landscape page (${decision.reason})`);
136 const w = num(attrValue(tag, "data-gstack-px-width"));
137 const h = num(attrValue(tag, "data-gstack-px-height"));
138 return wrapPageWide(tag, w && h ? h / w : null, opts.landscape);
139 });
141 // 2c. landscape promotion — rendered diagram figures (provenance is
142 // automatic; dims come from the SVG's width/height or viewBox).
143 out = out.replace(
144 /<figure class="diagram[^"]*"[^>]*>[\s\S]*?<\/figure>/gi,
145 (figure) => {
146 if (figure.includes("diagram-error")) return figure;
147 const decision = decideDiagramPromotion(figure, widthThresholdPx);
148 if (!decision.promote) return figure;
149 hasLandscape = true;
150 opts.warn(`promoting diagram to a landscape page (${decision.reason})`);
151 const dims = svgCssDims(figure);
152 return wrapPageWide(figure, dims ? dims.height / dims.width : null, opts.landscape);
153 },
154 );
156 return { html: out, hasLandscape };
159/**
160 * Wrap a promoted block in the wide-page div, vertically centered via a
161 * computed margin-top: placed height = landscape content width × aspect,
162 * centered in the landscape content height. Unknown aspect → no margin
163 * (top placement beats a wrong guess).
164 */
165function wrapPageWide(
166 inner: string,
167 aspectHoverW: number | null,
168 landscape: { contentWIn: number; contentHIn: number },
169): string {
170 if (!aspectHoverW) return `<div class="page-wide">${inner}</div>`;
171 const placedHIn = landscape.contentWIn * aspectHoverW;
172 const marginIn = Math.max(0, (landscape.contentHIn - placedHIn) / 2);
173 if (marginIn < 0.1) return `<div class="page-wide">${inner}</div>`;
174 return `<div class="page-wide" style="margin-top: ${marginIn.toFixed(2)}in">${inner}</div>`;
177interface PromotionDecision {
178 promote: boolean;
179 reason: string;
182function decideImagePromotion(tag: string, widthThresholdPx: number): PromotionDecision {
183 const page = attrValue(tag, "data-gstack-page");
184 if (page === "portrait") return { promote: false, reason: "page=portrait veto" };
185 if (page === "landscape") return { promote: true, reason: "page=landscape directive" };
187 const w = num(attrValue(tag, "data-gstack-px-width"));
188 const h = num(attrValue(tag, "data-gstack-px-height"));
189 if (!w || !h) return { promote: false, reason: "no intrinsic dimensions" };
190 if (w / h < MIN_ASPECT) return { promote: false, reason: "aspect below floor" };
191 if (w <= widthThresholdPx) return { promote: false, reason: "fits portrait readably" };
193 const alt = (attrValue(tag, "alt") ?? "").toLowerCase();
194 const hinted = ALT_HINT_TOKENS.some((t) => new RegExp(`\\b${t}\\b`).test(alt));
195 if (!hinted) return { promote: false, reason: "no diagram hint in alt text" };
197 return { promote: true, reason: `wide diagram-like image (${Math.round(w)}px, alt hint)` };
200function decideDiagramPromotion(figure: string, widthThresholdPx: number): PromotionDecision {
201 const page = attrValue(figure, "data-gstack-page");
202 if (page === "portrait") return { promote: false, reason: "page=portrait veto" };
203 if (page === "landscape") return { promote: true, reason: "page=landscape fence directive" };
205 const dims = svgCssDims(figure);
206 if (!dims) return { promote: false, reason: "no measurable SVG dimensions" };
207 if (dims.width / dims.height < MIN_ASPECT) return { promote: false, reason: "aspect below floor" };
208 if (dims.width <= widthThresholdPx) return { promote: false, reason: "fits portrait readably" };
209 return { promote: true, reason: `wide diagram (${Math.round(dims.width)}px)` };
212/** SVG dimension probing is shared with the byte prober — see image-size.ts. */
213const svgCssDims = svgTagDims;
215function attrValue(tag: string, name: string): string | null {
216 const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, "i"))
217 ?? tag.match(new RegExp(`\\b${name}\\s*=\\s*'([^']*)'`, "i"));
218 return m ? m[1] : null;
221function num(s: string | null): number | null {
222 if (s === null) return null;
223 const n = parseFloat(s);
224 return Number.isFinite(n) && n > 0 ? n : null;
227function mergeStyle(tag: string, css: string): string {
228 const existing = attrValue(tag, "style");
229 if (existing !== null) {
230 // Function replacement (no $-pattern expansion from user-controlled style
231 // values) and the existing declarations are preserved verbatim — attrValue
232 // already returned the unquoted inner value.
233 return tag.replace(/\bstyle\s*=\s*(".*?"|'.*?')/i, () => `style="${existing}; ${css}"`);
234 }
235 return tag.replace(/^<img\b/i, () => `<img style="${css}"`);