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
13 changes: 13 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,17 @@ export type SectionMetadata = {
titlePg?: boolean;
/** Vertical alignment of content within this section's pages */
vAlign?: SectionVerticalAlign;
/** Section page margins in CSS px */
margins?: {
top?: number;
right?: number;
bottom?: number;
left?: number;
header?: number;
footer?: number;
} | null;
/** Section page size in CSS px */
pageSize?: { w: number; h: number } | null;
};

export type PageBreakBlock = {
Expand Down Expand Up @@ -1581,6 +1592,8 @@ export type TableFragment = {
continuesOnNext?: boolean;
repeatHeaderCount?: number;
partialRow?: PartialRowInfo;
/** Rescaled column widths when table is clamped to a narrower section (SD-1859). */
columnWidths?: number[];
metadata?: TableFragmentMetadata;
pmStart?: number;
pmEnd?: number;
Expand Down
2 changes: 2 additions & 0 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
layout: HeaderFooterLayout;
blocks: FlowBlock[];
measures: Measure[];
/** Effective layout width when table grid widths exceed section content width (SD-1837). */
effectiveWidth?: number;
};

export type IncrementalLayoutResult = {
Expand Down Expand Up @@ -739,7 +741,7 @@
// Dirty region computation
const dirtyStart = performance.now();
const dirty = computeDirtyRegions(previousBlocks, nextBlocks);
const dirtyTime = performance.now() - dirtyStart;

Check warning on line 744 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
113 changes: 113 additions & 0 deletions packages/layout-engine/layout-engine/src/layout-table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3167,4 +3167,117 @@ describe('layoutTableBlock', () => {
}
});
});

describe('column width rescaling (SD-1859)', () => {
it('should rescale column widths when table is wider than section content width', () => {
// Simulate a table measured at landscape width (700px) but rendered in
// a portrait section (450px). Column widths should be rescaled to fit.
const block = createMockTableBlock(2);
const measure = createMockTableMeasure([250, 200, 250], [30, 30]);
// measure.totalWidth = 700

const fragments: TableFragment[] = [];
const mockPage = { fragments };

layoutTableBlock({
block,
measure,
columnWidth: 450, // Portrait section width (narrower than table)
ensurePage: () => ({
page: mockPage,
columnIndex: 0,
cursorY: 0,
contentBottom: 1000,
}),
advanceColumn: (state) => state,
columnX: () => 0,
});

expect(fragments).toHaveLength(1);
const fragment = fragments[0];

// Fragment width should be clamped to section width
expect(fragment.width).toBe(450);

// Column widths should be rescaled proportionally
expect(fragment.columnWidths).toBeDefined();
expect(fragment.columnWidths!.length).toBe(3);

// Sum of rescaled column widths should equal fragment width
const sum = fragment.columnWidths!.reduce((a, b) => a + b, 0);
expect(sum).toBe(450);

// Proportions should be maintained (250:200:250 → ~161:129:161)
expect(fragment.columnWidths![0]).toBeGreaterThan(fragment.columnWidths![1]);
expect(fragment.columnWidths![0]).toBeCloseTo(fragment.columnWidths![2], -1);
});

it('should set original columnWidths when table fits within section width (SD-1837)', () => {
const block = createMockTableBlock(2);
const measure = createMockTableMeasure([100, 150, 100], [30, 30]);
// measure.totalWidth = 350

const fragments: TableFragment[] = [];
const mockPage = { fragments };

layoutTableBlock({
block,
measure,
columnWidth: 450, // Section is wider than table
ensurePage: () => ({
page: mockPage,
columnIndex: 0,
cursorY: 0,
contentBottom: 1000,
}),
advanceColumn: (state) => state,
columnX: () => 0,
});

expect(fragments).toHaveLength(1);
// columnWidths should always be set for self-contained fragments (SD-1837)
expect(fragments[0].columnWidths).toEqual([100, 150, 100]);
});

it('should rescale column widths on paginated table fragments', () => {
// Table that splits across pages should have rescaled column widths on each fragment
const block = createMockTableBlock(4);
const measure = createMockTableMeasure([300, 300], [200, 200, 200, 200]);
// totalWidth = 600, each row = 200px

const fragments: TableFragment[] = [];
let pageIndex = 0;

layoutTableBlock({
block,
measure,
columnWidth: 400, // Narrower than table
ensurePage: () => ({
page: { fragments },
columnIndex: 0,
cursorY: 0,
contentBottom: 500, // Only fits ~2 rows per page
}),
advanceColumn: (state) => {
pageIndex++;
return {
...state,
cursorY: 0,
contentBottom: 500,
};
},
columnX: () => 0,
});

// Should have multiple fragments (table paginated)
expect(fragments.length).toBeGreaterThanOrEqual(1);

// Every fragment should have rescaled column widths
for (const fragment of fragments) {
expect(fragment.columnWidths).toBeDefined();
const sum = fragment.columnWidths!.reduce((a, b) => a + b, 0);
expect(sum).toBe(400);
}
});
});
});
40 changes: 40 additions & 0 deletions packages/layout-engine/layout-engine/src/layout-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,41 @@ function resolveTableFrame(
return applyTableIndent(baseX, width, tableIndent);
}

/**
* Rescales column widths when a table is clamped to fit a narrower section.
*
* In mixed-orientation documents, tables are measured at the widest section's
* content width but may render in narrower sections. When the measured total
* width exceeds the fragment width, column widths must be proportionally
* rescaled so cells don't overflow the fragment container (SD-1859).
*
* @returns Rescaled column widths if clamping occurred, undefined otherwise.
*/
function rescaleColumnWidths(
measureColumnWidths: number[] | undefined,
measureTotalWidth: number,
fragmentWidth: number,
): number[] | undefined {
if (!measureColumnWidths || measureColumnWidths.length === 0) {
return undefined;
}
// When the table fits within the fragment, return original widths unchanged.
// This ensures every table fragment is self-contained with its own column widths,
// which is critical for header/footer tables where multiple sections share the same
// blockId but have different content widths (SD-1837).
if (measureTotalWidth <= fragmentWidth || measureTotalWidth <= 0) {
return measureColumnWidths;
Comment on lines +177 to +200
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The JSDoc says this helper returns “Rescaled column widths if clamping occurred, undefined otherwise,” but the implementation returns the original measureColumnWidths when the table fits. Either update the docstring to reflect the actual behavior (always returns widths when available) or change the implementation to return undefined when no rescaling occurs—whichever the callers expect.

Copilot uses AI. Check for mistakes.
}
const scale = fragmentWidth / measureTotalWidth;
const scaled = measureColumnWidths.map((w) => Math.max(1, Math.round(w * scale)));
const scaledSum = scaled.reduce((a, b) => a + b, 0);
const target = Math.round(fragmentWidth);
if (scaledSum !== target && scaled.length > 0) {
scaled[scaled.length - 1] = Math.max(1, scaled[scaled.length - 1] + (target - scaledSum));
}
return scaled;
}

/**
* Calculate minimum width for a table column.
*
Expand Down Expand Up @@ -986,6 +1021,7 @@ function layoutMonolithicTable(context: TableLayoutContext): void {
y: state.cursorY,
width,
height,
columnWidths: rescaleColumnWidths(context.measure.columnWidths, context.measure.totalWidth, width),
metadata,
};
applyTableFragmentPmRange(fragment, context.block, context.measure);
Expand Down Expand Up @@ -1133,6 +1169,7 @@ export function layoutTableBlock({
y: state.cursorY,
width,
height,
columnWidths: rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width),

Choose a reason for hiding this comment

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

P2 Badge Apply rescaled table column widths during rendering

This writes resized widths into TableFragment.columnWidths, but the painter still renders table cells from measure.columnWidths (see packages/layout-engine/painters/dom/src/table/renderTableFragment.ts), so the new rescaling path is never consumed. In sections where a table fragment is clamped narrower than its measured grid, cells will still use the oversized columns and overflow the fragment width despite this change.

Useful? React with 👍 / 👎.

metadata,
};
applyTableFragmentPmRange(fragment, block, measure);
Expand Down Expand Up @@ -1219,6 +1256,7 @@ export function layoutTableBlock({
continuesOnNext: hasRemainingLinesAfterContinuation || rowIndex + 1 < block.rows.length,
repeatHeaderCount,
partialRow: continuationPartialRow,
columnWidths: rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width),
metadata: generateFragmentMetadata(measure, rowIndex, rowIndex + 1, repeatHeaderCount),
};

Expand Down Expand Up @@ -1282,6 +1320,7 @@ export function layoutTableBlock({
continuesOnNext: !forcedPartialRow.isLastPart || forcedEndRow < block.rows.length,
repeatHeaderCount,
partialRow: forcedPartialRow,
columnWidths: rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width),
metadata: generateFragmentMetadata(measure, bodyStartRow, forcedEndRow, repeatHeaderCount),
};

Expand Down Expand Up @@ -1323,6 +1362,7 @@ export function layoutTableBlock({
continuesOnNext: endRow < block.rows.length || (partialRow ? !partialRow.isLastPart : false),
repeatHeaderCount,
partialRow: partialRow || undefined,
columnWidths: rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width),
metadata: generateFragmentMetadata(measure, bodyStartRow, endRow, repeatHeaderCount),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,8 @@ describe('analysis', () => {
footerRefs: { default: 'footer1' },
numbering: { format: 'decimal' },
titlePg: false,
margins: null,
pageSize: null,
});
});

Expand Down
2 changes: 2 additions & 0 deletions packages/layout-engine/pm-adapter/src/sections/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ export function publishSectionMetadata(sectionRanges: SectionRange[], options?:
numbering: section.numbering,
titlePg: section.titlePg,
vAlign: section.vAlign,
margins: section.margins,
pageSize: section.pageSize,
});
});
}
Expand Down
12 changes: 12 additions & 0 deletions packages/super-editor/src/assets/styles/elements/prosemirror.css
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,18 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html
/* width: 100%; */
}

/* Header/footer editors: constrain table to the editor's available width.
Raw DOCX grid <col> widths may exceed the section's content area.
table-layout:auto treats col widths as preferred (not hard) constraints,
so the browser can redistribute columns to fit within width:100%.
!important overrides inline styles set by TableView.updateTable()
which resets styles via table.style.cssText. */
.ProseMirror.sd-header-footer table {
table-layout: auto !important;
width: 100% !important;
max-width: 100% !important;
}

.ProseMirror tr {
position: relative;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ export class EditorOverlayManager {
position: 'absolute',
pointerEvents: 'auto', // Critical: enables click interaction
visibility: 'hidden', // Hidden by default, shown during editing
overflow: 'hidden',
overflow: 'visible', // Allow table overflow (page's overflow:hidden still clips at page edge)
boxSizing: 'border-box',
});

Expand Down
Loading
Loading