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 <im.amruth@gmail.com>
This commit is contained in:
Santhi Prakash
2026-09-09 11:55:48 +02:00
committed by GitHub
co-authored by coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Amruth Pillai
parent c0c658c00c
commit 51ac77295e
3 changed files with 70 additions and 5 deletions
@@ -42,16 +42,23 @@ async function listPages(
try { try {
const doc = await task.promise; const doc = await task.promise;
const pages: string[][] = []; const pages: string[][] = [];
const positions: { str: string; x: number; y: number }[][] = [];
for (let n = 1; n <= doc.numPages; n++) { for (let n = 1; n <= doc.numPages; n++) {
const page = await doc.getPage(n); const page = await doc.getPage(n);
const text = await page.getTextContent(); const text = await page.getTextContent();
pages.push(text.items.flatMap((item) => ("str" in item && item.str ? [item.str] : []))); 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 { return {
marker: pages.findIndex((p) => p.some((s) => s.includes("•") || s.startsWith("1."))), marker: pages.findIndex((p) => p.some((s) => s.includes("•") || s.startsWith("1."))),
first: pages.findIndex((p) => p.some((s) => s.includes("TARGET"))), first: pages.findIndex((p) => p.some((s) => s.includes("TARGET"))),
last: pages.findIndex((p) => p.some((s) => s.includes("END"))), last: pages.findIndex((p) => p.some((s) => s.includes("END"))),
pages, pages,
positions,
}; };
} finally { } finally {
await task.destroy(); await task.destroy();
@@ -256,4 +263,40 @@ describe("list marker pagination (#3344)", () => {
.match(/\bSome\b/g), .match(/\bSome\b/g),
).toHaveLength(30); ).toHaveLength(30);
}); });
it.each(["ul", "ol"])("keeps %s continuation lines at the content indentation", async (tag) => {
const result = await listPages(0, 60, "", {
html: `<${tag}><li><p>TARGET ${"Some words to fill several lines and force wrapping. ".repeat(60)} END</p></li></${tag}>`,
});
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: `<ul><li>Outer intro<ul><li><p>TARGET ${"Some words to fill several lines and force wrapping. ".repeat(40)} END</p></li></ul></li></ul>`,
});
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: `<ol><li><p>TARGET ${"Some words to fill several lines and force wrapping. ".repeat(40)} END</p></li></ol>`,
});
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);
}
});
}); });
+24 -2
View File
@@ -1,5 +1,5 @@
diff --git a/lib/index.js b/lib/index.js diff --git a/lib/index.js b/lib/index.js
index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..8e6117947e87e40dd39f1bf33a1435194cc3f334 100644 index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..716ca874d496d40e0709236d4d7fe828a77088a7 100644
--- a/lib/index.js --- a/lib/index.js
+++ b/lib/index.js +++ b/lib/index.js
@@ -1887,7 +1887,7 @@ const isTextInstance$2 = (node) => node.type === P.TextInstance; @@ -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 // But if the current page is empty then we can just include the parent on the current page
if (currentChildren.length === 0) { if (currentChildren.length === 0) {
currentChildren.push(child, ...futureFixedNodes); 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 splitView = (node, height, contentArea) => {
const [currentNode, nextNode] = splitNode(node, height); const [currentNode, nextNode] = splitNode(node, height);
@@ -99,6 +99,28 @@ index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..8e6117947e87e40dd39f1bf33a143519
+ if (markerIndex < contentIndex) nextChildren.unshift(nextMarker); + if (markerIndex < contentIndex) nextChildren.unshift(nextMarker);
+ else nextChildren.push(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 [ return [
assingChildren(currentChilds, currentNode), assingChildren(currentChilds, currentNode),
+3 -3
View File
@@ -113,7 +113,7 @@ overrides:
uuid@<11.1.1: ^11.1.1 uuid@<11.1.1: ^11.1.1
patchedDependencies: patchedDependencies:
'@react-pdf/layout@5.2.0': 0b6c66a14ff45a9b594fcd6c207fcb72dc9608384451803359d36bc8ec5c1245 '@react-pdf/layout@5.2.0': 0409efd0fcb399ac0b7e962b4a7f0ab763c82317001a6637b71216cf2fc480b0
'@react-pdf/textkit': e1e963cedc6a2f322b4309c782bba317d2b49bad73da787321151a89491e5d62 '@react-pdf/textkit': e1e963cedc6a2f322b4309c782bba317d2b49bad73da787321151a89491e5d62
fontkit@2.0.4: 90f4c51c676a88b91dcc397d60a677c77b5e6dc8e15ffb3538310965ef5c05a4 fontkit@2.0.4: 90f4c51c676a88b91dcc397d60a677c77b5e6dc8e15ffb3538310965ef5c05a4
react-pdf-html@2.1.5: e8456fc885241f35e3b642ad6122015b88f855d717663793f5b3293c19afac9b react-pdf-html@2.1.5: e8456fc885241f35e3b642ad6122015b88f855d717663793f5b3293c19afac9b
@@ -11360,7 +11360,7 @@ snapshots:
jay-peg: 1.1.1 jay-peg: 1.1.1
png-js: 2.0.0 png-js: 2.0.0
'@react-pdf/layout@5.2.0(patch_hash=0b6c66a14ff45a9b594fcd6c207fcb72dc9608384451803359d36bc8ec5c1245)': '@react-pdf/layout@5.2.0(patch_hash=0409efd0fcb399ac0b7e962b4a7f0ab763c82317001a6637b71216cf2fc480b0)':
dependencies: dependencies:
'@react-pdf/fns': 3.1.3 '@react-pdf/fns': 3.1.3
'@react-pdf/image': 3.1.2 '@react-pdf/image': 3.1.2
@@ -11401,7 +11401,7 @@ snapshots:
'@babel/runtime': 7.29.7 '@babel/runtime': 7.29.7
'@react-pdf/fns': 3.1.3 '@react-pdf/fns': 3.1.3
'@react-pdf/font': 4.1.2 '@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/primitives': 4.4.0
'@react-pdf/reconciler': 2.0.0(react@19.2.8) '@react-pdf/reconciler': 2.0.0(react@19.2.8)
'@react-pdf/render': 4.7.0 '@react-pdf/render': 4.7.0