Skip to content

feat: implement autofit table column width algorithm (SD-2502)#2929

Merged
harbournick merged 42 commits into
mainfrom
luccas/sd-2502-feature-implement-autofit-table-column-width-algorithm
Apr 30, 2026
Merged

feat: implement autofit table column width algorithm (SD-2502)#2929
harbournick merged 42 commits into
mainfrom
luccas/sd-2502-feature-implement-autofit-table-column-width-algorithm

Conversation

@luccas-harbour
Copy link
Copy Markdown
Contributor

Summary

Implements the ECMA-376 AutoFit column-width algorithm (SD-2502) and rewires
table measurement around a clean fixed-vs-autofit split.

  • Two pure solvers live in layout-engine/measuring/dom/:
    • fixed-table-columns.ts — fixed-width resolver (first-row w:tcW +
      explicit grid), deterministic and DOM-free.
    • autofit-columns.ts — AutoFit resolver that starts from the fixed-pass
      result and redistributes by per-cell content demand (min/max widths from
      table-autofit-metrics.ts).
  • Runtime measurement (measuring/dom/src/index.ts) now delegates to
    those solvers instead of carrying its own width logic; the file drops
    ~350 lines of ad-hoc reconciliation.
  • pm-adapter materializes the effective tableLayout/tableWidth
    attrs once at conversion time, so every downstream consumer sees the
    same authoritative shape.
  • Editor integration: width-authoring edits (column resize, set-cell-
    width) are pinned to tableLayout: fixed and now mirror authored widths
    into tcW / tblGrid through a shared table-attr-sync helper, so the
    new solver's first-row-tcW precedence doesn't revert user edits.
  • Import fix: fallback logical-grid construction honors
    w:gridBefore/w:gridAfter skips and cell spans, fixing a class of
    DOCX tables that previously collapsed to the wrong column count.

Word parity

This is not a byte-exact reimplementation of Word's AutoFit. ECMA-376 does
not fully specify the algorithm — the spec explicitly calls out that the
described behavior is advisory and the final column widths are up to the
consumer application. Word itself diverges from the spec in several edge
cases (content-measurement heuristics, minimum-width fallbacks, rowspan
handling under tight constraints). This implementation follows the spec
where it is prescriptive and picks a consistent, documented behavior
where the spec leaves latitude, so rendered widths may differ from Word
in those cases. The tradeoffs are captured in the solver's unit tests.

Notable fixes caught during integration

  • fix(autofit): redistribute widths by content demand in no-trigger path
    — tables that didn't hit a redistribution trigger were rendering with
    stale fixed-pass widths; AutoFit now always redistributes.
  • fix(table-resize): keep tcW and borders aligned with authored widths
    — column-resize edits were being reverted by the next measure pass
    because only colwidth was updated; also stops
    syncExtractedTableAttrs from promoting borders: null (the table
    extension's renderDOM called Object.keys on it and threw).

The new fixed-layout solver treats first-row w:tcW as authoritative over
the authored grid, so column-resize edits that only updated colwidth
were reverted by the next measure pass. Mirror the new span width into
tableCellProperties.cellWidth on every affected cell so the solver
observes the authored change.

Also stop syncExtractedTableAttrs from promoting `borders: null`: the
table extension's renderDOM calls Object.keys(borders), which threw on
null and broke the render cycle after every width-authoring edit. Keep
the PM schema default shape ({}) when no borders are set.
buildWidthAuthoringTableAttrs now derives tableProperties.tableWidth
from the authored grid (or clears it when none exists) so column
resizes and tablesSetColumnWidthAdapter no longer leave stale totals
for the fixed solver and DOCX export to consume.
The working-grid normalizer started every row at column 0, so a cell in
row N whose first free column was occupied by an earlier row's rowspan
landed in the wrong logical column and shifted the rest of the row.
Track active rowspans across rows and advance past occupied columns for
both cells and gridBefore/gridAfter skips.
…dxa' }

syncExtractedTableAttrs was writing { w: String(twips), type }, which
diverged from the importer's { value: <px>, type: 'dxa' } shape and
blanked out cell spacing after any width-authoring edit. Convert through
twipsToPixels and use the value field so the promoted shape matches.
@luccas-harbour luccas-harbour self-assigned this Apr 23, 2026
@linear
Copy link
Copy Markdown

linear Bot commented Apr 23, 2026

@github-actions
Copy link
Copy Markdown
Contributor

Status: PASS

All OOXML elements and attributes in the changed test file check out against ECMA-376.

Elements verified:

  • w:tblLayout (w:type) — §17.4.52. Valid values fixed and autofit per ST_TblLayoutType §17.18.87. Tests use both correctly.
  • w:tblW (w:w, w:type="dxa") — §17.4.63/§17.4.87. Attribute names and dxa type are correct.
  • w:gridCol (w:w) — per tblGrid spec, w:w is the only attribute on gridCol entries. ✓
  • w:gridBefore/w:gridAfter (w:val) — §17.4.15/§17.4.14. Both use w:val for decimal count. ✓
  • w:wBefore/w:wAfter (w:w, w:type) — §17.4.86/§17.4.85. Both use the CT_TblWidth measurement shape (w:w + w:type). ✓

One behavior worth noting (not a violation): the decode tests assert that w:tblPr is not emitted when only the promoted top-level tableLayout attr is set — only tableProperties.tableLayout drives export. Since the spec defaults w:tblLayout omission to auto, this means a table can only export as fixed-layout if that intent is captured in the nested tableProperties object. That's a deliberate design choice and is internally consistent.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 00350b57e9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/layout-engine/measuring/dom/src/autofit-columns.ts Outdated
Without an explicit type, TS narrowed the object literal to
{ tableLayout: string }, dropping the Record<string, unknown> index
signature from the spread operands. That broke the subsequent
updatedTableProps.tableWidth assignment and `delete` added by the
tableWidth-recompute fix, failing `tsc -b`.

Annotate the binding as Record<string, unknown> so both operations
remain well-typed. No behavior change.
@codecov-commenter
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Mirror the new span width into first-row tableCellProperties.cellWidth
in tablesSetColumnWidthAdapter so the fixed solver (which reads first-
row tcW as authoritative) observes the edit instead of reverting it on
the next measure pass. Span width is resolved from the mutated grid,
falling back to pixel colwidths when the grid is missing. Same class of
bug the column-resize overlay fix addressed, now on the document-api
path.
Mirror distributed span widths into first-row
tableCellProperties.cellWidth so the fixed solver (which reads first-row
tcW as authoritative) observes the edit. Hoist grid normalization above
the cell loop so the mutated grid is available to the first-row sync.
Same fix as the set-column-width path, now on the distribute path.
collectTriggerCells deduped {startColumn, span, cellIndex}, collapsing
matching span-triggers from different rows to an arbitrary winner and
losing the stronger content demand. Tag cells with rowIndex so dedup
preserves all rows, then keep the strongest cell per {startColumn, span}
by preferredWidth / max content width.
@harbournick
Copy link
Copy Markdown
Collaborator

@luccas-harbour I think this PR regresses some docs in an unintended way. Check my comment in linear pls.

… converter

Importer emits __placeholder cells to represent wBefore/wAfter spacing,
but those should not appear as real cells in the TableBlock. Filter them
out in parseTableRow while preserving gridBefore/gridAfter row attrs.
Min-token measurement split each run independently, so a token spanning
multiple runs (e.g. "EXHIBIT\u00a0\u201cA\u201d" rendered as three styled
runs) was measured as separate fragments. Track an in-progress token
across runs and only flush at whitespace, hyphen, or explicit line break
boundaries, treating non-breakable separators as part of the token.
…to tblW

Fixed-layout tables whose authored grid is complete and already sums to
the requested table width now skip per-cell tcW reconciliation and use
the authored column widths directly. Incomplete grids and cases where
the grid disagrees with tblW continue through the existing fixed-layout
solver. Cache key includes the new flag so cached results don't leak
across the two paths.
…auto

When an AutoFit table has tblW=auto and a complete authored grid, treat
those column widths as preferred geometry: skip the maximum/content-
weighted redistribution passes so the table doesn't expand toward column
maxima beyond the authored shape. Content-minimum growth and shrink-to-
target still apply, so columns can grow when content forces it. Cache
key includes the new flag.
…cit tblW that matches the grid

Extend the preferred-grid preservation path to AutoFit tables with an
explicit tblW whose authored column widths already sum to that width.
Like the tblW=auto case, these grids skip the maximum/content-weighted
redistribution passes so the authored shape is kept unless content
minimums force growth. Cache key includes the new flag.
… non-uniform

A uniform authored grid (all columns equal width) carries no shape
information from the author — it's the default Word emits when no per-
column intent exists. Treating those as preferred geometry suppressed
content-driven redistribution. Restrict preserveAutoGrid and
preserveExplicitAutoGrid to grids with at least one column whose width
differs from the rest.
…ear-tblW authored grids

Imported tables sometimes carry a trailing ~0px grid column (often paired
with a tiny gridAfter/wAfter on the first row) that no real cell occupies.
Trim those unoccupied <=1px columns from preferredColumnWidths, ignore
gridAfter when wAfter sums to placeholder width, and clamp cell spans so
they don't extend into the trimmed region. Authored-grid preservation
also accepts grids that fall slightly under tblW (within 5%), since the
trimmed placeholder leaves the remaining grid just shy of the authored
total.
…ls request concrete widths

The non-uniform-grid heuristic skipped preservation for uniform authored
grids on the assumption they were Word's default placeholder. But a
table whose cells each carry an explicit dxa tcW request is intentional
geometry, even when the resulting columns happen to be equal. Allow
preserveExplicitAutoGrid in that case while still excluding uniform
grids whose cells use auto tcW.
@luccas-harbour luccas-harbour requested a review from a team as a code owner April 29, 2026 19:34
@luccas-harbour
Copy link
Copy Markdown
Contributor Author

@harbournick I pushed some more commits addressing these problems. I added more details to the linear ticket.

Copy link
Copy Markdown
Collaborator

@harbournick harbournick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@harbournick harbournick merged commit 0af690d into main Apr 30, 2026
60 checks passed
@harbournick harbournick deleted the luccas/sd-2502-feature-implement-autofit-table-column-width-algorithm branch April 30, 2026 17:48
@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 30, 2026

🎉 This PR is included in @superdoc-dev/mcp v0.3.0-next.18

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 30, 2026

🎉 This PR is included in vscode-ext v2.3.0-next.63

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 30, 2026

🎉 This PR is included in @superdoc-dev/react v1.2.0-next.61

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 30, 2026

🎉 This PR is included in superdoc-cli v0.8.0-next.36

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 30, 2026

🎉 This PR is included in superdoc v1.30.0-next.20

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 30, 2026

🎉 This PR is included in superdoc-sdk v1.8.0-next.22

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 1, 2026

🎉 This PR is included in superdoc-cli v0.8.0

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 1, 2026

🎉 This PR is included in superdoc-sdk v1.8.0

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 1, 2026

🎉 This PR is included in @superdoc-dev/mcp v0.3.0

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 1, 2026

🎉 This PR is included in superdoc v1.31.0

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 5, 2026

🎉 This PR is included in vscode-ext v2.3.0

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 7, 2026

🎉 This PR is included in @superdoc-dev/react v1.3.0

The release is available on GitHub release

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants