whose CSS named
+ * page (`@page wide { size:
landscape }`, print-css.ts) rotates
+ * just that page. Chromium only honors CSS page sizes when the print call
+ * passes preferCSSPageSize — the orchestrator sets it when hasLandscape.
+ */
+
+export interface ImagePolicyOptions {
+ /** Physical content-box width in inches (page width minus margins). */
+ contentWidthIn: number;
+ warn: (msg: string) => void;
+}
+
+export interface ImagePolicyResult {
+ html: string;
+ /** True when at least one block was promoted to the landscape named page. */
+ hasLandscape: boolean;
+}
+
+/** Aspect ratio floor for auto-promotion. */
+const MIN_ASPECT = 1.8;
+/**
+ * Auto-promote only when the intrinsic CSS-px width exceeds this multiple of
+ * the content box (in CSS px @96dpi). 2.5 ≈ the plan's ~1600px threshold on a
+ * 6.5in letter box; calibrated against fixtures (design doc Open Question 4).
+ */
+const SHRINK_LIMIT = 2.5;
+/** Alt-text tokens that mark a plain image as diagram-like (case-insensitive). */
+const ALT_HINT_TOKENS = ["diagram", "architecture", "flowchart", "chart", "graph"];
+
+// ─── Pass 1: directive suffixes ───────────────────────────────────────
+
+const IMG_WITH_SUFFIX_RE = /(
]*>)\s*\{([^{}<>\n]{1,120})\}/gi;
+
+/**
+ * Consume `{...}` directive suffixes adjacent to
tags. Unrecognized
+ * brace groups are left untouched (someone's literal prose).
+ */
+export function applyImageDirectives(html: string): string {
+ return html.replace(IMG_WITH_SUFFIX_RE, (full, imgTag: string, body: string) => {
+ const parsed = parseDirectives(body);
+ if (!parsed) return full;
+ let tag = imgTag;
+ if (parsed.width) tag = addAttr(tag, "data-gstack-width", parsed.width);
+ if (parsed.page) tag = addAttr(tag, "data-gstack-page", parsed.page);
+ return tag;
+ });
+}
+
+export function parseDirectives(body: string): { width?: string; page?: string } | null {
+ let width: string | undefined;
+ let page: string | undefined;
+ let recognized = false;
+ for (const part of body.trim().split(/\s+/)) {
+ const m = part.match(/^(width|page)=(.+)$/i);
+ if (!m) return null; // any unknown token ⇒ not a directive group
+ const key = m[1].toLowerCase();
+ const value = m[2].toLowerCase();
+ if (key === "width" && /^(full|\d{1,3}%|[0-9.]+(in|cm|mm|pt|px))$/.test(value)) {
+ width = value;
+ recognized = true;
+ } else if (key === "page" && /^(landscape|portrait)$/.test(value)) {
+ page = value;
+ recognized = true;
+ } else {
+ return null; // recognized key, malformed value ⇒ leave visible, not silent
+ }
+ }
+ return recognized ? { width, page } : null;
+}
+
+function addAttr(imgTag: string, name: string, value: string): string {
+ return imgTag.replace(/^
]*>/gi, (tag) => {
+ const width = attrValue(tag, "data-gstack-width");
+ if (!width) return tag;
+ const css = width === "full" ? "100%" : width;
+ return mergeStyle(tag, `width: ${css}; height: auto;`);
+ });
+
+ // 2b. landscape promotion — standalone images (markdown images render as
+ // ![]()
; promote by swapping the paragraph for the wide wrapper).
+ out = out.replace(/\s*(
]*>)\s*<\/p>/gi, (full, tag: string) => {
+ const decision = decideImagePromotion(tag, widthThresholdPx);
+ if (!decision.promote) return full;
+ hasLandscape = true;
+ opts.warn(`promoting image to a landscape page (${decision.reason})`);
+ return `
${tag}
`;
+ });
+
+ // 2c. landscape promotion — rendered diagram figures (provenance is
+ // automatic; dims come from the SVG's width/height or viewBox).
+ out = out.replace(
+ /]*>[\s\S]*?<\/figure>/gi,
+ (figure) => {
+ if (figure.includes("diagram-error")) return figure;
+ const decision = decideDiagramPromotion(figure, widthThresholdPx);
+ if (!decision.promote) return figure;
+ hasLandscape = true;
+ opts.warn(`promoting diagram to a landscape page (${decision.reason})`);
+ return `${figure}
`;
+ },
+ );
+
+ return { html: out, hasLandscape };
+}
+
+interface PromotionDecision {
+ promote: boolean;
+ reason: string;
+}
+
+function decideImagePromotion(tag: string, widthThresholdPx: number): PromotionDecision {
+ const page = attrValue(tag, "data-gstack-page");
+ if (page === "portrait") return { promote: false, reason: "page=portrait veto" };
+ if (page === "landscape") return { promote: true, reason: "page=landscape directive" };
+
+ const w = num(attrValue(tag, "data-gstack-px-width"));
+ const h = num(attrValue(tag, "data-gstack-px-height"));
+ if (!w || !h) return { promote: false, reason: "no intrinsic dimensions" };
+ if (w / h < MIN_ASPECT) return { promote: false, reason: "aspect below floor" };
+ if (w <= widthThresholdPx) return { promote: false, reason: "fits portrait readably" };
+
+ const alt = (attrValue(tag, "alt") ?? "").toLowerCase();
+ const hinted = ALT_HINT_TOKENS.some((t) => new RegExp(`\\b${t}\\b`).test(alt));
+ if (!hinted) return { promote: false, reason: "no diagram hint in alt text" };
+
+ return { promote: true, reason: `wide diagram-like image (${Math.round(w)}px, alt hint)` };
+}
+
+function decideDiagramPromotion(figure: string, widthThresholdPx: number): PromotionDecision {
+ const page = attrValue(figure, "data-gstack-page");
+ if (page === "portrait") return { promote: false, reason: "page=portrait veto" };
+ if (page === "landscape") return { promote: true, reason: "page=landscape fence directive" };
+
+ const dims = svgCssDims(figure);
+ if (!dims) return { promote: false, reason: "no measurable SVG dimensions" };
+ if (dims.width / dims.height < MIN_ASPECT) return { promote: false, reason: "aspect below floor" };
+ if (dims.width <= widthThresholdPx) return { promote: false, reason: "fits portrait readably" };
+ return { promote: true, reason: `wide diagram (${Math.round(dims.width)}px)` };
+}
+
+/**
+ * Best-effort CSS-px dimensions of the first