Skip to content

Commit 0af690d

Browse files
feat: implement autofit table column width algorithm (SD-2502) (#2929)
* test: lock down table width import/export and grid invariants * refactor: materialize effective table layout attrs in pm-adapter * feat: add pure AutoFit column algorithm module * refactor: normalize TableBlock data into AutoFit working-grid input * feat: add table cell content metrics for AutoFit measurement * fix: build fallback logical grids from row skips and spans * feat: switch table measurement to full fixed-vs-autofit width resolution * feat: pin width-authoring table edits to fixed layout * test: lock down current autofit divergences * refactor: formalize shared working table grid model * feat: implement pure fixed-width table solver * refactor: route fixed-layout table measurement through the new fixed solver * refactor: adapt autofit content metrics to fixed-pass baseline inputs * feat: rework pure autofit solver to consume fixed-pass output * feat: switch runtime autofit measurement to fixed-plus-overrides solver * fix(autofit): redistribute widths by content demand in no-trigger path * fix(table-resize): keep tcW and borders aligned with authored widths 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. * fix(table-resize): recompute nested tableWidth from grid on width edits 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. * fix(autofit): skip rowspan-occupied columns when placing later-row cells 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. * fix(table-attr-sync): emit tableCellSpacing as { value: <px>, type: '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. * fix(table-attr-sync): restore index signature on updatedTableProps 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. * fix(tables-adapter): sync first-row tcW when setting a column width 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. * fix(tables-adapter): sync first-row tcW when distributing column widths 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. * fix(autofit): coalesce repeated trigger cells by strongest demand 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. * test(behavior): cover width-authoring sync flow (SD-2502) Lock the new TableResizeOverlay -> table-attr-sync path: a column drag must mirror the resolved span width into tableCellProperties.cellWidth (twips, dxa) on every affected row, and flip the table to fixed-layout so the AutoFit measuring solver respects the authored grid on the next pass. Existing tests/tables/resize.spec.ts only checks the grid attr; this adds the per-cell tcW mirror that the new layout-engine modules depend on. Also uploaded rendering/sd-2502-autofit-table-algorithm.docx to the corpus for layout/visual auto-discovery (Word-native, tblLayout omitted = autofit per ECMA, gridSpan=2, long unbreakable token forces column growth). * fix(autofit): preserve protected column widths when no slack basis remains distributeRemainingSlack fell back to a uniform targetWidth / N spread whenever the proportional basis collapsed to 0, ignoring the growableColumns set passed in by the caller. Protected columns (e.g. ones already locked to a span trigger) were silently overwritten back to the uniform target, undoing the trigger. Honor growableColumns in the zero-basis branch: leave widths untouched when no growable columns remain, otherwise spread the remaining slack equally across only the growable indexes so protected columns keep their resolved widths. Regression test: 2-col table with a span trigger pulling both columns to a summed max of 240 — totalWidth should stay at 240 instead of expanding back to the preferred 300. * fix(autofit): include working grid and fixed layout in result cache key The autofit table-result cache key only hashed maxWidth, cellMetricKeys, and layoutEpoch, so tables with identical cell metrics but different row placements or fixed-layout starting widths collided and reused stale cached results. Hash the working-grid placements (cell starts, spans, preferred widths, skipped columns, logical column count) and the fixed-layout result alongside the existing inputs. * chore: remove dead getMeasuredCellBorderWidthPx helper * chore(autofit): explicitly type working-grid cells in cache-key serializer No behavior change — narrows row.cells to WorkingTableCellInput inside buildAutoFitTableResultCacheKey so the per-cell field accesses resolve through the working-grid contract. * fix(pm-adapter): skip gridBefore/gridAfter placeholder cells in table 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. * fix(measuring): accumulate token widths across runs in autofit min-width 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. * feat(measuring): preserve authored fixed-table grid when columns sum 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. * feat(measuring): preserve authored grid for AutoFit tables with tblW=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. * feat(measuring): preserve authored grid for AutoFit tables with explicit 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. * fix(measuring): only preserve authored AutoFit grids when columns are 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. * fix(measuring): drop trailing placeholder grid columns and tolerate near-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. * fix(measuring): preserve uniform explicit-tblW AutoFit grids when cells 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.
1 parent 38a1785 commit 0af690d

26 files changed

Lines changed: 7604 additions & 470 deletions

packages/layout-engine/measuring/dom/src/autofit-columns.test.ts

Lines changed: 872 additions & 0 deletions
Large diffs are not rendered by default.

packages/layout-engine/measuring/dom/src/autofit-columns.ts

Lines changed: 1224 additions & 0 deletions
Large diffs are not rendered by default.

packages/layout-engine/measuring/dom/src/autofit-normalize.test.ts

Lines changed: 657 additions & 0 deletions
Large diffs are not rendered by default.

packages/layout-engine/measuring/dom/src/autofit-normalize.ts

Lines changed: 576 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { WorkingTableGridInput } from './autofit-normalize.js';
3+
import { computeFixedTableColumnWidths } from './fixed-table-columns.js';
4+
5+
describe('computeFixedTableColumnWidths', () => {
6+
it('preserves a plain authored grid when no row requests change it', () => {
7+
const result = computeFixedTableColumnWidths({
8+
layoutMode: 'fixed',
9+
maxTableWidth: 500,
10+
preferredColumnWidths: [60, 90],
11+
preferredTableWidth: undefined,
12+
gridColumnCount: 2,
13+
rows: [],
14+
});
15+
16+
expect(result.columnWidths).toEqual([60, 90]);
17+
expect(result.totalWidth).toBe(150);
18+
expect(result.gridColumnCount).toBe(2);
19+
});
20+
21+
it('shrinks proportionally to a dxa-style preferred table width target once row requests exceed it', () => {
22+
const result = computeFixedTableColumnWidths(
23+
buildFixedInput({
24+
preferredColumnWidths: [100, 100],
25+
preferredTableWidth: 150,
26+
rows: [
27+
{
28+
skippedBefore: [],
29+
skippedAfter: [],
30+
skippedColumns: [],
31+
logicalColumnCount: 2,
32+
cells: [{ startColumn: 0, span: 2, preferredWidth: 240 }],
33+
},
34+
],
35+
}),
36+
);
37+
38+
expect(result.columnWidths).toEqual([62.5, 87.5]);
39+
expect(result.totalWidth).toBe(150);
40+
});
41+
42+
it('shrinks proportionally to a pct-resolved preferred table width target once row requests exceed it', () => {
43+
const result = computeFixedTableColumnWidths(
44+
buildFixedInput({
45+
preferredColumnWidths: [120, 80],
46+
preferredTableWidth: 100,
47+
rows: [
48+
{
49+
skippedBefore: [],
50+
skippedAfter: [],
51+
skippedColumns: [],
52+
logicalColumnCount: 2,
53+
cells: [{ startColumn: 0, span: 2, preferredWidth: 240 }],
54+
},
55+
],
56+
}),
57+
);
58+
59+
expect(result.columnWidths).toEqual([50, 50]);
60+
expect(result.totalWidth).toBe(100);
61+
});
62+
63+
it('applies preferred table width even when no rows are present', () => {
64+
const result = computeFixedTableColumnWidths(
65+
buildFixedInput({
66+
preferredColumnWidths: [100, 100],
67+
preferredTableWidth: 150,
68+
rows: [],
69+
}),
70+
);
71+
72+
expect(result.columnWidths).toEqual([75, 75]);
73+
expect(result.totalWidth).toBe(150);
74+
});
75+
76+
it('lets first-row skipped-column requests set widths downward', () => {
77+
const result = computeFixedTableColumnWidths(
78+
buildFixedInput({
79+
preferredColumnWidths: [100],
80+
gridColumnCount: 1,
81+
rows: [
82+
{
83+
skippedBefore: [{ columnIndex: 0, preferredWidth: 40, minContentWidth: 0, maxContentWidth: 0 }],
84+
skippedAfter: [],
85+
skippedColumns: [{ columnIndex: 0, preferredWidth: 40, minContentWidth: 0, maxContentWidth: 0 }],
86+
logicalColumnCount: 1,
87+
cells: [],
88+
},
89+
],
90+
}),
91+
);
92+
93+
expect(result.columnWidths).toEqual([40]);
94+
expect(result.totalWidth).toBe(40);
95+
});
96+
97+
it('applies row requests before shrinking to the preferred table width', () => {
98+
const result = computeFixedTableColumnWidths(
99+
buildFixedInput({
100+
preferredColumnWidths: [100, 100],
101+
preferredTableWidth: 100,
102+
rows: [
103+
{
104+
skippedBefore: [],
105+
skippedAfter: [],
106+
skippedColumns: [],
107+
logicalColumnCount: 2,
108+
cells: [{ startColumn: 0, span: 2, preferredWidth: 150 }],
109+
},
110+
],
111+
}),
112+
);
113+
114+
expect(result.columnWidths).toEqual([66.66666666666666, 33.33333333333334]);
115+
expect(result.totalWidth).toBe(100);
116+
});
117+
118+
it('lets first-row tcW requests set a span downward from a larger seeded width', () => {
119+
const result = computeFixedTableColumnWidths(
120+
buildFixedInput({
121+
preferredColumnWidths: [80],
122+
gridColumnCount: 1,
123+
rows: [
124+
{
125+
skippedBefore: [],
126+
skippedAfter: [],
127+
skippedColumns: [],
128+
logicalColumnCount: 2,
129+
cells: [{ startColumn: 0, span: 2, preferredWidth: 80 }],
130+
},
131+
],
132+
}),
133+
);
134+
135+
expect(result.columnWidths).toEqual([80, 0]);
136+
expect(result.gridColumnCount).toBe(2);
137+
expect(result.totalWidth).toBe(80);
138+
});
139+
140+
it('applies tcW requests by growing the affected logical span', () => {
141+
const result = computeFixedTableColumnWidths(
142+
buildFixedInput({
143+
preferredColumnWidths: [60, 60],
144+
rows: [
145+
{
146+
skippedBefore: [],
147+
skippedAfter: [],
148+
skippedColumns: [],
149+
logicalColumnCount: 2,
150+
cells: [
151+
{ startColumn: 0, span: 1, preferredWidth: 100 },
152+
{ startColumn: 1, span: 1, preferredWidth: 50 },
153+
],
154+
},
155+
],
156+
}),
157+
);
158+
159+
expect(result.columnWidths).toEqual([100, 50]);
160+
expect(result.totalWidth).toBe(150);
161+
});
162+
163+
it('reconciles later-row conflicts by adding width to the last column of the affected span', () => {
164+
const result = computeFixedTableColumnWidths(
165+
buildFixedInput({
166+
preferredColumnWidths: [60, 60],
167+
rows: [
168+
{
169+
skippedBefore: [],
170+
skippedAfter: [],
171+
skippedColumns: [],
172+
logicalColumnCount: 2,
173+
cells: [{ startColumn: 0, span: 2, preferredWidth: 150 }],
174+
},
175+
{
176+
skippedBefore: [],
177+
skippedAfter: [],
178+
skippedColumns: [],
179+
logicalColumnCount: 2,
180+
cells: [
181+
{ startColumn: 0, span: 1, preferredWidth: 100 },
182+
{ startColumn: 1, span: 1, preferredWidth: 70 },
183+
],
184+
},
185+
],
186+
}),
187+
);
188+
189+
expect(result.columnWidths).toEqual([100, 90]);
190+
expect(result.totalWidth).toBe(190);
191+
});
192+
193+
it('accounts for skipped columns and their preferred widths', () => {
194+
const result = computeFixedTableColumnWidths(
195+
buildFixedInput({
196+
preferredColumnWidths: [],
197+
gridColumnCount: 3,
198+
rows: [
199+
{
200+
skippedBefore: [{ columnIndex: 0, preferredWidth: 40, minContentWidth: 0, maxContentWidth: 0 }],
201+
skippedAfter: [{ columnIndex: 2, preferredWidth: 20, minContentWidth: 0, maxContentWidth: 0 }],
202+
skippedColumns: [
203+
{ columnIndex: 0, preferredWidth: 40, minContentWidth: 0, maxContentWidth: 0 },
204+
{ columnIndex: 2, preferredWidth: 20, minContentWidth: 0, maxContentWidth: 0 },
205+
],
206+
logicalColumnCount: 3,
207+
cells: [{ startColumn: 1, span: 1, preferredWidth: 60 }],
208+
},
209+
],
210+
}),
211+
);
212+
213+
expect(result.columnWidths).toEqual([40, 60, 20]);
214+
expect(result.totalWidth).toBe(120);
215+
});
216+
217+
it('extends the logical grid when spans exceed the authored grid width', () => {
218+
const result = computeFixedTableColumnWidths(
219+
buildFixedInput({
220+
preferredColumnWidths: [80],
221+
gridColumnCount: 2,
222+
rows: [
223+
{
224+
skippedBefore: [],
225+
skippedAfter: [],
226+
skippedColumns: [],
227+
logicalColumnCount: 2,
228+
cells: [{ startColumn: 0, span: 2, preferredWidth: 200 }],
229+
},
230+
],
231+
}),
232+
);
233+
234+
expect(result.columnWidths).toEqual([80, 120]);
235+
expect(result.gridColumnCount).toBe(2);
236+
expect(result.totalWidth).toBe(200);
237+
});
238+
239+
it('assigns a non-zero default width to dynamically added grid columns', () => {
240+
const result = computeFixedTableColumnWidths(
241+
buildFixedInput({
242+
preferredColumnWidths: [80],
243+
gridColumnCount: 1,
244+
rows: [
245+
{
246+
skippedBefore: [],
247+
skippedAfter: [],
248+
skippedColumns: [],
249+
logicalColumnCount: 2,
250+
cells: [{ startColumn: 0, span: 1, preferredWidth: 80 }],
251+
},
252+
],
253+
}),
254+
);
255+
256+
expect(result.columnWidths).toEqual([80, 80]);
257+
expect(result.gridColumnCount).toBe(2);
258+
expect(result.totalWidth).toBe(160);
259+
});
260+
261+
it('shrinks after each row when cumulative requests exceed the preferred table width', () => {
262+
const result = computeFixedTableColumnWidths(
263+
buildFixedInput({
264+
preferredColumnWidths: [100, 100, 100],
265+
preferredTableWidth: 240,
266+
rows: [
267+
{
268+
skippedBefore: [],
269+
skippedAfter: [],
270+
skippedColumns: [],
271+
logicalColumnCount: 3,
272+
cells: [
273+
{ startColumn: 0, span: 1, preferredWidth: 120 },
274+
{ startColumn: 1, span: 1, preferredWidth: 120 },
275+
{ startColumn: 2, span: 1, preferredWidth: 120 },
276+
],
277+
},
278+
],
279+
}),
280+
);
281+
282+
expect(result.columnWidths).toEqual([80, 80, 80]);
283+
expect(result.totalWidth).toBe(240);
284+
});
285+
286+
it('preserves complete authored grid for fixed tables when grid sums to tblW', () => {
287+
const result = computeFixedTableColumnWidths(
288+
buildFixedInput({
289+
preferredColumnWidths: [57.53333333333333, 239.46666666666667, 103],
290+
preferredTableWidth: 400,
291+
preserveAuthoredGrid: true,
292+
rows: [
293+
{
294+
skippedBefore: [],
295+
skippedAfter: [],
296+
skippedColumns: [],
297+
logicalColumnCount: 3,
298+
cells: [
299+
{ startColumn: 0, span: 1, preferredWidth: 192 },
300+
{ startColumn: 1, span: 1, preferredWidth: 96 },
301+
{ startColumn: 2, span: 1, preferredWidth: 384 },
302+
],
303+
},
304+
{
305+
skippedBefore: [],
306+
skippedAfter: [],
307+
skippedColumns: [],
308+
logicalColumnCount: 3,
309+
cells: [
310+
{ startColumn: 0, span: 1, preferredWidth: 192 },
311+
{ startColumn: 1, span: 1, preferredWidth: 960 },
312+
{ startColumn: 2, span: 1, preferredWidth: 384 },
313+
],
314+
},
315+
],
316+
}),
317+
);
318+
319+
expect(result.columnWidths).toEqual([57.53333333333333, 239.46666666666667, 103]);
320+
expect(result.totalWidth).toBe(400);
321+
});
322+
323+
it('still applies fixed tcW requests when authored grid is not protected', () => {
324+
const result = computeFixedTableColumnWidths(
325+
buildFixedInput({
326+
preferredColumnWidths: [57.53333333333333, 239.46666666666667],
327+
preferredTableWidth: 400,
328+
gridColumnCount: 3,
329+
rows: [
330+
{
331+
skippedBefore: [],
332+
skippedAfter: [],
333+
skippedColumns: [],
334+
logicalColumnCount: 3,
335+
cells: [
336+
{ startColumn: 0, span: 1, preferredWidth: 192 },
337+
{ startColumn: 1, span: 1, preferredWidth: 96 },
338+
{ startColumn: 2, span: 1, preferredWidth: 384 },
339+
],
340+
},
341+
],
342+
}),
343+
);
344+
345+
expect(result.columnWidths).not.toEqual([57.53333333333333, 239.46666666666667, 103]);
346+
expect(result.columnWidths[0]).toBeCloseTo(114.28571428571428, 10);
347+
expect(result.columnWidths[1]).toBeCloseTo(57.14285714285714, 10);
348+
expect(result.columnWidths[2]).toBeCloseTo(228.57142857142856, 10);
349+
expect(result.totalWidth).toBe(400);
350+
});
351+
352+
it('does not clamp fixed results to the available container width', () => {
353+
const result = computeFixedTableColumnWidths(
354+
buildFixedInput({
355+
maxTableWidth: 400,
356+
preferredColumnWidths: [300, 300],
357+
rows: [],
358+
}),
359+
);
360+
361+
expect(result.columnWidths).toEqual([300, 300]);
362+
expect(result.totalWidth).toBe(600);
363+
});
364+
});
365+
366+
/**
367+
* Build a complete fixed-layout working-grid input with sensible defaults for
368+
* focused unit tests.
369+
*/
370+
function buildFixedInput(overrides: Partial<WorkingTableGridInput>): WorkingTableGridInput {
371+
return {
372+
layoutMode: 'fixed',
373+
maxTableWidth: 500,
374+
preferredTableWidth: undefined,
375+
preferredColumnWidths: [],
376+
gridColumnCount: overrides.preferredColumnWidths?.length ?? 0,
377+
rows: [],
378+
...overrides,
379+
};
380+
}

0 commit comments

Comments
 (0)