diff --git a/make-pdf/test/e2e/landscape-gate.test.ts b/make-pdf/test/e2e/landscape-gate.test.ts new file mode 100644 index 000000000..cadcfc29a --- /dev/null +++ b/make-pdf/test/e2e/landscape-gate.test.ts @@ -0,0 +1,134 @@ +/** + * Landscape promotion gate — proves the conservative auto-landscape policy + * end-to-end through the compiled binary, asserted on pdfinfo per-page boxes + * (the only oracle that can't lie about orientation). + * + * The fixture encodes one of each decision: + * - wide screenshot, no alt hint → MUST stay portrait (false-positive guard) + * - wide image, alt "architecture diagram" → promotes + * - small image with {page=landscape} → promotes (directive force) + * - wide mermaid sequence diagram → promotes (provenance automatic) + * - wide mermaid with page=portrait fence → MUST stay portrait (veto) + * + * Also runs the --toc combo: Paged.js isn't shipped in v1 (TOC renders + * without page numbers, browse falls through after 3s), so named-page + * landscape must survive a --toc run unchanged. If Paged.js ever lands and + * re-paginates, this is the test that catches the interaction. + */ + +import { describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { resolvePopplerTool } from "../../src/pdftotext"; + +const FIXTURE = path.resolve(__dirname, "../fixtures/landscape-gate.md"); +const ROOT = path.resolve(__dirname, "../../.."); +const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf"); +const BROWSE_BIN = path.join(ROOT, "browse/dist/browse"); +const BUNDLE = path.join(ROOT, "lib/diagram-render/dist/diagram-render.html"); + +const CHILD_TIMEOUT_MS = 60_000; + +function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } { + if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` }; + if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` }; + if (!fs.existsSync(BUNDLE)) return { ok: false, reason: `diagram-render bundle missing (${BUNDLE}).` }; + if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` }; + if (!resolvePopplerTool("pdfinfo")) return { ok: false, reason: "pdfinfo not found (install poppler-utils)." }; + if (!resolvePopplerTool("pdftotext")) return { ok: false, reason: "pdftotext not found (install poppler-utils)." }; + return { ok: true }; +} + +interface PageBox { + page: number; + width: number; + height: number; +} + +function pageBoxes(pdfPath: string): PageBox[] { + const pdfinfo = resolvePopplerTool("pdfinfo")!; + const out = execFileSync(pdfinfo, ["-f", "1", "-l", "99", pdfPath], { + encoding: "utf8", + timeout: CHILD_TIMEOUT_MS, + }); + const boxes: PageBox[] = []; + for (const m of out.matchAll(/Page\s+(\d+)\s+size:\s+([0-9.]+)\s+x\s+([0-9.]+)\s+pts/g)) { + boxes.push({ page: Number(m[1]), width: parseFloat(m[2]), height: parseFloat(m[3]) }); + } + if (boxes.length === 0) throw new Error(`pdfinfo reported no page sizes:\n${out}`); + return boxes; +} + +const isLandscape = (b: PageBox) => b.width > b.height; + +function generate(args: string[], outputPdf: string): void { + execFileSync(PDF_BIN, ["generate", FIXTURE, outputPdf, "--quiet", ...args], { + encoding: "utf8", + env: { ...process.env, BROWSE_BIN }, + stdio: ["ignore", "pipe", "pipe"], + timeout: CHILD_TIMEOUT_MS, + }); +} + +describe("landscape promotion gate", () => { + const avail = prerequisitesAvailable(); + + test.skipIf(!avail.ok)("exactly the promoted blocks get landscape pages", () => { + if (!avail.ok) return; + const workDir = fs.mkdtempSync("/tmp/make-pdf-landscape-gate-"); + const outputPdf = path.join(workDir, "out.pdf"); + try { + generate([], outputPdf); + const boxes = pageBoxes(outputPdf); + const landscape = boxes.filter(isLandscape); + const portrait = boxes.filter((b) => !isLandscape(b)); + + // Three promotions: alt-hinted image, directive-forced image, wide diagram. + expect(landscape.length).toBe(3); + // First page (intro + screenshot) and the veto'd diagram stay portrait. + expect(portrait.length).toBeGreaterThanOrEqual(2); + expect(isLandscape(boxes[0])).toBe(false); + + // The veto'd diagram rendered (its labels exist) on a PORTRAIT page. + const pdftotext = resolvePopplerTool("pdftotext")!; + const lastPortrait = portrait[portrait.length - 1]; + const vetoText = execFileSync( + pdftotext, + ["-f", String(lastPortrait.page), "-l", String(lastPortrait.page), outputPdf, "-"], + { encoding: "utf8", timeout: CHILD_TIMEOUT_MS }, + ); + expect(vetoText).toContain("vetoalpha"); + } finally { + try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }, 120000); + + test.skipIf(!avail.ok)("--toc combo: TOC renders and landscape promotion survives", () => { + if (!avail.ok) return; + const workDir = fs.mkdtempSync("/tmp/make-pdf-landscape-toc-"); + const outputPdf = path.join(workDir, "out.pdf"); + try { + generate(["--toc"], outputPdf); + const boxes = pageBoxes(outputPdf); + expect(boxes.filter(isLandscape).length).toBe(3); + + const pdftotext = resolvePopplerTool("pdftotext")!; + const text = execFileSync(pdftotext, [outputPdf, "-"], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS }); + // TOC heading extracts uppercase (small-caps styling). + expect(text.toUpperCase()).toContain("CONTENTS"); + } finally { + try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }, 120000); + + if (!avail.ok) { + test("landscape gate prerequisites are present (hard-required in CI)", () => { + if (process.env.CI) { + throw new Error(`landscape gate prerequisites missing in CI: ${avail.reason}`); + } + console.warn(`[skip] ${avail.reason}`); + }); + } +}); diff --git a/make-pdf/test/fixtures/diagram-assets/wide-arch.png b/make-pdf/test/fixtures/diagram-assets/wide-arch.png new file mode 100644 index 000000000..cd71db58d Binary files /dev/null and b/make-pdf/test/fixtures/diagram-assets/wide-arch.png differ diff --git a/make-pdf/test/fixtures/diagram-assets/wide-screenshot.png b/make-pdf/test/fixtures/diagram-assets/wide-screenshot.png new file mode 100644 index 000000000..f1c5fff6f Binary files /dev/null and b/make-pdf/test/fixtures/diagram-assets/wide-screenshot.png differ diff --git a/make-pdf/test/fixtures/landscape-gate.md b/make-pdf/test/fixtures/landscape-gate.md new file mode 100644 index 000000000..473d7fbbc --- /dev/null +++ b/make-pdf/test/fixtures/landscape-gate.md @@ -0,0 +1,52 @@ +# Landscape Gate + +Intro text under the first heading. + +## Negative: screenshot stays portrait + + + +## Positive: alt-hinted wide image promotes + + + +## Positive: directive forces a small image + +{page=landscape} + +## Positive: wide diagram auto-promotes + +```mermaid title="Wide sequence" +sequenceDiagram + participant A as seqalpha + participant B as seqbeta + participant C as seqgamma + participant D as seqdelta + participant E as seqepsilon + participant F as seqzeta + participant G as seqeta + participant H as seqtheta + participant I as seqiota + participant J as seqkappa + A->>J: long hop + B->>I: cross +``` + +## Negative: directive vetoes a wide diagram + +```mermaid page=portrait +sequenceDiagram + participant A as vetoalpha + participant B as vetobeta + participant C as vetogamma + participant D as vetodelta + participant E as vetoepsilon + participant F as vetozeta + participant G as vetoeta + participant H as vetotheta + participant I as vetoiota + participant J as vetokappa + A->>J: long hop +``` + +Closing text. diff --git a/make-pdf/test/image-policy.test.ts b/make-pdf/test/image-policy.test.ts new file mode 100644 index 000000000..e46f8eb4e --- /dev/null +++ b/make-pdf/test/image-policy.test.ts @@ -0,0 +1,190 @@ +/** + * Unit tests for the image width policy + conservative auto-landscape + * (image-policy.ts). Pure HTML-in/HTML-out — no browse daemon. + * + * The promotion heuristic is deliberately conservative (eng-review P4): + * false negatives are cheap (add {page=landscape}), false positives feel + * broken. The negative cases here are the load-bearing ones. + */ +import { describe, expect, test } from "bun:test"; + +import { + applyImageDirectives, + applyImagePolicy, + parseDirectives, +} from "../src/image-policy"; + +const silent = { warn: () => {} }; + +// 6.5in content box → threshold = 6.5 × 96 × 2.5 = 1560 CSS px. +const OPTS = { contentWidthIn: 6.5, ...silent }; + +function img(attrs: string): string { + return `
{width=50%}
{not a directive}
set {width=full} in config
`; + expect(applyImageDirectives(html)).toBe(html); + }); +}); + +// ─── width policy ───────────────────────────────────────────────────── + +describe("width styles", () => { + test("width=full → inline 100% style", () => { + const { html } = applyImagePolicy(img(`src="x" data-gstack-width="full"`), OPTS); + expect(html).toContain("width: 100%"); + }); + test("explicit dimension passes through", () => { + const { html } = applyImagePolicy(img(`src="x" data-gstack-width="3in"`), OPTS); + expect(html).toContain("width: 3in"); + }); + test("no directive → no inline style (CSS max-width owns the default)", () => { + const { html } = applyImagePolicy(img(`src="x" data-gstack-px-width="40" data-gstack-px-height="20"`), OPTS); + expect(html).not.toContain("style="); + }); +}); + +// ─── landscape promotion ────────────────────────────────────────────── + +describe("auto-landscape: negative cases (the load-bearing ones)", () => { + test("wide screenshot with no alt hint stays portrait", () => { + const r = applyImagePolicy( + img(`src="x" alt="screenshot of the app" data-gstack-px-width="3000" data-gstack-px-height="900"`), + OPTS, + ); + expect(r.hasLandscape).toBe(false); + expect(r.html).not.toContain("page-wide"); + }); + test("wide banner with hint but below width threshold stays portrait", () => { + const r = applyImagePolicy( + img(`src="x" alt="chart" data-gstack-px-width="1200" data-gstack-px-height="400"`), + OPTS, + ); + expect(r.hasLandscape).toBe(false); + }); + test("tall diagram (aspect below 1.8) stays portrait", () => { + const r = applyImagePolicy( + img(`src="x" alt="architecture diagram" data-gstack-px-width="2000" data-gstack-px-height="1500"`), + OPTS, + ); + expect(r.hasLandscape).toBe(false); + }); + test("no intrinsic dimensions stays portrait", () => { + const r = applyImagePolicy(img(`src="x" alt="diagram"`), OPTS); + expect(r.hasLandscape).toBe(false); + }); + test("page=portrait vetoes everything", () => { + const r = applyImagePolicy( + img(`src="x" alt="diagram" data-gstack-page="portrait" data-gstack-px-width="4000" data-gstack-px-height="1000"`), + OPTS, + ); + expect(r.hasLandscape).toBe(false); + }); + test("threshold boundary is deterministic: exactly at threshold stays portrait", () => { + // threshold = 6.5 × 96 × 2.5 = 1560 + const r = applyImagePolicy( + img(`src="x" alt="diagram" data-gstack-px-width="1560" data-gstack-px-height="600"`), + OPTS, + ); + expect(r.hasLandscape).toBe(false); + const r2 = applyImagePolicy( + img(`src="x" alt="diagram" data-gstack-px-width="1561" data-gstack-px-height="600"`), + OPTS, + ); + expect(r2.hasLandscape).toBe(true); + }); +}); + +describe("auto-landscape: positive cases", () => { + test("wide + alt hint + over threshold promotes and wraps", () => { + const warnings: string[] = []; + const r = applyImagePolicy( + img(`src="x" alt="architecture diagram" data-gstack-px-width="2400" data-gstack-px-height="1000"`), + { contentWidthIn: 6.5, warn: (m) => warnings.push(m) }, + ); + expect(r.hasLandscape).toBe(true); + expect(r.html).toContain('