From 51ac77295e77512afa4fed1b562fb18de133f544 Mon Sep 17 00:00:00 2001 From: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:25:48 +0530 Subject: [PATCH] fix(pdf): preserve list indentation on continuation pages (#3495) (#3497) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Amruth Pillai --- .../templates/shared/list-pagination.test.tsx | 43 +++++++++++++++++++ patches/@react-pdf__layout@5.2.0.patch | 26 ++++++++++- pnpm-lock.yaml | 6 +-- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/pdf/src/templates/shared/list-pagination.test.tsx b/packages/pdf/src/templates/shared/list-pagination.test.tsx index c37f92f57..2359fcbdc 100644 --- a/packages/pdf/src/templates/shared/list-pagination.test.tsx +++ b/packages/pdf/src/templates/shared/list-pagination.test.tsx @@ -42,16 +42,23 @@ async function listPages( try { const doc = await task.promise; const pages: string[][] = []; + const positions: { str: string; x: number; y: number }[][] = []; 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] : []))); + positions.push( + text.items.flatMap((item) => + "str" in item && item.str ? [{ str: item.str, x: item.transform[4], y: item.transform[5] }] : [], + ), + ); } 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, + positions, }; } finally { await task.destroy(); @@ -256,4 +263,40 @@ describe("list marker pagination (#3344)", () => { .match(/\bSome\b/g), ).toHaveLength(30); }); + it.each(["ul", "ol"])("keeps %s continuation lines at the content indentation", async (tag) => { + const result = await listPages(0, 60, "", { + html: `<${tag}>
  • TARGET ${"Some words to fill several lines and force wrapping. ".repeat(60)} END

  • `, + }); + expect(result.marker).toBe(result.first); + expect(result.last).toBeGreaterThan(result.first); + // The widest x on the item's first page is its content column; the marker + // and the section heading sit further left. Continuation pages must keep it. + const contentX = Math.max(...(result.positions[result.first] ?? []).map((item) => item.x)); + for (const page of result.positions.slice(result.first + 1)) { + for (const item of page) expect(item.x).toBeCloseTo(contentX, 0); + } + }); + it("keeps a nested list's inner indentation on continuation pages", async () => { + const result = await listPages(0, 1, "", { + html: ``, + }); + expect(result.last).toBeGreaterThan(result.first); + const contentX = Math.max(...(result.positions[result.first] ?? []).map((item) => item.x)); + for (const page of result.positions.slice(result.first + 1)) { + for (const item of page) expect(item.x).toBeCloseTo(contentX, 0); + } + }); + it("preserves a widened ordered-marker column on continuation pages", async () => { + const result = await listPages(0, 40, "list-marker { font-size: 24pt; }", { + html: `
    1. TARGET ${"Some words to fill several lines and force wrapping. ".repeat(40)} END

    `, + }); + expect(result.marker).toBe(result.first); + expect(result.last).toBeGreaterThan(result.first); + const contentX = Math.max(...(result.positions[result.first] ?? []).map((item) => item.x)); + // The 24pt marker pushes the content column well past the default gutter. + expect(contentX).toBeGreaterThan(30); + for (const page of result.positions.slice(result.first + 1)) { + for (const item of page) expect(item.x).toBeCloseTo(contentX, 0); + } + }); }); diff --git a/patches/@react-pdf__layout@5.2.0.patch b/patches/@react-pdf__layout@5.2.0.patch index b752e32a9..092dd3dae 100644 --- a/patches/@react-pdf__layout@5.2.0.patch +++ b/patches/@react-pdf__layout@5.2.0.patch @@ -1,5 +1,5 @@ diff --git a/lib/index.js b/lib/index.js -index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..8e6117947e87e40dd39f1bf33a1435194cc3f334 100644 +index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..716ca874d496d40e0709236d4d7fe828a77088a7 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1887,7 +1887,7 @@ const isTextInstance$2 = (node) => node.type === P.TextInstance; @@ -49,7 +49,7 @@ index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..8e6117947e87e40dd39f1bf33a143519 // 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 +3202,52 @@ const splitChildren = (height, contentArea, node) => { +@@ -3196,7 +3202,74 @@ const splitChildren = (height, contentArea, node) => { }; const splitView = (node, height, contentArea) => { const [currentNode, nextNode] = splitNode(node, height); @@ -99,6 +99,28 @@ index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..8e6117947e87e40dd39f1bf33a143519 + if (markerIndex < contentIndex) nextChildren.unshift(nextMarker); + else nextChildren.push(nextMarker); + } ++ if (markerIndex !== -1 && !hasMarker(nextChildren) && hasText(content(nextChildren))) { ++ // The marker was fully consumed on an earlier fragment while the ++ // content continues. Page relayout runs Yoga on the remaining ++ // children only, so a missing marker collapses the flex gutter and ++ // continuation lines shift to the row's left edge. Re-seat an ++ // empty marker column pinned to the width Yoga already measured, ++ // preserving the authored indentation without drawing a marker. ++ const marker = node.children[markerIndex]; ++ const ghostMarker = { ++ ...marker, ++ children: [], ++ lines: [], ++ box: { ...marker.box, top: 0, height: 0 }, ++ props: { ...marker.props, break: false, minPresenceAhead: 0 }, ++ style: { ++ ...marker.style, ++ ...(typeof marker.box?.width === 'number' ? { width: marker.box.width } : {}), ++ }, ++ }; ++ if (markerIndex < contentIndex) nextChildren.unshift(ghostMarker); ++ else nextChildren.push(ghostMarker); ++ } + } return [ assingChildren(currentChilds, currentNode), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a3041080..d68e1ba96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,7 +113,7 @@ overrides: uuid@<11.1.1: ^11.1.1 patchedDependencies: - '@react-pdf/layout@5.2.0': 0b6c66a14ff45a9b594fcd6c207fcb72dc9608384451803359d36bc8ec5c1245 + '@react-pdf/layout@5.2.0': 0409efd0fcb399ac0b7e962b4a7f0ab763c82317001a6637b71216cf2fc480b0 '@react-pdf/textkit': e1e963cedc6a2f322b4309c782bba317d2b49bad73da787321151a89491e5d62 fontkit@2.0.4: 90f4c51c676a88b91dcc397d60a677c77b5e6dc8e15ffb3538310965ef5c05a4 react-pdf-html@2.1.5: e8456fc885241f35e3b642ad6122015b88f855d717663793f5b3293c19afac9b @@ -11360,7 +11360,7 @@ snapshots: jay-peg: 1.1.1 png-js: 2.0.0 - '@react-pdf/layout@5.2.0(patch_hash=0b6c66a14ff45a9b594fcd6c207fcb72dc9608384451803359d36bc8ec5c1245)': + '@react-pdf/layout@5.2.0(patch_hash=0409efd0fcb399ac0b7e962b4a7f0ab763c82317001a6637b71216cf2fc480b0)': dependencies: '@react-pdf/fns': 3.1.3 '@react-pdf/image': 3.1.2 @@ -11401,7 +11401,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=0b6c66a14ff45a9b594fcd6c207fcb72dc9608384451803359d36bc8ec5c1245) + '@react-pdf/layout': 5.2.0(patch_hash=0409efd0fcb399ac0b7e962b4a7f0ab763c82317001a6637b71216cf2fc480b0) '@react-pdf/primitives': 4.4.0 '@react-pdf/reconciler': 2.0.0(react@19.2.8) '@react-pdf/render': 4.7.0