Skip to content
Open
7 changes: 7 additions & 0 deletions .changeset/fuzzy-pans-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@react-pdf/layout": minor
"@react-pdf/renderer": minor
"@react-pdf/types": minor
---

feat: add `breakWhenNeeded` for wrap-able nodes that should move to the next page before their first split
85 changes: 85 additions & 0 deletions break-when-needed-demo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Renders the same invoice-ish document three ways so the effect of
* breakWhenNeeded is visible side by side.
*
* node break-when-needed-demo.mjs [outputDir]
*/

import path from 'path';
import { createElement as h } from 'react';
import {
Document,
Page,
View,
Text,
renderToFile,
} from '@react-pdf/renderer';

const outDir = process.argv[2] || process.cwd();

const row = (i) =>
h(
View,
{
key: i,
style: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 6,
borderBottom: '1pt solid #dddddd',
},
},
h(Text, { style: { fontSize: 10 } }, `Position ${i + 1}`),
h(Text, { style: { fontSize: 10 } }, `${(i + 1) * 12.5} EUR`),
);

const build = ({ breakWhenNeeded, experimentalPagination }) =>
h(
Document,
null,
h(
Page,
{
size: 'A4',
style: { padding: 40 },
...(experimentalPagination ? { experimentalPagination: true } : {}),
},
// Tall enough to push the table near the bottom of page 1
h(
View,
{
style: {
height: 520,
backgroundColor: '#f2f2f2',
marginBottom: 12,
},
},
h(Text, { style: { fontSize: 12, padding: 8 } }, 'Header / cover block'),
),
// The table. Without the prop it starts splitting at the page bottom.
h(
View,
{
style: { border: '1pt solid #999999', padding: 8 },
...(breakWhenNeeded ? { breakWhenNeeded: true } : {}),
},
h(Text, { style: { fontSize: 12, marginBottom: 6 } }, 'Invoice positions'),
...Array.from({ length: 18 }, (_, i) => row(i)),
),
),
);

const cases = [
['without-breakWhenNeeded.pdf', {}],
['with-breakWhenNeeded.pdf', { breakWhenNeeded: true }],
[
'with-breakWhenNeeded-new-engine.pdf',
{ breakWhenNeeded: true, experimentalPagination: true },
],
];

for (const [name, opts] of cases) {
const file = path.join(outDir, name);
await renderToFile(build(opts), file);
console.log('wrote', file);
}
95 changes: 95 additions & 0 deletions long-table-demo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* A long invoice table that spans several pages, rendered twice:
*
* wrap - the default: the table starts on page 1 and splits
* breakWhenNeeded - the table waits for page 2, then splits as usual
*
* Renders both PDFs and prints how the rows ended up distributed, so the
* difference is visible without opening anything.
*
* node long-table-demo.mjs [outputDir] [rowCount]
*/

import fs from 'fs';
import path from 'path';
import { createElement as h } from 'react';
import { Document, Page, View, Text, renderToFile } from '@react-pdf/renderer';
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';

const outDir = process.argv[2] || process.cwd();
const ROWS = Number(process.argv[3] || 120);

const row = (i) =>
h(
View,
{
key: i,
style: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 6,
borderBottom: '1pt solid #dddddd',
},
},
h(Text, { style: { fontSize: 10 } }, `Position ${i + 1}`),
h(Text, { style: { fontSize: 10 } }, `${((i + 1) * 12.5).toFixed(2)} EUR`),
);

const build = (tableProps) =>
h(
Document,
null,
h(
Page,
{ size: 'A4', style: { padding: 40 } },
// Tall cover block: leaves only a sliver of page 1 for the table
h(
View,
{
style: {
height: 520,
backgroundColor: '#f2f2f2',
marginBottom: 12,
},
},
h(Text, { style: { fontSize: 12, padding: 8 } }, 'Header / cover block'),
),
h(
View,
{ style: { border: '1pt solid #999999', padding: 8 }, ...tableProps },
h(
Text,
{ style: { fontSize: 12, marginBottom: 6 } },
`Invoice positions (${ROWS} rows)`,
),
...Array.from({ length: ROWS }, (_, i) => row(i)),
),
),
);

const report = async (label, file) => {
const data = new Uint8Array(fs.readFileSync(file));
const doc = await getDocument({ data, verbosity: 0 }).promise;

const perPage = [];
for (let i = 1; i <= doc.numPages; i += 1) {
const page = await doc.getPage(i);
const items = (await page.getTextContent()).items.map((it) => it.str);
perPage.push(items.filter((s) => s.startsWith('Position')).length);
}

console.log(
`${label.padEnd(16)} ${doc.numPages} pages | rows per page: [${perPage.join(', ')}]`,
);
};

const cases = [
['wrap', { wrap: false }],
['breakWhenNeeded', { wrap: true, breakWhenNeeded: true }],
];

for (const [label, props] of cases) {
const file = path.join(outDir, `long-table-${label}.pdf`);
await renderToFile(build(props), file);
await report(label, file);
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@
"vitest-fetch-mock": "^0.2.2"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"yarn lint",
"packages/**/*.{js,jsx,ts,tsx}": [
"eslint",
"prettier --write"
]
},
Expand Down
9 changes: 9 additions & 0 deletions packages/image/src/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,20 @@ const isDataImageSrc = (src: ImageSrc): src is DataImageSrc => {
const isDataUri = (imageSrc: ImageSrc): imageSrc is Base64ImageSrc =>
'uri' in imageSrc && imageSrc.uri.startsWith('data:');

// Windows drive-letter paths (C:\foo, c:/foo) parse as a URL whose protocol is
// "c:", so they must be recognised before the URL handling below rejects them
// as non-local. Gated on the platform: on POSIX "c:/foo" is not an absolute
// path but an ordinary relative filename, and must keep falling through.
const isWindowsAbsolutePath = (src: string) =>
path.sep === '\\' && /^[a-zA-Z]:[\\/]/.test(src);

const getAbsoluteLocalPath = (src: string) => {
if (BROWSER) {
throw new Error('Cannot check local paths in client-side environment');
}

if (isWindowsAbsolutePath(src)) return path.resolve(src);

try {
const parsed = new URL(src);

Expand Down
24 changes: 24 additions & 0 deletions packages/layout/src/node/shouldBreak.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ import { SafeNode } from '../types';
import getWrap from './getWrap';
import isFixed from './isFixed';

// Mirrors SAFETY_THRESHOLD in resolvePagination. An overflow smaller than this
// is not split there, so it must not cost a whole page here either.
const SAFETY_THRESHOLD = 0.001;

const getBreak = (node: SafeNode) =>
'break' in node.props ? node.props.break : false;

const getBreakWhenNeeded = (node: SafeNode) =>
'breakWhenNeeded' in node.props ? node.props.breakWhenNeeded : false;

const getMinPresenceAhead = (node: SafeNode) =>
'minPresenceAhead' in node.props ? node.props.minPresenceAhead : 0;

Expand Down Expand Up @@ -40,6 +47,7 @@ const shouldBreak = (
futureElements: SafeNode[],
height: number,
previousElements: SafeNode[],
contentAbove = false,
) => {
if ('fixed' in child.props) return false;

Expand All @@ -53,10 +61,26 @@ const shouldBreak = (
// (as long as react-pdf does not support breaking into differently sized containers)
const breakingImprovesPresence =
previousElements.filter((node: SafeNode) => !isFixed(node)).length > 0;
// Unlike minPresenceAhead, moving also pays off for a node that is the first
// child of its container, as long as something sits above that container on
// the page — it still gains a full page by waiting for the next one.
const movingImprovesPresence = contentAbove || breakingImprovesPresence;

// Use the same tolerance as resolvePagination, so an overflow it would leave
// alone never moves the node to the next page.
const overflowsPage =
height + SAFETY_THRESHOLD < child.box.top + child.box.height;

const shouldBreakWhenNeeded =
overflowsPage &&
canWrap &&
getBreakWhenNeeded(child) &&
movingImprovesPresence;

return (
getBreak(child) ||
(shouldSplit && !canWrap) ||
shouldBreakWhenNeeded ||
(!shouldSplit && endOfPresence > height && breakingImprovesPresence)
);
};
Expand Down
15 changes: 15 additions & 0 deletions packages/layout/src/paginate/toFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,26 @@ const absoluteOf = (node: SafeNode, ctx: PageCtx): FlowNode => {
return { box: boxOf(node), id: node.type, data: node, absolute: true };
};

// This engine has no notion of "do not split on the first attempt", so
// breakWhenNeeded cannot be honoured here. Say so once instead of silently
// laying the node out as if the prop were absent.
let warnedBreakWhenNeeded = false;

const warnBreakWhenNeededUnsupported = () => {
if (warnedBreakWhenNeeded) return;
warnedBreakWhenNeeded = true;
console.warn(
'breakWhenNeeded is not supported by the experimental pagination engine and will be ignored; the node splits as if the prop were absent.',
);
};

// Flags apply where a node enters a flow, never on split or materialized
// fragments.
const withFlags = (child: SafeNode, node: FlowNode): FlowNode => {
const presence = (child.props as any).minPresenceAhead;

if ((child.props as any).breakWhenNeeded) warnBreakWhenNeededUnsupported();

return {
...node,
...(isFixed(child) ? { repeat: true } : {}),
Expand Down
47 changes: 40 additions & 7 deletions packages/layout/src/steps/resolvePagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,16 @@ const warnUnavailableSpace = (node: SafeNode) => {
);
};

const splitNodes = (height: number, contentArea: number, nodes: SafeNode[]) => {
// `contentAbove` says whether anything is already placed above this group on
// the current page. Nested groups need it: a node can be the first child of its
// container and still gain a page by moving, when the container itself is not
// at the top of the page.
const splitNodes = (
height: number,
contentArea: number,
nodes: SafeNode[],
contentAbove = false,
) => {
const currentChildren: SafeNode[] = [];
const nextChildren: SafeNode[] = [];

Expand All @@ -61,6 +70,7 @@ const splitNodes = (height: number, contentArea: number, nodes: SafeNode[]) => {
futureNodes,
height,
currentChildren,
contentAbove,
);
const shouldSplit = height + SAFETY_THRESHOLD < nodeTop + nodeHeight;
const canWrap = canNodeWrap(child);
Expand Down Expand Up @@ -100,7 +110,12 @@ const splitNodes = (height: number, contentArea: number, nodes: SafeNode[]) => {
}

if (shouldSplit) {
const [currentChild, nextChild] = split(child, height, contentArea);
const [currentChild, nextChild] = split(
child,
height,
contentArea,
contentAbove || currentChildren.some((node) => !isFixed(node)),
);

// 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) {
Expand Down Expand Up @@ -132,18 +147,29 @@ const splitNodes = (height: number, contentArea: number, nodes: SafeNode[]) => {
return [currentChildren, nextChildren];
};

const splitChildren = (height: number, contentArea: number, node: SafeNode) => {
const splitChildren = (
height: number,
contentArea: number,
node: SafeNode,
contentAbove: boolean,
) => {
const children = node.children || [];
const availableHeight = height - getTop(node);
return splitNodes(availableHeight, contentArea, children);
return splitNodes(availableHeight, contentArea, children, contentAbove);
};

const splitView = (node: SafeNode, height: number, contentArea: number) => {
const splitView = (
node: SafeNode,
height: number,
contentArea: number,
contentAbove: boolean,
) => {
const [currentNode, nextNode] = splitNode(node, height);
const [currentChilds, nextChildren] = splitChildren(
height,
contentArea,
node,
contentAbove,
);

return [
Expand All @@ -152,8 +178,15 @@ const splitView = (node: SafeNode, height: number, contentArea: number) => {
];
};

const split = (node: SafeNode, height: number, contentArea: number) =>
isText(node) ? splitText(node, height) : splitView(node, height, contentArea);
const split = (
node: SafeNode,
height: number,
contentArea: number,
contentAbove: boolean,
) =>
isText(node)
? splitText(node, height)
: splitView(node, height, contentArea, contentAbove);

const shouldResolveDynamicNodes = (node: SafeNode) => {
const children = node.children || [];
Expand Down
Loading