diff --git a/.changeset/orange-moons-chew.md b/.changeset/orange-moons-chew.md new file mode 100644 index 000000000..9f1f49826 --- /dev/null +++ b/.changeset/orange-moons-chew.md @@ -0,0 +1,6 @@ +--- +'@react-pdf/layout': minor +--- + +- Nested page break support for views. +- Fallback behavior for unwrappable content to wrap if it is a wrappable component (View / Text) when it doesn't fit within a page. Content will start on next page. diff --git a/packages/examples/src/index.js b/packages/examples/src/index.js index b7a1b97d1..1ef0df28d 100644 --- a/packages/examples/src/index.js +++ b/packages/examples/src/index.js @@ -27,8 +27,6 @@ const EXAMPLES = { const Viewer = () => { const [example, setExample] = useState('pageWrap'); - console.log(example); - const handleExampleChange = e => { setExample(e.target.dataset.name); }; diff --git a/packages/layout/src/node/getWrap.js b/packages/layout/src/node/getWrap.js index 5cd18bb7a..c2a64ae20 100644 --- a/packages/layout/src/node/getWrap.js +++ b/packages/layout/src/node/getWrap.js @@ -1,7 +1,7 @@ import * as P from '@react-pdf/primitives'; import { isNil } from '@react-pdf/fns'; -const NON_WRAP_TYPES = [P.Svg, P.Note, P.Image, P.Canvas]; +export const NON_WRAP_TYPES = [P.Svg, P.Note, P.Image, P.Canvas]; const getWrap = node => { if (NON_WRAP_TYPES.includes(node.type)) return false; diff --git a/packages/layout/src/steps/resolveDimensions.js b/packages/layout/src/steps/resolveDimensions.js index 7d2102a4b..df9beb637 100644 --- a/packages/layout/src/steps/resolveDimensions.js +++ b/packages/layout/src/steps/resolveDimensions.js @@ -266,13 +266,15 @@ const freeYogaNodes = node => { export const resolvePageDimensions = (page, fontStore) => { if (isNil(page)) return null; - return compose( + const result = compose( destroyYogaNodes, freeYogaNodes, persistDimensions, calculateLayout, createYogaNodes(page, fontStore), )(page); + + return result; }; /** diff --git a/packages/layout/src/steps/resolvePagination.js b/packages/layout/src/steps/resolvePagination.js index c4a84903e..5a4773482 100644 --- a/packages/layout/src/steps/resolvePagination.js +++ b/packages/layout/src/steps/resolvePagination.js @@ -7,7 +7,7 @@ import { isNil, omit, compose } from '@react-pdf/fns'; import isFixed from '../node/isFixed'; import splitText from '../text/splitText'; import splitNode from '../node/splitNode'; -import canNodeWrap from '../node/getWrap'; +import canNodeWrap, { NON_WRAP_TYPES } from '../node/getWrap'; import getWrapArea from '../page/getWrapArea'; import getContentArea from '../page/getContentArea'; import createInstances from '../node/createInstances'; @@ -42,6 +42,34 @@ const warnUnavailableSpace = node => { ); }; +const warnFallbackSpace = node => { + console.warn( + `Node of type ${node.type} can't wrap between pages and it's bigger than available page height, falling back to wrap, and putting it on the next page`, + ); +}; +const breakableViewChild = (children, height, path = '') => { + for (let i = 0; i < children.length; i += 1) { + if (children[i].type !== 'VIEW') continue; + + if (shouldNodeBreak(children[i], children.slice(i + 1, height))) { + return { + child: children[i], + path: `${path}/${i}`, + }; + } + + if (children[i].children && children[i].children.length > 0) { + const breakable = breakableViewChild( + children[i].children, + height, + `${path}/${i}`, + ); + if (breakable) return breakable; + } + } + return null; +}; + const splitNodes = (height, contentArea, nodes) => { const currentChildren = []; const nextChildren = []; @@ -55,7 +83,14 @@ const splitNodes = (height, contentArea, nodes) => { const nodeHeight = child.box.height; const isOutside = height <= nodeTop; const shouldBreak = shouldNodeBreak(child, futureNodes, height); + + const firstBreakableViewChild = + child.children && + child.children.length > 0 && + breakableViewChild(child.children, height, ''); + const shouldSplit = height + SAFTY_THRESHOLD < nodeTop + nodeHeight; + const canWrap = canNodeWrap(child); const fitsInsidePage = nodeHeight <= contentArea; @@ -73,10 +108,29 @@ const splitNodes = (height, contentArea, nodes) => { } if (!fitsInsidePage && !canWrap) { - currentChildren.push(child); - nextChildren.push(...futureNodes); - warnUnavailableSpace(child); - break; + if (NON_WRAP_TYPES.includes(child.type)) { + // We don't want to break non wrapable nodes, so we just let them be. + // They will be cropped, user will need to fix their ~image usage? + currentChildren.push(child); + nextChildren.push(...futureNodes); + warnUnavailableSpace(child); + break; + } else { + // We don't want to break non wrapable nodes, so we just let them be. + warnFallbackSpace(child); + const box = Object.assign({}, child.box, { + top: child.box.top - height, + }); + const props = Object.assign({}, child.props, { + wrap: true, + break: false, + }); + const next = Object.assign({}, child, { box, props }); + + currentChildren.push(...futureFixedNodes); + nextChildren.push(next, ...futureNodes); + break; + } } if (shouldBreak) { @@ -92,15 +146,16 @@ const splitNodes = (height, contentArea, nodes) => { break; } - if (shouldSplit) { + if (shouldSplit || firstBreakableViewChild) { 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 + // This was causing an infinite loop parent will now be discarded when it has no content if (child.children.length > 0 && currentChild.children.length === 0) { - const box = Object.assign({}, child.box, { - top: child.box.top - height, + const box = Object.assign({}, nextChild.box, { + top: nextChild.box.top - height, }); - const next = Object.assign({}, child, { box }); + const next = Object.assign({}, nextChild, { box }); currentChildren.push(...futureFixedNodes); nextChildren.push(next, ...futureNodes); @@ -121,16 +176,19 @@ const splitNodes = (height, contentArea, nodes) => { const splitChildren = (height, contentArea, node) => { const children = node.children || []; + const availableHeight = height - getTop(node); + return splitNodes(availableHeight, contentArea, children); }; -const splitView = (node, height, contentArea) => { +const splitView = (node, height, contentArea, foundBreakableViewChild) => { const [currentNode, nextNode] = splitNode(node, height); const [currentChilds, nextChildren] = splitChildren( height, contentArea, node, + foundBreakableViewChild, ); return [ @@ -139,8 +197,10 @@ const splitView = (node, height, contentArea) => { ]; }; -const split = (node, height, contentArea) => - isText(node) ? splitText(node, height) : splitView(node, height, contentArea); +const split = (node, height, contentArea, foundBreakableViewChild) => + isText(node) + ? splitText(node, height) + : splitView(node, height, contentArea, foundBreakableViewChild); const shouldResolveDynamicNodes = node => { const children = node.children || []; diff --git a/packages/renderer/tests/pageBreak.test.js b/packages/renderer/tests/pageBreak.test.js new file mode 100644 index 000000000..bfa5ca5f2 --- /dev/null +++ b/packages/renderer/tests/pageBreak.test.js @@ -0,0 +1,177 @@ +/* eslint-disable react/no-array-index-key */ +import renderToImage from './renderComponent'; +import { Document, Font, Page, Text, View, StyleSheet } from '..'; + +const styles = StyleSheet.create({ + body: { + paddingTop: 35, + paddingBottom: 65, + paddingHorizontal: 35, + }, + title: { + fontSize: 24, + textAlign: 'center', + fontFamily: 'Oswald', + }, + author: { + fontSize: 12, + textAlign: 'center', + marginBottom: 40, + }, + subtitle: { + fontSize: 18, + margin: 12, + fontFamily: 'Oswald', + }, + text: { + fontFamily: 'Open Sans', + margin: 12, + fontSize: 14, + textAlign: 'justify', + }, + image: { + marginVertical: 15, + marginHorizontal: 100, + }, + header: { + fontFamily: 'Open Sans', + fontSize: 12, + marginBottom: 20, + textAlign: 'center', + color: 'grey', + }, + footer: { + fontFamily: 'Open Sans', + fontSize: 12, + marginBottom: 20, + textAlign: 'center', + color: 'grey', + }, + pageNumber: { + fontFamily: 'Open Sans', + position: 'absolute', + fontSize: 12, + bottom: 30, + left: 0, + right: 0, + textAlign: 'center', + color: 'grey', + }, +}); + +Font.register({ + family: 'Oswald', + src: 'https://fonts.gstatic.com/s/oswald/v13/Y_TKV6o8WovbUd3m_X9aAA.ttf', +}); + +Font.register({ + family: 'Open Sans', + src: 'https://fonts.gstatic.com/s/opensans/v17/mem8YaGs126MiZpBA-UFVZ0e.ttf', +}); + +const PageWrap = () => ( + + + + ~ Created with react-pdf ~ + + New Page 1 + + + + {'New Page 1 Content'.repeat(800)} + End of Page 1 content + + + + New Page 2 + + New Page 2 Content + <> + + + New Page 3 + + + {'New Page 3 Content'.repeat(1000)} + End of Page 3 content + + <> + + New Page 4 + + {'New Page 4 Content'.repeat(1000)} + End of Page 4 content + + + New Page 5 + + New Page 5 Content + + + + + + New Page 6 + New Page 6 Content + + + New Page 7 + New Page 7 Content + + New Page 8 + + New Page 8 Content + + + {`New Page ${9}`} + New Page 9 Content + + + + + New Page 10 + New Page 10 Content + + + + New Page 11 + New Page 11 Content + + + New Page 12 + New Page 12 Content + + New Page 13 + New Page 13 Content + + New Page 14 + New Page 14 Content + + + + + + New Page 15 + New Page 15 Content + + + + + + `${pageNumber} / ${totalPages}`} + fixed + /> + + +); + +describe('pageBreak', () => { + test('should put every break instance on a new page', async () => { + const image = await renderToImage(); + + expect(image).toMatchImageSnapshot(); + }, 30_000); +}); diff --git a/packages/renderer/tests/pageWrapFallback.test.js b/packages/renderer/tests/pageWrapFallback.test.js new file mode 100644 index 000000000..b05a43fa4 --- /dev/null +++ b/packages/renderer/tests/pageWrapFallback.test.js @@ -0,0 +1,92 @@ +/* eslint-disable react/no-array-index-key */ +import renderToImage from './renderComponent'; +import { Document, Font, Page, Text, View, StyleSheet, Image } from '..'; + +const styles = StyleSheet.create({ + body: { + paddingTop: 35, + paddingBottom: 65, + paddingHorizontal: 35, + }, + title: { + fontSize: 24, + textAlign: 'center', + fontFamily: 'Oswald', + }, + author: { + fontSize: 12, + textAlign: 'center', + marginBottom: 40, + }, + subtitle: { + fontSize: 18, + margin: 12, + fontFamily: 'Oswald', + }, + text: { + fontFamily: 'Open Sans', + margin: 12, + fontSize: 14, + textAlign: 'justify', + }, + image: { + marginVertical: 15, + marginHorizontal: 100, + }, + header: { + fontFamily: 'Open Sans', + fontSize: 12, + marginBottom: 20, + textAlign: 'center', + color: 'grey', + }, + pageNumber: { + fontFamily: 'Open Sans', + position: 'absolute', + fontSize: 12, + bottom: 30, + left: 0, + right: 0, + textAlign: 'center', + color: 'grey', + }, +}); + +Font.register({ + family: 'Oswald', + src: 'https://fonts.gstatic.com/s/oswald/v13/Y_TKV6o8WovbUd3m_X9aAA.ttf', +}); + +Font.register({ + family: 'Open Sans', + src: 'https://fonts.gstatic.com/s/opensans/v17/mem8YaGs126MiZpBA-UFVZ0e.ttf', +}); + +const PageWrap = () => ( + + + + {'yadayada '.repeat(1000)} + + + + {'yada '.repeat(1000)} + + + + + + +); + +describe('pageBreak', () => { + test('When content does not fit in one page, it should wrap if possible', async () => { + const image = await renderToImage(); + + expect(image).toMatchImageSnapshot(); + }, 30_000); +}); diff --git a/packages/renderer/tests/snapshots/page-break-test-js-page-break-should-put-every-break-instance-on-a-new-page-1-snap.png b/packages/renderer/tests/snapshots/page-break-test-js-page-break-should-put-every-break-instance-on-a-new-page-1-snap.png new file mode 100644 index 000000000..e6cae749f Binary files /dev/null and b/packages/renderer/tests/snapshots/page-break-test-js-page-break-should-put-every-break-instance-on-a-new-page-1-snap.png differ diff --git a/packages/renderer/tests/snapshots/page-wrap-fallback-test-js-page-break-when-content-does-not-fit-in-one-page-it-should-wrap-if-possible-1-snap.png b/packages/renderer/tests/snapshots/page-wrap-fallback-test-js-page-break-when-content-does-not-fit-in-one-page-it-should-wrap-if-possible-1-snap.png new file mode 100644 index 000000000..d82c55fd6 Binary files /dev/null and b/packages/renderer/tests/snapshots/page-wrap-fallback-test-js-page-break-when-content-does-not-fit-in-one-page-it-should-wrap-if-possible-1-snap.png differ