From 63613ba2579d509bdef690cba072c09c8ff30591 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 5 Sep 2026 18:12:47 +0200 Subject: [PATCH] fix(pdf): preserve page breaks while rewinding list companions --- .../templates/shared/list-pagination.test.tsx | 39 +++++++++++++++++++ patches/@react-pdf__layout@5.2.0.patch | 35 ++++++++++++++--- pnpm-lock.yaml | 6 +-- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/packages/pdf/src/templates/shared/list-pagination.test.tsx b/packages/pdf/src/templates/shared/list-pagination.test.tsx index c15f21dee..8312f7bd9 100644 --- a/packages/pdf/src/templates/shared/list-pagination.test.tsx +++ b/packages/pdf/src/templates/shared/list-pagination.test.tsx @@ -163,4 +163,43 @@ describe("list marker pagination (#3344)", () => { expect(result.marker).toBe(0); expect(result.first).toBe(0); }); + it.each([ + [false, 0], + [false, 180], + [true, 0], + [true, 180], + ] as const)( + "consumes reordered marker page breaks and continues long content (RTL %s, margin %i)", + async (rtl, margin) => { + const result = await listPages( + margin, + 30, + "list-item-content { order: -1; } list-marker { break-before: page; }", + { rtl }, + ); + expect(result.first).toBe(1); + 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.each([false, true])("consumes marker-first page breaks without overflowing (RTL %s)", async (rtl) => { + const result = await listPages(180, 30, "list-marker { order: -1; break-before: page; }", { rtl }); + expect(result.first).toBe(1); + 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); + }); }); diff --git a/patches/@react-pdf__layout@5.2.0.patch b/patches/@react-pdf__layout@5.2.0.patch index 5acab8d6f..2c7541996 100644 --- a/patches/@react-pdf__layout@5.2.0.patch +++ b/patches/@react-pdf__layout@5.2.0.patch @@ -1,8 +1,20 @@ diff --git a/lib/index.js b/lib/index.js -index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..dbe7b6eaa6817e8cb0f526a68cdaa92b4ca78eb7 100644 +index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..d4471eda64ee99695bedf01f7a0098806d2fc961 100644 --- a/lib/index.js +++ b/lib/index.js -@@ -3196,7 +3196,34 @@ const splitChildren = (height, contentArea, node) => { +@@ -3163,7 +3163,10 @@ const splitNodes = (height, contentArea, nodes) => { + if (shouldSplit) { + const [currentChild, nextChild] = split(child, height, contentArea); + // All children are moved to the next page, it doesn't make sense to show the parent on the current page +- if (child.children.length > 0 && currentChild.children.length === 0) { ++ // Tagged list companions can intentionally rewind all their children. ++ // Keep their empty fragment and already-consumed break state instead ++ // of restoring the unsplit row (which can exceed a whole page). ++ if (child.children.length > 0 && currentChild.children.length === 0 && !child.props['data-resume-list-item']) { + // But if the current page is empty then we can just include the parent on the current page + if (currentChildren.length === 0) { + currentChildren.push(child, ...futureFixedNodes); +@@ -3196,7 +3199,45 @@ const splitChildren = (height, contentArea, node) => { }; const splitView = (node, height, contentArea) => { const [currentNode, nextNode] = splitNode(node, height); @@ -19,16 +31,27 @@ index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..dbe7b6eaa6817e8cb0f526a68cdaa92b + 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']); ++ const deferredMarker = nextChildren.find((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. ++ // A presence hint or page break can defer a marker after content. ++ // Defer unsplit content while retaining the marker's consumed flags. + 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']); ++ nextChildren = node.children ++ .filter((child) => child.props['data-resume-list-content'] || child.props['data-resume-list-marker']) ++ .map((child) => child.props['data-resume-list-marker'] ++ ? { ...child, props: deferredMarker.props } ++ : child); + } 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 } }; ++ // Preserve break: false after splitNodes has consumed an explicit ++ // page break; restoring the original props would replay it. ++ const nextMarker = { ++ ...marker, ++ props: deferredMarker?.props || marker.props, ++ 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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efa7940dd..dbde32e0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,7 @@ overrides: uuid@<11.1.1: ^11.1.1 patchedDependencies: - '@react-pdf/layout@5.2.0': d1e4335e8221591bf9847f5ede5bf12fc0f1b254568b303909711e36e3f7a995 + '@react-pdf/layout@5.2.0': add03fa9efd8cf4ab004a49c4c37bdf11f6d82109bfd3f6a2c48b7473aad29ae '@react-pdf/textkit': 092a6fe8baf3c472a81cdf2ab52a00ec98ef3364e1b8e744005dbcf219a8a213 importers: @@ -11198,7 +11198,7 @@ snapshots: jay-peg: 1.1.1 png-js: 2.0.0 - '@react-pdf/layout@5.2.0(patch_hash=d1e4335e8221591bf9847f5ede5bf12fc0f1b254568b303909711e36e3f7a995)': + '@react-pdf/layout@5.2.0(patch_hash=add03fa9efd8cf4ab004a49c4c37bdf11f6d82109bfd3f6a2c48b7473aad29ae)': dependencies: '@react-pdf/fns': 3.1.3 '@react-pdf/image': 3.1.2 @@ -11239,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(patch_hash=d1e4335e8221591bf9847f5ede5bf12fc0f1b254568b303909711e36e3f7a995) + '@react-pdf/layout': 5.2.0(patch_hash=add03fa9efd8cf4ab004a49c4c37bdf11f6d82109bfd3f6a2c48b7473aad29ae) '@react-pdf/primitives': 4.4.0 '@react-pdf/reconciler': 2.0.0(react@19.2.8) '@react-pdf/render': 4.7.0