diff --git a/.changeset/fuzzy-pans-happen.md b/.changeset/fuzzy-pans-happen.md new file mode 100644 index 000000000..bfa8a7850 --- /dev/null +++ b/.changeset/fuzzy-pans-happen.md @@ -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 diff --git a/break-when-needed-demo.mjs b/break-when-needed-demo.mjs new file mode 100644 index 000000000..d23e5b142 --- /dev/null +++ b/break-when-needed-demo.mjs @@ -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); +} diff --git a/long-table-demo.mjs b/long-table-demo.mjs new file mode 100644 index 000000000..cf5e86f67 --- /dev/null +++ b/long-table-demo.mjs @@ -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); +} diff --git a/package.json b/package.json index 32ee0a3d8..8a5823dae 100644 --- a/package.json +++ b/package.json @@ -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" ] }, diff --git a/packages/image/src/resolve.ts b/packages/image/src/resolve.ts index fb8ebde63..6fa28bbe2 100644 --- a/packages/image/src/resolve.ts +++ b/packages/image/src/resolve.ts @@ -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); diff --git a/packages/layout/src/node/shouldBreak.ts b/packages/layout/src/node/shouldBreak.ts index eaffe48f4..e6fa47de2 100644 --- a/packages/layout/src/node/shouldBreak.ts +++ b/packages/layout/src/node/shouldBreak.ts @@ -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; @@ -40,6 +47,7 @@ const shouldBreak = ( futureElements: SafeNode[], height: number, previousElements: SafeNode[], + contentAbove = false, ) => { if ('fixed' in child.props) return false; @@ -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) ); }; diff --git a/packages/layout/src/paginate/toFlow.ts b/packages/layout/src/paginate/toFlow.ts index 01de2b391..d9cf7822e 100644 --- a/packages/layout/src/paginate/toFlow.ts +++ b/packages/layout/src/paginate/toFlow.ts @@ -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 } : {}), diff --git a/packages/layout/src/steps/resolvePagination.ts b/packages/layout/src/steps/resolvePagination.ts index 9e679ecc6..d17a9e5f1 100644 --- a/packages/layout/src/steps/resolvePagination.ts +++ b/packages/layout/src/steps/resolvePagination.ts @@ -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[] = []; @@ -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); @@ -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) { @@ -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 [ @@ -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 || []; diff --git a/packages/layout/src/types/base.ts b/packages/layout/src/types/base.ts index 3fd49cb2c..4092914eb 100644 --- a/packages/layout/src/types/base.ts +++ b/packages/layout/src/types/base.ts @@ -76,6 +76,12 @@ export type NodeProps = { * @see https://react-pdf.org/advanced#page-breaks */ break?: boolean; + /** + * Move the element to the next page when it would otherwise start splitting + * in the remaining space, while still allowing it to continue across later + * pages. + */ + breakWhenNeeded?: boolean; /** * Hint that no page wrapping should occur between all sibling elements following the element within n points * @see https://react-pdf.org/advanced#orphan-&-widow-protection diff --git a/packages/layout/tests/node/shouldBreak.test.ts b/packages/layout/tests/node/shouldBreak.test.ts index 665881515..e746d903a 100644 --- a/packages/layout/tests/node/shouldBreak.test.ts +++ b/packages/layout/tests/node/shouldBreak.test.ts @@ -110,6 +110,69 @@ describe('node shouldBreak', () => { expect(result).toEqual(true); }); + test('should break when breakWhenNeeded is enabled and moving improves presence', () => { + const result = shouldBreak( + { + type: 'VIEW', + props: { wrap: true, breakWhenNeeded: true }, + style: {}, + children: [], + box: { + top: 700, + right: 0, + bottom: 0, + left: 0, + height: 400, + width: 200, + }, + }, + [], + 1000, + [ + { + type: 'VIEW', + props: {}, + style: {}, + children: [], + box: { + top: 0, + right: 0, + bottom: 0, + left: 0, + height: 700, + width: 200, + }, + }, + ], + ); + + expect(result).toEqual(true); + }); + + test('should not break when breakWhenNeeded is enabled but the node is already first on the page', () => { + const result = shouldBreak( + { + type: 'VIEW', + props: { wrap: true, breakWhenNeeded: true }, + style: {}, + children: [], + box: { + top: 700, + right: 0, + bottom: 0, + left: 0, + height: 400, + width: 200, + }, + }, + [], + 1000, + [], + ); + + expect(result).toEqual(false); + }); + test('should break when minPresenceAhead is large enough and there are overflowing siblings after the child', () => { const result = shouldBreak( { @@ -1035,4 +1098,103 @@ describe('node shouldBreak', () => { // endOfPresence = 600 + 200 + 0 + 300 = 1100 > 1000 expect(result).toEqual(true); }); + + test('should not break for breakWhenNeeded when the overflow is below the safety threshold', () => { + // 20 + 40.0005 = 60.0005 against a height of 60: resolvePagination + // tolerates this, so moving the node to the next page would be wrong. + const result = shouldBreak( + { + type: 'VIEW', + props: { wrap: true, breakWhenNeeded: true }, + style: {}, + children: [], + box: { + top: 20, + right: 0, + bottom: 0, + left: 0, + height: 40.0005, + width: 5, + marginTop: 0, + marginBottom: 0, + }, + }, + [], + 60, + [ + { + type: 'VIEW', + props: {}, + style: {}, + children: [], + box: { + top: 0, + right: 0, + bottom: 0, + left: 0, + height: 20, + width: 5, + }, + }, + ], + ); + + expect(result).toEqual(false); + }); + + test('should break for breakWhenNeeded on a first child when content sits above its container', () => { + // No previous siblings inside the container, but contentAbove is true, + // so the node still gains a full page by moving. + const result = shouldBreak( + { + type: 'VIEW', + props: { wrap: true, breakWhenNeeded: true }, + style: {}, + children: [], + box: { + top: 0, + right: 0, + bottom: 0, + left: 0, + height: 90, + width: 5, + marginTop: 0, + marginBottom: 0, + }, + }, + [], + 40, + [], + true, + ); + + expect(result).toEqual(true); + }); + + test('should not break for breakWhenNeeded when nothing sits above the node', () => { + const result = shouldBreak( + { + type: 'VIEW', + props: { wrap: true, breakWhenNeeded: true }, + style: {}, + children: [], + box: { + top: 0, + right: 0, + bottom: 0, + left: 0, + height: 90, + width: 5, + marginTop: 0, + marginBottom: 0, + }, + }, + [], + 40, + [], + false, + ); + + expect(result).toEqual(false); + }); }); diff --git a/packages/layout/tests/steps/resolvePagination.test.ts b/packages/layout/tests/steps/resolvePagination.test.ts index 2466aaf4b..ab631d439 100644 --- a/packages/layout/tests/steps/resolvePagination.test.ts +++ b/packages/layout/tests/steps/resolvePagination.test.ts @@ -238,6 +238,83 @@ describe('pagination step', () => { expect(page2.children![0].box!.height).toBe(40); }); + test('should move breakWhenNeeded containers to the next page before splitting them', async () => { + const yoga = await loadYoga(); + + const layout = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { + width: 5, + height: 60, + }, + children: [ + { + type: 'VIEW', + style: { + width: 5, + height: 20, + }, + props: {}, + children: [], + }, + { + type: 'VIEW', + style: { + width: 5, + }, + props: { + breakWhenNeeded: true, + }, + children: [ + { + type: 'VIEW', + style: { + height: 30, + }, + props: {}, + children: [], + }, + { + type: 'VIEW', + style: { + height: 30, + }, + props: {}, + children: [], + }, + { + type: 'VIEW', + style: { + height: 30, + }, + props: {}, + children: [], + }, + ], + }, + ], + }, + ], + }); + + const page1 = layout.children[0]; + const page2 = layout.children[1]; + const page3 = layout.children[2]; + + expect(layout.children).toHaveLength(3); + expect(page1.children).toHaveLength(1); + expect(page2.children).toHaveLength(1); + expect(page3.children).toHaveLength(1); + expect(page2.children![0].children).toHaveLength(2); + expect(page3.children![0].children).toHaveLength(1); + }); + test('should not infinitely loop when splitting pages', async () => { const yoga = await loadYoga(); @@ -458,4 +535,150 @@ describe('pagination step', () => { expect(subChapter3.props!.bookmark).toEqual(bookmarkSubChapter3); }); + + test('should move a nested breakWhenNeeded container that is the first child of its wrapper', async () => { + const yoga = await loadYoga(); + + // The wrapper starts 20pt down the page, so its first child has nothing + // above it inside the wrapper but still gains a page by moving. + const layout = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 5, height: 60 }, + children: [ + { + type: 'VIEW', + style: { width: 5, height: 20 }, + props: {}, + children: [], + }, + { + type: 'VIEW', + style: { width: 5 }, + props: {}, + children: [ + { + type: 'VIEW', + style: { width: 5 }, + props: { breakWhenNeeded: true }, + children: [ + { + type: 'VIEW', + style: { height: 30 }, + props: {}, + children: [], + }, + { + type: 'VIEW', + style: { height: 30 }, + props: {}, + children: [], + }, + { + type: 'VIEW', + style: { height: 30 }, + props: {}, + children: [], + }, + ], + }, + ], + }, + ], + }, + ], + }); + + expect(layout.children).toHaveLength(3); + + // Page 1 keeps only the 20pt sibling: the wrapper moved whole + expect(layout.children[0].children).toHaveLength(1); + + // The container splits from page 2 onwards, never on page 1 + const onPage2 = layout.children[1].children![0].children![0]; + const onPage3 = layout.children[2].children![0].children![0]; + + expect(onPage2.children).toHaveLength(2); + expect(onPage3.children).toHaveLength(1); + }); + + // A table long enough to run over five pages. The cover block above it is + // 60 tall on a 100 tall page, so only two rows fit in what is left of + // page 1; every later page holds five. + const LONG_TABLE_ROWS = 20; + + const longTableDocument = (yoga: any, tableProps: any): any => ({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 10, height: 100 }, + children: [ + { + type: 'VIEW', + style: { width: 10, height: 60 }, + props: {}, + children: [], + }, + { + type: 'VIEW', + style: { width: 10 }, + props: tableProps, + children: Array.from({ length: LONG_TABLE_ROWS }, () => ({ + type: 'VIEW', + style: { height: 20 }, + props: {}, + children: [], + })), + }, + ], + }, + ], + }); + + // How many table rows landed on each page. The cover block has no + // children, so the table is the only node with any. + const rowsPerPage = (layout: any): number[] => + layout.children.map((page: any) => { + const table = (page.children || []).find( + (child: any) => (child.children || []).length > 0, + ); + + return table ? table.children.length : 0; + }); + + test('should split a long wrapping table starting at the bottom of page 1', async () => { + const yoga = await loadYoga(); + + const layout = calcLayout(longTableDocument(yoga, { wrap: true })); + + expect(layout.children).toHaveLength(5); + + // Two rows are stranded under the cover block before the first split + expect(rowsPerPage(layout)).toEqual([2, 5, 5, 5, 3]); + }); + + test('should move a long breakWhenNeeded table to page 2 and split it from there', async () => { + const yoga = await loadYoga(); + + const layout = calcLayout( + longTableDocument(yoga, { wrap: true, breakWhenNeeded: true }), + ); + + // Same row count and same page count, but page 1 keeps none of them + expect(layout.children).toHaveLength(5); + expect(rowsPerPage(layout)).toEqual([0, 5, 5, 5, 5]); + + // Every row still gets rendered, just starting one page later + const total = rowsPerPage(layout).reduce((a, b) => a + b, 0); + expect(total).toBe(LONG_TABLE_ROWS); + }); }); diff --git a/packages/render/src/operations/addBookmarks.ts b/packages/render/src/operations/addBookmarks.ts index ab28dd002..fbc8f04ce 100644 --- a/packages/render/src/operations/addBookmarks.ts +++ b/packages/render/src/operations/addBookmarks.ts @@ -1,4 +1,4 @@ -import { SafeDocumentNode, SafeNode } from '@react-pdf/layout'; +import { Bookmark, SafeDocumentNode, SafeNode } from '@react-pdf/layout'; import { Context } from '../types'; @@ -14,7 +14,10 @@ const addNodeBookmark = ( if (!node.props) return; if ('bookmark' in node.props && node.props.bookmark) { - const bookmark = node.props.bookmark; + // resolveBookmarks expands plain string titles before rendering, but the + // prop type still admits one — normalize so that case stays sound here. + const raw = node.props.bookmark; + const bookmark: Bookmark = typeof raw === 'string' ? { title: raw } : raw; const { title, parent, expanded, zoom, fit } = bookmark; const outline = registry[parent!] || ctx.outline; const top = bookmark.top || node.box.top; diff --git a/packages/render/src/primitives/renderSvgImage.ts b/packages/render/src/primitives/renderSvgImage.ts index fc3e464f7..807c83395 100644 --- a/packages/render/src/primitives/renderSvgImage.ts +++ b/packages/render/src/primitives/renderSvgImage.ts @@ -6,6 +6,11 @@ const renderImage = (ctx: Context, node: SafeImageNode) => { if (!node.box) return; if (!node.image?.data) return; + // An SVG source resolves to a node tree rather than raster bytes, and + // ctx.image only takes the latter. + const data = node.image.data; + if (!Buffer.isBuffer(data)) return; + const { x = 0, y = 0 } = node.props; const { width, height, opacity } = node.style; const paddingTop = node.box.paddingLeft || 0; @@ -31,12 +36,10 @@ const renderImage = (ctx: Context, node: SafeImageNode) => { ctx.save(); - ctx - .fillOpacity(opacity || 1) - .image(node.image.data, x + paddingLeft, y + paddingTop, { - width, - height, - }); + ctx.fillOpacity(opacity || 1).image(data, x + paddingLeft, y + paddingTop, { + width, + height, + }); ctx.restore(); }; diff --git a/packages/renderer/index.d.ts b/packages/renderer/index.d.ts index d6d9eaa87..d024ec139 100644 --- a/packages/renderer/index.d.ts +++ b/packages/renderer/index.d.ts @@ -89,6 +89,12 @@ declare namespace ReactPDF { * @see https://react-pdf.org/advanced#page-breaks */ break?: boolean; + /** + * Move the element to the next page when it would otherwise start + * splitting in the remaining space, while still allowing it to continue + * across later pages. + */ + breakWhenNeeded?: boolean; /** * Hint that no page wrapping should occur between all sibling elements following the element within n points * @see https://react-pdf.org/advanced#orphan-&-widow-protection diff --git a/packages/types/node.d.ts b/packages/types/node.d.ts index 96ecbf866..6b09e0f9a 100644 --- a/packages/types/node.d.ts +++ b/packages/types/node.d.ts @@ -8,6 +8,7 @@ interface BaseProps { id?: string; fixed?: boolean; break?: boolean; + breakWhenNeeded?: boolean; debug?: boolean; bookmark?: Bookmark; minPresenceAhead?: number;