normalizeColumnLayout and resolveColumnLayout disagree about which per-column gap survives when an unusable w:col/@w:w is dropped, so the geometry that paints and the metadata that describes it place the same column 30px apart.
Reporting rather than fixing: it is pre-existing on main, direction-independent, and I found it while working nearby in #3953. Deliberately left out of that branch to keep it to one subject.
Reproduction
import { normalizeColumnLayout, resolveColumnLayout, getColumnGeometry } from '@superdoc/contracts';
const input = { count: 3, equalWidth: false, widths: [0, 200, 150], gaps: [10, 40] };
const direct = normalizeColumnLayout({ ...input }, 624);
const viaResolve = normalizeColumnLayout(resolveColumnLayout({ ...input }), 624);
Measured:
| path |
widths |
gaps |
column x |
normalizeColumnLayout directly |
[200, 150] |
[10] |
[0, 210] |
through resolveColumnLayout first |
[200, 150] |
[40] |
[0, 240] |
Both agree on the widths. They disagree on the single surviving gutter, and therefore on where column 1 starts.
Cause
gaps[i] is the gap after column i, so a width and the gap following it are one record. The authored columns are:
| col |
width |
gap after |
| 0 |
0 |
10 |
| 1 |
200 |
40 |
| 2 |
150 |
— |
Column 0 is unusable (width > 0 is required), so both functions drop it and resolve count to 2. The gap 10 belonged to that dropped column and should go with it, leaving 40 as the gutter between the two surviving columns.
resolveColumnLayout (column-layout.ts) pairs each width with its following gap, filters on the width, then slices — so it keeps 40. Correct.
normalizeColumnLayout slices input.gaps positionally, independently of which widths survived — so it keeps 10, the gap of a column that no longer exists.
resolveColumnLayout already carries a comment explaining why positional slicing is wrong for widths ([0,192,384] → count 2 → [0,192] keeps an unusable leading entry and drops a usable later one). The same argument applies to gaps, and only one of the two functions acts on it.
Why it matters
The two are used for different things and are expected to agree: geometry is built from the normalized layout, while resolveColumnLayout produces the render-facing metadata (page.columns / layout.columns / columnRegions). A disagreement means what is painted and what is reported about it describe different pages. In the case above the gutter is 4× wider on one path than the other.
Scope
Narrow. It needs explicit w:col widths (equalWidth="0") where at least one is unusable and per-column w:space values that are not all equal. A document declaring a zero-width column is unusual — Word tolerates it, which is why both functions handle the case at all. No RTL involvement: the numbers above are LTR, and the mirrored path inherits the same gaps.
I have not checked whether any current consumer pairing actually reaches a visible mis-render, only that the two functions answer differently.
Suggested fix
Pair in normalizeColumnLayout the way resolveColumnLayout already does — widths.map((width, i) => ({ width, gapAfter: rawGaps[i] })), filter on the width, then slice — so the two cannot drift. A single shared helper for "select the usable width/gap records" would be better than two implementations of one rule.
normalizeColumnLayoutandresolveColumnLayoutdisagree about which per-column gap survives when an unusablew:col/@w:wis dropped, so the geometry that paints and the metadata that describes it place the same column 30px apart.Reporting rather than fixing: it is pre-existing on
main, direction-independent, and I found it while working nearby in #3953. Deliberately left out of that branch to keep it to one subject.Reproduction
Measured:
normalizeColumnLayoutdirectly[200, 150][10][0, 210]resolveColumnLayoutfirst[200, 150][40][0, 240]Both agree on the widths. They disagree on the single surviving gutter, and therefore on where column 1 starts.
Cause
gaps[i]is the gap after columni, so a width and the gap following it are one record. The authored columns are:Column 0 is unusable (
width > 0is required), so both functions drop it and resolvecountto 2. The gap10belonged to that dropped column and should go with it, leaving40as the gutter between the two surviving columns.resolveColumnLayout(column-layout.ts) pairs each width with its following gap, filters on the width, then slices — so it keeps40. Correct.normalizeColumnLayoutslicesinput.gapspositionally, independently of which widths survived — so it keeps10, the gap of a column that no longer exists.resolveColumnLayoutalready carries a comment explaining why positional slicing is wrong forwidths([0,192,384]→ count 2 →[0,192]keeps an unusable leading entry and drops a usable later one). The same argument applies togaps, and only one of the two functions acts on it.Why it matters
The two are used for different things and are expected to agree: geometry is built from the normalized layout, while
resolveColumnLayoutproduces the render-facing metadata (page.columns/layout.columns/columnRegions). A disagreement means what is painted and what is reported about it describe different pages. In the case above the gutter is 4× wider on one path than the other.Scope
Narrow. It needs explicit
w:colwidths (equalWidth="0") where at least one is unusable and per-columnw:spacevalues that are not all equal. A document declaring a zero-width column is unusual — Word tolerates it, which is why both functions handle the case at all. No RTL involvement: the numbers above are LTR, and the mirrored path inherits the same gaps.I have not checked whether any current consumer pairing actually reaches a visible mis-render, only that the two functions answer differently.
Suggested fix
Pair in
normalizeColumnLayoutthe wayresolveColumnLayoutalready does —widths.map((width, i) => ({ width, gapAfter: rawGaps[i] })), filter on the width, then slice — so the two cannot drift. A single shared helper for "select the usable width/gap records" would be better than two implementations of one rule.