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/.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 new file mode 100644 index 000000000..4d428a359 --- /dev/null +++ b/packages/pdf/src/templates/shared/item-pagination.test.tsx @@ -0,0 +1,212 @@ +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 numberedTokens = (prefix: string, count: number) => + Array.from({ length: count }, (_value, index) => `${prefix}_${String(index + 1).padStart(3, "0")}`); + +const numberedParagraphs = (prefix: string, count: number) => + numberedTokens(prefix, count) + .map((token) => `

${token}

`) + .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)), + ]), + 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", + data: makeFixture([ + makeItem("boundary", numberedParagraphs("BOUNDARY", 48)), + makeItem("full-page", numberedParagraphs("FULL_PAGE", 42)), + ]), + 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))]), + 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", + data: makeFixture([ + makeItem("boundary", numberedParagraphs("BOUNDARY", 52)), + makeItem("paragraph", "

PARAGRAPH_001 first line
PARAGRAPH_002 second line

"), + ]), + allTokens: [...numberedTokens("BOUNDARY", 52), "PARAGRAPH_001", "PARAGRAPH_002"], + sampledTokens: ["PARAGRAPH_001", "PARAGRAPH_002"], + expectedPages: 2, + expectedTokenPages: [1, 1], + expectsPhysicalOverflow: true, + }, + { + name: "nested bullets", + data: makeFixture([ + makeItem( + "nested", + "
  • 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, 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))]); + 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 authoredPagesBeforeRender = structuredClone(data.metadata.layout.pages); + 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, [...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 () => { + 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"); + }); +});