diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index c7e997a5dd..3728d37dbb 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -2992,6 +2992,7 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); + // Auto layout preserves explicit w:tblGrid widths (no scale-up) expect(measure.columnWidths).toEqual([100, 150, 200]); expect(measure.totalWidth).toBe(450); }); @@ -3237,6 +3238,7 @@ 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] — auto-layout preserves widths (no scale-up) expect(measure.columnWidths).toEqual([100, 150]); }); }); @@ -3505,6 +3507,7 @@ 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 preserves explicit widths (no scale-up) expect(measure.columnWidths).toHaveLength(4); expect(measure.columnWidths).toEqual([172, 13, 128, 310]); expect(measure.totalWidth).toBe(623); @@ -3553,7 +3556,8 @@ describe('measureBlock', () => { expect(measure.rows[0].cells).toHaveLength(2); expect(measure.rows[1].cells).toHaveLength(2); - // Cell widths should correctly sum their spanned columns + // 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 @@ -3591,7 +3595,7 @@ 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 preserves widths) expect(measure.rows[0].cells).toHaveLength(1); expect(measure.rows[0].cells[0].width).toBe(550); // 100+50+100+300 @@ -3697,7 +3701,7 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // Should not scale - widths are within target + // Auto layout preserves explicit widths (no scale-up) expect(measure.columnWidths).toEqual([50, 50]); expect(measure.totalWidth).toBe(100); }); @@ -4326,8 +4330,7 @@ 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 without explicit width means column widths used as-is + // Zero percentage is invalid - auto layout preserves column widths expect(measure.totalWidth).toBe(100); expect(measure.columnWidths[0]).toBe(100); }); @@ -4364,7 +4367,7 @@ 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 + // Negative percentage is invalid - auto layout preserves column widths expect(measure.totalWidth).toBe(150); expect(measure.columnWidths[0]).toBe(150); }); @@ -4401,7 +4404,7 @@ 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 + // NaN is invalid - auto layout preserves column widths expect(measure.totalWidth).toBe(200); expect(measure.columnWidths[0]).toBe(200); }); @@ -4438,7 +4441,7 @@ 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 + // Infinity is invalid - auto layout preserves column widths expect(measure.totalWidth).toBe(175); expect(measure.columnWidths[0]).toBe(175); }); @@ -4475,7 +4478,7 @@ 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 + // Missing value is invalid - auto layout preserves column widths expect(measure.totalWidth).toBe(120); expect(measure.columnWidths[0]).toBe(120); }); @@ -4512,7 +4515,7 @@ 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 + // NaN pixel width is invalid - auto layout preserves column widths expect(measure.totalWidth).toBe(130); expect(measure.columnWidths[0]).toBe(130); }); @@ -4547,7 +4550,7 @@ 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 + // No tableWidth - auto layout preserves column widths expect(measure.totalWidth).toBe(140); expect(measure.columnWidths[0]).toBe(140); }); diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 6994ca8d63..0ae0da77c4 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,19 @@ 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. + // 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) { - columnWidths = scaleColumnWidths(columnWidths, effectiveTargetWidth); + if (totalWidth > effectiveTargetWidth && effectiveTargetWidth > 0) { + const scale = effectiveTargetWidth / totalWidth; + columnWidths = columnWidths.map((w) => Math.max(1, Math.round(w * scale))); + 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 { 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 07d011e4f9..8a0cb66c52 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 baf6d52d50..d3feff2f80 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 3e3406fac4..047aecc138 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 949ecaf160..162b347562 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);