Skip to content

Commit 68dcff2

Browse files
feat: support rendering of column line separators (#2088)
* feat: support column line separators - Extracts 'w:sep' tag according to OOXML spec - Renders one separator for each page column ('w:col' / 'w:num') in DOM painter Closes #2067 * test: add docx file with line separator between columns enabled * fix: rendering color of separators * fix: reuse ColumnLayout and SINGLE_COLUMN_DEFAULT * fix: ensure column width larger than separator width * chore: lint * chore: fix imports and missing SINGLE_COLUMN_DEFAULT usage * chore: reuse type * fix: render column separators per region on pages with continuous breaks Two related fixes so the new column-line-separator feature works correctly on pages where a continuous section break changes column layout mid-page. 1. isColumnConfigChanging now compares withSeparator. Before, a sep-only toggle (count+gap unchanged) returned false, so no mid-page region was created and the toggle was silently dropped. Applied in both section-breaks.ts and the inline fallback in index.ts. 2. constraintBoundaries captured during layout are serialized onto a new page.columnRegions contract field. renderColumnSeparators now iterates regions and draws each separator bounded by its yStart/yEnd instead of painting a single full-page overlay. When no mid-page change occurs, columnRegions is omitted and the renderer falls back to page.columns (unchanged behavior). Verified by loading a fixture with 7 scenarios (2-col, 3-col, unequal widths, separator on/off, continuous breaks toggling the separator). Pages now show per-region separators tiled correctly; a 3-col region followed by a 2-col region no longer paints a shared full-page line. Out of scope here, tracked for follow-up: widths/equalWidth are still dropped at pm-adapter extractColumns, so unequal-width separators render at the equal-width midpoint; body-level w:sep is dropped at v2 docxImporter; there is no w:sep export. * test: cover DomPainter renderColumnSeparators 13 unit tests over the separator renderer. Splits coverage into the fallback path (page.columns only) and the region-aware path (page.columnRegions). Fallback path: pins the 2-col and 3-col geometry, and each early-return guard (withSeparator false/undefined, single column, missing margins, no columns at all, pathologically-small columnWidth). Region path: verifies per-region yStart/yEnd bounding, mixed regions (some draw, some skip for withSeparator=false or count<=1), zero-height regions, and that columnRegions wins when both it and page.columns are present. Previously renderColumnSeparators had zero DOM-level coverage — the region-aware refactor in the prior commit relied entirely on layout-engine tests that never exercised the DOM output. --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 8b1da54 commit 68dcff2

20 files changed

Lines changed: 748 additions & 46 deletions

File tree

packages/layout-engine/contracts/src/column-layout.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout {
1919
gap: columns.gap,
2020
...(Array.isArray(columns.widths) ? { widths: [...columns.widths] } : {}),
2121
...(columns.equalWidth !== undefined ? { equalWidth: columns.equalWidth } : {}),
22+
...(columns.withSeparator !== undefined ? { withSeparator: columns.withSeparator } : {}),
2223
}
2324
: { count: 1, gap: 0 };
2425
}
@@ -62,6 +63,7 @@ export function normalizeColumnLayout(
6263
count: 1,
6364
gap: 0,
6465
width: Math.max(0, contentWidth),
66+
...(input?.withSeparator !== undefined ? { withSeparator: input.withSeparator } : {}),
6567
};
6668
}
6769

@@ -70,6 +72,7 @@ export function normalizeColumnLayout(
7072
gap,
7173
...(widths.length > 0 ? { widths } : {}),
7274
...(input?.equalWidth !== undefined ? { equalWidth: input.equalWidth } : {}),
75+
...(input?.withSeparator !== undefined ? { withSeparator: input.withSeparator } : {}),
7376
width,
7477
};
7578
}

packages/layout-engine/contracts/src/index.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -987,10 +987,7 @@ export type SectionBreakBlock = {
987987
even?: string;
988988
odd?: string;
989989
};
990-
columns?: {
991-
count: number;
992-
gap: number;
993-
widths?: number[];
990+
columns?: ColumnLayout & {
994991
equalWidth?: boolean;
995992
};
996993
/**
@@ -1478,10 +1475,28 @@ export type FlowBlock =
14781475
export type ColumnLayout = {
14791476
count: number;
14801477
gap: number;
1478+
withSeparator?: boolean;
14811479
widths?: number[];
14821480
equalWidth?: boolean;
14831481
};
14841482

1483+
/**
1484+
* A vertical region of a page that shares a single column configuration.
1485+
*
1486+
* Continuous section breaks can introduce multiple column configurations on the
1487+
* same page (see ECMA-376 §17.6.22 and §17.18.77). A page may therefore carry
1488+
* multiple regions stacked vertically. Consumers (e.g. DomPainter) use
1489+
* `yStart`/`yEnd` to bound any per-region overlays such as column separators.
1490+
*/
1491+
export type ColumnRegion = {
1492+
/** Inclusive top of the region, in pixels from the page top. */
1493+
yStart: number;
1494+
/** Exclusive bottom of the region, in pixels from the page top. */
1495+
yEnd: number;
1496+
/** Column configuration active within this region. */
1497+
columns: ColumnLayout;
1498+
};
1499+
14851500
/** A measured line within a block, output by the measurer. */
14861501
export type Line = {
14871502
fromRun: number;
@@ -1706,6 +1721,29 @@ export type Page = {
17061721
* Sections are 0-indexed, matching the sectionIndex in SectionMetadata.
17071722
*/
17081723
sectionIndex?: number;
1724+
/**
1725+
* Column layout configuration for this page.
1726+
*
1727+
* Reflects the column configuration at page start. For pages with continuous
1728+
* section breaks that change column layout mid-page, use `columnRegions` for
1729+
* accurate per-region information.
1730+
*
1731+
* Used by the renderer to draw column separator lines when `withSeparator`
1732+
* is set to true.
1733+
*/
1734+
columns?: ColumnLayout;
1735+
/**
1736+
* Vertical column regions on this page, ordered top to bottom.
1737+
*
1738+
* Populated when continuous section breaks change column layout mid-page. Each
1739+
* region pairs a `{yStart, yEnd}` span with the column config active inside it
1740+
* (see ECMA-376 §17.6.22). Renderers should prefer this field over
1741+
* `columns` when drawing per-region overlays (e.g. column separators).
1742+
*
1743+
* If omitted, the page has a single column region and consumers can fall back
1744+
* to `columns`.
1745+
*/
1746+
columnRegions?: ColumnRegion[];
17091747
};
17101748

17111749
/** A paragraph fragment positioned on a page. */

packages/layout-engine/layout-bridge/src/incrementalLayout.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
resolvePageNumberTokens,
2020
type NumberingContext,
2121
SEMANTIC_PAGE_HEIGHT_PX,
22+
SINGLE_COLUMN_DEFAULT,
2223
} from '@superdoc/layout-engine';
2324
import { remeasureParagraph } from './remeasure';
2425
import { computeDirtyRegions } from './diff';
@@ -183,7 +184,7 @@ const resolvePageColumns = (layout: Layout, options: LayoutOptions, blocks?: Flo
183184
);
184185
const contentWidth = pageSize.w - (marginLeft + marginRight);
185186
const sectionIndex = page.sectionIndex ?? 0;
186-
const columnsConfig = sectionColumns.get(sectionIndex) ?? options.columns ?? { count: 1, gap: 0 };
187+
const columnsConfig = sectionColumns.get(sectionIndex) ?? options.columns ?? SINGLE_COLUMN_DEFAULT;
187188
const normalized = normalizeColumnsForFootnotes(columnsConfig, contentWidth);
188189
result.set(pageIndex, { ...normalized, left: marginLeft, contentWidth });
189190
}
@@ -1503,7 +1504,7 @@ export async function incrementalLayout(
15031504
);
15041505
const pageContentWidth = pageSize.w - (marginLeft + marginRight);
15051506
const fallbackColumns = normalizeColumnsForFootnotes(
1506-
options.columns ?? { count: 1, gap: 0 },
1507+
options.columns ?? SINGLE_COLUMN_DEFAULT,
15071508
pageContentWidth,
15081509
);
15091510
const columns = pageColumns.get(pageIndex) ?? {

packages/layout-engine/layout-engine/src/index.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,100 @@ describe('layoutDocument', () => {
228228
expect(layout.columns).toMatchObject({ count: 2, gap: 20 });
229229
});
230230

231+
it('sets "page.columns" with separator when column separator is enabled', () => {
232+
const options: LayoutOptions = {
233+
pageSize: { w: 600, h: 800 },
234+
margins: { top: 40, right: 40, bottom: 40, left: 40 },
235+
columns: { count: 2, gap: 20, withSeparator: true },
236+
};
237+
const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options);
238+
239+
expect(layout.pages).toHaveLength(1);
240+
expect(layout.pages[0].columns).toEqual({ count: 2, gap: 20, withSeparator: true });
241+
expect(layout.columns).toMatchObject({ count: 2, gap: 20, withSeparator: true });
242+
});
243+
244+
it('does not set "page.columns" on single column layout', () => {
245+
const options: LayoutOptions = {
246+
pageSize: { w: 600, h: 800 },
247+
margins: { top: 40, right: 40, bottom: 40, left: 40 },
248+
};
249+
const layout = layoutDocument([block], [makeMeasure([350])], options);
250+
251+
expect(layout.pages).toHaveLength(1);
252+
expect(layout.pages[0].columns).toBeUndefined();
253+
expect(layout.columns).toBeUndefined();
254+
});
255+
256+
it('sets "page.columns" without separator when column separator is not enabled', () => {
257+
const options: LayoutOptions = {
258+
pageSize: { w: 600, h: 800 },
259+
margins: { top: 40, right: 40, bottom: 40, left: 40 },
260+
columns: { count: 2, gap: 20, withSeparator: false },
261+
};
262+
const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options);
263+
264+
expect(layout.pages).toHaveLength(1);
265+
expect(layout.pages[0].columns).toEqual({ count: 2, gap: 20, withSeparator: false });
266+
expect(layout.columns).toEqual({ count: 2, gap: 20, withSeparator: false });
267+
});
268+
269+
it('emits page.columnRegions for continuous section breaks that change column config mid-page', () => {
270+
// Two sections on the same page: first 2-col with separator, then a
271+
// continuous break that switches to 3-col still with separator. The
272+
// layout engine should record a ConstraintBoundary and surface it on
273+
// page.columnRegions so the renderer can bound each separator to the
274+
// correct Y range.
275+
const blocks: FlowBlock[] = [
276+
{ kind: 'paragraph', id: 'intro', runs: [] },
277+
{
278+
kind: 'sectionBreak',
279+
id: 'sb-continuous',
280+
type: 'continuous',
281+
columns: { count: 3, gap: 20, withSeparator: true },
282+
},
283+
{ kind: 'paragraph', id: 'body', runs: [] },
284+
];
285+
const measures: Measure[] = [makeMeasure([30]), { kind: 'sectionBreak' }, makeMeasure([30, 30, 30])];
286+
287+
const options: LayoutOptions = {
288+
pageSize: { w: 600, h: 800 },
289+
margins: { top: 40, right: 40, bottom: 40, left: 40 },
290+
columns: { count: 2, gap: 20, withSeparator: true },
291+
};
292+
293+
const layout = layoutDocument(blocks, measures, options);
294+
295+
expect(layout.pages).toHaveLength(1);
296+
const regions = layout.pages[0].columnRegions;
297+
expect(regions).toBeDefined();
298+
expect(regions!.length).toBeGreaterThanOrEqual(2);
299+
// First region covers the initial 2-col layout from topMargin to the boundary.
300+
expect(regions![0].yStart).toBe(40);
301+
expect(regions![0].columns).toEqual({ count: 2, gap: 20, withSeparator: true });
302+
// Second region picks up the continuous break's 3-col config and ends at
303+
// the bottom of the content area.
304+
const last = regions![regions!.length - 1];
305+
expect(last.columns).toMatchObject({ count: 3, gap: 20, withSeparator: true });
306+
expect(last.yEnd).toBe(800 - 40);
307+
// Regions must tile (no gaps, no overlap).
308+
for (let i = 1; i < regions!.length; i++) {
309+
expect(regions![i].yStart).toBe(regions![i - 1].yEnd);
310+
}
311+
});
312+
313+
it('omits page.columnRegions when no mid-page column change occurs', () => {
314+
const options: LayoutOptions = {
315+
pageSize: { w: 600, h: 800 },
316+
margins: { top: 40, right: 40, bottom: 40, left: 40 },
317+
columns: { count: 2, gap: 20, withSeparator: true },
318+
};
319+
const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options);
320+
321+
expect(layout.pages).toHaveLength(1);
322+
expect(layout.pages[0].columnRegions).toBeUndefined();
323+
});
324+
231325
it('applies spacing before and after paragraphs', () => {
232326
const spacingBlock: FlowBlock = {
233327
kind: 'paragraph',

packages/layout-engine/layout-engine/src/index.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
ColumnLayout,
3+
ColumnRegion,
34
FlowBlock,
45
Fragment,
56
HeaderFooterLayout,
@@ -35,6 +36,7 @@ import {
3536
scheduleSectionBreak as scheduleSectionBreakExport,
3637
type SectionState,
3738
applyPendingToActive,
39+
SINGLE_COLUMN_DEFAULT,
3840
} from './section-breaks.js';
3941
import { layoutParagraphBlock } from './layout-paragraph.js';
4042
import { layoutImageBlock } from './layout-image.js';
@@ -1001,14 +1003,18 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
10011003
if (block.orientation) next.pendingOrientation = block.orientation;
10021004
const sectionType = block.type ?? 'continuous';
10031005
// Check if columns are changing: either explicitly to a different config,
1004-
// or implicitly resetting to single column (undefined = single column in OOXML)
1006+
// or implicitly resetting to single column (undefined = single column in OOXML).
1007+
// withSeparator must be compared because a sep-only toggle still needs a new
1008+
// column region so the renderer can draw (or stop drawing) the separator from
1009+
// the toggle point onward.
10051010
const isColumnsChanging =
10061011
(block.columns &&
10071012
(block.columns.count !== next.activeColumns.count ||
10081013
block.columns.gap !== next.activeColumns.gap ||
1014+
Boolean(block.columns.withSeparator) !== Boolean(next.activeColumns.withSeparator) ||
10091015
block.columns.equalWidth !== next.activeColumns.equalWidth ||
10101016
!widthsEqual(block.columns.widths, next.activeColumns.widths))) ||
1011-
(!block.columns && next.activeColumns.count > 1);
1017+
(!block.columns && (next.activeColumns.count > 1 || Boolean(next.activeColumns.withSeparator)));
10121018
// Schedule section index change for next page (enables section-aware page numbering)
10131019
const sectionIndexRaw = block.attrs?.sectionIndex;
10141020
const metadataIndex = typeof sectionIndexRaw === 'number' ? sectionIndexRaw : Number(sectionIndexRaw ?? NaN);
@@ -1074,6 +1080,11 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
10741080
if (activeOrientation) {
10751081
page.orientation = activeOrientation;
10761082
}
1083+
1084+
if (activeColumns.count > 1) {
1085+
page.columns = { count: activeColumns.count, gap: activeColumns.gap, withSeparator: activeColumns.withSeparator };
1086+
}
1087+
10771088
// Set vertical alignment from active section state
10781089
if (activeVAlign && activeVAlign !== 'top') {
10791090
page.vAlign = activeVAlign;
@@ -2527,14 +2538,50 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
25272538
}
25282539
}
25292540

2541+
// Serialize constraint boundaries into page.columnRegions so DomPainter can
2542+
// draw per-region overlays (e.g. column separator lines) bounded by the
2543+
// correct Y span. Continuous section breaks with a changed column config
2544+
// push boundaries into PageState.constraintBoundaries during layout; without
2545+
// this step the renderer only sees the page-start column config and would
2546+
// draw a single full-page separator across regions it no longer applies to.
2547+
for (const state of states) {
2548+
const boundaries = state.constraintBoundaries;
2549+
if (boundaries.length === 0) continue;
2550+
2551+
const regions: ColumnRegion[] = [];
2552+
// First region spans from the top of the content area to the first boundary.
2553+
// Its columns come from page.columns (set at page creation before any
2554+
// mid-page region change) or fall back to a single-column default so the
2555+
// contract stays self-describing even when the page starts single-column.
2556+
const firstRegionColumns: ColumnLayout = state.page.columns ?? { count: 1, gap: 0 };
2557+
regions.push({
2558+
yStart: state.topMargin,
2559+
yEnd: boundaries[0].y,
2560+
columns: firstRegionColumns,
2561+
});
2562+
for (let i = 0; i < boundaries.length; i++) {
2563+
const start = boundaries[i];
2564+
const end = boundaries[i + 1];
2565+
regions.push({
2566+
yStart: start.y,
2567+
yEnd: end ? end.y : state.contentBottom,
2568+
columns: start.columns,
2569+
});
2570+
}
2571+
state.page.columnRegions = regions;
2572+
}
2573+
25302574
return {
25312575
pageSize,
25322576
pages,
25332577
// Note: columns here reflects the effective default for subsequent pages
25342578
// after processing sections. Page/region-specific column changes are encoded
25352579
// implicitly via fragment positions. Consumers should not assume this is
25362580
// a static document-wide value.
2537-
columns: activeColumns.count > 1 ? { count: activeColumns.count, gap: activeColumns.gap } : undefined,
2581+
columns:
2582+
activeColumns.count > 1
2583+
? { count: activeColumns.count, gap: activeColumns.gap, withSeparator: activeColumns.withSeparator }
2584+
: undefined,
25382585
};
25392586
}
25402587

@@ -2961,3 +3008,5 @@ export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTok
29613008
// Table utilities consumed by layout-bridge and cross-package sync tests
29623009
export { getCellLines, getEmbeddedRowLines } from './layout-table.js';
29633010
export { describeCellRenderBlocks, computeCellSliceContentHeight } from './table-cell-slice.js';
3011+
3012+
export { SINGLE_COLUMN_DEFAULT } from './section-breaks.js';

packages/layout-engine/layout-engine/src/section-breaks.d.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { SectionBreakBlock } from '@superdoc/contracts';
1+
import type { ColumnLayout, SectionBreakBlock } from '@superdoc/contracts';
2+
23
export type SectionState = {
34
activeTopMargin: number;
45
activeBottomMargin: number;
@@ -20,14 +21,8 @@ export type SectionState = {
2021
w: number;
2122
h: number;
2223
} | null;
23-
activeColumns: {
24-
count: number;
25-
gap: number;
26-
};
27-
pendingColumns: {
28-
count: number;
29-
gap: number;
30-
} | null;
24+
activeColumns: ColumnLayout;
25+
pendingColumns: ColumnLayout | null;
3126
activeOrientation: 'portrait' | 'landscape' | null;
3227
pendingOrientation: 'portrait' | 'landscape' | null;
3328
hasAnyPages: boolean;
@@ -37,6 +32,7 @@ export type BreakDecision = {
3732
forceMidPageRegion: boolean;
3833
requiredParity?: 'even' | 'odd';
3934
};
35+
4036
/**
4137
* Schedule section break effects by updating pending/active state and returning a break decision.
4238
* This function is pure with respect to inputs/outputs and does not mutate external variables.
@@ -56,6 +52,7 @@ export declare function scheduleSectionBreak(
5652
decision: BreakDecision;
5753
state: SectionState;
5854
};
55+
5956
/**
6057
* Apply pending margins/pageSize/columns/orientation to active values at a page boundary and clear pending.
6158
*/

0 commit comments

Comments
 (0)