columnRenderLayoutsEqual decides whether two column configs split a region and whether the
normalized-columns cache is invalidated. It skips per-column gaps on purpose, and says why:
* Per-column `gaps` are
* intentionally ignored until geometry/separators consume them (step 4), so a gaps-only authored
* delta does not split regions or invalidate the normalized-columns cache before it becomes
* paint-significant.
Step 4 has landed. buildColumnGeometry reads gaps[i] ?? gap for both the column x and the
separator x, so a gaps-only delta moves every column after the first and moves the separators with
them. The stated precondition no longer holds, and the skip has become a defect.
Reporting separately from #3964: that one is normalizeColumnLayout and resolveColumnLayout
disagreeing about which gutter survives a dropped column. This one is the render-equality predicate
not looking at gutters at all. Different function, different trigger.
Impact
Two sections differing only in their per-column gutters compare render-equal. Nothing splits the
region, nothing invalidates the normalized-columns cache, so the later section is laid out with the
earlier section's gutters — and the painter draws the whole page's separators from them.
Direction-independent. A plain LTR document with two sections that differ only in
w:cols/w:col/@w:space reaches it.
Reproduction
import { columnRenderLayoutsEqual, buildColumnGeometry } from '@superdoc/contracts';
const base = { count: 3, gap: 48, widths: [100, 100, 300], equalWidth: false };
const a = { ...base, gaps: [20] }; // second gutter omitted -> falls back to the scalar 48
const b = { ...base, gaps: [20, 0] }; // second gutter spelled out as 0
columnRenderLayoutsEqual(a, b); // true
They do not render the same. A short gaps array falls back to the scalar gap for the gutter it
omits; it does not mean 0. So buildColumnGeometry places column 2 forty-eight pixels apart
between the two, and puts the separator in two different places. The predicate calls them equal.
Why the obvious fix is wrong
Comparing the authored arrays directly trades this for the opposite defect, because the authored
arrays do not describe what renders. resolveColumnLayout emits gaps only when the author supplied
them and pads a short array with 0, while geometry falls back to the scalar gap and floors at 0.
So the authored arrays differ where the output is identical — an omitted array against one spelling
out the scalar gap, or a negative gutter against 0 — and they match where the output differs.
A spurious split is not merely a cache miss: a continuous section break with changed columns resets
to column 0 mid-page, so following content restarts in the first column instead of continuing where
it was.
The predicate has to derive the effective gutters the way normalizeColumnLayout does, which keeps
it exactly as discriminating as the geometry it stands in for.
Where
columnRenderLayoutsEqual in packages/layout-engine/contracts/src/column-layout.ts.
columnRenderLayoutsEqualdecides whether two column configs split a region and whether thenormalized-columns cache is invalidated. It skips per-column
gapson purpose, and says why:Step 4 has landed.
buildColumnGeometryreadsgaps[i] ?? gapfor both the column x and theseparator x, so a gaps-only delta moves every column after the first and moves the separators with
them. The stated precondition no longer holds, and the skip has become a defect.
Reporting separately from #3964: that one is
normalizeColumnLayoutandresolveColumnLayoutdisagreeing about which gutter survives a dropped column. This one is the render-equality predicate
not looking at gutters at all. Different function, different trigger.
Impact
Two sections differing only in their per-column gutters compare render-equal. Nothing splits the
region, nothing invalidates the normalized-columns cache, so the later section is laid out with the
earlier section's gutters — and the painter draws the whole page's separators from them.
Direction-independent. A plain LTR document with two sections that differ only in
w:cols/w:col/@w:spacereaches it.Reproduction
They do not render the same. A short
gapsarray falls back to the scalar gap for the gutter itomits; it does not mean
0. SobuildColumnGeometryplaces column 2 forty-eight pixels apartbetween the two, and puts the separator in two different places. The predicate calls them equal.
Why the obvious fix is wrong
Comparing the authored arrays directly trades this for the opposite defect, because the authored
arrays do not describe what renders.
resolveColumnLayoutemitsgapsonly when the author suppliedthem and pads a short array with
0, while geometry falls back to the scalar gap and floors at0.So the authored arrays differ where the output is identical — an omitted array against one spelling
out the scalar gap, or a negative gutter against
0— and they match where the output differs.A spurious split is not merely a cache miss: a continuous section break with changed columns resets
to column 0 mid-page, so following content restarts in the first column instead of continuing where
it was.
The predicate has to derive the effective gutters the way
normalizeColumnLayoutdoes, which keepsit exactly as discriminating as the geometry it stands in for.
Where
columnRenderLayoutsEqualinpackages/layout-engine/contracts/src/column-layout.ts.