Skip to content

Split a container across a sheet, and place 4 260 fields that were never a splitting problem - #13

Merged
tannevaled merged 3 commits into
mainfrom
slice6-split-containers
Sep 4, 2026
Merged

Split a container across a sheet, and place 4 260 fields that were never a splitting problem#13
tannevaled merged 3 commits into
mainfrom
slice6-split-containers

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

The number, and the disagreement first

75 490 of the 81 750 body fields, on 2 864 sheets. Slice 5 placed 71 230 on
2 737.

"Most of the 6 208 want a container broken across a sheet" is not what they
wanted. All 4 214 of the largest bucket are positioned subforms, and a
positioned layout fails clause 2 of $isSplittable — no rule anywhere would
ever have split one of them. They are whole-page subforms (form1.Page1,
topmostSubform.Page5), six to thirty points taller than the content area they
are drawn for, one per printed sheet, which is how LiveCycle writes a
multi-page form.

What was refusing them was not the absence of a splitter. It was a fit-check
this package applied and pdf.js does not: the first thing on a sheet that
moves in one piece is never measured against anything
(checkDimensions
returns true while firstUnsplittable is null, layout.js:266-268, and the
node that claims that pass switches noLayoutFailure on before its own check
runs, template.js:313-319). Because the first whole-page subform was
rejected, the flow never turned the page at all and every later one was
rejected against the same sheet. us-irs__fw9 came out on one sheet with
321 fields unplaced, against pdf.js's six.

The reconciliation, field by field

Every field's disposition dumped before and after and paired on
(form, path) — 81 750 of them, no length mismatch:

taller than a whole content area → placed 4 214
no room inside a container that moves whole → placed 46
no room inside … → still no room inside 61
placed → unplaced 0
net +4 260

71 230 + 4 260 = 75 490. This is the first slice where the honest count did
not go down anywhere. 9 430 fields placed both times are on a different sheet
now, which is what turning pages that were never turned looks like.

slice 5 slice 6
placed 71 230 75 490
taller than a whole content area, and cannot be broken 4 214 0
past the last sheet the page set gives 1 887 1 887
no room inside a container that moves in one piece 107 61
under lr-tb 4 303 4 303
anchored by a corner, with no size of its own 9 9

1 887 past the last sheet did not move, which is worth saying: turning more
pages exhausted no page set that was not exhausted before.

What is in it

The splittable predicate is asked of the whole chain. flowable(n) became
(*placer).splittable(n), which recurses upward as pdf.js does
(template.js:4943-4946) rather than relying on the chain being enforced by
construction. Four clauses, read rather than assumed:

  1. the container above must be splittable — the recursion ends at <template>,
    which answers yes (template.js:5401-5403), and every other kind of node
    inherits the base answer, no (xfa_object.js:214-216). The one that
    matters is <area>
    : it holds body content, it is not a subform, and
    $getSubformParent does not skip it, so nothing inside an area is ever
    split;
  2. its layout is neither position nor anything containing row;
  3. its <keep intact> is none — and an exclGroup has the same predicate
    without this clause (template.js:2405-2429), checked there as asked;
  4. the -tb / numberInLine clause is quoted in full in the doc comment and
    is not code, which is a measurement rather than an omission: no
    container this package flows through has a layout ending in -tb, so it
    cannot change an answer, and it needs numberInLine, which the lr-tb
    slice will bring.

Clause 1 cannot be reached from the flow — the flow only descends through
containers that already passed — so
TestSplittabilityIsAskedOfTheWholeChainAndNotOfOneNode is its witness, and
the guard for a flow that one day descends somewhere else. It pins all nine
answers, <area> and the exclGroup's missing keep clause included.

The pass, and what it means for splitting. The first container of each
sheet that moves in one piece is placed unconditionally, and nothing inside it
is checked either (noLayoutFailure is cleared only on the way out of that
same node, template.js:5075-5178). Everything after it on the sheet is
checked, and what does not fit sends the whole chain to the next content area —
which is what splitting the containers above it means. There it is first in its
turn. The pass is handed out once per sheet, not once per content area:
pdf.js clears firstUnsplittable in the page loop, before the loop over that
sheet's content areas (template.js:5536-5538).

tooTallForAPage is gone, because pdf.js never refuses a container for being
too tall.

The judge, with the pairing re-audited

container heights
heights this package computes, paired with pdf.js's 7 764
agree to within 1/100 pt 7 764 100.00%
disagree 0 0.00%
where the boxes went
boxes where pdf.js emits a real place 176
agree exactly 176 100.00%
boxes pdf.js placed by flexbox 161 820
above or to the left of their container, which would be outside it 0 0.00%
which sheet they went on slice 5 slice 6
forms where this package placed every element 431 473
of those, agreeing with pdf.js on the sheet count 428 469 99.2%
boxes paired on those forms 117 378 149 433
on the same sheet as pdf.js put them 117 378 149 433 100.00%
forms placing fewer elements and using MORE sheets — a defect 0 0 0.00%

The pairing itself was re-audited, not only the rate, because splitting is
exactly the change that alters which boxes are comparable. Of 156 574 body
boxes on the 469 agreeing forms: 149 433 paired, 6 680 unnamed and so never
keyed, 461 keyed but absent from pdf.js's dump — 460 of those 461 are
presence="hidden"
, which pdf.js emits as display:none and the dump does
not carry — and 0 dropped because the two sides produced a different number
of boxes for one key
. That last zero is the one that matters: splitting
created no new mispairing.

Four forms disagree on the sheet count, and two are new.
us-opm__sf813 and us-opm__sf39a are the same two as before and are not
pagination: they have a positioned outermost subform, whose fit-check
(layout.js:355-364) has never been done here. us-uscis__i-600a and
us-uscis__i-821 are newly visible — i-600a came out on one sheet before
this change against pdf.js's fourteen, and comes out on thirteen now. In both,
every box agrees up to a <breakBefore targetType="pageArea" startNew="1"/>
and is one sheet behind after it. That is a question about breaks, not about
splitting, and it is not diagnosed to the line here.

What none of this covers, unchanged: where inside a container the children
ended up; borders, margins and insets on positioned layouts; anything under a
rotated ancestor; where a break inside a container that moves whole would have
sent the page; real per-glyph advances, which neither side has. Still 77 of 560
forms pdf.js cannot lay out.

Gate

go vet, gofmt -l empty, exactly 100% statement coverage, -race, nine
cross-compile targets, CGO_ENABLED=0, GOWORK=off, all against Go 1.27.1 via
GOTOOLCHAIN.

tannevaled and others added 3 commits September 4, 2026 08:29
The splittable predicate is now asked of the whole CHAIN rather than of
one node, as pdf.js asks it (Subform[$isSplittable], template.js:4940-4975):
the container above first, the recursion ending at the <template> element
which answers yes, and every other kind of node — an <area> above all —
inheriting the base answer, no. The keep intact clause belongs to a subform
and not to an exclGroup, which was read at template.js:2405-2429 rather
than assumed.

What that predicate refuses is not dropped. pdf.js's checkDimensions returns
true outright while the sheet has had nothing that moves in one piece
(layout.js:266-268), and the one that claims that pass switches
noLayoutFailure on before its own check runs, so it cannot fail either, and
neither can anything inside it. Everything after it on the sheet is checked,
and what does not fit sends the whole chain to the next content area — which
is what splitting the containers above it means. There it is first in its
turn and goes down whatever its height.

So a container taller than a whole content area is no longer a refusal: it
comes out one per sheet, overflowing, as pdf.js draws it. Over the corpus
that places 4 260 more fields — 75 490 of 81 750 on 2 864 sheets — and every
one of them is a positioned subform the size of a page, which no rule would
ever have split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The package comment and the README described a slice that did not split
containers and reported a too-tall one unplaced. Both now say the rule as
pdf.js states it — the chain, its four clauses, the <area> terminus, and the
exclGroup's missing keep clause — and say plainly that what moves whole is
still put on the paper, one per sheet, hanging over the bottom.

The judge tables carry the new population, and the pairing audit with them:
of 156 574 body boxes on the agreeing forms, 149 433 paired and NONE were
dropped because the two sides counted differently. 460 of the 461 keyed but
missing from pdf.js's dump are presence="hidden".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 03a63b0 into main Sep 4, 2026
1 check passed
@tannevaled
tannevaled deleted the slice6-split-containers branch September 4, 2026 10:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant