Skip to content

Commit 38a1785

Browse files
authored
feat: support wide tables rendering outside of margins (#2823)
* feat: support wide tables rendering outside of margins * fix: fix for multi-column layout * fix: added missing tests and removed duplicated code * test: add coverage for wide-table helpers (SD-2544 / SD-2545) - direct unit tests for resolveTableWidthAttr (contracts) - the new public helper had no test of its own; covers width/value field, type passthrough, zero/negative/non-finite/null rejection. - direct unit tests for resolveTableFrame and resolveRenderedTableWidth (layout-engine) - these are now exported and used by incrementalLayout, so worth testing in isolation: pct calculation, px/dxa fallback to measured, negative x for centered/right wide tables, indent + wide. - pin the quiet behavior change for tableLayout: 'fixed' without an explicit tableWidth (measuring/dom) - grid widths wider than the column now pass through; previously they were scaled to fit. - two click-to-position cases: legacy fragments without columnIndex fall back to visual x via determineColumn, and a fragment claiming a columnIndex past the document column count is clamped to the last valid column. * chore: add/update comments
1 parent a63e00d commit 38a1785

13 files changed

Lines changed: 984 additions & 130 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { OOXML_PCT_DIVISOR, resolveTableWidthAttr } from '../tables.js';
3+
4+
describe('OOXML_PCT_DIVISOR', () => {
5+
it('equals 5000 (1/50th of a percent)', () => {
6+
expect(OOXML_PCT_DIVISOR).toBe(5000);
7+
});
8+
});
9+
10+
describe('resolveTableWidthAttr', () => {
11+
it('reads the width field with its type', () => {
12+
expect(resolveTableWidthAttr({ width: 600, type: 'px' })).toEqual({ width: 600, type: 'px' });
13+
});
14+
15+
it('reads the value field when width is not present', () => {
16+
expect(resolveTableWidthAttr({ value: 2500, type: 'pct' })).toEqual({ width: 2500, type: 'pct' });
17+
});
18+
19+
it('prefers width over value when both are present', () => {
20+
expect(resolveTableWidthAttr({ width: 100, value: 200, type: 'px' })).toEqual({ width: 100, type: 'px' });
21+
});
22+
23+
it('preserves type for dxa', () => {
24+
expect(resolveTableWidthAttr({ width: 1440, type: 'dxa' })).toEqual({ width: 1440, type: 'dxa' });
25+
});
26+
27+
it('returns width with undefined type when type is omitted', () => {
28+
const result = resolveTableWidthAttr({ width: 300 });
29+
expect(result).toEqual({ width: 300, type: undefined });
30+
});
31+
32+
it('rejects null', () => {
33+
expect(resolveTableWidthAttr(null)).toBeNull();
34+
});
35+
36+
it('rejects undefined', () => {
37+
expect(resolveTableWidthAttr(undefined)).toBeNull();
38+
});
39+
40+
it('rejects primitives', () => {
41+
expect(resolveTableWidthAttr(600)).toBeNull();
42+
expect(resolveTableWidthAttr('600')).toBeNull();
43+
expect(resolveTableWidthAttr(true)).toBeNull();
44+
});
45+
46+
it('rejects objects with no width or value', () => {
47+
expect(resolveTableWidthAttr({ type: 'px' })).toBeNull();
48+
expect(resolveTableWidthAttr({})).toBeNull();
49+
});
50+
51+
it('rejects non-numeric width', () => {
52+
expect(resolveTableWidthAttr({ width: '600' as unknown as number, type: 'px' })).toBeNull();
53+
expect(resolveTableWidthAttr({ value: null as unknown as number, type: 'pct' })).toBeNull();
54+
});
55+
56+
it('rejects NaN', () => {
57+
expect(resolveTableWidthAttr({ width: NaN, type: 'pct' })).toBeNull();
58+
});
59+
60+
it('rejects Infinity', () => {
61+
expect(resolveTableWidthAttr({ width: Infinity, type: 'pct' })).toBeNull();
62+
expect(resolveTableWidthAttr({ width: -Infinity, type: 'pct' })).toBeNull();
63+
});
64+
65+
it('rejects zero', () => {
66+
expect(resolveTableWidthAttr({ width: 0, type: 'pct' })).toBeNull();
67+
expect(resolveTableWidthAttr({ value: 0, type: 'pct' })).toBeNull();
68+
});
69+
70+
it('rejects negative widths', () => {
71+
expect(resolveTableWidthAttr({ width: -100, type: 'px' })).toBeNull();
72+
expect(resolveTableWidthAttr({ value: -2500, type: 'pct' })).toBeNull();
73+
});
74+
});

packages/layout-engine/contracts/src/engines/tables.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,29 @@ export interface TableWidthAttr {
3636
type?: 'pct' | 'px' | 'pixel' | string;
3737
}
3838

39+
/**
40+
* Extract a validated numeric width from a table width attribute object.
41+
*
42+
* Supports either `width` or `value` fields and rejects non-finite or non-positive
43+
* values so callers can safely use the result in layout calculations.
44+
*/
45+
export function resolveTableWidthAttr(value: unknown): { width: number; type?: TableWidthAttr['type'] } | null {
46+
if (!value || typeof value !== 'object') {
47+
return null;
48+
}
49+
50+
const measurement = value as TableWidthAttr;
51+
const width = measurement.width ?? measurement.value;
52+
if (typeof width !== 'number' || !Number.isFinite(width) || width <= 0) {
53+
return null;
54+
}
55+
56+
return {
57+
width,
58+
type: measurement.type,
59+
};
60+
}
61+
3962
export interface TableColumnSpec {
4063
type: 'auto' | 'fixed' | 'pct';
4164
width?: number; // pt or percentage (0-100)

packages/layout-engine/contracts/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ export { computeTabStops, layoutWithTabs, calculateTabWidth } from './engines/ta
55
export type { TabStop };
66

77
// Export table contracts
8-
export { OOXML_PCT_DIVISOR, type TableWidthAttr, type TableColumnSpec } from './engines/tables.js';
8+
export {
9+
OOXML_PCT_DIVISOR,
10+
resolveTableWidthAttr,
11+
type TableWidthAttr,
12+
type TableColumnSpec,
13+
} from './engines/tables.js';
914

1015
export { effectiveTableCellSpacing } from './table-cell-spacing.js';
1116

@@ -1885,6 +1890,8 @@ export type PartialRowInfo = {
18851890
export type TableFragment = {
18861891
kind: 'table';
18871892
blockId: BlockId;
1893+
/** Flow column that owns this fragment, distinct from visual x when overflow crosses margins. */
1894+
columnIndex?: number;
18881895
fromRow: number;
18891896
toRow: number;
18901897
x: number;

packages/layout-engine/layout-bridge/src/incrementalLayout.ts

Lines changed: 22 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
SectionBreakBlock,
1010
NormalizedColumnLayout,
1111
} from '@superdoc/contracts';
12-
import { cloneColumnLayout, normalizeColumnLayout } from '@superdoc/contracts';
12+
import { cloneColumnLayout, normalizeColumnLayout, rescaleColumnWidths } from '@superdoc/contracts';
1313
import {
1414
layoutDocument,
1515
layoutHeaderFooter,
@@ -20,6 +20,7 @@ import {
2020
type NumberingContext,
2121
SEMANTIC_PAGE_HEIGHT_PX,
2222
SINGLE_COLUMN_DEFAULT,
23+
resolveTableFrame,
2324
} from '@superdoc/layout-engine';
2425
import { remeasureParagraph } from './remeasure';
2526
import { computeDirtyRegions } from './diff';
@@ -228,7 +229,9 @@ const assignFootnotesToColumns = (
228229

229230
if (columns && columns.count > 1 && page) {
230231
const fragment = findFragmentForPos(page, ref.pos);
231-
if (fragment && typeof fragment.x === 'number') {
232+
if (fragment?.kind === 'table' && typeof fragment.columnIndex === 'number') {
233+
columnIndex = Math.max(0, Math.min(columns.count - 1, fragment.columnIndex));
234+
} else if (fragment && typeof fragment.x === 'number') {
232235
const widths = Array.isArray(columns.widths) && columns.widths.length > 0 ? columns.widths : undefined;
233236
if (widths) {
234237
let cursorX = columns.left;
@@ -1728,46 +1731,27 @@ export async function incrementalLayout(
17281731
const block = blockById.get(range.blockId);
17291732
if (!measure || measure.kind !== 'table') return;
17301733
if (!block || block.kind !== 'table') return;
1731-
const tableWidthRaw = Math.max(0, measure.totalWidth ?? 0);
1732-
let tableWidth = Math.min(contentWidth, tableWidthRaw);
1733-
let tableX = columnX;
1734-
const justification =
1735-
typeof block.attrs?.justification === 'string' ? block.attrs.justification : undefined;
1736-
if (justification === 'center') {
1737-
tableX = columnX + Math.max(0, (contentWidth - tableWidth) / 2);
1738-
} else if (justification === 'right' || justification === 'end') {
1739-
tableX = columnX + Math.max(0, contentWidth - tableWidth);
1740-
} else {
1741-
const indentValue = (block.attrs?.tableIndent as { width?: unknown } | undefined)?.width;
1742-
const indent = typeof indentValue === 'number' && Number.isFinite(indentValue) ? indentValue : 0;
1743-
tableX += indent;
1744-
tableWidth = Math.max(0, tableWidth - indent);
1745-
}
1746-
// Rescale column widths when table was clamped to section width.
1747-
// This happens in mixed-orientation docs where measurement uses the
1748-
// widest section but rendering is per-section (SD-1859).
1749-
let fragmentColumnWidths: number[] | undefined;
1750-
if (
1751-
tableWidthRaw > tableWidth &&
1752-
measure.columnWidths &&
1753-
measure.columnWidths.length > 0 &&
1754-
tableWidthRaw > 0
1755-
) {
1756-
const scale = tableWidth / tableWidthRaw;
1757-
fragmentColumnWidths = measure.columnWidths.map((w: number) => Math.max(1, Math.round(w * scale)));
1758-
const scaledSum = fragmentColumnWidths.reduce((a: number, b: number) => a + b, 0);
1759-
const target = Math.round(tableWidth);
1760-
if (scaledSum !== target && fragmentColumnWidths.length > 0) {
1761-
fragmentColumnWidths[fragmentColumnWidths.length - 1] = Math.max(
1762-
1,
1763-
fragmentColumnWidths[fragmentColumnWidths.length - 1] + (target - scaledSum),
1764-
);
1765-
}
1766-
}
1734+
const tableWidthRaw = Math.max(0, measure.totalWidth ?? contentWidth);
1735+
const { x: tableX, width: tableWidth } = resolveTableFrame(
1736+
columnX,
1737+
contentWidth,
1738+
tableWidthRaw,
1739+
block.attrs,
1740+
);
1741+
// Rescale column widths only when the resolved fragment width is narrower
1742+
// than the measured table width. Today that primarily happens for
1743+
// percentage-width tables rendered in a narrower section (SD-1859),
1744+
// while non-percent wide tables keep their measured overflow width.
1745+
const fragmentColumnWidths = rescaleColumnWidths(
1746+
measure.columnWidths,
1747+
measure.totalWidth,
1748+
tableWidth,
1749+
);
17671750

17681751
page.fragments.push({
17691752
kind: 'table',
17701753
blockId: range.blockId,
1754+
columnIndex,
17711755
fromRow: 0,
17721756
toRow: block.rows.length,
17731757
x: tableX,

packages/layout-engine/layout-bridge/src/position-hit.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,14 @@ export const determineColumn = (layout: Layout, fragmentX: number): number => {
143143
return Math.max(0, Math.min(columns.count - 1, raw));
144144
};
145145

146+
const determineTableColumn = (layout: Layout, fragment: TableFragment): number => {
147+
if (typeof fragment.columnIndex === 'number') {
148+
const count = layout.columns?.count ?? 1;
149+
return Math.max(0, Math.min(Math.max(0, count - 1), fragment.columnIndex));
150+
}
151+
return determineColumn(layout, fragment.x);
152+
};
153+
146154
// ---------------------------------------------------------------------------
147155
// Line / position helpers
148156
// ---------------------------------------------------------------------------
@@ -935,7 +943,7 @@ export function clickToPositionGeometry(
935943
layoutEpoch,
936944
blockId: tableHit.fragment.blockId,
937945
pageIndex,
938-
column: determineColumn(layout, tableHit.fragment.x),
946+
column: determineTableColumn(layout, tableHit.fragment),
939947
lineIndex,
940948
};
941949
}
@@ -949,7 +957,7 @@ export function clickToPositionGeometry(
949957
layoutEpoch,
950958
blockId: tableHit.fragment.blockId,
951959
pageIndex,
952-
column: determineColumn(layout, tableHit.fragment.x),
960+
column: determineTableColumn(layout, tableHit.fragment),
953961
lineIndex: 0,
954962
};
955963
}

0 commit comments

Comments
 (0)