From 30f4edf45d6bf023d694cb2e3aa9e8db575fdcad Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sun, 6 Sep 2026 05:47:56 +0200 Subject: [PATCH 1/2] test(pdf): characterize item pagination blocker --- .../plan-23-item-pagination-blocker.md | 32 +++ .../templates/shared/item-pagination.test.tsx | 188 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 .orchestration/plan-23-item-pagination-blocker.md create mode 100644 packages/pdf/src/templates/shared/item-pagination.test.tsx diff --git a/.orchestration/plan-23-item-pagination-blocker.md b/.orchestration/plan-23-item-pagination-blocker.md new file mode 100644 index 000000000..cc2b05a86 --- /dev/null +++ b/.orchestration/plan-23-item-pagination-blocker.md @@ -0,0 +1,32 @@ +# Plan 23 item-pagination execution evidence + +## Scope + +Plan 23 steps 1–3 were evaluated from `origin/main` at `368858a56` (Plan 21 / PR #3477 merged). Widow/orphan UI and authored-page continuation guidance remain deferred from this execution, and Semantic CSS was not changed. + +## Durable diagnostic matrix + +`packages/pdf/src/templates/shared/item-pagination.test.tsx` renders physical PDF pages and checks numbered tokens exactly once for: + +- an item that fits remaining space; +- an item that fits a full page but not the remaining space; +- an oversized item taller than one page; +- a two-line paragraph at a boundary; +- nested bullets; and +- built-in plus custom items in an Azurill sidebar/main-column overflow fixture. + +The fixture also keeps authored `metadata.layout.pages` separate from renderer-generated physical pages. + +The current deterministic baseline is: fit remainder = 1 physical page; full-page-but-not-remainder = 3 pages with sampled tokens on pages 2/2/3; oversized = 5 pages with sampled tokens on pages 1/3/5; two-line boundary = 2 pages; nested bullets = 1 page; Azurill built-in/custom/sidebar = 5 pages with sampled tokens on pages 1/4/5/5/1. Page numbers here are 1-based; every token still appears exactly once. + +## Concrete blocker + +React PDF's only available item-level keep-together primitive is `View wrap={false}`. A durable renderer fixture with 180 paragraph-like child views shows that a non-wrapping item cannot safely fall back when its content exceeds one page: the renderer omits the oversized tail instead of splitting it. Applying the same prop to shared `SectionItem` would therefore violate the lossless token requirement; no item schema flag or menu control was added. + +Do not estimate item height from HTML length, persist physical pages, alter existing Semantic CSS, or claim #3350 complete. A future implementation needs renderer-supported conditional keep-together behavior or an actual measured two-pass fallback that preserves every token. + +## Verification + +- `pnpm --filter @reactive-resume/pdf exec vitest run src/templates/shared/item-pagination.test.tsx`: 7 tests passed. +- No production source or schema changes made after the unsafe fallback was reproduced. +- Undo/persistence/lock UI coverage is intentionally absent because no item control was shipped; add it only when safe fallback exists. diff --git a/packages/pdf/src/templates/shared/item-pagination.test.tsx b/packages/pdf/src/templates/shared/item-pagination.test.tsx new file mode 100644 index 000000000..c10376491 --- /dev/null +++ b/packages/pdf/src/templates/shared/item-pagination.test.tsx @@ -0,0 +1,188 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { Template } from "@reactive-resume/schema/templates"; +import { describe, expect, it, vi } from "vitest"; +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; +import { createElement } from "react"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { ResumeDocument } from "../../document"; + +vi.mock("@react-pdf/renderer", async (importOriginal) => ({ + ...(await importOriginal()), +})); + +type PdfTextItem = { str: string }; +type ParsedPdfPage = { getTextContent: () => Promise<{ items: PdfTextItem[] }> }; +type ParsedPdf = { numPages: number; getPage: (pageNumber: number) => Promise }; + +const renderPdf = async (data: ResumeData, template: Template = "onyx"): Promise => { + const renderer = await vi.importActual("@react-pdf/renderer"); + const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters< + typeof renderer.renderToBuffer + >[0]; + return new Uint8Array(await renderer.renderToBuffer(element)); +}; + +const parsePdf = (data: Uint8Array): Promise => getDocument({ data }).promise as Promise; + +const readPhysicalPages = async (document: ParsedPdf): Promise => { + const pages: string[] = []; + for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) { + const page = await document.getPage(pageNumber); + const content = await page.getTextContent(); + pages.push(content.items.map(({ str }) => str).join(" ")); + } + return pages; +}; + +const makeItem = (id: string, description: string) => ({ + id, + hidden: false, + company: id, + position: "Synthetic item", + location: "", + period: "", + website: { url: "", label: "", inlineLink: false }, + description, + roles: [], +}); + +const makeFixture = (items: ResumeData["sections"]["experience"]["items"]): ResumeData => { + const data = structuredClone(defaultResumeData); + data.picture.hidden = true; + data.basics.name = "ITEM PAGINATION HEADER"; + data.metadata.template = "onyx"; + data.metadata.typography.body.fontFamily = "Helvetica"; + data.metadata.typography.heading.fontFamily = "Helvetica"; + data.metadata.layout.pages = [{ fullWidth: true, main: ["experience"], sidebar: [] }]; + data.sections.experience.title = "Experience"; + data.sections.experience.items = items; + return data; +}; + +const numberedParagraphs = (prefix: string, count: number) => + Array.from({ length: count }, (_value, index) => `

${prefix}_${String(index + 1).padStart(3, "0")}

`).join(""); + +const expectTokensExactlyOnce = (pages: string[], tokens: string[]) => { + const renderedText = pages.join(" "); + for (const token of tokens) expect(renderedText.split(token)).toHaveLength(2); +}; + +describe("item pagination token matrix", () => { + it.each([ + { + name: "item fits remaining space", + data: makeFixture([ + makeItem("boundary", numberedParagraphs("BOUNDARY", 26)), + makeItem("fit", numberedParagraphs("FIT", 4)), + ]), + tokens: ["FIT_001", "FIT_002", "FIT_003", "FIT_004"], + expectedPages: 1, + expectedTokenPages: [0, 0, 0, 0], + }, + { + name: "item fits a full page but not remaining space", + data: makeFixture([ + makeItem("boundary", numberedParagraphs("BOUNDARY", 48)), + makeItem("full-page", numberedParagraphs("FULL_PAGE", 42)), + ]), + tokens: ["FULL_PAGE_001", "FULL_PAGE_021", "FULL_PAGE_042"], + expectedPages: 3, + expectedTokenPages: [1, 1, 2], + }, + { + name: "item taller than a page", + data: makeFixture([makeItem("oversized", numberedParagraphs("OVERSIZED", 180))]), + tokens: ["OVERSIZED_001", "OVERSIZED_090", "OVERSIZED_180"], + expectedPages: 5, + expectedTokenPages: [0, 2, 4], + }, + { + name: "two-line paragraph near boundary", + data: makeFixture([ + makeItem("boundary", numberedParagraphs("BOUNDARY", 52)), + makeItem("paragraph", "

PARAGRAPH_001 first line
PARAGRAPH_002 second line

"), + ]), + tokens: ["PARAGRAPH_001", "PARAGRAPH_002"], + expectedPages: 2, + expectedTokenPages: [1, 1], + }, + { + name: "nested bullets", + data: makeFixture([ + makeItem( + "nested", + "
  • NESTED_001

    • NESTED_002

    • NESTED_003

", + ), + ]), + tokens: ["NESTED_001", "NESTED_002", "NESTED_003"], + expectedPages: 1, + expectedTokenPages: [0, 0, 0], + }, + ])("preserves every token exactly once: $name", async ({ data, tokens, expectedPages, expectedTokenPages }) => { + const pages = await readPhysicalPages(await parsePdf(await renderPdf(data))); + expect(pages).toHaveLength(expectedPages); + expect(tokens.map((token) => pages.findIndex((page) => page.includes(token)))).toEqual(expectedTokenPages); + expectTokensExactlyOnce(pages, tokens); + }); + + it("preserves built-in and custom items across an Azurill sidebar and main-column overflow", async () => { + const data = makeFixture([makeItem("builtin", numberedParagraphs("BUILTIN", 150))]); + data.metadata.template = "azurill"; + data.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: ["profiles"] }]; + data.sections.profiles.items = [ + { + id: "sidebar", + hidden: false, + icon: "github-logo", + iconColor: "", + network: "SIDEBAR_001", + username: "sidebar", + website: { url: "", label: "", inlineLink: false }, + }, + ]; + data.customSections = [ + { + id: "custom-experience", + type: "experience", + title: "Custom Experience", + icon: "", + columns: 1, + hidden: false, + showHeading: true, + keepTogether: false, + startOnNewPage: false, + items: [makeItem("custom", numberedParagraphs("CUSTOM", 30))], + }, + ]; + data.metadata.layout.pages[0]?.main.push("custom-experience"); + + const pages = await readPhysicalPages(await parsePdf(await renderPdf(data, "azurill"))); + expect(pages).toHaveLength(5); + expect( + ["BUILTIN_001", "BUILTIN_150", "CUSTOM_001", "CUSTOM_030", "SIDEBAR_001"].map((token) => + pages.findIndex((page) => page.includes(token)), + ), + ).toEqual([0, 3, 4, 4, 0]); + expectTokensExactlyOnce(pages, ["BUILTIN_001", "BUILTIN_150", "CUSTOM_001", "CUSTOM_030", "SIDEBAR_001"]); + }); + + it("records unsafe renderer fallback for an oversized non-wrapping item", async () => { + const renderer = await vi.importActual("@react-pdf/renderer"); + const tokens = Array.from({ length: 180 }, (_value, index) => `UNSAFE_${String(index + 1).padStart(3, "0")}`); + const item = createElement( + renderer.View, + { wrap: false }, + ...tokens.map((token) => createElement(renderer.View, { key: token }, createElement(renderer.Text, null, token))), + ); + const element = createElement( + renderer.Document, + null, + createElement(renderer.Page, { size: "A4" }, item), + ) as Parameters[0]; + const pages = await readPhysicalPages(await parsePdf(new Uint8Array(await renderer.renderToBuffer(element)))); + + // React PDF warns that an oversized View cannot wrap and drops content; this is the blocker for item Keep together. + expect(pages.join(" ")).toContain("UNSAFE_001"); + expect(pages.join(" ")).not.toContain("UNSAFE_180"); + }); +}); From f2769dce54ca6c3ee9c344eec195f036ba14645f Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sun, 6 Sep 2026 05:56:20 +0200 Subject: [PATCH 2/2] test(pdf): strengthen item pagination coverage --- .../remediation-issue-3350-item-pagination.md | 17 +++++++ .../templates/shared/item-pagination.test.tsx | 50 ++++++++++++++----- 2 files changed, 54 insertions(+), 13 deletions(-) create mode 100644 .orchestration/remediation-issue-3350-item-pagination.md diff --git a/.orchestration/remediation-issue-3350-item-pagination.md b/.orchestration/remediation-issue-3350-item-pagination.md new file mode 100644 index 000000000..6ab351f6f --- /dev/null +++ b/.orchestration/remediation-issue-3350-item-pagination.md @@ -0,0 +1,17 @@ +# Issue 3350 remediation evidence + +## Findings addressed + +- `item-pagination.test.tsx` now derives complete numbered-token inventories for each generated fixture and asserts every token exactly once. Sampled token-to-physical-page placement checks remain separate. +- Pagination fixtures snapshot `metadata.layout.pages` before rendering and assert authored layout pages are unchanged afterward. Overflow fixtures also assert physical PDF page count exceeds authored page count. +- Unsafe `wrap={false}` renderer coverage remains diagnostic-only; no item controls, schema flags, or runtime behavior were added. + +## Verification + +- `rtk proxy pnpm --filter @reactive-resume/pdf exec vitest run src/semantic/pagination.test.tsx src/templates/shared/item-pagination.test.tsx` — 2 files, 11 tests passed. +- `rtk proxy pnpm --filter @reactive-resume/pdf typecheck` — passed. +- `rtk proxy pnpm exec biome check packages/pdf/src/templates/shared/item-pagination.test.tsx` — passed. +- `rtk proxy pnpm exec turbo boundaries` — passed; 1109 files checked. +- `rtk git diff --check origin/main...HEAD` — passed. + +Only PDF test coverage and this evidence file changed; production behavior remains untouched. diff --git a/packages/pdf/src/templates/shared/item-pagination.test.tsx b/packages/pdf/src/templates/shared/item-pagination.test.tsx index c10376491..4d428a359 100644 --- a/packages/pdf/src/templates/shared/item-pagination.test.tsx +++ b/packages/pdf/src/templates/shared/item-pagination.test.tsx @@ -59,8 +59,13 @@ const makeFixture = (items: ResumeData["sections"]["experience"]["items"]): Resu return data; }; +const numberedTokens = (prefix: string, count: number) => + Array.from({ length: count }, (_value, index) => `${prefix}_${String(index + 1).padStart(3, "0")}`); + const numberedParagraphs = (prefix: string, count: number) => - Array.from({ length: count }, (_value, index) => `

${prefix}_${String(index + 1).padStart(3, "0")}

`).join(""); + numberedTokens(prefix, count) + .map((token) => `

${token}

`) + .join(""); const expectTokensExactlyOnce = (pages: string[], tokens: string[]) => { const renderedText = pages.join(" "); @@ -75,9 +80,11 @@ describe("item pagination token matrix", () => { makeItem("boundary", numberedParagraphs("BOUNDARY", 26)), makeItem("fit", numberedParagraphs("FIT", 4)), ]), - tokens: ["FIT_001", "FIT_002", "FIT_003", "FIT_004"], + allTokens: [...numberedTokens("BOUNDARY", 26), ...numberedTokens("FIT", 4)], + sampledTokens: ["FIT_001", "FIT_002", "FIT_003", "FIT_004"], expectedPages: 1, expectedTokenPages: [0, 0, 0, 0], + expectsPhysicalOverflow: false, }, { name: "item fits a full page but not remaining space", @@ -85,16 +92,20 @@ describe("item pagination token matrix", () => { makeItem("boundary", numberedParagraphs("BOUNDARY", 48)), makeItem("full-page", numberedParagraphs("FULL_PAGE", 42)), ]), - tokens: ["FULL_PAGE_001", "FULL_PAGE_021", "FULL_PAGE_042"], + allTokens: [...numberedTokens("BOUNDARY", 48), ...numberedTokens("FULL_PAGE", 42)], + sampledTokens: ["FULL_PAGE_001", "FULL_PAGE_021", "FULL_PAGE_042"], expectedPages: 3, expectedTokenPages: [1, 1, 2], + expectsPhysicalOverflow: true, }, { name: "item taller than a page", data: makeFixture([makeItem("oversized", numberedParagraphs("OVERSIZED", 180))]), - tokens: ["OVERSIZED_001", "OVERSIZED_090", "OVERSIZED_180"], + allTokens: numberedTokens("OVERSIZED", 180), + sampledTokens: ["OVERSIZED_001", "OVERSIZED_090", "OVERSIZED_180"], expectedPages: 5, expectedTokenPages: [0, 2, 4], + expectsPhysicalOverflow: true, }, { name: "two-line paragraph near boundary", @@ -102,9 +113,11 @@ describe("item pagination token matrix", () => { makeItem("boundary", numberedParagraphs("BOUNDARY", 52)), makeItem("paragraph", "

PARAGRAPH_001 first line
PARAGRAPH_002 second line

"), ]), - tokens: ["PARAGRAPH_001", "PARAGRAPH_002"], + allTokens: [...numberedTokens("BOUNDARY", 52), "PARAGRAPH_001", "PARAGRAPH_002"], + sampledTokens: ["PARAGRAPH_001", "PARAGRAPH_002"], expectedPages: 2, expectedTokenPages: [1, 1], + expectsPhysicalOverflow: true, }, { name: "nested bullets", @@ -114,16 +127,24 @@ describe("item pagination token matrix", () => { "
  • NESTED_001

    • NESTED_002

    • NESTED_003

", ), ]), - tokens: ["NESTED_001", "NESTED_002", "NESTED_003"], + allTokens: ["NESTED_001", "NESTED_002", "NESTED_003"], + sampledTokens: ["NESTED_001", "NESTED_002", "NESTED_003"], expectedPages: 1, expectedTokenPages: [0, 0, 0], + expectsPhysicalOverflow: false, }, - ])("preserves every token exactly once: $name", async ({ data, tokens, expectedPages, expectedTokenPages }) => { - const pages = await readPhysicalPages(await parsePdf(await renderPdf(data))); - expect(pages).toHaveLength(expectedPages); - expect(tokens.map((token) => pages.findIndex((page) => page.includes(token)))).toEqual(expectedTokenPages); - expectTokensExactlyOnce(pages, tokens); - }); + ])( + "preserves every token exactly once: $name", + async ({ data, allTokens, sampledTokens, expectedPages, expectedTokenPages, expectsPhysicalOverflow }) => { + const authoredPagesBeforeRender = structuredClone(data.metadata.layout.pages); + const pages = await readPhysicalPages(await parsePdf(await renderPdf(data))); + expect(pages).toHaveLength(expectedPages); + expect(sampledTokens.map((token) => pages.findIndex((page) => page.includes(token)))).toEqual(expectedTokenPages); + expectTokensExactlyOnce(pages, allTokens); + expect(data.metadata.layout.pages).toEqual(authoredPagesBeforeRender); + if (expectsPhysicalOverflow) expect(pages.length).toBeGreaterThan(authoredPagesBeforeRender.length); + }, + ); it("preserves built-in and custom items across an Azurill sidebar and main-column overflow", async () => { const data = makeFixture([makeItem("builtin", numberedParagraphs("BUILTIN", 150))]); @@ -156,6 +177,7 @@ describe("item pagination token matrix", () => { ]; data.metadata.layout.pages[0]?.main.push("custom-experience"); + const authoredPagesBeforeRender = structuredClone(data.metadata.layout.pages); const pages = await readPhysicalPages(await parsePdf(await renderPdf(data, "azurill"))); expect(pages).toHaveLength(5); expect( @@ -163,7 +185,9 @@ describe("item pagination token matrix", () => { pages.findIndex((page) => page.includes(token)), ), ).toEqual([0, 3, 4, 4, 0]); - expectTokensExactlyOnce(pages, ["BUILTIN_001", "BUILTIN_150", "CUSTOM_001", "CUSTOM_030", "SIDEBAR_001"]); + expectTokensExactlyOnce(pages, [...numberedTokens("BUILTIN", 150), ...numberedTokens("CUSTOM", 30), "SIDEBAR_001"]); + expect(data.metadata.layout.pages).toEqual(authoredPagesBeforeRender); + expect(pages.length).toBeGreaterThan(authoredPagesBeforeRender.length); }); it("records unsafe renderer fallback for an oversized non-wrapping item", async () => {