test(make-pdf): pin zero-truncation invariant, typography floor, centering math

Global img cap pinned as a regex invariant (the figure-scoped-cap regression
class); typography floor (12pt body, 56pt cover, 12pt TOC); .page-wide must
NOT carry min-height/flex (the phantom-landscape-page regression class);
centering margin math verified both ways (2400×1000 image → 1.38in,
2050×600 viewBox diagram → 1.93in, page-filling directive block → no margin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-06-12 06:32:55 -07:00
parent 9397e7f847
commit 4c11fe4e61
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 51 additions and 7 deletions

View File

@ -17,7 +17,9 @@ import {
const silent = { warn: () => {} }; const silent = { warn: () => {} };
// 6.5in content box → threshold = 6.5 × 96 × 2.5 = 1560 CSS px. // 6.5in content box → threshold = 6.5 × 96 × 2.5 = 1560 CSS px.
const OPTS = { contentWidthIn: 6.5, ...silent }; // Letter landscape content box: 9in wide × 6.5in tall.
const LANDSCAPE = { contentWIn: 9, contentHIn: 6.5 };
const OPTS = { contentWidthIn: 6.5, landscape: LANDSCAPE, ...silent };
function img(attrs: string): string { function img(attrs: string): string {
return `<p><img ${attrs}></p>`; return `<p><img ${attrs}></p>`;
@ -131,20 +133,34 @@ describe("auto-landscape: negative cases (the load-bearing ones)", () => {
}); });
describe("auto-landscape: positive cases", () => { describe("auto-landscape: positive cases", () => {
test("wide + alt hint + over threshold promotes and wraps", () => { test("wide + alt hint + over threshold promotes, wraps, and vertically centers", () => {
const warnings: string[] = []; const warnings: string[] = [];
const r = applyImagePolicy( const r = applyImagePolicy(
img(`src="x" alt="architecture diagram" data-gstack-px-width="2400" data-gstack-px-height="1000"`), img(`src="x" alt="architecture diagram" data-gstack-px-width="2400" data-gstack-px-height="1000"`),
{ contentWidthIn: 6.5, warn: (m) => warnings.push(m) }, { contentWidthIn: 6.5, landscape: LANDSCAPE, warn: (m) => warnings.push(m) },
);
expect(r.hasLandscape).toBe(true);
// placed height = 9in × (1000/2400) = 3.75in → margin-top = (6.53.75)/2 ≈ 1.38in
expect(r.html).toContain('<div class="page-wide" style="margin-top: 1.38in"><img');
expect(r.html).not.toContain("<p><img");
expect(warnings[0]).toContain("landscape");
});
test("directive-forced tall block that fills the page gets no centering margin", () => {
// aspect 0.9 → placed height 9×0.9 = 8.1in > 6.5in box → margin clamps to 0
const r = applyImagePolicy(
img(`src="x" data-gstack-page="landscape" data-gstack-px-width="1000" data-gstack-px-height="900"`),
OPTS,
); );
expect(r.hasLandscape).toBe(true); expect(r.hasLandscape).toBe(true);
expect(r.html).toContain('<div class="page-wide"><img'); expect(r.html).toContain('<div class="page-wide"><img');
expect(r.html).not.toContain("<p><img"); expect(r.html).not.toContain("margin-top");
expect(warnings[0]).toContain("landscape");
}); });
test("page=landscape forces promotion regardless of size", () => { test("page=landscape forces promotion regardless of size", () => {
const r = applyImagePolicy(img(`src="x" data-gstack-page="landscape"`), OPTS); const r = applyImagePolicy(img(`src="x" data-gstack-page="landscape"`), OPTS);
expect(r.hasLandscape).toBe(true); expect(r.hasLandscape).toBe(true);
// no intrinsic dims → no centering guess, top placement
expect(r.html).toContain('<div class="page-wide"><img');
}); });
test("alt hint matches whole words only", () => { test("alt hint matches whole words only", () => {
const r = applyImagePolicy( const r = applyImagePolicy(
@ -159,10 +175,11 @@ describe("auto-landscape: diagram figures", () => {
const fig = (svgAttrs: string, figAttrs = "") => const fig = (svgAttrs: string, figAttrs = "") =>
`<figure class="diagram" role="img" aria-label="d"${figAttrs}>\n<svg ${svgAttrs}><g/></svg>\n</figure>`; `<figure class="diagram" role="img" aria-label="d"${figAttrs}>\n<svg ${svgAttrs}><g/></svg>\n</figure>`;
test("wide diagram via viewBox promotes (provenance automatic, no alt needed)", () => { test("wide diagram via viewBox promotes and centers (provenance automatic, no alt needed)", () => {
const r = applyImagePolicy(fig(`width="100%" viewBox="0 0 2050 600"`), OPTS); const r = applyImagePolicy(fig(`width="100%" viewBox="0 0 2050 600"`), OPTS);
expect(r.hasLandscape).toBe(true); expect(r.hasLandscape).toBe(true);
expect(r.html).toContain('<div class="page-wide"><figure'); // placed height = 9 × 600/2050 ≈ 2.63in → margin-top = (6.52.63)/2 ≈ 1.93in
expect(r.html).toContain('<div class="page-wide" style="margin-top: 1.93in"><figure');
}); });
test("normal flowchart stays portrait", () => { test("normal flowchart stays portrait", () => {
const r = applyImagePolicy(fig(`width="100%" viewBox="0 0 800 400"`), OPTS); const r = applyImagePolicy(fig(`width="100%" viewBox="0 0 800 400"`), OPTS);

View File

@ -327,6 +327,33 @@ describe("printCss", () => {
expect(css).toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/); expect(css).toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
}); });
// Zero image truncation, ever: the cap must be a GLOBAL img rule. Markdown
// images render as <p><img> (no figure), so a figure-scoped cap alone lets
// wide screenshots run off the page edge — the exact regression this pins.
test("emits a global img max-width cap (zero truncation invariant)", () => {
const css = printCss();
expect(css).toMatch(/(^|\n)img\s*\{\s*max-width:\s*100%;\s*height:\s*auto;\s*\}/);
});
test("typography floor: body 12pt, poster cover, readable TOC", () => {
const css = printCss({ cover: true, toc: true });
expect(css).toContain("font-size: 12pt"); // body
expect(css).toMatch(/\.cover h1\.cover-title\s*\{[^}]*font-size:\s*56pt/);
expect(css).toMatch(/\.cover \.cover-meta\s*\{[^}]*font-size:\s*13pt/);
expect(css).toMatch(/\.toc li\s*\{[^}]*font-size:\s*12pt/);
});
test("page-wide carries the named page and NO height/flex centering", () => {
const css = printCss();
expect(css).toMatch(/\.page-wide\s*\{[^}]*page:\s*wide/);
// Centering is computed by image-policy as an inline margin-top. CSS
// flex/min-height centering fragments into phantom empty landscape pages
// in Chromium — this pins the regression (landscape-gate: 5 pages for 3
// promotions, bisected to min-height at any value).
expect(css).not.toMatch(/\.page-wide\s*\{[^}]*min-height/);
expect(css).not.toMatch(/\.page-wide\s*\{[^}]*flex/);
});
test("font stacks include Liberation Sans adjacent to Helvetica", () => { test("font stacks include Liberation Sans adjacent to Helvetica", () => {
const css = printCss({ confidential: true }); const css = printCss({ confidential: true });
// Body stack // Body stack