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: `- 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: `- Outer item
- TARGET ${"Some words to fill several lines and force wrapping. ".repeat(30)} END
`,
+ });
+ 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