Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions packages/layout-engine/measuring/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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]);
});
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
});
Expand Down
85 changes: 14 additions & 71 deletions packages/layout-engine/measuring/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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 = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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' });
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading