From 5e5ff5514a38394779268c61a74c8be142a640cf Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 19 Feb 2026 08:06:17 -0300 Subject: [PATCH 1/2] fix(tables): expand auto-width tables to fill available page width Tables without explicit w:tblW (or with type="auto") were rendering at a fraction of the page width instead of filling the available content area like Word does. --- .../measuring/dom/src/index.test.ts | 81 +++++++++-------- .../layout-engine/measuring/dom/src/index.ts | 86 ++++--------------- 2 files changed, 61 insertions(+), 106 deletions(-) diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index c7e997a5d..c2a0be2c2 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -2992,8 +2992,10 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - expect(measure.columnWidths).toEqual([100, 150, 200]); - expect(measure.totalWidth).toBe(450); + // Auto layout scales columns to fill available width (OOXML autofit) + // Original ratio 100:150:200 = 2:3:4, scaled to 600px total + expect(measure.columnWidths).toEqual([133, 200, 267]); + expect(measure.totalWidth).toBe(600); }); it('scales column widths proportionally when exceeding available width', async () => { @@ -3237,7 +3239,9 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); expect(measure.columnWidths).toHaveLength(2); - expect(measure.columnWidths).toEqual([100, 150]); + // Truncated to [100, 150] (250px), then auto-layout scales up to 600px + // 100*(600/250)=240, 150*(600/250)=360 + expect(measure.columnWidths).toEqual([240, 360]); }); }); @@ -3505,9 +3509,10 @@ describe('measureBlock', () => { if (measure.kind !== 'table') throw new Error('expected table measure'); // All 4 column widths should be preserved (not truncated to 3) + // Auto-layout scales to fill maxWidth (800), ratio preserved expect(measure.columnWidths).toHaveLength(4); - expect(measure.columnWidths).toEqual([172, 13, 128, 310]); - expect(measure.totalWidth).toBe(623); + expect(measure.columnWidths).toEqual([221, 17, 164, 398]); + expect(measure.totalWidth).toBe(800); // Row 0: 2 cells spanning 3+1 = both cells measured expect(measure.rows[0].cells).toHaveLength(2); @@ -3553,15 +3558,16 @@ describe('measureBlock', () => { expect(measure.rows[0].cells).toHaveLength(2); expect(measure.rows[1].cells).toHaveLength(2); - // Cell widths should correctly sum their spanned columns - // Row 0 cell 0: cols 0+1 = 100+50 = 150 - expect(measure.rows[0].cells[0].width).toBe(150); - // Row 0 cell 1: cols 2+3 = 100+300 = 400 - expect(measure.rows[0].cells[1].width).toBe(400); - // Row 1 cell 0: cols 0+1+2 = 100+50+100 = 250 - expect(measure.rows[1].cells[0].width).toBe(250); - // Row 1 cell 1: col 3 = 300 - expect(measure.rows[1].cells[1].width).toBe(300); + // Cell widths should correctly sum their spanned columns (after auto-layout scale-up to 800px) + // Scaled columns: [145, 73, 145, 437] + // Row 0 cell 0: cols 0+1 = 145+73 = 218 + expect(measure.rows[0].cells[0].width).toBe(218); + // Row 0 cell 1: cols 2+3 = 145+437 = 582 + expect(measure.rows[0].cells[1].width).toBe(582); + // Row 1 cell 0: cols 0+1+2 = 145+73+145 = 363 + expect(measure.rows[1].cells[0].width).toBe(363); + // Row 1 cell 1: col 3 = 437 + expect(measure.rows[1].cells[1].width).toBe(437); }); it('handles single-cell full-span row correctly', async () => { @@ -3591,9 +3597,9 @@ describe('measureBlock', () => { expect(measure.columnWidths).toHaveLength(4); - // Full-span row: 1 cell spanning all 4 columns + // Full-span row: 1 cell spanning all 4 columns (auto-layout scaled to 800px) expect(measure.rows[0].cells).toHaveLength(1); - expect(measure.rows[0].cells[0].width).toBe(550); // 100+50+100+300 + expect(measure.rows[0].cells[0].width).toBe(800); // 145+73+145+437 after scale-up // 3-cell row: all cells present expect(measure.rows[1].cells).toHaveLength(3); @@ -3697,9 +3703,9 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // Should not scale - widths are within target - expect(measure.columnWidths).toEqual([50, 50]); - expect(measure.totalWidth).toBe(100); + // Auto layout expands columns to fill available width (OOXML autofit) + expect(measure.columnWidths).toEqual([100, 100]); + expect(measure.totalWidth).toBe(200); }); it('produces exact sum after rounding adjustment', async () => { @@ -4327,9 +4333,9 @@ describe('measureBlock', () => { if (measure.kind !== 'table') throw new Error('expected table measure'); // Zero percentage is invalid - should fall back to auto layout - // Auto layout without explicit width means column widths used as-is - expect(measure.totalWidth).toBe(100); - expect(measure.columnWidths[0]).toBe(100); + // Auto layout expands columns to fill available width (OOXML autofit) + expect(measure.totalWidth).toBe(600); + expect(measure.columnWidths[0]).toBe(600); }); it('ignores negative percentage value', async () => { @@ -4365,8 +4371,9 @@ describe('measureBlock', () => { if (measure.kind !== 'table') throw new Error('expected table measure'); // Negative percentage is invalid - should fall back to auto layout - expect(measure.totalWidth).toBe(150); - expect(measure.columnWidths[0]).toBe(150); + // Auto layout expands columns to fill available width (OOXML autofit) + expect(measure.totalWidth).toBe(600); + expect(measure.columnWidths[0]).toBe(600); }); it('ignores NaN percentage value', async () => { @@ -4402,8 +4409,9 @@ describe('measureBlock', () => { if (measure.kind !== 'table') throw new Error('expected table measure'); // NaN is invalid - should fall back to auto layout - expect(measure.totalWidth).toBe(200); - expect(measure.columnWidths[0]).toBe(200); + // Auto layout expands columns to fill available width (OOXML autofit) + expect(measure.totalWidth).toBe(600); + expect(measure.columnWidths[0]).toBe(600); }); it('ignores Infinity percentage value', async () => { @@ -4439,8 +4447,9 @@ describe('measureBlock', () => { if (measure.kind !== 'table') throw new Error('expected table measure'); // Infinity is invalid - should fall back to auto layout - expect(measure.totalWidth).toBe(175); - expect(measure.columnWidths[0]).toBe(175); + // Auto layout expands columns to fill available width (OOXML autofit) + expect(measure.totalWidth).toBe(600); + expect(measure.columnWidths[0]).toBe(600); }); it('ignores tableWidth with missing both width and value properties', async () => { @@ -4476,8 +4485,9 @@ describe('measureBlock', () => { if (measure.kind !== 'table') throw new Error('expected table measure'); // Missing value is invalid - should fall back to auto layout - expect(measure.totalWidth).toBe(120); - expect(measure.columnWidths[0]).toBe(120); + // Auto layout expands columns to fill available width (OOXML autofit) + expect(measure.totalWidth).toBe(600); + expect(measure.columnWidths[0]).toBe(600); }); it('ignores tableWidth when type is pixel with invalid value', async () => { @@ -4513,8 +4523,9 @@ describe('measureBlock', () => { if (measure.kind !== 'table') throw new Error('expected table measure'); // NaN pixel width is invalid - should fall back to auto layout - expect(measure.totalWidth).toBe(130); - expect(measure.columnWidths[0]).toBe(130); + // Auto layout expands columns to fill available width (OOXML autofit) + expect(measure.totalWidth).toBe(600); + expect(measure.columnWidths[0]).toBe(600); }); it('handles missing tableWidth property entirely', async () => { @@ -4547,9 +4558,9 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // No tableWidth - auto layout uses column widths as-is - expect(measure.totalWidth).toBe(140); - expect(measure.columnWidths[0]).toBe(140); + // No tableWidth - auto layout expands columns to fill available width (OOXML autofit) + expect(measure.totalWidth).toBe(600); + expect(measure.columnWidths[0]).toBe(600); }); it('does NOT scale up column widths for fixed layout tables with explicit width', async () => { diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 6994ca8d6..d5fe99568 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -2437,8 +2437,9 @@ function resolveTableWidth(attrs: TableBlock['attrs'], maxWidth: number): number // Convert OOXML percentage to pixels // OOXML_PCT_DIVISOR (5000) = 100% return Math.round(maxWidth * (validValue / OOXML_PCT_DIVISOR)); - } else if (typedAttr.type === 'px' || typedAttr.type === 'pixel') { + } else if (typedAttr.type === 'px' || typedAttr.type === 'pixel' || typedAttr.type === 'dxa') { // Explicit pixel width - use directly + // Note: 'dxa' values are already converted to pixels by tbl-translator during import return validValue; } @@ -2452,70 +2453,6 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai let columnWidths: number[]; - /** - * Scales column widths proportionally to fit within a target width. - * - * This function is used when table column widths exceed the available page width. - * It proportionally reduces all columns to fit the constraint while maintaining - * their relative proportions. - * - * Rounding Adjustment Logic: - * - Initial scaling uses Math.round() for each column, which can cause the sum - * to deviate from the target due to accumulated rounding errors - * - After scaling, the function adjusts columns one-by-one to reach the exact target - * - For excess width (sum > target): decrements columns starting from index 0 - * - For deficit width (sum < target): increments columns starting from index 0 - * - Ensures no column goes below 1px minimum width - * - Distributes adjustments cyclically to avoid bias toward any single column - * - * @param widths - Array of column widths in pixels - * @param targetWidth - Maximum total width in pixels - * @returns Scaled column widths that sum exactly to targetWidth (or original widths if already fit) - * - * @example - * ```typescript - * scaleColumnWidths([100, 200, 100], 300) - * // Returns: [75, 150, 75] (scaled from 400px down to 300px, maintaining 1:2:1 ratio) - * - * scaleColumnWidths([50, 50], 200) - * // Returns: [50, 50] (already within target, no scaling needed) - * - * scaleColumnWidths([33, 33, 33], 100) - * // Returns: [34, 33, 33] (sum adjusted from 99 to exactly 100) - * ``` - */ - const scaleColumnWidths = (widths: number[], targetWidth: number): number[] => { - const totalWidth = widths.reduce((a, b) => a + b, 0); - if (totalWidth <= targetWidth || widths.length === 0) return widths; - - const scale = targetWidth / totalWidth; - const scaled = widths.map((w) => Math.max(1, Math.round(w * scale))); - const sum = scaled.reduce((a, b) => a + b, 0); - - // Normalize to the exact target to avoid overflows from rounding. - if (sum !== targetWidth) { - const adjust = (delta: number): void => { - let idx = 0; - const direction = delta > 0 ? 1 : -1; - delta = Math.abs(delta); - while (delta > 0 && scaled.length > 0) { - const i = idx % scaled.length; - if (direction > 0) { - scaled[i] += 1; - delta -= 1; - } else if (scaled[i] > 1) { - scaled[i] -= 1; - delta -= 1; - } - idx += 1; - if (idx > scaled.length * 2 && delta > 0) break; - } - }; - adjust(targetWidth - sum); - } - - return scaled; - }; // Determine actual column count from table structure (accounting for colspan) const maxCellCount = Math.max( 1, @@ -2572,13 +2509,20 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai columnWidths = columnWidths.slice(0, maxCellCount); } - // Scale down if total width exceeds available space (prevent overflow). - // Auto-width tables (w:tblW type="auto") size to their grid/content in Word. - // Do NOT scale up — tables that fill the page do so because their grid columns - // already sum to the page width, not because of scaling. + // ECMA-376 §2.4.64 (tblW): when omitted, width defaults to type "auto". + // The table layout algorithm (§2.4.49/§2.4.50) then uses autofit, which + // in practice expands the table to fill available page content width. + // Scale columns proportionally to match this behavior. const totalWidth = columnWidths.reduce((a, b) => a + b, 0); - if (totalWidth > effectiveTargetWidth) { - columnWidths = scaleColumnWidths(columnWidths, effectiveTargetWidth); + if (totalWidth !== effectiveTargetWidth && effectiveTargetWidth > 0 && totalWidth > 0) { + const scale = effectiveTargetWidth / totalWidth; + columnWidths = columnWidths.map((w) => Math.max(1, Math.round(w * scale))); + // Normalize to exact target width (handle rounding errors) + const scaledSum = columnWidths.reduce((a, b) => a + b, 0); + if (scaledSum !== effectiveTargetWidth && columnWidths.length > 0) { + const diff = effectiveTargetWidth - scaledSum; + columnWidths[columnWidths.length - 1] = Math.max(1, columnWidths[columnWidths.length - 1] + diff); + } } } } else { From 3439e2a62a3b9fa882f4aed634b7a104846d53d2 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 19 Feb 2026 08:47:41 -0300 Subject: [PATCH 2/2] fix(tables): ensure auto-layout preserves column widths and defaults to full page width Updated the table measurement logic to prevent scaling up column widths when explicit widths are defined. Adjusted tests to reflect the new behavior where tables without explicit widths default to filling the available page content width, aligning with Word's autofit behavior. --- .../measuring/dom/src/index.test.ts | 96 +++++++++---------- .../layout-engine/measuring/dom/src/index.ts | 11 +-- .../helpers/tableFallbackHelpers.js | 4 +- .../v3/handlers/w/tbl/tbl-translator.js | 22 +++-- .../v3/handlers/w/tbl/tbl-translator.test.js | 5 +- .../helpers/tableFallbackHelpers.test.js | 14 +++ 6 files changed, 84 insertions(+), 68 deletions(-) diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index c2a0be2c2..3728d37db 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -2992,10 +2992,9 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // Auto layout scales columns to fill available width (OOXML autofit) - // Original ratio 100:150:200 = 2:3:4, scaled to 600px total - expect(measure.columnWidths).toEqual([133, 200, 267]); - expect(measure.totalWidth).toBe(600); + // Auto layout preserves explicit w:tblGrid widths (no scale-up) + expect(measure.columnWidths).toEqual([100, 150, 200]); + expect(measure.totalWidth).toBe(450); }); it('scales column widths proportionally when exceeding available width', async () => { @@ -3239,9 +3238,8 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); expect(measure.columnWidths).toHaveLength(2); - // Truncated to [100, 150] (250px), then auto-layout scales up to 600px - // 100*(600/250)=240, 150*(600/250)=360 - expect(measure.columnWidths).toEqual([240, 360]); + // Truncated to [100, 150] — auto-layout preserves widths (no scale-up) + expect(measure.columnWidths).toEqual([100, 150]); }); }); @@ -3509,10 +3507,10 @@ describe('measureBlock', () => { if (measure.kind !== 'table') throw new Error('expected table measure'); // All 4 column widths should be preserved (not truncated to 3) - // Auto-layout scales to fill maxWidth (800), ratio preserved + // Auto-layout preserves explicit widths (no scale-up) expect(measure.columnWidths).toHaveLength(4); - expect(measure.columnWidths).toEqual([221, 17, 164, 398]); - expect(measure.totalWidth).toBe(800); + expect(measure.columnWidths).toEqual([172, 13, 128, 310]); + expect(measure.totalWidth).toBe(623); // Row 0: 2 cells spanning 3+1 = both cells measured expect(measure.rows[0].cells).toHaveLength(2); @@ -3558,16 +3556,16 @@ describe('measureBlock', () => { expect(measure.rows[0].cells).toHaveLength(2); expect(measure.rows[1].cells).toHaveLength(2); - // Cell widths should correctly sum their spanned columns (after auto-layout scale-up to 800px) - // Scaled columns: [145, 73, 145, 437] - // Row 0 cell 0: cols 0+1 = 145+73 = 218 - expect(measure.rows[0].cells[0].width).toBe(218); - // Row 0 cell 1: cols 2+3 = 145+437 = 582 - expect(measure.rows[0].cells[1].width).toBe(582); - // Row 1 cell 0: cols 0+1+2 = 145+73+145 = 363 - expect(measure.rows[1].cells[0].width).toBe(363); - // Row 1 cell 1: col 3 = 437 - expect(measure.rows[1].cells[1].width).toBe(437); + // Cell widths sum their spanned columns (auto-layout preserves widths, no scale-up) + // Columns: [100, 50, 100, 300] + // Row 0 cell 0: cols 0+1 = 100+50 = 150 + expect(measure.rows[0].cells[0].width).toBe(150); + // Row 0 cell 1: cols 2+3 = 100+300 = 400 + expect(measure.rows[0].cells[1].width).toBe(400); + // Row 1 cell 0: cols 0+1+2 = 100+50+100 = 250 + expect(measure.rows[1].cells[0].width).toBe(250); + // Row 1 cell 1: col 3 = 300 + expect(measure.rows[1].cells[1].width).toBe(300); }); it('handles single-cell full-span row correctly', async () => { @@ -3597,9 +3595,9 @@ describe('measureBlock', () => { expect(measure.columnWidths).toHaveLength(4); - // Full-span row: 1 cell spanning all 4 columns (auto-layout scaled to 800px) + // Full-span row: 1 cell spanning all 4 columns (auto-layout preserves widths) expect(measure.rows[0].cells).toHaveLength(1); - expect(measure.rows[0].cells[0].width).toBe(800); // 145+73+145+437 after scale-up + expect(measure.rows[0].cells[0].width).toBe(550); // 100+50+100+300 // 3-cell row: all cells present expect(measure.rows[1].cells).toHaveLength(3); @@ -3703,9 +3701,9 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // Auto layout expands columns to fill available width (OOXML autofit) - expect(measure.columnWidths).toEqual([100, 100]); - expect(measure.totalWidth).toBe(200); + // Auto layout preserves explicit widths (no scale-up) + expect(measure.columnWidths).toEqual([50, 50]); + expect(measure.totalWidth).toBe(100); }); it('produces exact sum after rounding adjustment', async () => { @@ -4332,10 +4330,9 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // Zero percentage is invalid - should fall back to auto layout - // Auto layout expands columns to fill available width (OOXML autofit) - expect(measure.totalWidth).toBe(600); - expect(measure.columnWidths[0]).toBe(600); + // Zero percentage is invalid - auto layout preserves column widths + expect(measure.totalWidth).toBe(100); + expect(measure.columnWidths[0]).toBe(100); }); it('ignores negative percentage value', async () => { @@ -4370,10 +4367,9 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // Negative percentage is invalid - should fall back to auto layout - // Auto layout expands columns to fill available width (OOXML autofit) - expect(measure.totalWidth).toBe(600); - expect(measure.columnWidths[0]).toBe(600); + // Negative percentage is invalid - auto layout preserves column widths + expect(measure.totalWidth).toBe(150); + expect(measure.columnWidths[0]).toBe(150); }); it('ignores NaN percentage value', async () => { @@ -4408,10 +4404,9 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // NaN is invalid - should fall back to auto layout - // Auto layout expands columns to fill available width (OOXML autofit) - expect(measure.totalWidth).toBe(600); - expect(measure.columnWidths[0]).toBe(600); + // NaN is invalid - auto layout preserves column widths + expect(measure.totalWidth).toBe(200); + expect(measure.columnWidths[0]).toBe(200); }); it('ignores Infinity percentage value', async () => { @@ -4446,10 +4441,9 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // Infinity is invalid - should fall back to auto layout - // Auto layout expands columns to fill available width (OOXML autofit) - expect(measure.totalWidth).toBe(600); - expect(measure.columnWidths[0]).toBe(600); + // Infinity is invalid - auto layout preserves column widths + expect(measure.totalWidth).toBe(175); + expect(measure.columnWidths[0]).toBe(175); }); it('ignores tableWidth with missing both width and value properties', async () => { @@ -4484,10 +4478,9 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // Missing value is invalid - should fall back to auto layout - // Auto layout expands columns to fill available width (OOXML autofit) - expect(measure.totalWidth).toBe(600); - expect(measure.columnWidths[0]).toBe(600); + // Missing value is invalid - auto layout preserves column widths + expect(measure.totalWidth).toBe(120); + expect(measure.columnWidths[0]).toBe(120); }); it('ignores tableWidth when type is pixel with invalid value', async () => { @@ -4522,10 +4515,9 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // NaN pixel width is invalid - should fall back to auto layout - // Auto layout expands columns to fill available width (OOXML autofit) - expect(measure.totalWidth).toBe(600); - expect(measure.columnWidths[0]).toBe(600); + // NaN pixel width is invalid - auto layout preserves column widths + expect(measure.totalWidth).toBe(130); + expect(measure.columnWidths[0]).toBe(130); }); it('handles missing tableWidth property entirely', async () => { @@ -4558,9 +4550,9 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // No tableWidth - auto layout expands columns to fill available width (OOXML autofit) - expect(measure.totalWidth).toBe(600); - expect(measure.columnWidths[0]).toBe(600); + // No tableWidth - auto layout preserves column widths + expect(measure.totalWidth).toBe(140); + expect(measure.columnWidths[0]).toBe(140); }); it('does NOT scale up column widths for fixed layout tables with explicit width', async () => { diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index d5fe99568..0ae0da77c 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -2509,15 +2509,14 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai columnWidths = columnWidths.slice(0, maxCellCount); } - // ECMA-376 §2.4.64 (tblW): when omitted, width defaults to type "auto". - // The table layout algorithm (§2.4.49/§2.4.50) then uses autofit, which - // in practice expands the table to fill available page content width. - // Scale columns proportionally to match this behavior. + // Auto-layout: only scale DOWN if columns exceed available width. + // Do NOT scale up — explicit w:tblGrid column widths are authoritative. + // Tables without w:tblGrid already arrive with page-width columns via + // the fallback grid builder in tableFallbackHelpers. const totalWidth = columnWidths.reduce((a, b) => a + b, 0); - if (totalWidth !== effectiveTargetWidth && effectiveTargetWidth > 0 && totalWidth > 0) { + if (totalWidth > effectiveTargetWidth && effectiveTargetWidth > 0) { const scale = effectiveTargetWidth / totalWidth; columnWidths = columnWidths.map((w) => Math.max(1, Math.round(w * scale))); - // Normalize to exact target width (handle rounding errors) const scaledSum = columnWidths.reduce((a, b) => a + b, 0); if (scaledSum !== effectiveTargetWidth && columnWidths.length > 0) { const diff = effectiveTargetWidth - scaledSum; diff --git a/packages/super-editor/src/core/super-converter/helpers/tableFallbackHelpers.js b/packages/super-editor/src/core/super-converter/helpers/tableFallbackHelpers.js index 07d011e4f..8a0cb66c5 100644 --- a/packages/super-editor/src/core/super-converter/helpers/tableFallbackHelpers.js +++ b/packages/super-editor/src/core/super-converter/helpers/tableFallbackHelpers.js @@ -77,7 +77,9 @@ export const buildFallbackGridForTable = ({ params, rows, tableWidth, tableWidth } if (totalWidthPx == null) { - totalWidthPx = minimumColumnWidthPx * columnCount; + // No explicit width available — default to full page content width. + // This matches Word's autofit behavior for tables without w:tblGrid. + totalWidthPx = twipsToPixels(DEFAULT_CONTENT_WIDTH_TWIPS); } const rawColumnWidthPx = Math.max(totalWidthPx / columnCount, minimumColumnWidthPx); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js index baf6d52d5..d3feff2f8 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js @@ -1,13 +1,13 @@ // @ts-check -import { NodeTranslator } from '@translator'; -import { twipsToPixels, eighthPointsToPixels, halfPointToPoints } from '@core/super-converter/helpers.js'; +import { translator as tblStylePrTranslator } from '@converter/v3/handlers/w/tblStylePr'; import { preProcessVerticalMergeCells } from '@core/super-converter/export-helpers/pre-process-vertical-merge-cells.js'; +import { eighthPointsToPixels, halfPointToPoints, twipsToPixels } from '@core/super-converter/helpers.js'; +import { buildFallbackGridForTable } from '@core/super-converter/helpers/tableFallbackHelpers.js'; import { translateChildNodes } from '@core/super-converter/v2/exporter/helpers/index.js'; -import { translator as trTranslator } from '../tr'; -import { translator as tblPrTranslator } from '../tblPr'; +import { NodeTranslator } from '@translator'; import { translator as tblGridTranslator } from '../tblGrid'; -import { translator as tblStylePrTranslator } from '@converter/v3/handlers/w/tblStylePr'; -import { buildFallbackGridForTable } from '@core/super-converter/helpers/tableFallbackHelpers.js'; +import { translator as tblPrTranslator } from '../tblPr'; +import { translator as trTranslator } from '../tr'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:tbl'; @@ -170,7 +170,8 @@ const encode = (params, encodedAttrs) => { Math.sign(indentDiff) === Math.sign(tableIndentTwips) && Math.abs(indentDiff - tableIndentTwips) <= INDENT_TWIPS_TOLERANCE; - if (!columnWidths.length) { + const hasUsableGrid = columnWidths.length > 0 && columnWidths.some((w) => w > 0); + if (!hasUsableGrid) { const fallback = buildFallbackGridForTable({ params, rows, @@ -181,6 +182,13 @@ const encode = (params, encodedAttrs) => { encodedAttrs.grid = fallback.grid; columnWidths = fallback.columnWidths; } + // No usable grid means the table has no explicit column sizing. + // Default to 100% width so measuring-dom scales to actual page width. + const tw = encodedAttrs.tableWidth; + const hasUsableWidth = tw && tw.type !== 'auto' && (tw.width > 0 || tw.value > 0); + if (!hasUsableWidth) { + encodedAttrs.tableWidth = { value: 5000, type: 'pct' }; + } } const content = []; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js index 3e3406fac..047aecc13 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js @@ -210,7 +210,7 @@ describe('w:tbl translator', () => { expect(result.attrs.grid).toEqual([{ col: 2880 }, { col: 2880 }]); }); - it('handles auto table width type as fallback', () => { + it('converts auto table width to 100% when no usable grid exists', () => { const autoWidthTable = { name: 'w:tbl', elements: [ @@ -224,7 +224,8 @@ describe('w:tbl translator', () => { const result = translator.encode(params, {}); - expect(result.attrs.tableWidth).toEqual({ width: 0, type: 'auto' }); + // No usable grid → table defaults to 100% width (fill page) + expect(result.attrs.tableWidth).toEqual({ value: 5000, type: 'pct' }); }); }); diff --git a/packages/super-editor/src/tests/helpers/tableFallbackHelpers.test.js b/packages/super-editor/src/tests/helpers/tableFallbackHelpers.test.js index 949ecaf16..162b34756 100644 --- a/packages/super-editor/src/tests/helpers/tableFallbackHelpers.test.js +++ b/packages/super-editor/src/tests/helpers/tableFallbackHelpers.test.js @@ -105,6 +105,20 @@ describe('tableFallbackHelpers', () => { expect(result).toBeNull(); }); + it('defaults to page content width when no width info available', () => { + const rows = [createRow([1, 1, 1])]; + + const result = buildFallbackGridForTable({ ...baseParams, rows }); + + expect(result).not.toBeNull(); + const totalTwips = result.grid.reduce((sum, col) => sum + col.col, 0); + expect(totalTwips).toBeCloseTo(DEFAULT_CONTENT_WIDTH_TWIPS, 0); + // 3 equal columns spanning page width + result.columnWidths.forEach((widthPx) => { + expect(widthPx).toBeCloseTo((totalTwips / 3 / 1440) * 96, 0); + }); + }); + it('resolves measurement width from dxa values', () => { const measurement = { value: 1440, type: 'dxa' }; // 1" expect(resolveMeasurementWidthPx(measurement)).toBeCloseTo(96, 3);