mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-29 16:24:22 +10:00
test(pdf): characterize item pagination blocker
This commit is contained in:
@@ -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.
|
||||
@@ -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<typeof import("@react-pdf/renderer")>()),
|
||||
}));
|
||||
|
||||
type PdfTextItem = { str: string };
|
||||
type ParsedPdfPage = { getTextContent: () => Promise<{ items: PdfTextItem[] }> };
|
||||
type ParsedPdf = { numPages: number; getPage: (pageNumber: number) => Promise<ParsedPdfPage> };
|
||||
|
||||
const renderPdf = async (data: ResumeData, template: Template = "onyx"): Promise<Uint8Array> => {
|
||||
const renderer = await vi.importActual<typeof import("@react-pdf/renderer")>("@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<ParsedPdf> => getDocument({ data }).promise as Promise<ParsedPdf>;
|
||||
|
||||
const readPhysicalPages = async (document: ParsedPdf): Promise<string[]> => {
|
||||
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) => `<p>${prefix}_${String(index + 1).padStart(3, "0")}</p>`).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", "<p>PARAGRAPH_001 first line<br />PARAGRAPH_002 second line</p>"),
|
||||
]),
|
||||
tokens: ["PARAGRAPH_001", "PARAGRAPH_002"],
|
||||
expectedPages: 2,
|
||||
expectedTokenPages: [1, 1],
|
||||
},
|
||||
{
|
||||
name: "nested bullets",
|
||||
data: makeFixture([
|
||||
makeItem(
|
||||
"nested",
|
||||
"<ul><li><p>NESTED_001</p><ul><li><p>NESTED_002</p></li><li><p>NESTED_003</p></li></ul></li></ul>",
|
||||
),
|
||||
]),
|
||||
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<typeof import("@react-pdf/renderer")>("@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<typeof renderer.renderToBuffer>[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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user