Skip to content

Column geometry and column metadata keep different per-column gaps when an unusable w:col width is dropped #3964

Description

@Nathaniel-260

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    status: queuedEngineering work is queued; no delivery date is committed.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions