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
2 changes: 1 addition & 1 deletion devtools/visual-testing/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { defineStory } from '@superdoc-testing/helpers';

const WAIT_LONG_MS = 800;

/**
* SD-1895: Auto-layout tables from DOCX should fill page width
*
* Uses the actual document from the SD-1895 bug report which has
* auto-layout tables with column widths defined by cell size.
* Before the fix, columns rendered at their raw measurement widths
* leaving unused space. After the fix, columns scale up to fill
* the available page width.
*/
export default defineStory({
name: 'autofit-table-docx-rendering',
description: 'SD-1895: Auto-layout tables from DOCX should fill page width',
tickets: ['SD-1895'],
startDocument: 'tables/SD-1895-autofit-issue.docx',
layout: true,
hideCaret: true,
hideSelection: true,

async run(page, helpers): Promise<void> {
const { step, waitForStable, milestone } = helpers;

await step('Wait for document to load', async () => {
await page.waitForSelector('.superdoc-page', { timeout: 30_000 });
await waitForStable(WAIT_LONG_MS);
await milestone('page1-autofit-table', 'Auto-layout table should fill page width');
});

await step('Scroll to page 2 if present', async () => {
const hasPage2 = await page.evaluate(() => {
const pages = document.querySelectorAll('.superdoc-page');
if (pages.length > 1) {
const container = document.querySelector('.harness-main');
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable container.

Suggested change
const container = document.querySelector('.harness-main');

Copilot uses AI. Check for mistakes.
pages[1].scrollIntoView({ block: 'start' });
return true;
}
return false;
});
if (hasPage2) {
await waitForStable(WAIT_LONG_MS);
await milestone('page2-autofit-table', 'Table on page 2 should also fill page width');
}
});
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { defineStory } from '@superdoc-testing/helpers';

const WAIT_MS = 400;
const WAIT_LONG_MS = 800;

/**
* SD-1895: Auto-layout tables should fill page width
*
* Tables inserted via the editor use auto-layout mode. With the fix,
* auto-layout tables scale their column widths UP to fill the available
* page width (matching Word behavior) rather than leaving unused space.
*
* This test inserts tables with different column counts and verifies
* they render cleanly filling the page width.
*/
export default defineStory({
name: 'autofit-table-fill-width',
description: 'Verify auto-layout tables fill page width with proportional columns',
tickets: ['SD-1895'],
startDocument: null,
layout: true,
hideCaret: true,
hideSelection: true,

async run(_page, helpers): Promise<void> {
const { step, focus, type, press, waitForStable, milestone, executeCommand } = helpers;

await step('Insert a 4-column table', async () => {
await focus();
await type('Table with 4 columns:');
await press('Enter');
await executeCommand('insertTable', { rows: 3, cols: 4, withHeaderRow: false });
await waitForStable(WAIT_LONG_MS);
});

await step('Fill table cells with content', async () => {
// Row 1
await type('Name');
await press('Tab');
await type('Department');
await press('Tab');
await type('Role');
await press('Tab');
await type('Status');
// Row 2
await press('Tab');
await type('Alice Smith');
await press('Tab');
await type('Engineering');
await press('Tab');
await type('Senior Developer');
await press('Tab');
await type('Active');
// Row 3
await press('Tab');
await type('Bob Johnson');
await press('Tab');
await type('Marketing');
await press('Tab');
await type('Content Lead');
await press('Tab');
await type('Active');
await waitForStable(WAIT_LONG_MS);
await milestone('four-column-table', 'Table with 4 columns should fill page width');
});

await step('Add paragraph and insert 6-column table', async () => {
// Move cursor after table
await press('ArrowDown');
await press('ArrowDown');
await waitForStable(WAIT_MS);
await press('Enter');
await type('Table with 6 columns:');
await press('Enter');
await executeCommand('insertTable', { rows: 2, cols: 6, withHeaderRow: false });
await waitForStable(WAIT_LONG_MS);
});

await step('Fill 6-column table', async () => {
await type('Col 1');
await press('Tab');
await type('Col 2');
await press('Tab');
await type('Col 3');
await press('Tab');
await type('Col 4');
await press('Tab');
await type('Col 5');
await press('Tab');
await type('Col 6');
await press('Tab');
await type('Data A');
await press('Tab');
await type('Data B');
await press('Tab');
await type('Data C');
await press('Tab');
await type('Data D');
await press('Tab');
await type('Data E');
await press('Tab');
await type('Data F');
await waitForStable(WAIT_LONG_MS);
await milestone('six-column-table', 'Both tables should fill page width with proportional columns');
});
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { defineStory } from '@superdoc-testing/helpers';

const WAIT_LONG_MS = 800;

/**
* SD-1859: Percent-width tables should not overflow page bounds in mixed orientation docs
*
* Uses the actual document from the SD-1859 bug report: NuraBio document
* which has a percent-width table in a document with mixed portrait/landscape sections.
* Before the fix, the table overflowed the right edge of portrait pages because
* column widths were computed using landscape dimensions but rendered in portrait.
* After the fix, column widths are rescaled to fit the current page's content area.
*/
export default defineStory({
name: 'percent-width-table-overflow',
description: 'SD-1859: Percent-width tables should fit within portrait page bounds',
tickets: ['SD-1859'],
startDocument: 'tables/SD-1859-mixed-orientation.docx',
layout: true,
hideCaret: true,
hideSelection: true,

async run(page, helpers): Promise<void> {
const { step, waitForStable, milestone } = helpers;

await step('Wait for document to load', async () => {
await page.waitForSelector('.superdoc-page', { timeout: 30_000 });
await waitForStable(WAIT_LONG_MS);
await milestone('page1-table', 'Page 1 table should fit within page bounds');
});

await step('Scroll to see the table on page 2', async () => {
await page.evaluate(() => {
const container = document.querySelector('.harness-main');
const scrollTarget = container?.querySelector('.superdoc-page:nth-child(2)');
if (scrollTarget) {
scrollTarget.scrollIntoView({ block: 'start' });
}
});
await waitForStable(WAIT_LONG_MS);
await milestone('page2-table', 'Table should not overflow right edge of portrait page');
});
},
});
3 changes: 3 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1584,6 +1584,9 @@ export type TableFragment = {
metadata?: TableFragmentMetadata;
pmStart?: number;
pmEnd?: number;
/** Per-fragment column widths, rescaled when table is clamped to section width.
* When set, the renderer uses these instead of measure.columnWidths. */
columnWidths?: number[];
};

export type ImageFragment = {
Expand Down
23 changes: 23 additions & 0 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,7 +739,7 @@
// Dirty region computation
const dirtyStart = performance.now();
const dirty = computeDirtyRegions(previousBlocks, nextBlocks);
const dirtyTime = performance.now() - dirtyStart;

Check warning on line 742 in packages/layout-engine/layout-bridge/src/incrementalLayout.ts

View workflow job for this annotation

GitHub Actions / validate

'dirtyTime' is assigned a value but never used. Allowed unused vars must match /^_/u

if (dirty.deletedBlockIds.length > 0) {
measureCache.invalidate(dirty.deletedBlockIds);
Expand Down Expand Up @@ -1597,6 +1597,28 @@
tableX += indent;
tableWidth = Math.max(0, tableWidth - indent);
}
// Rescale column widths when table was clamped to section width.
// This happens in mixed-orientation docs where measurement uses the
// widest section but rendering is per-section (SD-1859).
let fragmentColumnWidths: number[] | undefined;
if (
tableWidthRaw > tableWidth &&
measure.columnWidths &&
measure.columnWidths.length > 0 &&
tableWidthRaw > 0
) {
const scale = tableWidth / tableWidthRaw;
fragmentColumnWidths = measure.columnWidths.map((w: number) => Math.max(1, Math.round(w * scale)));
const scaledSum = fragmentColumnWidths.reduce((a: number, b: number) => a + b, 0);
const target = Math.round(tableWidth);
if (scaledSum !== target && fragmentColumnWidths.length > 0) {
fragmentColumnWidths[fragmentColumnWidths.length - 1] = Math.max(
1,
fragmentColumnWidths[fragmentColumnWidths.length - 1] + (target - scaledSum),
);
}
}

page.fragments.push({
kind: 'table',
blockId: range.blockId,
Expand All @@ -1606,6 +1628,7 @@
y: cursorY,
width: tableWidth,
height: Math.max(0, measure.totalHeight ?? 0),
columnWidths: fragmentColumnWidths,
});
cursorY += getRangeRenderHeight(range);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -451,4 +451,56 @@ describe('resolveMeasurementConstraints', () => {
expect(result.measurementHeight).toBe(20);
});
});

describe('mixed-orientation documents (SD-1859)', () => {
it('takes max width across portrait and landscape sections', () => {
// First section: portrait (612 x 792)
const options: LayoutOptions = {
pageSize: { w: 612, h: 792 },
margins: { top: 72, right: 72, bottom: 72, left: 72 },
};

// Second section: landscape (792 x 612)
const blocks: FlowBlock[] = [
{
kind: 'sectionBreak',
id: 'sb-1',
pageSize: { w: 792, h: 612 },
margins: { top: 72, right: 72, bottom: 72, left: 72 },
} as SectionBreakBlock,
];

const result = resolveMeasurementConstraints(options, blocks);

// Portrait content width: 612 - 144 = 468
// Landscape content width: 792 - 144 = 648
// Should take MAX: 648 (landscape width)
expect(result.measurementWidth).toBe(648);
});

it('takes max width across sections with different margins', () => {
// First section: narrow margins (content width = 612 - 100 = 512)
const options: LayoutOptions = {
pageSize: { w: 612, h: 792 },
margins: { top: 72, right: 50, bottom: 72, left: 50 },
};

// Second section: wider margins (content width = 612 - 200 = 412)
const blocks: FlowBlock[] = [
{
kind: 'sectionBreak',
id: 'sb-1',
pageSize: { w: 612, h: 792 },
margins: { top: 72, right: 100, bottom: 72, left: 100 },
} as SectionBreakBlock,
];

const result = resolveMeasurementConstraints(options, blocks);

// First section content width: 612 - 100 = 512
// Second section content width: 612 - 200 = 412
// Should take MAX: 512 (first section is wider)
expect(result.measurementWidth).toBe(512);
});
});
});
Loading
Loading