From 7d8c80b83ba19aceca0eab1421519d42479a250e Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sat, 5 Sep 2026 16:15:21 +0200 Subject: [PATCH 1/2] Cut a positioned container where its author permits it, and place 182 more leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pdf.js refuses to split a container at `layout === "position"` BEFORE it reads `keep.intact`, so `` — the author's explicit "you may break this" — is unreachable in it. This package followed pdf.js and put 1 423 body leaves off the paper across 39 forms where pdfium put 25 across 6 (#7). pdfium reads the permission. `FindSplitPos` calls `FindLayoutItemSplitPos` (cxfa_contentlayoutprocessor.cpp:503-588), which switches on `CXFA_Node::GetIntact()`; and `GetIntact` (cxfa_node.cpp:1536-1591) answers ContentArea for a positioned subform — the cut is driven to nought and the container moves whole — UNLESS a `` is written, in which case `GetIntactFromKeep` returns what the author wrote. So the rule is NARROWER than the diagnosis on #7 stated. pdfium does not split positioned containers in general; it splits one exactly where the permission is written. The previous comment read the exemption off `bCalculateMargin = eLayout != Position`, which governs the margin arithmetic and not the gate. The population: 58 positioned subforms across 17 of the corpus's 560 forms. Not the 39 forms whose off-paper leaves the position clause dominates. ## The mechanism, which is pdfium's and not pdf.js's pdfium's split is a post-hoc slice of an already-laid-out item tree, so [placer.cuttable] is a different question from [placer.splittable] and answered by a different function. The container is never put on the flowing chain: it is laid out whole and then cut, and each part's children keep the y the template wrote for them, less however much of the container is on the sheets before — SplitLayoutItem's `pChildItem->s_pos_.y -= fSplitPos`. Letting the flow split it would have placed every child by the cursor instead. The cut is walked up out of whatever it lands inside, which is pdfium's guard at both ends. One difference, deliberate: pdfium recurses into a child whose intact is "none" — a field of a positioned container is one — and, finding nothing finer, CUTS IT IN HALF, emitting two layout items for the one node. A [Box] is one rectangle for one occurrence and [Layout] promises every body leaf appears exactly once, so the cut is REFUSED there and the container moves whole, which is also what pdfium does when FindSplitPos comes back at nought. ## Measured, against pdfium over 559 forms before after paired body leaves 177676 177676 ambiguous keys dropped 9157 9157 agree on x 173806 173806 (97.82%, unmoved) agree on sheet+y 162018 -> 162200 (91.19% -> 91.29%) forms perfect on x 465 465 forms perfect on sheet+y 403 403 Exactly two forms move and NO form is worse on either measure: us-uscis__i-956 42 -> 168 of 491 us-uscis__i-956g 118 -> 174 of 538 The judge's pairing was re-audited because this moves boxes onto sheets they were not on: the population is identical, 177676 paired and 9157 dropped before and after. Nothing became ambiguous, which is what the bisect refusal buys. i-956's page-area sequence goes from `Page2 x2, Page3 x10` to `Page2 x13, Page3` — pdfium's shape, `Page2 x16, Page3` — which repairs the regression #20 recorded rather than tuned away. Sheets: corpus 3086 -> 3088, i-956 12 -> 14 (pdfium 17). Off the paper: 1423 -> 1378 boxes across 39 -> 37 forms. ## The cut positions are pdfium's, to four decimals An instrumented build over all 560 forms attempts the cut FIVE times on four containers. i-956's sfPart2: behind=0.0000 room=162.1843 at=162.1843 5 children behind=162.1843 room=648.0000 at=744.1824 19 children behind=744.1824 rest 1 child pdfium's own trace: splitPos=162.1843, then 581.9981 from 162.1843, and three items of 162.1843 + 581.9981 + 222.0010. Independent arithmetic, same numbers, same partition of the 25 children. Two of the five attempts are refused because the cut would bisect a field: i-956's sfPart7_2 and i-956g's sfCertification. Both still move whole, and that is the named cost. Gate: 100% statement coverage, go vet, gofmt clean against Go 1.27.1, -race, thirteen cross-compile targets, CGO_ENABLED=0, GOWORK=off. Co-Authored-By: Claude Opus 5 --- doc.go | 5 + layout.go | 8 ++ paginate.go | 12 ++ split.go | 338 ++++++++++++++++++++++++++++++++++++++++++++++++++ split_test.go | 238 +++++++++++++++++++++++++++++++++++ 5 files changed, 601 insertions(+) create mode 100644 split.go create mode 100644 split_test.go diff --git a/doc.go b/doc.go index 2d25af3..e33cd3e 100644 --- a/doc.go +++ b/doc.go @@ -105,6 +105,11 @@ // positioned layout, a row, anything with keep intact, anything inside an // — moves whole. // +// Except a positioned container whose author wrote keep intact="none" on it. +// That is a permission pdf.js's clause order makes unreachable and pdfium +// reads, and such a container is laid out whole and then CUT across the +// boundary, its children keeping their written y. See [placer.cuttable]. +// // What moves whole is still put on the paper. The first such container of each // sheet is not measured against anything: pdf.js's checkDimensions returns // true while the sheet has had none (layout.js:266-268) and the one that diff --git a/layout.go b/layout.go index e0edd18..7b0794f 100644 --- a/layout.go +++ b/layout.go @@ -176,6 +176,14 @@ var flowLayouts = map[string]bool{ // unplaced where it fits no page at all. A container that WRAPS is split at a // line boundary and never inside one. // +// A POSITIONED container is the one exception, and only where its author asked +// for it. `` on one is a permission pdfium reads and +// pdf.js's clause ORDER cannot reach, and a container carrying it is laid out +// whole and then CUT: its children keep the y the template wrote for them, +// less however much of the container is on the sheets before. See +// [placer.cuttable], which is a different question from [placer.splittable] and +// answered by a different mechanism. +// // # What it deliberately does not do, and reports instead // // - rl-row, which fills a ROW from the right. That needs the row's own diff --git a/paginate.go b/paginate.go index ac03075..522999d 100644 --- a/paginate.go +++ b/paginate.go @@ -327,6 +327,18 @@ func (p *placer) whole(kid *FormNode, lv *level, lay string) bool { // This is why a container taller than a whole content area is not a // refusal: it comes out one per sheet, overflowing, exactly as pdf.js // draws it. + // + // Unless the author said it may be cut. A positioned container carrying + // `` is sliced across the sheets instead of being put + // down overflowing — see [placer.cuttable] — and the free pass does not + // reach it, because the pass exists so that something too tall for any + // sheet still lands somewhere and a container that can be cut does not + // need it. + if !fits(p.y+h, lv.bottom) && p.cuttable(kid) { + if done, cut := p.cut(kid, lv, h); cut { + return done + } + } for !p.free && !fits(p.y+h, lv.bottom) { if !p.advance(p.overflowTo(kid)) { p.blocked = noNextPage diff --git a/split.go b/split.go new file mode 100644 index 0000000..f4f7614 --- /dev/null +++ b/split.go @@ -0,0 +1,338 @@ +// Copyright (c) 2026, the go-pdfkit/xfa authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package xfa + +// cutPrecision is pdfium's kXFALayoutPrecision +// (cxfa_contentlayoutprocessor.h:28), the slop every comparison in its split +// arithmetic is made with. It is half a thousandth of a point: not a tolerance +// for heights written in millimetres — that is [fitSlop] — but a guard against +// a cut landing exactly on a child's edge and being read as falling inside it. +const cutPrecision = 0.0005 + +// cuttable says a container is one this package CUTS across a content area +// boundary rather than moving whole: it is laid out at its own coordinates and +// then sliced, so its children keep the y the template wrote for them. +// +// It is not [placer.splittable] and must not be confused with it. splittable +// gates a FLOW-based split, where a container's children are stacked by the +// cursor and the ones that did not fit begin again at the top of the next +// content area. That is pdf.js's mechanism and it is the wrong one here: a +// positioned container's children are placed by their own x and y, so resuming +// one under the flow cursor would move every one of them. +// +// # Why a positioned container is cut at all +// +// Because the author asked. pdfium decides what may be cut in +// FindLayoutItemSplitPos (cxfa_contentlayoutprocessor.cpp:503-588), which +// switches on CXFA_Node::GetIntact(): +// +// None recurse into the children; the cut may stand +// ContentArea, PageArea *fProposedSplitPos = fCurVerticalOffset — walk UP +// default return false — no change +// +// and GetIntact (cxfa_node.cpp:1536-1591) answers ContentArea for a subform +// whose layout is position or row — UNLESS the subform writes a , in +// which case GetIntactFromKeep (:2486-2500) returns what the author wrote. At +// the outermost call fCurVerticalOffset is nought, so a positioned subform with +// no keep drives the proposed cut to nought, FindSplitPos returns nought, and +// InsertFlowedItem's `if (fSplitPos > kXFALayoutPrecision)` (:2691) is false: +// the container moves whole. +// +// So pdfium does NOT cut positioned containers in general. It cuts one exactly +// where `` is written on it. +// +// pdf.js cannot reach that permission at all. Subform[$isSplittable] +// (template.js:4940-4975) answers false at +// +// if (this.layout === "position" || this.layout.includes("row")) { return false; } +// +// BEFORE it ever reads this.keep.intact. This package followed pdf.js there and +// put 1 423 body leaves off the paper across 39 forms where pdfium put 25 across +// 6. Following pdfium is the standard that has decided every other tie in this +// work, and here the template's author asked for it in the file. +// +// # How large the permission is +// +// Fifty-eight positioned subforms across seventeen of the corpus's 560 forms +// write it. None of them is a form root, thirty-four write a height and thirteen +// write a margin, of which all but three are nought on every side. That is the +// whole population this touches — not the thirty-nine forms whose off-paper +// leaves the position clause dominates, which is a wider set that mostly does +// NOT carry the permission. +// +// # What it does not cover, and why +// +// - A container that is not a subform. pdfium's GetIntact answers None for an +// exclGroup, so pdfium would cut one; no exclGroup of the corpus writes a +// keep of "none", so nothing would say whether it were right. +// - A row. GetIntact reads position and row alike, so `` +// on a row would be cut by pdfium too. A row's children are not at written +// coordinates — they are cut from the columnWidths above and stretched to +// the tallest — so cutting one is a different mechanism, and the corpus +// writes none. +// - A child of a container that WRAPS onto lines. [placer.whole] is reached +// only from [placer.flowStack]; a LINE is what moves in [placer.flowLines], +// and cutting one member of a line would leave the rest of that line on a +// sheet the first half of it is not on. +// - A container the template hides. pdfium does not lay one out at all — +// PresenceRequiresSpace is false and no layout item is made — so there is +// nothing to cut, and [placer.heightOf] gives it no height either. +func (p *placer) cuttable(n *FormNode) bool { + return n.Kind == "subform" && layoutOf(n) == "position" && !hidden(n.Template) && + n.Template.Child("keep").Get("intact") == "none" +} + +// intactOf is pdfium's CXFA_Node::GetIntact (cxfa_node.cpp:1536-1591): what a +// cut does when it lands inside this element. +// +// It is asked of the children of a container being cut, and the answer that +// matters is whether it is "none". Anything else means the cut may not fall +// inside the element and is walked up to its top. +// +// A written wins outright. GetIntactFromKeep has one exception — +// a keep of "none" on a ROW is ignored below XFA 2.08 — and it cannot fire +// here: every template of the corpus is an xfa-template 3.0 document, and +// [placer.cuttable] admits no row anyway. +// +// The field arm is the one that is easy to read past. A field's intact depends +// on the container above it: ContentArea where that container is itself kept +// together, and "none" where the container is positioned, a row or a table — +// which is exactly the case a cut arrives in. So a FIELD inside a container +// being cut may be cut in half, and pdfium does cut it in half. See [cutAt] for +// why this package will not. +func intactOf(p *placer, n *FormNode) string { + if v := n.Template.Child("keep").Get("intact"); v != "" { + return v + } + switch n.Kind { + case "subform": + switch layoutOf(n) { + case "position", "row": + return "contentArea" + default: + return "none" + } + case "draw": + // A draw is never cut, whatever it holds and wherever it sits. + return "contentArea" + case "field": + parent, ok := p.up[n] + if !ok || parent.Kind == "pageArea" { + return "contentArea" + } + if intactOf(p, parent) != "none" { + return "contentArea" + } + // The remaining arm of pdfium's switch — a tb parent below XFA 2.08 + // with a written height — keeps a field together. Every corpus template + // is 3.0, so it is named rather than written: a rule nothing measures is + // a rule nobody can be wrong about out loud. + return "none" + default: + return "none" + } +} + +// A piece is one child of a container being cut, with where it sits inside the +// container's own box. +// +// y is measured from the TOP of that box, with the container's own top inset +// already added, because that is where [placer.children] puts a positioned +// child and because pdfium's layout items carry their ancestors' insets in +// their position too (CXFA_ContentLayoutItem::GetAbsoluteRect, +// cxfa_contentlayoutitem.cpp:82-90). +type piece struct { + node *FormNode + y, h Measure + // gone says the template hides it, so it takes no room and no cut is ever + // walked up to it. pdfium does not lay a hidden element out at all — + // PresenceRequiresSpace is false and no layout item is made — so it is in no + // cut's arithmetic. It is still PLACED, with the part of the container its + // own y falls in, because this package reports a hidden element rather than + // dropping it. + gone bool + // keep says a cut may not fall inside it, which is [intactOf] answering + // anything but "none". + keep bool +} + +// piecesOf measures every child of a container about to be cut, in the order +// the container holds them. +// +// Every y and every height here is readable, and that is not an assumption: +// [placer.whole] asks [placer.heightOf] for this container's own height before +// it asks for a cut, and the positioned arm of [placer.contentHeight] reads the +// same y of the same children and the same [placer.heightOf] of each, at the +// same width. A container holding something none of that can be read for is +// blocked there and never arrives — see [placer.cut] for the margin, which +// [placer.measure] reads in the same pass. +func (p *placer) piecesOf(n *FormNode, in insets, wide Measure) []piece { + kids := contained(n) + out := make([]piece, 0, len(kids)) + for _, k := range kids { + y, _, _ := k.Template.Measure("y") + h, _ := p.heightOf(k, wide, 0) + out = append(out, piece{ + node: k, y: in.top + y, h: h, + gone: hidden(k.Template), + keep: intactOf(p, k) != "none", + }) + } + return out +} + +// cutAt walks a proposed cut up until it falls inside nothing, which is +// pdfium's FindLayoutItemSplitPos (cxfa_contentlayoutprocessor.cpp:503-588). +// +// pdfium's rule: an item the cut does not land INSIDE never moves it — the +// guard at :509-513 returns false for one wholly above and for one wholly +// below. An item it does land inside moves the cut up to that item's own top +// where the item's GetIntact is ContentArea or PageArea, and is recursed into +// where it is None. +// +// bisects is the case this package will not follow. A child whose intact is +// "none" — a field of a positioned container is one, by [intactOf] — moves the +// cut nowhere, and pdfium then CUTS IT IN HALF: SplitLayoutItem emits two +// layout items for the one node (:729-864), the top half on one sheet and the +// bottom half on the next. A [Box] is one rectangle for one occurrence and +// [Layout] promises every body leaf appears in exactly one of [Page.Boxes] and +// [Layout.Unplaced]; a field emitted twice would break the promise the type +// exists for, and any judge pairing by name would drop it as ambiguous. So the +// cut is REFUSED there rather than moved somewhere pdfium never puts it: see +// [placer.cut]. +// +// behind is how much of the container is already on earlier sheets, so that the +// walk cannot go back past what has been placed. +func cutAt(pieces []piece, behind, proposed Measure) (at Measure, bisects bool) { + at = proposed + for changed := true; changed; { + changed = false + for _, s := range pieces { + if !inside(s, behind, at) || !s.keep { + continue + } + at, changed = s.y, true + break + } + } + for _, s := range pieces { + if inside(s, behind, at) { + return at, true + } + } + return at, false +} + +// inside says a cut lands within one child, which is pdfium's guard at both +// ends (FindLayoutItemSplitPos, :509-513): a child the cut is at or above the +// top of, and one it is at or past the bottom of, is left alone. A child the +// flow has already carried past is not in the arithmetic at all, nor is one the +// template hides. +func inside(s piece, behind, at Measure) bool { + if s.gone || s.y+s.h <= behind+cutPrecision { + return false + } + return at > s.y+cutPrecision && at <= s.y+s.h-cutPrecision +} + +// cut lays a positioned container out whole and then slices it across as many +// content areas as it needs, which is pdfium's mechanism rather than pdf.js's. +// +// The container is never put on the flowing chain. Its children are placed at +// the y the template wrote for them, less however much of the container is +// already on earlier sheets — so a child written at 744 pt inside a container +// whose first 744 pt are on the sheet before lands at the top of this one, +// which is what SplitLayoutItem does when it moves a child to the second item +// with `pChildItem->s_pos_.y -= fSplitPos` (:806). The partition is by the +// child's TOP y, which is the same line's test: `fSplitPos <= childY` sends it +// on, `fSplitPos >= childY + childH` keeps it here. +// +// total is the height the container reports to the stack above it, which +// [placer.whole] has already measured. +// +// It returns done=false where the flow cannot go on, and cut=false where no cut +// was made at all and the caller should place the container the way it always +// did. Nothing is placed on the second of those. +func (p *placer) cut(n *FormNode, lv *level, total Measure) (done, cut bool) { + // The margin is readable, for the reason [placer.piecesOf] gives: the + // height [placer.whole] already has in hand was computed by + // [placer.measure], which stops at a margin it cannot read before it + // measures anything else. + in, _ := marginOf(n.Template) + wide := innerWide(n, lv.wide, 0, "position", in) + pieces := p.piecesOf(n, in, wide) + cols, _ := columnWidths(n.Template) + left := make([]bool, len(pieces)) + for i := range left { + left[i] = true + } + + var behind Measure + for { + at, last := unbounded, true + if !fits(p.y+total-behind, lv.bottom) { + // pdfium's FindSplitPos(fAvailHeight - fContentCurRowY), + // InsertFlowedItem:2690, in the frame the flow has got to. The fit + // it is asked after is [fits] rather than pdfium's own, because the + // flow around it is pdf.js's and one page boundary cannot be + // decided by two different tests. + var bisects bool + at, bisects = cutAt(pieces, behind, behind+lv.bottom-p.y) + last = bisects || at <= behind+cutPrecision + if last && behind == 0 { + // The container was never cut. Nothing has been placed, and the + // caller carries on as it always did. + return false, false + } + // A last part that could not be cut goes down whole, which is what + // pdfium does when FindSplitPos returns nought. + if last { + at = unbounded + } + } + // pdf.js's free pass, applied to the part in hand: the first thing on a + // sheet that moves in one piece cannot fail, and neither can anything + // inside it. See [placer.whole]. + first := p.free + p.free, p.noFail = false, first + p.touch() + top := p.y - behind + for i, s := range pieces { + if !left[i] || s.y+cutPrecision >= at { + continue + } + left[i] = false + // The container's own insets move its children in, exactly as + // [placer.children] does for a positioned container that is not + // cut. The top one is already in the part's own y, so the frame + // carries it and the part's y is added to it by [placer.place] + // reading the child's written y. + p.place(s.node, frame{ + x: lv.x + in.left, y: top + in.top, + avail: lv.bottom - top, wide: wide, cols: cols, + }) + } + p.noFail = false + if last { + // What the container adds to the stack above it is the whole of its + // height measured from where this last part began, which is + // SplitLayoutItem's second item being `s_size_.height - fSplitPos` + // tall (:762). + p.y = top + total + return true, true + } + behind = at + if !p.advance(p.overflowTo(n)) { + p.blocked = noNextPage + for i, s := range pieces { + if left[i] { + p.rejectAll(s.node, noNextPage) + } + } + return false, true + } + } +} diff --git a/split_test.go b/split_test.go new file mode 100644 index 0000000..5ec21b2 --- /dev/null +++ b/split_test.go @@ -0,0 +1,238 @@ +// Copyright (c) 2026, the go-pdfkit/xfa authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package xfa + +import ( + "strings" + "testing" +) + +// cutting is a positioned container of three draws at written y, inside a tb +// root, on sheets whose content area is 30 points tall. The container is 60 +// points, so it cannot fit on any sheet whole. +// +// B at y=0 h=10 -> 0..10 +// C at y=20 h=10 -> 20..30 +// D at y=50 h=10 -> 50..60 +func cutting(keep, attrs string) string { + return `` + keep + ` + + + ` +} + +func TestAPositionedContainerIsCutOnlyWhereItsAuthorPermitsIt(t *testing.T) { + // With no the subform is pdfium's ContentArea — GetIntact, + // cxfa_node.cpp:1550-1557 — so it moves whole onto the next sheet and runs + // off the bottom, which is what this package has always done and what + // pdf.js does. + l := laidOut(t, sheets(`>`+sheet("P", "", "30", ""), + ``+cutting("", ""))) + same(t, "an unpermitted container", byPage(l), []string{ + "0: draw f.A 0,0 1x10", + "1: draw f.G.B 0,0 1x10", "1: draw f.G.C 0,20 1x10", "1: draw f.G.D 0,50 1x10"}) + + // is the author's permission, and pdfium reads it + // through GetIntactFromKeep. The cut falls at 20 — the room left below A — + // bisecting nothing, and the second part begins again at the top of the + // next sheet with 20 taken off every child's written y. D still does not + // fit in the 30 that are left, so it is cut again at 50. + l = laidOut(t, sheets(`>`+sheet("P", "", "30", ""), + ``+cutting(``, ""))) + same(t, "a permitted container", byPage(l), []string{ + "0: draw f.A 0,0 1x10", "0: draw f.G.B 0,10 1x10", + "1: draw f.G.C 0,0 1x10", + "2: draw f.G.D 0,0 1x10"}) +} + +func TestACutIsWalkedUpToTheTopOfWhateverItLandsIn(t *testing.T) { + // The room left below A is 25, which falls inside C (20..30). C is a draw, + // and pdfium's GetIntact answers ContentArea for every draw + // (cxfa_node.cpp:1586-1587), so the cut is walked up to C's own top at 20 + // — FindLayoutItemSplitPos:580-583. B alone stays on the first sheet. + l := laidOut(t, sheets(`>`+sheet("P", "", "30", ""), + ``+cutting(``, ""))) + same(t, "the walked-up cut", byPage(l), []string{ + "0: draw f.A 0,0 1x5", "0: draw f.G.B 0,5 1x10", + "1: draw f.G.C 0,0 1x10", + "2: draw f.G.D 0,0 1x10"}) +} + +func TestACutIsRefusedRatherThanBisectAField(t *testing.T) { + // A field of a positioned container has pdfium's intact of "none" + // (cxfa_node.cpp:1558-1584), so pdfium would cut it in half and emit the + // node twice. This package will not: with the only cut position falling + // inside the field, no cut is made at all and the container moves whole, + // which is also what pdfium does when FindSplitPos comes back at nought. + body := ` + + ` + l := laidOut(t, sheets(`>`+sheet("P", "", "30", ""), body)) + same(t, "the refused cut", byPage(l), []string{ + "0: draw f.A 0,0 1x5", + "1: field f.G.B 0,0 1x40"}) + + // The same field with a of its own is one the cut may not fall + // inside, so it is walked up to the field's top instead — and there is + // nothing above it, so no cut is possible there either and the container + // still moves whole. This is the second way [placer.cut] arrives at "no + // cut": pdfium's, rather than ours. + l = laidOut(t, sheets(`>`+sheet("P", "", "30", ""), + strings.Replace(body, ``, + ``, 1))) + same(t, "the cut with nowhere to go", byPage(l), []string{ + "0: draw f.A 0,0 1x5", + "1: field f.G.B 0,0 1x40"}) +} + +func TestWhatCannotBeCutAgainGoesDownWhole(t *testing.T) { + // The first cut is made, and what is left holds one child taller than a + // whole content area. There is no second cut, so the rest of the container + // goes down on the sheet the flow has reached and overflows it — pdfium's + // answer when FindSplitPos returns nought mid-container. + l := laidOut(t, sheets(`>`+sheet("P", "", "30", ""), + ` + + `)) + // The sheet in between is the price, and it is pdfium's too: on it the cut + // proposed at 60 is walked up to C's own top at 40, which is progress from + // 30, so a part with nothing in it is emitted and the page ends. + // FindSplitPos returns the same 10 (relative) there. + same(t, "the uncuttable remainder", byPage(l), []string{ + "0: draw f.G.B 0,0 1x10", + "2: draw f.G.C 0,0 1x100"}) +} + +func TestACutContainerCarriesTheFlowOnBelowIt(t *testing.T) { + // The cut falls at 30, so B and C stay and D moves with 30 taken off its + // written y — SplitLayoutItem's `pChildItem->s_pos_.y -= fSplitPos` — and + // lands at 20 rather than at the top of the sheet. What the container then + // adds to the stack is its height measured from where its last part began, + // which is that item's `s_size_.height - fSplitPos` of 30: the flow reaches + // exactly the bottom, and E turns the page again. + l := laidOut(t, sheets(`>`+sheet("P", "", "30", ""), + cutting(``, "")+``)) + same(t, "the flow below a cut container", byPage(l), []string{ + "0: draw f.G.B 0,0 1x10", "0: draw f.G.C 0,20 1x10", + "1: draw f.G.D 0,20 1x10", + "2: draw f.E 0,0 1x5"}) +} + +func TestACutContainerKeepsItsOwnMarginAndOffsets(t *testing.T) { + // The container's top inset moves every child down inside it and is part of + // the height it is cut into, exactly as pdfium's layout items carry their + // ancestors' insets (CXFA_ContentLayoutItem::GetAbsoluteRect). With a top + // inset of 4 the children sit at 4, 24 and 54; the room left below A is 25, + // which falls inside C at 24..34, so the cut is walked up to 24. + l := laidOut(t, sheets(`>`+sheet("P", "", "30", ""), + ``+ + cutting(``, ""))) + same(t, "a cut container's margin", byPage(l), []string{ + "0: draw f.A 0,0 1x5", "0: draw f.G.B 2,9 1x10", + "1: draw f.G.C 2,0 1x10", + "2: draw f.G.D 2,0 1x10"}) +} + +func TestAHiddenChildIsInNoCutsArithmeticAndStillPlaced(t *testing.T) { + // pdfium lays a hidden element out not at all — PresenceRequiresSpace is + // false and no layout item is made — so it moves no cut. This package + // reports it rather than dropping it, with the part its own y falls in. + l := laidOut(t, sheets(`>`+sheet("P", "", "30", ""), + ` + + + + `)) + // The cut falls at 25, where the room ran out, because nothing that counts + // straddles it — so C keeps 15 of its written 40 on the sheet it moves to. + same(t, "a hidden child of a cut container", byPage(l), []string{ + "0: draw f.A 0,0 1x5", "0: draw f.G.B 0,5 1x10", "0: draw f.G.H 0,25 1x10", + "1: draw f.G.C 0,15 1x10"}) +} + +func TestACutStopsWhenTheFormRunsOutOfSheets(t *testing.T) { + // One sheet only. The first part goes down, and there is no second sheet + // for what is left, so the rest is reported rather than dropped. + l := laidOut(t, sheets(`>`+sheet("P", `>`, ""))) + same(t, "the one sheet", byPage(l), []string{ + "0: draw f.G.B 0,0 1x10", "0: draw f.G.C 0,20 1x10"}) + same(t, "what is left", notLaid(l), []string{"f.G.D: " + noNextPage}) +} + +func TestACutContainerNobodyCanMeasureNeverReachesTheCut(t *testing.T) { + // A margin that is not a length, a child whose y is not a place and a child + // whose height is not a length each stop the container ONE STEP EARLIER + // than the cut: [placer.whole] asks [placer.heightOf] first, and + // [placer.measure] reads exactly those three. That is why [placer.cut] and + // [placer.piecesOf] read them back without checking, and this says so with + // a form rather than with a comment. + for _, tc := range []struct{ what, body string }{ + {"a margin nobody can read", ` + `}, + {"an origin nobody can read", ` + `}, + {"a height nobody can read", ` + `}, + } { + l := laidOut(t, sheets(`>`+sheet("P", "", "30", ""), + ``+tc.body)) + if got := len(l.Pages); got != 1 { + t.Errorf("%s came to %d sheets, want 1: nothing should have been cut", tc.what, got) + } + if got := len(l.Pages[0].Boxes); got != 1 { + t.Errorf("%s put %d boxes on the sheet, want only the draw above it", tc.what, got) + } + } +} + +func TestIntactFollowsWhatTheContainerAboveSays(t *testing.T) { + // [intactOf] is pdfium's GetIntact, and the field arm is the one that reads + // upwards. Each of its answers is asked here of a form laid out for it, + // because the arms are reached through the cut rather than directly. + p := newPlacer() + src := `` + root := firstOfKind(Expand(parse(t, src), nil).Root, "subform") + p.mapUp(root) + find := func(name string) *FormNode { + var got *FormNode + root.Walk(func(n *FormNode) { + if n.Template.Get("name") == name { + got = n + } + }) + return got + } + for _, tc := range []struct{ name, want string }{ + {"G", "none"}, // a written keep wins outright + {"K", "contentArea"}, // and so does one that keeps it together + {"T", "none"}, // a tb subform with no keep + {"f", "none"}, // the root, likewise + {"E", "contentArea"}, // a draw, always + {"X", "none"}, // anything else pdfium's switch does not name + {"B", "none"}, // a field of a positioned container that is cut + {"C", "contentArea"}, // a field of a container kept together + {"D", "none"}, // a field of a tb container, XFA 3.0 + } { + if got := intactOf(p, find(tc.name)); got != tc.want { + t.Errorf("the intact of %s came out as %q, want %q", tc.name, got, tc.want) + } + } + // A field whose layout parent is not a container of the body at all is kept + // together, which is pdfium reading a null parent or a page area + // (cxfa_node.cpp:1559-1562). + if got := intactOf(newPlacer(), find("B")); got != "contentArea" { + t.Errorf("the intact of a parentless field came out as %q, want contentArea", got) + } +} From 3e836fa60bddcff721dd95812cb2365f846ac3a1 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sat, 5 Sep 2026 16:18:16 +0200 Subject: [PATCH 2/2] Cover a positioned subform's own intact without the corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The intact table left out the two arms that answer for a container carrying no — a positioned subform and a row, which pdfium's GetIntact reads the same way. The local run reported 100% because XFACORPUS is set here and the corpus reaches that line through a real form; CI, which sets no corpus, does not. A check that passed for the wrong reason. Co-Authored-By: Claude Opus 5 --- split_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/split_test.go b/split_test.go index 5ec21b2..aaedbe5 100644 --- a/split_test.go +++ b/split_test.go @@ -218,6 +218,8 @@ func TestIntactFollowsWhatTheContainerAboveSays(t *testing.T) { {"G", "none"}, // a written keep wins outright {"K", "contentArea"}, // and so does one that keeps it together {"T", "none"}, // a tb subform with no keep + {"P", "contentArea"}, // a POSITIONED subform with no keep: pdfium's default + {"R", "contentArea"}, // and a row, which pdfium reads the same way {"f", "none"}, // the root, likewise {"E", "contentArea"}, // a draw, always {"X", "none"}, // anything else pdfium's switch does not name