* feat(layout): banded region segmentation for vertically-changing column layouts
Pages whose column structure changes down the page (newsletter bands,
figure-split flows, a three-column strip inside a two-column page) cannot
be represented by one full-height column set: the projection profile
either finds nothing and Y-interleaves the columns, or weaves the odd
band's text into the wrong buckets.
- Split pages into horizontal bands at full-width whitespace gaps (wide
spanning items are excluded from occupancy — separators sit inside the
very gaps being sought), detect columns independently per band, and
re-merge consecutive bands with matching gutters across empty gaps so
figure floats keep flowing down their columns while headline-separated
bands stay independent.
- Engage only on contradicting evidence: a prose-validated band whose
column count differs from the page-level structure. Pages the flat
column model already explains keep their current ordering.
- Read short prose columns (5-14 lines, >=60% width fill on >=60% of
lines in every column) as newspaper instead of Y-interleaving them as
tabular. Kept as a standalone reading-order refinement so the table
pipeline's is_newspaper_layout veto is unaffected.
- Split ordering entry points: order_multi_column_region keeps every
page-level defense; order_validated_band (banded planner only) trusts
validated bands, skipping line-count minimums and straggler splitting
that would misfire on Y-cohesive bands.
* docs(layout): record why the band wide-item test is per-item
Assembling same-baseline fragments into runs before the wide test was
implemented and measured against the reading-order benchmark: word-gap
and gutter-gap distributions overlap in real documents, so assembled
runs fused narrow-guttered column pairs into page-wide lines, emptied
the occupancy, and disengaged banding on pages it rescues — a measured
regression with no measured win. Keep the per-item test (a fragmented
separator can suppress a cut, which only misses an engagement) and
document the boundary for future attempts.
* fix(layout): keep figure placeholders out of band whitespace probes
An image placeholder sitting between two matching column bands is the
very figure float whose flow-through the band merge exists for, yet it
read as content twice: its glyph box filled the occupancy gap (blocking
the cut) and the merge probe counted it as separator content (blocking
the merge). Both probes now see text layout items only.
* fix(layout): anchor band-merge matching on the founding band's columns
The merge comparison ran against the widened union, whose gutter is the
intersection of its constituents' gutters. Across a chain of
one-directionally drifting bands that intersection can walk past
GUTTER_TOLERANCE and reject a band identical to the run's own first
member. Compare candidates against the founding band's raw columns
instead: the run's column system is defined by its founder, so
drift can no longer accumulate in either direction. Union widening is
kept for item bucketing only.
* test(layout): differential coverage for the band-merge anchor rule
- banded_layout_rejects_creeping_drift: a band within tolerance of the
moving union but 36pt from the founder must not join the run — the
case the anchor rule exists for; the pre-anchor union admitted it.
- Reword the founder-anchor chain test as the invariant lock it is.
- Note at the merge site why a reject-overlapping-unions guard is
unimplementable: detect_columns returns contiguous partitions whose
adjacent regions share boundary coordinates, so the check degenerates
to exact-equality matching and rejects every legitimate merge; the
boundary-disagreement zone is bounded by GUTTER_TOLERANCE and split
proportionally by greatest-overlap bucketing.