When a multi-column section ends mid-page, balanceSectionOnPage redistributes its fragments and
writes the balanced x and y back onto them in the order it derived. That order is a sort on raw
x:
// packages/layout-engine/layout-engine/src/column-balancing.ts
const ordered = [...sectionFragments].sort((a, b) => {
if (a.x !== b.x) return a.x - b.x;
return a.y - b.y;
});
with the premise stated just above it: "During unbalanced layout the paginator fills column 0
top-to-bottom, then column 1, etc. — so (x, y) preserves the original sequence."
The premise is that every fragment in a column shares one origin. It does not hold:
- a negative
w:ind shifts a paragraph's origin left of its column,
- a float offset shifts it too,
resolveTableFrame right-aligns or centres an over-wide table, which moves its origin outside the
column entirely.
Because the derived order is written back rather than only read, getting it wrong reorders the
page rather than merely laying it out oddly.
Impact
Content surfaces in the wrong place. A difference of 1e-7 in x is enough to swap two paragraphs,
so this is not limited to visibly outdented content — ordinary sub-pixel drift between fragments in
the same column reaches it.
Direction-independent, and reachable in plain LTR documents.
Reproduction
Two equal columns, 288px wide, 48px gutter, 96px left margin. Six paragraphs: 0-3 fill the left
column, 4-5 have spilled into the right. Paragraph 1 carries a 20px outdent and paragraph 2 differs
from the column origin by a hair.
placement x y
para 0 96 96
para 1 116 116 <- negative w:ind
para 2 96.0000001 136 <- sub-pixel drift
para 3 96 156
para 4 432 96
para 5 432 116
Sorting on x orders these 0, 2, 3, 1, 4, 5: paragraph 1 (x = 116) sorts after every other
left-column fragment. The balanced positions are then written back in that order, and paragraph 1
surfaces in the wrong place on the page.
Expected reading order after balancing is 0, 1, 2, 3, 4, 5.
Suggested direction
Sort by the column each fragment occupies rather than by its x. The column ordinal is fill order, so
it needs no RTL special case — where an x comparison does, because column 0 sits on the right and
document order descends in x.
One caveat worth stating for whoever picks this up: the resolved ordinal has to always yield a
number. A sort key that is sometimes absent leaves the comparator mixing two metrics, which is not a
total order, and Array.prototype.sort may then return different orders for the same input — it
does differ between engines, so the product and the test suite would not agree.
Where
balanceSectionOnPage in packages/layout-engine/layout-engine/src/column-balancing.ts.
When a multi-column section ends mid-page,
balanceSectionOnPageredistributes its fragments andwrites the balanced x and y back onto them in the order it derived. That order is a sort on raw
x:
with the premise stated just above it: "During unbalanced layout the paginator fills column 0
top-to-bottom, then column 1, etc. — so (x, y) preserves the original sequence."
The premise is that every fragment in a column shares one origin. It does not hold:
w:indshifts a paragraph's origin left of its column,resolveTableFrameright-aligns or centres an over-wide table, which moves its origin outside thecolumn entirely.
Because the derived order is written back rather than only read, getting it wrong reorders the
page rather than merely laying it out oddly.
Impact
Content surfaces in the wrong place. A difference of
1e-7in x is enough to swap two paragraphs,so this is not limited to visibly outdented content — ordinary sub-pixel drift between fragments in
the same column reaches it.
Direction-independent, and reachable in plain LTR documents.
Reproduction
Two equal columns, 288px wide, 48px gutter, 96px left margin. Six paragraphs: 0-3 fill the left
column, 4-5 have spilled into the right. Paragraph 1 carries a 20px outdent and paragraph 2 differs
from the column origin by a hair.
Sorting on x orders these
0, 2, 3, 1, 4, 5: paragraph 1 (x = 116) sorts after every otherleft-column fragment. The balanced positions are then written back in that order, and paragraph 1
surfaces in the wrong place on the page.
Expected reading order after balancing is
0, 1, 2, 3, 4, 5.Suggested direction
Sort by the column each fragment occupies rather than by its x. The column ordinal is fill order, so
it needs no RTL special case — where an x comparison does, because column 0 sits on the right and
document order descends in x.
One caveat worth stating for whoever picks this up: the resolved ordinal has to always yield a
number. A sort key that is sometimes absent leaves the comparator mixing two metrics, which is not a
total order, and
Array.prototype.sortmay then return different orders for the same input — itdoes differ between engines, so the product and the test suite would not agree.
Where
balanceSectionOnPageinpackages/layout-engine/layout-engine/src/column-balancing.ts.