diff --git a/.changeset/two-boats-accept.md b/.changeset/two-boats-accept.md new file mode 100644 index 000000000..fb8e40513 --- /dev/null +++ b/.changeset/two-boats-accept.md @@ -0,0 +1,5 @@ +--- +"@react-pdf/layout": minor +--- + +perf: Optimize page splitting and relayout, so both per-iteration costs drop from O(remaining-children) to O(children-per-page) \ No newline at end of file diff --git a/.changeset/two-pane-page-relative-top.md b/.changeset/two-pane-page-relative-top.md new file mode 100644 index 000000000..c5e40cdfb --- /dev/null +++ b/.changeset/two-pane-page-relative-top.md @@ -0,0 +1,5 @@ +--- +"@react-pdf/layout": patch +--- + +fix: synthesize page-relative `box.top` for nodes pushed to the next page during pagination so subsequent iterations match what a yoga relayout would have produced. This accounts for normal-flow fixed siblings (e.g., a fixed header above a flex:1 body) and for sibling margins (`marginTop`/`marginBottom`). Without these, common two-pane report layouts packed too much content onto subsequent pages, causing yoga to flex-shrink content and overflow page borders. diff --git a/packages/layout/src/node/shouldBreak.ts b/packages/layout/src/node/shouldBreak.ts index eaffe48f4..9dd1c9aa5 100644 --- a/packages/layout/src/node/shouldBreak.ts +++ b/packages/layout/src/node/shouldBreak.ts @@ -22,42 +22,61 @@ const getEndOfMinPresenceAhead = (child: SafeNode) => { ); }; -const getEndOfPresence = (child: SafeNode, futureElements: SafeNode[]) => { - const afterMinPresenceAhead = getEndOfMinPresenceAhead(child); - const nonFixedFuture = futureElements.filter( - (node) => !('fixed' in node.props), - ); - const endOfFurthestFutureElement = getFurthestEnd(nonFixedFuture); +/** + * Determines whether a node should break to the next page. + * Accepts pre-computed values to avoid O(N) array scans per call. + * + * @param child - The node to evaluate + * @param furthestEndOfNonFixedFuture - Pre-computed max(top+height) of future non-fixed siblings, or null if none + * @param height - Available page height + * @param hasNonFixedPrevious - Whether any non-fixed sibling precedes this node + */ +export const shouldBreakOptimized = ( + child: SafeNode, + furthestEndOfNonFixedFuture: number | null, + height: number, + hasNonFixedPrevious: boolean, +) => { + if (isFixed(child)) return false; - // When there are no future non-fixed siblings, use only minPresenceAhead - if (endOfFurthestFutureElement === null) return afterMinPresenceAhead; + const shouldSplit = height < child.box.top + child.box.height; + const canWrap = getWrap(child); + + const afterMinPresenceAhead = getEndOfMinPresenceAhead(child); + const endOfPresence = + furthestEndOfNonFixedFuture === null + ? afterMinPresenceAhead + : Math.min(afterMinPresenceAhead, furthestEndOfNonFixedFuture); - return Math.min(afterMinPresenceAhead, endOfFurthestFutureElement); + return ( + getBreak(child) || + (shouldSplit && !canWrap) || + (!shouldSplit && endOfPresence > height && hasNonFixedPrevious) + ); }; +/** + * Array-based wrapper around shouldBreakOptimized. + * Computes the pre-computed values from raw arrays for convenience in tests. + */ const shouldBreak = ( child: SafeNode, futureElements: SafeNode[], height: number, previousElements: SafeNode[], ) => { - if ('fixed' in child.props) return false; - - const shouldSplit = height < child.box.top + child.box.height; - const canWrap = getWrap(child); - - // Calculate the y coordinate where the desired presence of the child ends - const endOfPresence = getEndOfPresence(child, futureElements); - - // If the child is already at the top of the page, breaking won't improve its presence - // (as long as react-pdf does not support breaking into differently sized containers) - const breakingImprovesPresence = + const nonFixedFuture = futureElements.filter( + (node) => !('fixed' in node.props), + ); + const furthestEndOfNonFixedFuture = getFurthestEnd(nonFixedFuture); + const hasNonFixedPrevious = previousElements.filter((node: SafeNode) => !isFixed(node)).length > 0; - return ( - getBreak(child) || - (shouldSplit && !canWrap) || - (!shouldSplit && endOfPresence > height && breakingImprovesPresence) + return shouldBreakOptimized( + child, + furthestEndOfNonFixedFuture, + height, + hasNonFixedPrevious, ); }; diff --git a/packages/layout/src/node/splitNode.ts b/packages/layout/src/node/splitNode.ts index d2aba8c3d..13c80bd8b 100644 --- a/packages/layout/src/node/splitNode.ts +++ b/packages/layout/src/node/splitNode.ts @@ -28,15 +28,17 @@ const splitNode = (node: SafeNode, height: number) => { current.style.height = height - nodeTop; - const nextHeight = hasFixedHeight(node) - ? node.box.height - (height - nodeTop) - : null; + const nextBoxHeight = node.box.height - (height - nodeTop); + + const nextHeight = hasFixedHeight(node) ? nextBoxHeight : null; const next: SafeNode = Object.assign({}, node, { box: { ...node.box, top: 0, + marginTop: 0, borderTopWidth: 0, + height: nextBoxHeight, }, style: { ...node.style, @@ -52,7 +54,7 @@ const splitNode = (node: SafeNode, height: number) => { }, }); - if (nextHeight) { + if (nextHeight !== null) { next.style.height = nextHeight; } diff --git a/packages/layout/src/steps/resolvePagination.ts b/packages/layout/src/steps/resolvePagination.ts index f029d7619..a456c74a9 100644 --- a/packages/layout/src/steps/resolvePagination.ts +++ b/packages/layout/src/steps/resolvePagination.ts @@ -1,5 +1,5 @@ import * as P from '@react-pdf/primitives'; -import { omit, compose } from '@react-pdf/fns'; +import { compose, omit } from '@react-pdf/fns'; import FontStore from '@react-pdf/font'; import isFixed from '../node/isFixed'; @@ -9,7 +9,7 @@ import canNodeWrap from '../node/getWrap'; import getWrapArea from '../page/getWrapArea'; import getContentArea from '../page/getContentArea'; import createInstances from '../node/createInstances'; -import shouldNodeBreak from '../node/shouldBreak'; +import { shouldBreakOptimized } from '../node/shouldBreak'; import resolveTextLayout from './resolveTextLayout'; import resolveInheritance from './resolveInheritance'; import { resolvePageDimensions } from './resolveDimensions'; @@ -37,6 +37,36 @@ const getTop = (node: SafeNode) => node.box?.top || 0; const allFixed = (nodes: SafeNode[]) => nodes.every(isFixed); +/** + * Build suffix-max array: suffixFurthestEnd[i] = max(top + height) + * of non-fixed-prop nodes at indices > i, or null if none. + */ +const computeSuffixFurthestEnd = (nodes: SafeNode[]): (number | null)[] => { + const length = nodes.length; + const result: (number | null)[] = new Array(length); + let max: number | null = null; + + for (let i = length - 1; i >= 0; i -= 1) { + result[i] = max; + const node = nodes[i]; + if (!isFixed(node)) { + const end = (node.box?.top || 0) + (node.box?.height || 0); + max = max === null ? end : Math.max(max, end); + } + } + + return result; +}; + +const collectFixedIndices = (nodes: SafeNode[]): number[] => { + const indices: number[] = []; + const length = nodes.length; + for (let i = 0; i < length; i += 1) { + if (isFixed(nodes[i])) indices.push(i); + } + return indices; +}; + const isDynamic = ( node: SafeNode, ): node is SafeLinkNode | SafeTextNode | SafeViewNode => @@ -55,89 +85,175 @@ const warnUnavailableSpace = (node: SafeNode) => { ); }; -const splitNodes = (height: number, contentArea: number, nodes: SafeNode[]) => { +const isAbsolutePositioned = (node: SafeNode) => + node.style?.position === 'absolute'; + +// Sum the vertical space a node would occupy in a flex column: top margin + +// height + bottom margin. Yoga's flex layout positions siblings using this +// (margins do not collapse in flexbox), so we mirror it when synthesizing +// next-page positions without a yoga relayout. +const getOutsetHeight = (node: SafeNode) => + (node.box?.marginTop || 0) + + (node.box?.height || 0) + + (node.box?.marginBottom || 0); + +const splitNodes = ( + height: number, + contentArea: number, + nodes: SafeNode[], + isRowLayout = false, +) => { const currentChildren: SafeNode[] = []; const nextChildren: SafeNode[] = []; + const suffixFurthestEnd = computeSuffixFurthestEnd(nodes); + const fixedIndices = collectFixedIndices(nodes); + const length = nodes.length; - for (let i = 0; i < nodes.length; i += 1) { - const child = nodes[i]; - const futureNodes = nodes.slice(i + 1); - const futureFixedNodes = futureNodes.filter(isFixed); + const pushFutureFixed = (target: SafeNode[], afterIndex: number) => { + for (const idx of fixedIndices) { + if (idx > afterIndex) target.push(nodes[idx]); + } + }; - const nodeTop = getTop(child); - const nodeHeight = child.box.height; - const isOutside = height <= nodeTop; - const shouldBreak = shouldNodeBreak( - child, - futureNodes, - height, - currentChildren, - ); - const shouldSplit = height + SAFETY_THRESHOLD < nodeTop + nodeHeight; - const canWrap = canNodeWrap(child); - const fitsInsidePage = nodeHeight <= contentArea; + // The next iteration's splitNodes makes decisions from `box.top`/`box.height`. + // To match what yoga's relayout would have produced (the dropped step), we + // synthesize page-relative positions: cumFixedHeight is the bottom edge of the + // last normal-flow fixed sibling, cumNonFixedNextHeight is the bottom edge of + // the last non-fixed sibling already pushed to nextChildren. + let cumFixedHeight = 0; + let cumNonFixedNextHeight = 0; + + // Returns `node` cloned with `box.top` set to its expected position on the + // next page. Mutates the running cursor — must only be called when pushing to + // nextChildren, in left-to-right sibling order. + // + // In a flex:row container children share the same top (they are side-by-side, + // not stacked). The cumulative column approach must not be applied there — + // preserve box.top as-is, since splitNode already resets next-half tops to 0. + const placeOnNextPage = (node: SafeNode): SafeNode => { + if (isRowLayout) { + return Object.assign({}, node, { + box: Object.assign({}, node.box, { top: node.box?.top || 0 }), + }); + } + const marginTop = node.box?.marginTop || 0; + const newTop = cumFixedHeight + cumNonFixedNextHeight + marginTop; + cumNonFixedNextHeight += getOutsetHeight(node); + return Object.assign({}, node, { + box: Object.assign({}, node.box, { top: newTop }), + }); + }; + + // Keep cumFixedHeight in sync for normal-flow fixed siblings. + // Use the node's actual bottom edge (box.top + height + marginBottom) rather + // than just getOutsetHeight (height + margins), because box.top already + // encodes the parent's paddingTop and any preceding content. Without this, + // a page-level fixed header at box.top=44 (paddingTop=44) would only + // contribute its size (41pt) to cumFixedHeight instead of its bottom (85pt), + // making the synthesized table.box.top 44pt too low and over-allocating + // content height per page. + const advanceFixed = (node: SafeNode) => { + if (!isRowLayout && !isAbsolutePositioned(node)) { + const bottom = + (node.box?.top || 0) + + (node.box?.height || 0) + + (node.box?.marginBottom || 0); + cumFixedHeight = Math.max(cumFixedHeight, bottom); + } + }; + + const adjustRemaining = (fromIndex: number): SafeNode[] => { + const result: SafeNode[] = []; + for (let j = fromIndex; j < length; j += 1) { + const node = nodes[j]; + if (isFixed(node)) { + result.push(node); + advanceFixed(node); + continue; + } + // Absolute-positioned nodes are not part of the flex flow; push as-is + // so their parent-relative position is preserved on the next page. + if (isAbsolutePositioned(node)) { + result.push(node); + continue; + } + result.push(placeOnNextPage(node)); + } + return result; + }; + + let hasNonFixedPrevious = false; + + for (let i = 0; i < length; i += 1) { + const child = nodes[i]; if (isFixed(child)) { nextChildren.push(child); currentChildren.push(child); + advanceFixed(child); continue; } + const nodeTop = getTop(child); + const isOutside = height <= nodeTop; if (isOutside) { - const box = Object.assign({}, child.box, { top: child.box.top - height }); - const next = Object.assign({}, child, { box }); - nextChildren.push(next); + nextChildren.push(placeOnNextPage(child)); continue; } - if (!fitsInsidePage && !canWrap) { + const nodeHeight = child.box.height; + const fitsInsidePage = nodeHeight <= contentArea; + if (!fitsInsidePage && !canNodeWrap(child)) { currentChildren.push(child); - nextChildren.push(...futureNodes); + nextChildren.push(...adjustRemaining(i + 1)); warnUnavailableSpace(child); break; } + const shouldBreak = shouldBreakOptimized( + child, + suffixFurthestEnd[i], + height, + hasNonFixedPrevious, + ); if (shouldBreak) { - 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 }); + const placed = placeOnNextPage(child); + const next = Object.assign({}, placed, { props }); - currentChildren.push(...futureFixedNodes); - nextChildren.push(next, ...futureNodes); + pushFutureFixed(currentChildren, i); + nextChildren.push(next, ...adjustRemaining(i + 1)); break; } + const shouldSplit = height + SAFETY_THRESHOLD < nodeTop + nodeHeight; if (shouldSplit) { 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 if (child.children.length > 0 && currentChild.children.length === 0) { - // 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); - nextChildren.push(...futureNodes); + currentChildren.push(child); + pushFutureFixed(currentChildren, i); + nextChildren.push(...adjustRemaining(i + 1)); } else { - const box = Object.assign({}, child.box, { - top: child.box.top - height, - }); - const next = Object.assign({}, child, { box }); - - currentChildren.push(...futureFixedNodes); - nextChildren.push(next, ...futureNodes); + pushFutureFixed(currentChildren, i); + nextChildren.push(placeOnNextPage(child), ...adjustRemaining(i + 1)); } break; } if (currentChild) currentChildren.push(currentChild); - if (nextChild) nextChildren.push(nextChild); + if (nextChild) nextChildren.push(placeOnNextPage(nextChild)); + hasNonFixedPrevious = true; continue; } currentChildren.push(child); + hasNonFixedPrevious = true; } return [currentChildren, nextChildren]; @@ -146,7 +262,70 @@ const splitNodes = (height: number, contentArea: number, nodes: SafeNode[]) => { const splitChildren = (height: number, contentArea: number, node: SafeNode) => { const children = node.children || []; const availableHeight = height - getTop(node); - return splitNodes(availableHeight, contentArea, children); + const fd = (node.style as { flexDirection?: string } | undefined) + ?.flexDirection; + const isRowLayout = fd === 'row' || fd === 'row-reverse'; + return splitNodes(availableHeight, contentArea, children, isRowLayout); +}; + +// Compute the height that yoga would assign to `node`'s next-page half given +// the already-computed nextChildren. For auto-height nodes, splitNode derives +// nextBoxHeight geometrically (original.h − splitPoint) which can diverge from +// the real content height when text lines don't align with the split boundary. +// +// This function: +// • For flex:column: sums outset heights (margin + height + margin) of flow +// children — equivalent to yoga's stacking computation. +// • For flex:row: takes max of flow children heights — mirrors alignItems:stretch — +// and propagates that stretched height back to EVERY direct flow child so +// the next splitNodes iteration sees consistent heights for shouldSplit checks. +// +// Returns { actualH, updatedChildren } where updatedChildren has the stretched +// heights applied to direct row children (grandchildren are left unchanged so +// their own content-height checks remain accurate). +const computeActualNextDimensions = ( + nextChildren: SafeNode[], + parent: SafeNode, +): { actualH: number; updatedChildren: SafeNode[] } => { + // Use style-based padding: splitNode zeroes paddingTop in style for the + // next-half, so parent.style.paddingTop reflects the real rendered value (0). + // box.paddingTop retains the pre-split original, which would over-count. + const ptRaw = parent.style?.paddingTop; + const pbRaw = parent.style?.paddingBottom; + const pt = typeof ptRaw === 'number' ? ptRaw : 0; + const pb = typeof pbRaw === 'number' ? pbRaw : 0; + const flow = nextChildren.filter( + (c) => !isFixed(c) && !isAbsolutePositioned(c), + ); + const fd = (parent.style as { flexDirection?: string } | undefined) + ?.flexDirection; + const isRow = fd === 'row' || fd === 'row-reverse'; + + if (!flow.length) { + // No flow content — actual height is padding only (e.g. empty stretched col). + return { actualH: pt + pb, updatedChildren: nextChildren }; + } + + if (isRow) { + // alignItems:stretch: each column's rendered height = max of all columns. + const maxH = Math.max(...flow.map((c) => c.box?.height || 0)); + const actualH = pt + maxH + pb; + // Propagate stretched height to direct children so subsequent splitNodes + // calls use the correct height for shouldSplit decisions at this level. + const updatedChildren = nextChildren.map((c) => { + if (isFixed(c) || isAbsolutePositioned(c)) return c; + const h = c.box?.height || 0; + if (h === maxH) return c; + return Object.assign({}, c, { + box: Object.assign({}, c.box, { height: maxH }), + }); + }); + return { actualH, updatedChildren }; + } + + // flex:column (default): sum outset heights of flow children. + const actualH = pt + flow.reduce((s, c) => s + getOutsetHeight(c), 0) + pb; + return { actualH, updatedChildren: nextChildren }; }; const splitView = (node: SafeNode, height: number, contentArea: number) => { @@ -157,10 +336,27 @@ const splitView = (node: SafeNode, height: number, contentArea: number) => { node, ); - return [ - assingChildren(currentChilds, currentNode), - assingChildren(nextChildren, nextNode), - ]; + const current = assingChildren(currentChilds, currentNode); + + if (!nextNode) return [current, null]; + + // For auto-height nodes, replace splitNode's geometric nextBoxHeight with + // the real content height and apply flex:row stretch to direct children. + if (node.style?.height == null) { + const { actualH, updatedChildren } = computeActualNextDimensions( + nextChildren, + nextNode, // next-half: style.paddingTop=0, style.paddingBottom=original + ); + const next = assingChildren( + updatedChildren, + Object.assign({}, nextNode, { + box: Object.assign({}, nextNode.box, { height: actualH }), + }), + ); + return [current, next]; + } + + return [current, assingChildren(nextChildren, nextNode)]; }; const split = (node: SafeNode, height: number, contentArea: number) => @@ -247,13 +443,15 @@ const splitPage = ( const nextBox = omit('height', page.box); const nextProps = omit('bookmark', page.props); - const nextPage = relayout( - Object.assign({}, page, { - props: nextProps, - box: nextBox, - children: nextChilds, - }), - ); + // Skip relayout for nextPage: it's only used as input to the next splitPage call, + // never added to final output. Children already have correct box values + // (splitNodes adjusts box.top, split() computes dimensions for split nodes). + // The currentPage from the next iteration will be properly relayed out. + const nextPage = Object.assign({}, page, { + props: nextProps, + box: nextBox, + children: nextChilds, + }) as SafePageNode; return [currentPage, nextPage]; }; diff --git a/packages/layout/src/text/splitText.ts b/packages/layout/src/text/splitText.ts index 848b3b68e..441728583 100644 --- a/packages/layout/src/text/splitText.ts +++ b/packages/layout/src/text/splitText.ts @@ -36,7 +36,19 @@ const getLineBreak = (node: SafeTextNode, height: number) => { const splitText = (node: SafeTextNode, height: number) => { const slicedLineIndex = getLineBreak(node, height); const currentHeight = heightAtLineIndex(node, slicedLineIndex); - const nextHeight = node.box.height - currentHeight; + // Compute next-half height from actual remaining line heights rather than + // geometric remainder (node.box.height - currentHeight). The geometric + // form includes paddingTop (zeroed for the next-half by splitText) and + // mishandles minHeight when all lines end up on the current page, leaving a + // 0-line next node that yoga would render at minHeight, not at the ~1-2pt + // remainder. + const remainingLineHeight = + heightAtLineIndex(node, node.lines.length) - currentHeight; + const nextPaddingBottom = + typeof node.box?.paddingBottom === 'number' ? node.box.paddingBottom : 0; + const minH = + typeof node.style?.minHeight === 'number' ? node.style.minHeight : 0; + const nextHeight = Math.max(remainingLineHeight + nextPaddingBottom, minH); const current: SafeTextNode = Object.assign({}, node, { box: { diff --git a/packages/layout/tests/steps/resolvePagination.bench.ts b/packages/layout/tests/steps/resolvePagination.bench.ts new file mode 100644 index 000000000..57d39eb5c --- /dev/null +++ b/packages/layout/tests/steps/resolvePagination.bench.ts @@ -0,0 +1,69 @@ +import { bench, describe } from 'vitest'; +import FontStore from '@react-pdf/font'; + +import { loadYoga } from '../../src/yoga'; +import resolvePagination from '../../src/steps/resolvePagination'; +import resolveDimensions from '../../src/steps/resolveDimensions'; +import { SafeDocumentNode, SafeNode } from '../../src/types'; + +const fontStore = new FontStore(); + +const createChildren = (count: number, pageHeight: number): SafeNode[] => { + const childHeight = pageHeight / 10; // ~10 children per page + const children: SafeNode[] = []; + + for (let i = 0; i < count; i += 1) { + children.push({ + type: 'VIEW', + style: { height: childHeight }, + props: {}, + children: [], + } as unknown as SafeNode); + } + + return children; +}; + +const createDocument = ( + childCount: number, + yoga: ReturnType>>, +): SafeDocumentNode => { + const pageHeight = 792; + + return { + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 612, height: pageHeight }, + children: createChildren(childCount, pageHeight), + }, + ], + } as unknown as SafeDocumentNode; +}; + +const calcLayout = (node: SafeDocumentNode) => + resolvePagination(resolveDimensions(node, fontStore), fontStore); + +describe('resolvePagination benchmark', async () => { + const yoga = await loadYoga(); + + bench('100 children (~10 pages)', () => { + calcLayout(createDocument(100, yoga)); + }); + + bench('500 children (~50 pages)', () => { + calcLayout(createDocument(500, yoga)); + }); + + bench('1000 children (~100 pages)', () => { + calcLayout(createDocument(1000, yoga)); + }); + + bench('2000 children (~200 pages)', () => { + calcLayout(createDocument(2000, yoga)); + }); +}); diff --git a/packages/layout/tests/steps/resolvePagination.test.ts b/packages/layout/tests/steps/resolvePagination.test.ts index 2466aaf4b..0679e30a4 100644 --- a/packages/layout/tests/steps/resolvePagination.test.ts +++ b/packages/layout/tests/steps/resolvePagination.test.ts @@ -458,4 +458,330 @@ describe('pagination step', () => { expect(subChapter3.props!.bookmark).toEqual(bookmarkSubChapter3); }); + + test('should split auto-height container with multiple children across pages', async () => { + const yoga = await loadYoga(); + + const layout = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 100, height: 100 }, + children: [ + { + type: 'VIEW', + style: {}, + props: {}, + children: [ + { + type: 'VIEW', + style: { height: 40 }, + props: {}, + children: [], + }, + { + type: 'VIEW', + style: { height: 40 }, + props: {}, + children: [], + }, + { + type: 'VIEW', + style: { height: 40 }, + props: {}, + children: [], + }, + ], + }, + ], + }, + ], + }); + + // Total content 120 > page height 100, should produce 2 pages + expect(layout.children.length).toBe(2); + + const page1 = layout.children[0]; + const page2 = layout.children[1]; + + expect(page1.box!.height).toBe(100); + // Page 1: 2 full children + partial 3rd (split at page boundary) + expect(page1.children![0].children!.length).toBe(3); + expect(page1.children![0].children![0].box!.height).toBe(40); + expect(page1.children![0].children![1].box!.height).toBe(40); + expect(page1.children![0].children![2].box!.height).toBe(20); + + // Page 2: remainder of 3rd child + expect(page2.children![0].children!.length).toBe(1); + expect(page2.children![0].children![0].box!.height).toBe(20); + }); + + test('should split deeply nested views (3+ levels) across pages', async () => { + const yoga = await loadYoga(); + + const layout = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 100, height: 100 }, + children: [ + { + type: 'VIEW', + style: {}, + props: {}, + children: [ + { + type: 'VIEW', + style: {}, + props: {}, + children: [ + { + type: 'VIEW', + style: { height: 60 }, + props: {}, + children: [], + }, + { + type: 'VIEW', + style: { height: 60 }, + props: {}, + children: [], + }, + ], + }, + ], + }, + ], + }, + ], + }); + + // Total content 120 > page height 100, should produce 2 pages + expect(layout.children.length).toBe(2); + + // Page 1 outermost container + const page1Outer = layout.children[0].children![0]; + expect(page1Outer.box!.height).toBe(100); + + // Page 2 should have remaining content + const page2Outer = layout.children[1].children![0]; + expect(page2Outer.children!.length).toBeGreaterThan(0); + }); + + test('should correctly split page with padding and verify dimensions', async () => { + const yoga = await loadYoga(); + + const layout = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { + width: 100, + height: 100, + paddingTop: 10, + paddingBottom: 10, + }, + children: [ + { + type: 'VIEW', + style: { height: 50 }, + props: {}, + children: [], + }, + { + type: 'VIEW', + style: { height: 50 }, + props: {}, + children: [], + }, + ], + }, + ], + }); + + // Content area = 100 - 10 - 10 = 80, content = 100 > 80 + expect(layout.children.length).toBe(2); + + const page1 = layout.children[0]; + const page2 = layout.children[1]; + + expect(page1.box!.height).toBe(100); + // First child should be on page 1 + expect( + page1.children!.filter((c) => c.type !== 'TEXT_INSTANCE').length, + ).toBeGreaterThanOrEqual(1); + // Second page should have remaining content + expect(page2.children!.length).toBeGreaterThan(0); + }); + + test('should produce correct pages for many children (50+)', async () => { + const yoga = await loadYoga(); + + const children = Array.from({ length: 50 }, () => ({ + type: 'VIEW' as const, + style: { height: 20 }, + props: {}, + children: [], + })); + + const layout = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 100, height: 100 }, + children, + }, + ], + }); + + // 50 children × 20 height = 1000 total, page height 100 + // Should produce 10 pages + expect(layout.children.length).toBe(10); + + // Each page should have 5 children (100 / 20 = 5) + for (const page of layout.children) { + expect(page.children!.length).toBe(5); + } + + // Last page's last child should have correct position + const lastPage = layout.children[9]; + const lastChild = lastPage.children![4]; + expect(lastChild.box!.height).toBe(20); + }); + + test('should split flex-grow children across pages', async () => { + const yoga = await loadYoga(); + + const layout = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 100, height: 100 }, + children: [ + { + type: 'VIEW', + style: { height: 60, flexGrow: 1 }, + props: {}, + children: [], + }, + { + type: 'VIEW', + style: { height: 60, flexGrow: 1 }, + props: {}, + children: [], + }, + ], + }, + ], + }); + + // Total 120 > page 100, should split + expect(layout.children.length).toBe(2); + + const page1 = layout.children[0]; + const page2 = layout.children[1]; + + expect(page1.box!.height).toBe(100); + expect(page2.children!.length).toBeGreaterThan(0); + }); + + test('should handle large non-wrappable element (like Image)', async () => { + const yoga = await loadYoga(); + + const layout = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 100, height: 100 }, + children: [ + { + type: 'VIEW', + style: { height: 30 }, + props: {}, + children: [], + }, + { + type: 'VIEW', + style: { height: 80 }, + props: { wrap: false }, + children: [], + }, + ], + }, + ], + }); + + // 30 + 80 = 110 > 100, and second view can't wrap + expect(layout.children.length).toBe(2); + + // Page 1: only first view + expect(layout.children[0].children!.length).toBe(1); + expect(layout.children[0].children![0].box!.height).toBe(30); + + // Page 2: large non-wrappable view + expect(layout.children[1].children!.length).toBe(1); + expect(layout.children[1].children![0].box!.height).toBe(80); + }); + + test('should handle oversized non-wrappable element larger than page', async () => { + const yoga = await loadYoga(); + + const layout = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 100, height: 100 }, + children: [ + { + type: 'VIEW', + style: { height: 150 }, + props: { wrap: false }, + children: [], + }, + { + type: 'VIEW', + style: { height: 30 }, + props: {}, + children: [], + }, + ], + }, + ], + }); + + // Oversized non-wrappable element overflows page 1, remaining child on next page + expect(layout.children.length).toBeGreaterThanOrEqual(2); + + // Last page should have the small view + const lastPage = layout.children[layout.children.length - 1]; + expect(lastPage.children!.length).toBeGreaterThan(0); + }); }); diff --git a/packages/layout/tests/steps/twoPaneRegression.test.ts b/packages/layout/tests/steps/twoPaneRegression.test.ts new file mode 100644 index 000000000..0a91faecd --- /dev/null +++ b/packages/layout/tests/steps/twoPaneRegression.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, test } from 'vitest'; +import FontStore from '@react-pdf/font'; + +import { loadYoga } from '../../src/yoga'; +import resolvePagination from '../../src/steps/resolvePagination'; +import resolveDimensions from '../../src/steps/resolveDimensions'; +import { SafeDocumentNode, SafeNode } from '../../src/types'; + +const fontStore = new FontStore(); + +const calcLayout = (node: SafeDocumentNode) => + resolvePagination(resolveDimensions(node, fontStore), fontStore); + +const makeBlock = (id: string, style: Record): SafeNode => + ({ + type: 'VIEW', + style, + props: { id }, + children: [], + }) as any; + +describe('pagination with page-level fixed siblings', () => { + // Regression for a bug in the splitNodes optimization: when a page has a + // normal-flow fixed sibling (header) above a flex:1 container that must split + // across pages, the next-page input was given box.top=0 instead of the actual + // header-offset position. splitChildren then computed availableHeight=wrapArea + // instead of wrapArea-headerHeight, packing too much content onto subsequent + // pages and causing yoga to compress sections (content loss). + test('flex:1 body below fixed header splits without losing content', async () => { + const yoga = await loadYoga(); + + const layout = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 100, height: 100 }, + children: [ + { + type: 'VIEW', + style: { height: 20 }, + props: { id: 'header', fixed: true }, + children: [], + }, + { + type: 'VIEW', + style: { flex: 1, position: 'relative' }, + props: { id: 'body' }, + children: [ + { + type: 'VIEW', + style: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 1, + }, + props: { id: 'bottomBorder', fixed: true }, + children: [], + }, + makeBlock('section1', { height: 50 }) as any, + makeBlock('section2', { height: 50 }) as any, + makeBlock('section3', { height: 50 }) as any, + makeBlock('section4', { height: 50 }) as any, + makeBlock('section5', { height: 50 }) as any, + ], + }, + ], + }, + ], + }); + // 5 sections × 50pt = 250pt; body area per page = 100 - 20 (header) = 80pt. + // ceil(250 / 80) = 4 pages expected. + expect(layout.children.length).toBe(4); + + // Verify the body on every page is laid out below the header. + for (const page of layout.children) { + const body = page.children!.find( + (c) => c.props && (c.props as any).id === 'body', + ); + expect(body).toBeDefined(); + expect(body!.box!.top).toBe(20); + expect(body!.box!.height).toBe(80); + } + + // Verify no content was lost — the heights of all section fragments across + // pages should sum back to the original (50pt × 5 = 250pt). Buggy behavior + // compresses sections via yoga shrink and the sum is < 250. + const fragmentHeightById = new Map(); + for (const page of layout.children) { + const body = page.children!.find( + (c) => c.props && (c.props as any).id === 'body', + )!; + for (const child of body.children || []) { + const id = (child.props as any).id as string; + if (!id?.startsWith('section')) continue; + fragmentHeightById.set( + id, + (fragmentHeightById.get(id) || 0) + (child.box?.height ?? 0), + ); + } + } + for (const [id, total] of fragmentHeightById) { + expect(total, `section ${id} total height after split`).toBe(50); + } + expect(fragmentHeightById.size).toBe(5); + }); + + // Regression for incomplete fix: cumulative tracking in splitNodes ignored + // marginTop/marginBottom of siblings. Yoga's layout positions siblings with + // these margins included, so without margin awareness the synthesized box.top + // values for the next page diverge from what yoga relayout would produce — + // splitNode's `current.style.height = height - nodeTop` becomes too large and + // yoga compresses content via flex-shrink. + test('header marginBottom shifts body splits and no content is compressed', async () => { + const yoga = await loadYoga(); + + const layout = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 100, height: 100 }, + children: [ + { + type: 'VIEW', + style: { height: 20, marginBottom: 10 }, + props: { id: 'header', fixed: true }, + children: [], + }, + makeBlock('section1', { height: 30 }) as any, + makeBlock('section2', { height: 30 }) as any, + makeBlock('section3', { height: 30 }) as any, + makeBlock('section4', { height: 30 }) as any, + makeBlock('section5', { height: 30 }) as any, + ], + }, + ], + }); + + // Effective space below header per page = 100 - 20 - 10 (mb) = 70. + // 5 sections × 30pt = 150pt; ceil(150 / 70) = 3 pages expected. + expect(layout.children.length).toBe(3); + + // Heights of each section across pages should sum to original 30. + const fragmentHeightById = new Map(); + for (const page of layout.children) { + for (const child of page.children || []) { + const id = (child.props as any).id as string; + if (!id?.startsWith('section')) continue; + fragmentHeightById.set( + id, + (fragmentHeightById.get(id) || 0) + (child.box?.height ?? 0), + ); + } + } + for (const [id, total] of fragmentHeightById) { + expect(total, `section ${id} total height after split`).toBe(30); + } + expect(fragmentHeightById.size).toBe(5); + + // Header must keep its original height on every page (no flex-shrink). + for (const page of layout.children) { + const header = page.children!.find( + (c) => c.props && (c.props as any).id === 'header', + ); + expect(header).toBeDefined(); + expect(header!.box!.height).toBe(20); + } + }); + + // Regression: placeOnNextPage assumed flex-column stacking and applied a + // cumulative vertical offset to every non-fixed child pushed to nextChildren. + // In a flex:row container, both columns share top=0 — the left column's height + // must NOT be added as a top offset to the right column on the next page. + test('flex:row sectionRow with asymmetric column heights splits without overlap', async () => { + const yoga = await loadYoga(); + + // Structure mirrors form2: + // PAGE (w=100, h=100) + // header (fixed, h=10) + // table (flex:1, position:relative) + // tableHeader (fixed, h=10) + // encounterWrapper (marginBottom=5) + // sectionRow (flexDirection:row) + // left (flex:1) – tall + // right (flex:1) – short, stretched to left's height + // + // Content area within table per page = 100 - header(10) - tableHeader(10) = 80. + // sectionRow height = 70 (left content). With marginBottom=5 encounter = 75. + // First page fits encounter partially (split at 80 - 0 = 80... row.h=70 < 80, fits whole). + // Actually let's make row tall enough to overflow: left=90, right=20 → row=90. + // encounter = 90 + 5(mb) = 95 > 80 → split at 80. + // After split, next page: leftRemainder(h=10) at top=0, rightRemainder(h=10) at top=0. + // With the bug: rightRemainder.top = leftRemainder.height = 10 → wrong. + const layout = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 100, height: 100 }, + children: [ + { + type: 'VIEW', + style: { height: 10 }, + props: { id: 'header', fixed: true }, + children: [], + }, + { + type: 'VIEW', + style: { flex: 1 }, + props: { id: 'table' }, + children: [ + { + type: 'VIEW', + style: { height: 10 }, + props: { id: 'tableHeader', fixed: true }, + children: [], + }, + { + type: 'VIEW', + style: { marginBottom: 5 }, + props: { id: 'encounter' }, + children: [ + { + type: 'VIEW', + style: { flexDirection: 'row' }, + props: { id: 'sectionRow' }, + children: [ + { + type: 'VIEW', + style: { flex: 1 }, + props: { id: 'left' }, + children: [ + makeBlock('l1', { height: 10 }) as any, + makeBlock('l2', { height: 10 }) as any, + makeBlock('l3', { height: 10 }) as any, + makeBlock('l4', { height: 10 }) as any, + makeBlock('l5', { height: 10 }) as any, + makeBlock('l6', { height: 10 }) as any, + makeBlock('l7', { height: 10 }) as any, + makeBlock('l8', { height: 10 }) as any, + makeBlock('l9', { height: 10 }) as any, + ], + }, + { + type: 'VIEW', + style: { flex: 1 }, + props: { id: 'right' }, + children: [ + makeBlock('r1', { height: 10 }) as any, + makeBlock('r2', { height: 10 }) as any, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }); + + // The encounter (sectionRow h=90, mb=5) occupies 95pt. + // Per-page body = 100 - 10(header) = 90; table content = 90 - 10(tableHeader) = 80. + // 95 > 80, so the encounter must split across pages. + expect(layout.children.length).toBeGreaterThanOrEqual(2); + + // On every page, left and right columns within sectionRow must share the same + // top value (both = 0 relative to sectionRow — they are side-by-side in a row). + for (const page of layout.children) { + const table = page.children!.find((c) => (c.props as any).id === 'table'); + if (!table) continue; + const enc = table.children!.find( + (c) => (c.props as any).id === 'encounter', + ); + if (!enc) continue; + const row = enc.children!.find( + (c) => (c.props as any).id === 'sectionRow', + ); + if (!row) continue; + const left = row.children!.find((c) => (c.props as any).id === 'left'); + const right = row.children!.find((c) => (c.props as any).id === 'right'); + if (!left || !right) continue; + + expect( + left.box!.top, + `page ${page.subPageNumber}: left.top should equal right.top`, + ).toBe(right.box!.top); + } + + // Total height of left fragments must equal original left content (9×10 = 90). + // Total height of right fragments must equal original right content (2×10 = 20, + // but right is stretched to match left's height by alignItems:stretch). + // The key invariant: no content loss (heights sum to the split total). + let totalLeftH = 0; + let totalRightH = 0; + for (const page of layout.children) { + const table = page.children!.find((c) => (c.props as any).id === 'table'); + if (!table) continue; + const enc = table.children!.find( + (c) => (c.props as any).id === 'encounter', + ); + if (!enc) continue; + const row = enc.children!.find( + (c) => (c.props as any).id === 'sectionRow', + ); + if (!row) continue; + const left = row.children!.find((c) => (c.props as any).id === 'left'); + const right = row.children!.find((c) => (c.props as any).id === 'right'); + if (left) totalLeftH += left.box?.height ?? 0; + if (right) totalRightH += right.box?.height ?? 0; + } + // sectionRow height = max(leftContent, rightContent) with alignItems:stretch = 90. + expect(totalLeftH).toBe(90); + expect(totalRightH).toBe(90); + }); + + // Critical regression: when a flex:row container spans 3+ pages, the buggy + // placeOnNextPage accumulates cumNonFixedNextHeight per column and assigns + // right.box.top = left.box.height on the next page. In iter2 this wrong top + // trips the `isOutside` guard (availH <= wrongTop), so the right column skips + // page 2 entirely and lands on page 3 — leaving page 2's right pane empty and + // page 3 overloaded. + test('flex:row spanning 3 pages keeps both columns on every page', async () => { + const yoga = await loadYoga(); + + // Minimal structure: sectionRow (flex:row) whose natural height (120) + // exceeds page height (50) by 2.4×, forcing splits across 3 pages. + // alignItems:stretch makes right.h == left.h == 120 despite right having + // only 20pt of content. + const layout = calcLayout({ + type: 'DOCUMENT', + yoga, + props: {}, + children: [ + { + type: 'PAGE', + props: {}, + style: { width: 100, height: 50 }, + children: [ + { + type: 'VIEW', + style: { flexDirection: 'row' }, + props: { id: 'row' }, + children: [ + { + type: 'VIEW', + style: { flex: 1 }, + props: { id: 'left' }, + children: [ + makeBlock('la', { height: 30 }) as any, + makeBlock('lb', { height: 30 }) as any, + makeBlock('lc', { height: 30 }) as any, + makeBlock('ld', { height: 30 }) as any, + ], + }, + { + type: 'VIEW', + style: { flex: 1 }, + props: { id: 'right' }, + children: [makeBlock('ra', { height: 20 }) as any], + }, + ], + }, + ], + }, + ], + }); + + // left content = 120pt, right stretched to 120pt. Page h=50 → 3 pages. + expect(layout.children.length).toBe(3); + + // Both columns must appear on EVERY page (never missing due to isOutside + // mis-classification from wrong top offset). + for (const page of layout.children) { + const row = page.children!.find((c) => (c.props as any).id === 'row'); + expect(row, `row missing on page`).toBeDefined(); + const left = row!.children!.find((c) => (c.props as any).id === 'left'); + const right = row!.children!.find((c) => (c.props as any).id === 'right'); + expect(left, `left column missing`).toBeDefined(); + expect(right, `right column missing`).toBeDefined(); + // Both columns are side-by-side: same top within the row. + expect(left!.box!.top, `left/right top mismatch`).toBe(right!.box!.top); + } + + // Heights across all pages must sum to original (120 each). + let totalLeft = 0; + let totalRight = 0; + for (const page of layout.children) { + const row = page.children!.find((c) => (c.props as any).id === 'row'); + if (!row) continue; + const left = row.children!.find((c) => (c.props as any).id === 'left'); + const right = row.children!.find((c) => (c.props as any).id === 'right'); + totalLeft += left?.box?.height ?? 0; + totalRight += right?.box?.height ?? 0; + } + expect(totalLeft).toBe(120); + expect(totalRight).toBe(120); + }); +}); diff --git a/packages/renderer/tests/form2TwoPaneOverflow.test.jsx b/packages/renderer/tests/form2TwoPaneOverflow.test.jsx new file mode 100644 index 000000000..124659515 --- /dev/null +++ b/packages/renderer/tests/form2TwoPaneOverflow.test.jsx @@ -0,0 +1,299 @@ +/** + * Regression test mirroring the henry-web form2 (二号用紙) layout. + * + * Structure mirrors Form2Page.tsx + Form2SectionColumn.tsx exactly: + * A4 (paddingTop=44, paddingBottom=32, paddingHorizontal=48) + * ├─ fixed page header (flexDirection:row, marginBottom:8, 3 text lines each col, fontSize:10) + * └─ table (flex:1, borderLeft/Right, position:relative) + * ├─ centerLine (fixed, absolute, left=50%) + * ├─ tableBottom (fixed, absolute, bottom=0) + * ├─ tableHeader (fixed, flexDirection:row, borderTop+Bottom, fontSize:12) + * └─ encounterWrapper × N (marginBottom=16) + * ├─ sectionRow (flexDirection:row) + * │ ├─ sectionLeft (flex:1) → SectionColumn + * │ └─ sectionRight (flex:1) → SectionColumn + * │ SectionColumn = View{padding:"8 8 0 8"} + * │ date text (fontSize:9) + * │ groups → View{blockItems} (spacer between groups) + * └─ footerText (width=50%, alignSelf=flex-end, fontSize:9) + * + * Content: lorem ipsum. Three encounters, each with BOTH left and right + * columns containing meaningful content, sized so multiple pages are needed. + * This exercises the flex:row multi-page split path that was broken by the + * original optimization. + */ +import { describe, expect, test } from 'vitest'; +import { Document, Page, View, Text, StyleSheet } from '@react-pdf/renderer'; +import renderToImage from './renderComponent'; + +const BORDER = '1pt solid #000'; + +const styles = StyleSheet.create({ + page: { paddingTop: 44, paddingBottom: 32, paddingHorizontal: 48 }, + header: { flexDirection: 'row', marginBottom: 8 }, + headerCol: { flex: 1 }, + headerText: { fontSize: 10 }, + table: { + borderLeft: BORDER, + borderRight: BORDER, + flex: 1, + position: 'relative', + }, + centerLine: { + position: 'absolute', + top: 0, + left: '50%', + bottom: 0, + borderLeft: BORDER, + }, + tableBottom: { + position: 'absolute', + bottom: 0, + left: -1, + right: -1, + borderBottom: BORDER, + }, + tableHeaderRow: { + flexDirection: 'row', + borderTop: BORDER, + borderBottom: BORDER, + }, + tableHeaderCell: { + flex: 1, + fontSize: 12, + textAlign: 'center', + padding: '0 8', + }, + encounterWrapper: { marginBottom: 16 }, + sectionRow: { flexDirection: 'row' }, + column: { flex: 1, padding: '8 8 0 8' }, + dateText: { fontSize: 9 }, + spacer: { fontSize: 9, minHeight: 12 }, + block: { fontSize: 9, minHeight: 12 }, + footer: { + width: '50%', + alignSelf: 'flex-end', + fontSize: 9, + textAlign: 'right', + padding: '0 8', + }, +}); + +// SectionColumn mirrors Form2SectionColumn exactly +const SectionColumn = ({ date, groups }) => { + if (!groups.length) return ; + return ( + + {date && {date}} + {groups.map((lines, gi) => ( + + {gi > 0 && } + {lines.map((line, li) => ( + + {line} + + ))} + + ))} + + ); +}; + +const Encounter = ({ date, leftGroups, rightGroups, footer }) => ( + + + + + + {footer} + +); + +const L = [ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Sed do eiusmod tempor incididunt ut labore et dolore magna.', + 'Ut enim ad minim veniam, quis nostrud exercitation ullamco.', + 'Duis aute irure dolor in reprehenderit in voluptate velit.', + 'Excepteur sint occaecat cupidatat non proident deserunt.', + 'Nisi ut aliquip ex ea commodo consequat enim veniam quis.', + 'Adipiscing elit sed do eiusmod tempor incididunt labore.', + 'Reprehenderit in voluptate velit esse cillum dolore fugiat.', + 'Sunt in culpa qui officia deserunt mollit anim id est.', + 'Laborum perspiciatis unde omnis iste natus error voluptatem.', + 'Accusantium doloremque laudantium totam rem aperiam eaque.', + 'Ipsa quae ab illo inventore veritatis et quasi architecto.', +]; +const l = (i, n) => Array.from({ length: n }, (_, j) => L[(i + j) % L.length]); + +const Form2Doc = () => ( + + + + + Lorem: 000001 + Ipsum: Lorem Ipsum + Dolor: 45 1980-01-01 + + + Sit: 2026-01-01 ~ 2026-06-30 + Amet: Lorem Ipsum + Consectetur + + + + + + + + Lorem / Ipsum / Dolor + Sit / Amet / Consectetur + + + {/* Encounter 1: left-heavy (~430pt) */} + + + {/* Encounter 2: symmetric (~380pt) */} + + + {/* Encounter 3: right-heavy (~420pt driven by right) */} + + + {/* Encounter 4: symmetric, forces page 3+ (~380pt) */} + + + {/* Encounter 5: left-heavy, forces page 4+ (~440pt) */} + + + {/* Encounter 6: right-heavy, forces page 5 (~400pt) */} + + + {/* Encounter 7: symmetric (~380pt), forces page 5 */} + + + {/* Encounter 8: short closer */} + + + + +); + +describe('form2 two-pane overflow regression', () => { + test('multi-encounter A4 two-pane layout should match snapshot', async () => { + const image = await renderToImage(); + expect(image).toMatchImageSnapshot(); + }, 30_000); +}); diff --git a/packages/renderer/tests/snapshots/form-2-two-pane-overflow-test-jsx-tests-form-2-two-pane-overflow-test-jsx-form-2-two-pane-overflow-regression-multi-encounter-a-4-two-pane-layout-should-match-snapshot-1-snap.png b/packages/renderer/tests/snapshots/form-2-two-pane-overflow-test-jsx-tests-form-2-two-pane-overflow-test-jsx-form-2-two-pane-overflow-regression-multi-encounter-a-4-two-pane-layout-should-match-snapshot-1-snap.png new file mode 100644 index 000000000..9ecb52f58 Binary files /dev/null and b/packages/renderer/tests/snapshots/form-2-two-pane-overflow-test-jsx-tests-form-2-two-pane-overflow-test-jsx-form-2-two-pane-overflow-regression-multi-encounter-a-4-two-pane-layout-should-match-snapshot-1-snap.png differ