diff --git a/packages/pdf/src/templates/shared/list-pagination.test.tsx b/packages/pdf/src/templates/shared/list-pagination.test.tsx new file mode 100644 index 000000000..c15f21dee --- /dev/null +++ b/packages/pdf/src/templates/shared/list-pagination.test.tsx @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { renderToBuffer } from "@react-pdf/renderer"; +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; +import { act } from "react"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { ResumeDocument } from "../../document"; +import { resolveResumeRuntime } from "../../semantic/resolve"; + +async function listPages( + margin: number, + repeats = 5, + css = "", + options: { html?: string; rtl?: boolean; multipleParagraphs?: boolean } = {}, +) { + const data = structuredClone(defaultResumeData); + data.metadata.typography.body.fontFamily = "Helvetica"; + data.metadata.typography.heading.fontFamily = "Helvetica"; + if (options.rtl) data.metadata.page.locale = "ar-SA"; + data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }]; + data.summary.title = "Summary"; + // Normalization unwraps a lone paragraph in a list item. Two paragraphs retain + // real paragraph nodes so authored orphans are exercised by the renderer. + data.summary.content = + options.html ?? + ``; + data.metadata.stylesheet = { + mode: "semantic", + source: { + languageVersion: 1, + text: `@version 1; page { size: 300pt 300pt; } rich-text { margin-top: ${margin}pt; } ${css}`, + }, + }; + const runtime = resolveResumeRuntime({ data, template: "onyx", mode: "semantic" }); + expect(runtime.diagnostics).toEqual([]); + const bytes = await act(() => renderToBuffer()); + const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: true }); + try { + const doc = await task.promise; + const pages: string[][] = []; + for (let n = 1; n <= doc.numPages; n++) { + const page = await doc.getPage(n); + const text = await page.getTextContent(); + pages.push(text.items.flatMap((item) => ("str" in item && item.str ? [item.str] : []))); + } + return { + marker: pages.findIndex((p) => p.some((s) => s.includes("•") || s.startsWith("1."))), + first: pages.findIndex((p) => p.some((s) => s.includes("TARGET"))), + last: pages.findIndex((p) => p.some((s) => s.includes("END"))), + pages, + }; + } finally { + await task.destroy(); + } +} + +describe("list marker pagination (#3344)", () => { + it("moves a bullet with its first paragraph when the paragraph cannot start on this page", async () => { + const result = await listPages(194); + expect(result.first).toBe(1); + expect(result.marker).toBe(result.first); + }); + it("keeps content on the current page when the first paragraph can start", async () => { + const result = await listPages(192); + expect(result.first).toBe(0); + expect(result.marker).toBe(result.first); + }); + it("allows a long list item to continue across pages", async () => { + const result = await listPages(194, 30); + expect(result.marker).toBe(result.first); + expect(result.last).toBeGreaterThan(result.first); + expect(result.pages.flat().join(" ").match(/•/g)).toHaveLength(1); + expect( + result.pages + .flat() + .join(" ") + .match(/\bSome\b/g), + ).toHaveLength(30); + }); + it("respects authored paragraph orphan counts", async () => { + const result = await listPages(190, 30, "paragraph { orphans: 3; }", { multipleParagraphs: true }); + expect(result.marker).toBe(result.first); + expect(result.last).toBeGreaterThan(result.first); + }); + it("allows the first line when the author requests one orphan line", async () => { + const result = await listPages(194, 30, "paragraph { orphans: 1; }", { multipleParagraphs: true }); + expect(result.first).toBe(0); + expect(result.marker).toBe(result.first); + }); + it.each([180, 190, 192, 194, 200, 208, 215])( + "keeps reordered list markers with content at margin %i", + async (margin) => { + const result = await listPages(margin, 30, "list-item-content { order: -1; }"); + expect(result.marker).toBe(result.first); + }, + ); + it.each([180, 190, 192, 194, 200, 208, 215])("keeps RTL markers with content at margin %i", async (margin) => { + const result = await listPages(margin, 30, "", { rtl: true }); + expect(result.marker).toBe(result.first); + }); + it("keeps ordered markers with first text", async () => { + const result = await listPages(194, 30, "", { + html: `
  1. TARGET ${"Some words to fill several lines and force wrapping. ".repeat(30)} END
`, + }); + expect(result.marker).toBe(result.first); + }); + it("does not add a marker when Semantic CSS hides it", async () => { + const result = await listPages(194, 30, "list-marker { display: none; }"); + expect(result.marker).toBe(-1); + expect(result.first).toBe(1); + }); + it("respects an explicit marker presence override", async () => { + const result = await listPages(180, 30, "list-marker { -resume-min-presence-ahead: 60pt; }"); + expect(result.first).toBe(1); + expect(result.marker).toBe(result.first); + }); + it("keeps a nested list marker with its first text", async () => { + const result = await listPages(179, 30, "", { + html: ``, + }); + const markerPages = result.pages.flatMap((page, index) => + page.flatMap((text) => (text.includes("•") ? [index] : [])), + ); + expect(markerPages.at(1)).toBe(result.first); + }); + it("uses the visible list item's orphan count after filtering", async () => { + const result = await listPages(194, 30, "list-item:first-child { display: none; } paragraph { orphans: 1; }", { + html: ``, + }); + expect(result.first).toBe(0); + expect(result.marker).toBe(result.first); + expect(result.pages.flat().join(" ")).not.toContain("Hidden first item"); + }); + it("uses the first rendered paragraph after semantic reordering", async () => { + const result = await listPages(194, 30, "paragraph:last-child { order: -1; orphans: 1; }", { + html: ``, + }); + expect(result.first).toBe(0); + expect(result.marker).toBe(result.first); + }); + it("keeps the marker with a paragraph using larger text", async () => { + const result = await listPages(180, 30, "paragraph { font-size: 15pt; orphans: 2; }", { multipleParagraphs: true }); + expect(result.marker).toBe(result.first); + }); + it.each([false, true])("keeps explicitly deferred markers with reordered content (RTL %s)", async (rtl) => { + const result = await listPages( + 180, + 30, + "list-item-content { order: -1; } list-marker { -resume-min-presence-ahead: 60pt; }", + { rtl }, + ); + expect(result.first).toBe(1); + expect(result.marker).toBe(result.first); + expect( + result.pages + .flat() + .join(" ") + .match(/\bSome\b/g), + ).toHaveLength(30); + }); + it("keeps short content and its marker on one page", async () => { + const result = await listPages(0, 1); + expect(result.pages).toHaveLength(1); + expect(result.marker).toBe(0); + expect(result.first).toBe(0); + }); +}); diff --git a/packages/pdf/src/templates/shared/rich-text.tsx b/packages/pdf/src/templates/shared/rich-text.tsx index 2f3f7c213..4ba2125a7 100644 --- a/packages/pdf/src/templates/shared/rich-text.tsx +++ b/packages/pdf/src/templates/shared/rich-text.tsx @@ -248,14 +248,14 @@ export const RichText = ({ children, semanticField }: RichTextProps) => { const itemStyles = toRichTextStyleArray(style); const contentItemStyles = itemStyles.map(stripRichTextVerticalMargins); + // The scoped @react-pdf/layout patch keeps these companions together using + // actual text fragments, including authored orphan counts and fallback fonts. const markerNode = ( {marker} @@ -265,6 +265,7 @@ export const RichText = ({ children, semanticField }: RichTextProps) => { const contentNode = rtl ? ( { ) : ( { // (works fine for split-row/contact-list). Swap DOM order to position the marker. return ( { itemResolved.style, )} > + {/* React PDF only honors an authored presence hint after a preceding sibling. */} + {markerResolved.minPresenceAhead ? : null} {renderedChildren} ); diff --git a/patches/@react-pdf__layout@5.2.0.patch b/patches/@react-pdf__layout@5.2.0.patch new file mode 100644 index 000000000..5acab8d6f --- /dev/null +++ b/patches/@react-pdf__layout@5.2.0.patch @@ -0,0 +1,40 @@ +diff --git a/lib/index.js b/lib/index.js +index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..dbe7b6eaa6817e8cb0f526a68cdaa92b4ca78eb7 100644 +--- a/lib/index.js ++++ b/lib/index.js +@@ -3196,7 +3196,34 @@ const splitChildren = (height, contentArea, node) => { + }; + const splitView = (node, height, contentArea) => { + const [currentNode, nextNode] = splitNode(node, height); +- const [currentChilds, nextChildren] = splitChildren(height, contentArea, node); ++ let [currentChilds, nextChildren] = splitChildren(height, contentArea, node); ++ // Reactive Resume opts list rows into companion pagination. Use the text ++ // fragments after orphan/widow splitting, not estimated font line heights. ++ // Untagged rows and ordinary absolutely positioned nodes are unaffected. ++ if (node.props['data-resume-list-item']) { ++ const hasText = (child) => child && (child.type === P.Text ++ ? child.lines?.length > 0 ++ : child.children?.some(hasText)); ++ const content = (children) => children.find((child) => child.props['data-resume-list-content']); ++ const markerIndex = node.children.findIndex((child) => child.props['data-resume-list-marker']); ++ const contentIndex = node.children.findIndex((child) => child.props['data-resume-list-content']); ++ const hasMarker = (children) => children.some((child) => child.props['data-resume-list-marker']); ++ if (markerIndex !== -1 && !hasMarker(currentChilds) && hasMarker(nextChildren) && hasText(content(currentChilds))) { ++ // An authored presence hint can defer a marker that follows content ++ // in RTL or semantic order. Defer the unsplit content with it. ++ currentChilds = currentChilds.filter((child) => !child.props['data-resume-list-content']); ++ nextChildren = node.children.filter((child) => child.props['data-resume-list-content'] || child.props['data-resume-list-marker']); ++ } else if (markerIndex !== -1 && !hasText(content(currentChilds)) && hasText(content(nextChildren))) { ++ // The first text did not fit. Preserve the authored marker/content ++ // order when moving the marker to the next fragment of this row. ++ const marker = node.children[markerIndex]; ++ const nextMarker = { ...marker, box: { ...marker.box, top: 0 } }; ++ currentChilds = currentChilds.filter((child) => !child.props['data-resume-list-marker']); ++ nextChildren = nextChildren.filter((child) => !child.props['data-resume-list-marker']); ++ if (markerIndex < contentIndex) nextChildren.unshift(nextMarker); ++ else nextChildren.push(nextMarker); ++ } ++ } + return [ + assingChildren(currentChilds, currentNode), + assingChildren(nextChildren, nextNode), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e510c21f..efa7940dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,7 @@ overrides: uuid@<11.1.1: ^11.1.1 patchedDependencies: + '@react-pdf/layout@5.2.0': d1e4335e8221591bf9847f5ede5bf12fc0f1b254568b303909711e36e3f7a995 '@react-pdf/textkit': 092a6fe8baf3c472a81cdf2ab52a00ec98ef3364e1b8e744005dbcf219a8a213 importers: @@ -11197,7 +11198,7 @@ snapshots: jay-peg: 1.1.1 png-js: 2.0.0 - '@react-pdf/layout@5.2.0': + '@react-pdf/layout@5.2.0(patch_hash=d1e4335e8221591bf9847f5ede5bf12fc0f1b254568b303909711e36e3f7a995)': dependencies: '@react-pdf/fns': 3.1.3 '@react-pdf/image': 3.1.2 @@ -11238,7 +11239,7 @@ snapshots: '@babel/runtime': 7.29.7 '@react-pdf/fns': 3.1.3 '@react-pdf/font': 4.1.2 - '@react-pdf/layout': 5.2.0 + '@react-pdf/layout': 5.2.0(patch_hash=d1e4335e8221591bf9847f5ede5bf12fc0f1b254568b303909711e36e3f7a995) '@react-pdf/primitives': 4.4.0 '@react-pdf/reconciler': 2.0.0(react@19.2.8) '@react-pdf/render': 4.7.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b5b807613..5d5b6d2b2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,4 +17,5 @@ overrides: samlify@<2.13.0: ^2.13.0 uuid@<11.1.1: ^11.1.1 patchedDependencies: + '@react-pdf/layout@5.2.0': patches/@react-pdf__layout@5.2.0.patch '@react-pdf/textkit': patches/@react-pdf__textkit.patch