diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index e63e4e95d2..7dc794c592 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -19,6 +19,7 @@ export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout { gap: columns.gap, ...(Array.isArray(columns.widths) ? { widths: [...columns.widths] } : {}), ...(columns.equalWidth !== undefined ? { equalWidth: columns.equalWidth } : {}), + ...(columns.withSeparator !== undefined ? { withSeparator: columns.withSeparator } : {}), } : { count: 1, gap: 0 }; } @@ -62,6 +63,7 @@ export function normalizeColumnLayout( count: 1, gap: 0, width: Math.max(0, contentWidth), + ...(input?.withSeparator !== undefined ? { withSeparator: input.withSeparator } : {}), }; } @@ -70,6 +72,7 @@ export function normalizeColumnLayout( gap, ...(widths.length > 0 ? { widths } : {}), ...(input?.equalWidth !== undefined ? { equalWidth: input.equalWidth } : {}), + ...(input?.withSeparator !== undefined ? { withSeparator: input.withSeparator } : {}), width, }; } diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 69ca4dc0cc..4cc339e09c 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -987,10 +987,7 @@ export type SectionBreakBlock = { even?: string; odd?: string; }; - columns?: { - count: number; - gap: number; - widths?: number[]; + columns?: ColumnLayout & { equalWidth?: boolean; }; /** @@ -1478,10 +1475,28 @@ export type FlowBlock = export type ColumnLayout = { count: number; gap: number; + withSeparator?: boolean; widths?: number[]; equalWidth?: boolean; }; +/** + * A vertical region of a page that shares a single column configuration. + * + * Continuous section breaks can introduce multiple column configurations on the + * same page (see ECMA-376 §17.6.22 and §17.18.77). A page may therefore carry + * multiple regions stacked vertically. Consumers (e.g. DomPainter) use + * `yStart`/`yEnd` to bound any per-region overlays such as column separators. + */ +export type ColumnRegion = { + /** Inclusive top of the region, in pixels from the page top. */ + yStart: number; + /** Exclusive bottom of the region, in pixels from the page top. */ + yEnd: number; + /** Column configuration active within this region. */ + columns: ColumnLayout; +}; + /** A measured line within a block, output by the measurer. */ export type Line = { fromRun: number; @@ -1706,6 +1721,29 @@ export type Page = { * Sections are 0-indexed, matching the sectionIndex in SectionMetadata. */ sectionIndex?: number; + /** + * Column layout configuration for this page. + * + * Reflects the column configuration at page start. For pages with continuous + * section breaks that change column layout mid-page, use `columnRegions` for + * accurate per-region information. + * + * Used by the renderer to draw column separator lines when `withSeparator` + * is set to true. + */ + columns?: ColumnLayout; + /** + * Vertical column regions on this page, ordered top to bottom. + * + * Populated when continuous section breaks change column layout mid-page. Each + * region pairs a `{yStart, yEnd}` span with the column config active inside it + * (see ECMA-376 §17.6.22). Renderers should prefer this field over + * `columns` when drawing per-region overlays (e.g. column separators). + * + * If omitted, the page has a single column region and consumers can fall back + * to `columns`. + */ + columnRegions?: ColumnRegion[]; }; /** A paragraph fragment positioned on a page. */ diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 4f82c339fc..cafffe5c72 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -19,6 +19,7 @@ import { resolvePageNumberTokens, type NumberingContext, SEMANTIC_PAGE_HEIGHT_PX, + SINGLE_COLUMN_DEFAULT, } from '@superdoc/layout-engine'; import { remeasureParagraph } from './remeasure'; import { computeDirtyRegions } from './diff'; @@ -183,7 +184,7 @@ const resolvePageColumns = (layout: Layout, options: LayoutOptions, blocks?: Flo ); const contentWidth = pageSize.w - (marginLeft + marginRight); const sectionIndex = page.sectionIndex ?? 0; - const columnsConfig = sectionColumns.get(sectionIndex) ?? options.columns ?? { count: 1, gap: 0 }; + const columnsConfig = sectionColumns.get(sectionIndex) ?? options.columns ?? SINGLE_COLUMN_DEFAULT; const normalized = normalizeColumnsForFootnotes(columnsConfig, contentWidth); result.set(pageIndex, { ...normalized, left: marginLeft, contentWidth }); } @@ -1503,7 +1504,7 @@ export async function incrementalLayout( ); const pageContentWidth = pageSize.w - (marginLeft + marginRight); const fallbackColumns = normalizeColumnsForFootnotes( - options.columns ?? { count: 1, gap: 0 }, + options.columns ?? SINGLE_COLUMN_DEFAULT, pageContentWidth, ); const columns = pageColumns.get(pageIndex) ?? { diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index ab883680e2..e080a72196 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -228,6 +228,100 @@ describe('layoutDocument', () => { expect(layout.columns).toMatchObject({ count: 2, gap: 20 }); }); + it('sets "page.columns" with separator when column separator is enabled', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 2, gap: 20, withSeparator: true }, + }; + const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options); + + expect(layout.pages).toHaveLength(1); + expect(layout.pages[0].columns).toEqual({ count: 2, gap: 20, withSeparator: true }); + expect(layout.columns).toMatchObject({ count: 2, gap: 20, withSeparator: true }); + }); + + it('does not set "page.columns" on single column layout', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + }; + const layout = layoutDocument([block], [makeMeasure([350])], options); + + expect(layout.pages).toHaveLength(1); + expect(layout.pages[0].columns).toBeUndefined(); + expect(layout.columns).toBeUndefined(); + }); + + it('sets "page.columns" without separator when column separator is not enabled', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 2, gap: 20, withSeparator: false }, + }; + const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options); + + expect(layout.pages).toHaveLength(1); + expect(layout.pages[0].columns).toEqual({ count: 2, gap: 20, withSeparator: false }); + expect(layout.columns).toEqual({ count: 2, gap: 20, withSeparator: false }); + }); + + it('emits page.columnRegions for continuous section breaks that change column config mid-page', () => { + // Two sections on the same page: first 2-col with separator, then a + // continuous break that switches to 3-col still with separator. The + // layout engine should record a ConstraintBoundary and surface it on + // page.columnRegions so the renderer can bound each separator to the + // correct Y range. + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'intro', runs: [] }, + { + kind: 'sectionBreak', + id: 'sb-continuous', + type: 'continuous', + columns: { count: 3, gap: 20, withSeparator: true }, + }, + { kind: 'paragraph', id: 'body', runs: [] }, + ]; + const measures: Measure[] = [makeMeasure([30]), { kind: 'sectionBreak' }, makeMeasure([30, 30, 30])]; + + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 2, gap: 20, withSeparator: true }, + }; + + const layout = layoutDocument(blocks, measures, options); + + expect(layout.pages).toHaveLength(1); + const regions = layout.pages[0].columnRegions; + expect(regions).toBeDefined(); + expect(regions!.length).toBeGreaterThanOrEqual(2); + // First region covers the initial 2-col layout from topMargin to the boundary. + expect(regions![0].yStart).toBe(40); + expect(regions![0].columns).toEqual({ count: 2, gap: 20, withSeparator: true }); + // Second region picks up the continuous break's 3-col config and ends at + // the bottom of the content area. + const last = regions![regions!.length - 1]; + expect(last.columns).toMatchObject({ count: 3, gap: 20, withSeparator: true }); + expect(last.yEnd).toBe(800 - 40); + // Regions must tile (no gaps, no overlap). + for (let i = 1; i < regions!.length; i++) { + expect(regions![i].yStart).toBe(regions![i - 1].yEnd); + } + }); + + it('omits page.columnRegions when no mid-page column change occurs', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 2, gap: 20, withSeparator: true }, + }; + const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options); + + expect(layout.pages).toHaveLength(1); + expect(layout.pages[0].columnRegions).toBeUndefined(); + }); + it('applies spacing before and after paragraphs', () => { const spacingBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index f2a384ee37..8729e109d1 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -1,5 +1,6 @@ import type { ColumnLayout, + ColumnRegion, FlowBlock, Fragment, HeaderFooterLayout, @@ -35,6 +36,7 @@ import { scheduleSectionBreak as scheduleSectionBreakExport, type SectionState, applyPendingToActive, + SINGLE_COLUMN_DEFAULT, } from './section-breaks.js'; import { layoutParagraphBlock } from './layout-paragraph.js'; import { layoutImageBlock } from './layout-image.js'; @@ -1001,14 +1003,18 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (block.orientation) next.pendingOrientation = block.orientation; const sectionType = block.type ?? 'continuous'; // Check if columns are changing: either explicitly to a different config, - // or implicitly resetting to single column (undefined = single column in OOXML) + // or implicitly resetting to single column (undefined = single column in OOXML). + // withSeparator must be compared because a sep-only toggle still needs a new + // column region so the renderer can draw (or stop drawing) the separator from + // the toggle point onward. const isColumnsChanging = (block.columns && (block.columns.count !== next.activeColumns.count || block.columns.gap !== next.activeColumns.gap || + Boolean(block.columns.withSeparator) !== Boolean(next.activeColumns.withSeparator) || block.columns.equalWidth !== next.activeColumns.equalWidth || !widthsEqual(block.columns.widths, next.activeColumns.widths))) || - (!block.columns && next.activeColumns.count > 1); + (!block.columns && (next.activeColumns.count > 1 || Boolean(next.activeColumns.withSeparator))); // Schedule section index change for next page (enables section-aware page numbering) const sectionIndexRaw = block.attrs?.sectionIndex; const metadataIndex = typeof sectionIndexRaw === 'number' ? sectionIndexRaw : Number(sectionIndexRaw ?? NaN); @@ -1074,6 +1080,11 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (activeOrientation) { page.orientation = activeOrientation; } + + if (activeColumns.count > 1) { + page.columns = { count: activeColumns.count, gap: activeColumns.gap, withSeparator: activeColumns.withSeparator }; + } + // Set vertical alignment from active section state if (activeVAlign && activeVAlign !== 'top') { page.vAlign = activeVAlign; @@ -2527,6 +2538,39 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } } + // Serialize constraint boundaries into page.columnRegions so DomPainter can + // draw per-region overlays (e.g. column separator lines) bounded by the + // correct Y span. Continuous section breaks with a changed column config + // push boundaries into PageState.constraintBoundaries during layout; without + // this step the renderer only sees the page-start column config and would + // draw a single full-page separator across regions it no longer applies to. + for (const state of states) { + const boundaries = state.constraintBoundaries; + if (boundaries.length === 0) continue; + + const regions: ColumnRegion[] = []; + // First region spans from the top of the content area to the first boundary. + // Its columns come from page.columns (set at page creation before any + // mid-page region change) or fall back to a single-column default so the + // contract stays self-describing even when the page starts single-column. + const firstRegionColumns: ColumnLayout = state.page.columns ?? { count: 1, gap: 0 }; + regions.push({ + yStart: state.topMargin, + yEnd: boundaries[0].y, + columns: firstRegionColumns, + }); + for (let i = 0; i < boundaries.length; i++) { + const start = boundaries[i]; + const end = boundaries[i + 1]; + regions.push({ + yStart: start.y, + yEnd: end ? end.y : state.contentBottom, + columns: start.columns, + }); + } + state.page.columnRegions = regions; + } + return { pageSize, pages, @@ -2534,7 +2578,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // after processing sections. Page/region-specific column changes are encoded // implicitly via fragment positions. Consumers should not assume this is // a static document-wide value. - columns: activeColumns.count > 1 ? { count: activeColumns.count, gap: activeColumns.gap } : undefined, + columns: + activeColumns.count > 1 + ? { count: activeColumns.count, gap: activeColumns.gap, withSeparator: activeColumns.withSeparator } + : undefined, }; } @@ -2961,3 +3008,5 @@ export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTok // Table utilities consumed by layout-bridge and cross-package sync tests export { getCellLines, getEmbeddedRowLines } from './layout-table.js'; export { describeCellRenderBlocks, computeCellSliceContentHeight } from './table-cell-slice.js'; + +export { SINGLE_COLUMN_DEFAULT } from './section-breaks.js'; diff --git a/packages/layout-engine/layout-engine/src/section-breaks.d.ts b/packages/layout-engine/layout-engine/src/section-breaks.d.ts index a4fec8ae80..310d6e4690 100644 --- a/packages/layout-engine/layout-engine/src/section-breaks.d.ts +++ b/packages/layout-engine/layout-engine/src/section-breaks.d.ts @@ -1,4 +1,5 @@ -import type { SectionBreakBlock } from '@superdoc/contracts'; +import type { ColumnLayout, SectionBreakBlock } from '@superdoc/contracts'; + export type SectionState = { activeTopMargin: number; activeBottomMargin: number; @@ -20,14 +21,8 @@ export type SectionState = { w: number; h: number; } | null; - activeColumns: { - count: number; - gap: number; - }; - pendingColumns: { - count: number; - gap: number; - } | null; + activeColumns: ColumnLayout; + pendingColumns: ColumnLayout | null; activeOrientation: 'portrait' | 'landscape' | null; pendingOrientation: 'portrait' | 'landscape' | null; hasAnyPages: boolean; @@ -37,6 +32,7 @@ export type BreakDecision = { forceMidPageRegion: boolean; requiredParity?: 'even' | 'odd'; }; + /** * Schedule section break effects by updating pending/active state and returning a break decision. * This function is pure with respect to inputs/outputs and does not mutate external variables. @@ -56,6 +52,7 @@ export declare function scheduleSectionBreak( decision: BreakDecision; state: SectionState; }; + /** * Apply pending margins/pageSize/columns/orientation to active values at a page boundary and clear pending. */ diff --git a/packages/layout-engine/layout-engine/src/section-breaks.test.ts b/packages/layout-engine/layout-engine/src/section-breaks.test.ts index 209c601ecf..49f3143b1f 100644 --- a/packages/layout-engine/layout-engine/src/section-breaks.test.ts +++ b/packages/layout-engine/layout-engine/src/section-breaks.test.ts @@ -140,6 +140,44 @@ describe('scheduleSectionBreak', () => { expect(result.decision.forceMidPageRegion).toBe(false); expect(result.state.pendingColumns).toEqual({ count: 2, gap: 48 }); }); + + it('detects column change when only withSeparator toggles on', () => { + const state = createSectionState({ activeColumns: { count: 2, gap: 48, withSeparator: false } }); + const block = createSectionBreak({ + type: 'continuous', + columns: { count: 2, gap: 48, withSeparator: true }, + }); + + const result = scheduleSectionBreak(block, state, BASE_MARGINS); + + expect(result.decision.forceMidPageRegion).toBe(true); + expect(result.state.pendingColumns).toEqual({ count: 2, gap: 48, withSeparator: true }); + }); + + it('detects column change when only withSeparator toggles off', () => { + const state = createSectionState({ activeColumns: { count: 2, gap: 48, withSeparator: true } }); + const block = createSectionBreak({ + type: 'continuous', + columns: { count: 2, gap: 48, withSeparator: false }, + }); + + const result = scheduleSectionBreak(block, state, BASE_MARGINS); + + expect(result.decision.forceMidPageRegion).toBe(true); + expect(result.state.pendingColumns).toEqual({ count: 2, gap: 48, withSeparator: false }); + }); + + it('does not trigger mid-page region change when undefined and defined false match', () => { + const state = createSectionState({ activeColumns: { count: 2, gap: 48, withSeparator: false } }); + const block = createSectionBreak({ + type: 'continuous', + columns: { count: 2, gap: 48 }, + }); + + const result = scheduleSectionBreak(block, state, BASE_MARGINS); + + expect(result.decision.forceMidPageRegion).toBe(false); + }); }); describe('first section handling', () => { diff --git a/packages/layout-engine/layout-engine/src/section-breaks.ts b/packages/layout-engine/layout-engine/src/section-breaks.ts index 6eb911657f..3fce475a66 100644 --- a/packages/layout-engine/layout-engine/src/section-breaks.ts +++ b/packages/layout-engine/layout-engine/src/section-breaks.ts @@ -30,7 +30,7 @@ export type BreakDecision = { }; /** Default single-column configuration per OOXML spec (absence of w:cols element) */ -const SINGLE_COLUMN_DEFAULT: Readonly = { count: 1, gap: 0 }; +export const SINGLE_COLUMN_DEFAULT: Readonly = { count: 1, gap: 0 }; /** * Get the column configuration for a section break. @@ -38,7 +38,7 @@ const SINGLE_COLUMN_DEFAULT: Readonly = { count: 1, gap: 0 }; * Per OOXML spec, absence of element means single column layout. * * @param blockColumns - The columns property from the section break block (may be undefined) - * @returns Column configuration with count and gap + * @returns Column configuration with count, gap, and separator presence */ function getColumnConfig(blockColumns: ColumnLayout | undefined): ColumnLayout { return blockColumns ? cloneColumnLayout(blockColumns) : { ...SINGLE_COLUMN_DEFAULT }; @@ -56,17 +56,22 @@ function getColumnConfig(blockColumns: ColumnLayout | undefined): ColumnLayout { */ function isColumnConfigChanging(blockColumns: ColumnLayout | undefined, activeColumns: ColumnLayout): boolean { if (blockColumns) { - // Explicit column change + // Explicit column change: any of count, gap, separator presence, equalWidth, + // or widths differs. withSeparator must be included because a sep-only toggle + // still needs a new column region so the renderer can draw (or stop drawing) + // the separator from the toggle point onward. return ( blockColumns.count !== activeColumns.count || blockColumns.gap !== activeColumns.gap || + Boolean(blockColumns.withSeparator) !== Boolean(activeColumns.withSeparator) || blockColumns.equalWidth !== activeColumns.equalWidth || !widthsEqual(blockColumns.widths, activeColumns.widths) ); } - // No columns specified = reset to single column (OOXML default) - // This is a change only if currently in multi-column layout - return activeColumns.count > 1; + // No columns specified = reset to single column (OOXML default). + // This is a change if currently in multi-column layout, or if the separator was on + // (the reset implicitly turns it off). + return activeColumns.count > 1 || Boolean(activeColumns.withSeparator); } /** diff --git a/packages/layout-engine/layout-engine/src/section-props.test.ts b/packages/layout-engine/layout-engine/src/section-props.test.ts index 49dbfd4f30..0d9601c369 100644 --- a/packages/layout-engine/layout-engine/src/section-props.test.ts +++ b/packages/layout-engine/layout-engine/src/section-props.test.ts @@ -61,4 +61,36 @@ describe('computeNextSectionPropsAtBreak', () => { expect(snapshot?.columns).toEqual({ count: 2, gap: 48 }); expect(snapshot?.columns).not.toBe(sourceColumns); }); + + it('propagates withSeparator flag through section property snapshots', () => { + const sourceColumns = { count: 2, gap: 48, withSeparator: true }; + const blocks: FlowBlock[] = [sectionBreak({ id: 'sb-0', columns: sourceColumns })]; + const map = computeNextSectionPropsAtBreak(blocks); + const snapshot = map.get(0); + + expect(snapshot?.columns).toEqual({ count: 2, gap: 48, withSeparator: true }); + expect(snapshot?.columns).not.toBe(sourceColumns); + }); + + it('omits withSeparator from the snapshot when not set on source block', () => { + const sourceColumns = { count: 2, gap: 48 }; + const blocks: FlowBlock[] = [sectionBreak({ id: 'sb-0', columns: sourceColumns })]; + const map = computeNextSectionPropsAtBreak(blocks); + const snapshot = map.get(0); + + expect(snapshot?.columns).toEqual({ count: 2, gap: 48 }); + expect(snapshot?.columns?.withSeparator).toBeUndefined(); + }); + + it('propagates withSeparator from the next section in lookahead', () => { + const blocks: FlowBlock[] = [ + sectionBreak({ id: 'sb-0', columns: { count: 1, gap: 0 } }), + { kind: 'paragraph', id: 'p-1', runs: [] } as FlowBlock, + sectionBreak({ id: 'sb-2', columns: { count: 2, gap: 48, withSeparator: true } }), + ]; + const map = computeNextSectionPropsAtBreak(blocks); + + expect(map.get(0)?.columns).toEqual({ count: 2, gap: 48, withSeparator: true }); + expect(map.get(2)?.columns).toEqual({ count: 2, gap: 48, withSeparator: true }); + }); }); diff --git a/packages/layout-engine/layout-engine/src/section-props.ts b/packages/layout-engine/layout-engine/src/section-props.ts index 6c8c6c84c3..82bab70f88 100644 --- a/packages/layout-engine/layout-engine/src/section-props.ts +++ b/packages/layout-engine/layout-engine/src/section-props.ts @@ -1,4 +1,4 @@ -import type { FlowBlock, SectionVerticalAlign } from '@superdoc/contracts'; +import type { ColumnLayout, FlowBlock, SectionVerticalAlign } from '@superdoc/contracts'; /** * Section-level formatting properties that control page layout. @@ -16,7 +16,7 @@ import type { FlowBlock, SectionVerticalAlign } from '@superdoc/contracts'; export type SectionProps = { margins?: { header?: number; footer?: number; top?: number; right?: number; bottom?: number; left?: number }; pageSize?: { w: number; h: number }; - columns?: { count: number; gap: number }; + columns?: ColumnLayout; orientation?: 'portrait' | 'landscape'; vAlign?: SectionVerticalAlign; }; @@ -59,7 +59,7 @@ const _snapshotSectionProps = (block: FlowBlock): SectionProps | null => { } if (block.columns) { hasProps = true; - props.columns = { count: block.columns.count, gap: block.columns.gap }; + props.columns = { count: block.columns.count, gap: block.columns.gap, withSeparator: block.columns.withSeparator }; } if (block.orientation) { hasProps = true; @@ -135,7 +135,11 @@ export function computeNextSectionPropsAtBreak(blocks: FlowBlock[]): Map = {}): Page => ({ + number: 1, + fragments: [], + margins: { top: 96, right: 96, bottom: 96, left: 96 }, + ...overrides, +}); + +const buildLayout = (page: Page, pageSize = { w: 816, h: 1056 }): Layout => ({ + pageSize, + pages: [page], +}); + +const querySeparators = (mount: HTMLElement): HTMLDivElement[] => { + // Separators are the only 1px-wide absolutely-positioned divs added to a page. + // Scoping by the inline styles keeps this brittle-free against unrelated + // absolute-positioned overlays (rulers, selection, floats). + return Array.from(mount.querySelectorAll('div')).filter((el) => { + const s = el.style; + return s.position === 'absolute' && s.width === '1px' && s.backgroundColor === '#000000'; + }) as HTMLDivElement[]; +}; + +const paintOnce = (layout: Layout, mount: HTMLElement): void => { + const painter = createDomPainter({ blocks: [], measures: [] }); + painter.paint(layout, mount); +}; + +describe('DomPainter renderColumnSeparators', () => { + let mount: HTMLElement; + + beforeEach(() => { + mount = document.createElement('div'); + document.body.appendChild(mount); + }); + + afterEach(() => { + mount.remove(); + }); + + describe('fallback path (page.columns only)', () => { + it('draws a single separator centered in the gap for 2 equal columns', () => { + const page = buildPage({ columns: { count: 2, gap: 48, withSeparator: true } }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(1); + // pageWidth=816, margins=96 → contentWidth=624, columnWidth=(624-48)/2=288. + // separator x = leftMargin + columnWidth + gap/2 = 96 + 288 + 24 = 408. + expect(seps[0].style.left).toBe('408px'); + expect(seps[0].style.top).toBe('96px'); + // height = pageHeight - top - bottom = 1056 - 96 - 96 = 864. + expect(seps[0].style.height).toBe('864px'); + }); + + it('draws count-1 separators for 3 equal columns', () => { + const page = buildPage({ columns: { count: 3, gap: 48, withSeparator: true } }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(2); + // columnWidth = (624 - 48*2) / 3 = 176. + // sep 0: 96 + 176 + 48/2 = 296. sep 1: 96 + 2*176 + 48 + 48/2 = 520. + expect(seps.map((s) => s.style.left)).toEqual(['296px', '520px']); + }); + + it('renders nothing when withSeparator is false', () => { + const page = buildPage({ columns: { count: 2, gap: 48, withSeparator: false } }); + paintOnce(buildLayout(page), mount); + + expect(querySeparators(mount)).toHaveLength(0); + }); + + it('renders nothing when withSeparator is omitted (undefined)', () => { + const page = buildPage({ columns: { count: 2, gap: 48 } }); + paintOnce(buildLayout(page), mount); + + expect(querySeparators(mount)).toHaveLength(0); + }); + + it('renders nothing for single-column pages', () => { + const page = buildPage({ columns: { count: 1, gap: 0, withSeparator: true } }); + paintOnce(buildLayout(page), mount); + + expect(querySeparators(mount)).toHaveLength(0); + }); + + it('renders nothing when page has neither columns nor columnRegions', () => { + paintOnce(buildLayout(buildPage()), mount); + expect(querySeparators(mount)).toHaveLength(0); + }); + + it('renders nothing when page.margins is missing', () => { + const page: Page = { + number: 1, + fragments: [], + columns: { count: 2, gap: 48, withSeparator: true }, + }; + paintOnce(buildLayout(page), mount); + expect(querySeparators(mount)).toHaveLength(0); + }); + + it('renders nothing when columnWidth collapses to <=1px', () => { + // Pathological case: tiny page with a huge gap leaves no room for columns. + const page = buildPage({ + margins: { top: 10, right: 10, bottom: 10, left: 10 }, + columns: { count: 2, gap: 100, withSeparator: true }, + }); + paintOnce(buildLayout(page, { w: 110, h: 200 }), mount); + // contentWidth=90, columnWidth=(90-100)/2=-5 → guard fires. + expect(querySeparators(mount)).toHaveLength(0); + }); + }); + + describe('region-aware path (page.columnRegions)', () => { + it('draws per-region separators bounded by each region yStart/yEnd', () => { + const regions: ColumnRegion[] = [ + { yStart: 96, yEnd: 400, columns: { count: 2, gap: 48, withSeparator: true } }, + { yStart: 400, yEnd: 700, columns: { count: 3, gap: 48, withSeparator: true } }, + ]; + // page.columns is set to the first region's config (matches what the + // layout engine does); the renderer must prefer columnRegions. + const page = buildPage({ + columns: regions[0].columns, + columnRegions: regions, + }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + // Region 0: 1 separator for 2-col. Region 1: 2 separators for 3-col. + expect(seps).toHaveLength(3); + + // Region 0 bounds. + expect(seps[0].style.top).toBe('96px'); + expect(seps[0].style.height).toBe('304px'); // 400 - 96 + expect(seps[0].style.left).toBe('408px'); + + // Region 1 bounds. + expect(seps[1].style.top).toBe('400px'); + expect(seps[1].style.height).toBe('300px'); // 700 - 400 + expect(seps[2].style.top).toBe('400px'); + expect(seps[2].style.height).toBe('300px'); + // 3-col positions computed fresh for region 1: 296px and 520px. + expect([seps[1].style.left, seps[2].style.left]).toEqual(['296px', '520px']); + }); + + it('skips regions whose withSeparator is false even if other regions render', () => { + const regions: ColumnRegion[] = [ + { yStart: 96, yEnd: 400, columns: { count: 2, gap: 48, withSeparator: true } }, + { yStart: 400, yEnd: 700, columns: { count: 2, gap: 48, withSeparator: false } }, + { yStart: 700, yEnd: 960, columns: { count: 2, gap: 48, withSeparator: true } }, + ]; + const page = buildPage({ columnRegions: regions }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(2); + // Only regions 0 and 2 produce output. + expect(seps.map((s) => s.style.top)).toEqual(['96px', '700px']); + expect(seps.map((s) => s.style.height)).toEqual(['304px', '260px']); + }); + + it('skips single-column regions', () => { + const regions: ColumnRegion[] = [ + { yStart: 96, yEnd: 400, columns: { count: 1, gap: 0, withSeparator: true } }, + { yStart: 400, yEnd: 700, columns: { count: 2, gap: 48, withSeparator: true } }, + ]; + const page = buildPage({ columnRegions: regions }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(1); + expect(seps[0].style.top).toBe('400px'); + }); + + it('skips regions with non-positive height', () => { + const regions: ColumnRegion[] = [ + { yStart: 96, yEnd: 96, columns: { count: 2, gap: 48, withSeparator: true } }, + { yStart: 96, yEnd: 500, columns: { count: 2, gap: 48, withSeparator: true } }, + ]; + const page = buildPage({ columnRegions: regions }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(1); + expect(seps[0].style.height).toBe('404px'); + }); + + it('prefers columnRegions over page.columns when both are present', () => { + // page.columns says "no separator", but columnRegions says "draw one". + // The regions should win — they represent the authoritative per-region + // state, page.columns only represents the page-start config. + const page = buildPage({ + columns: { count: 2, gap: 48, withSeparator: false }, + columnRegions: [{ yStart: 96, yEnd: 960, columns: { count: 2, gap: 48, withSeparator: true } }], + }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(1); + expect(seps[0].style.top).toBe('96px'); + expect(seps[0].style.height).toBe('864px'); + }); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 28115ea837..8839e631d2 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2230,6 +2230,8 @@ export class DomPainter { ); }); this.renderDecorationsForPage(el, page, pageIndex); + this.renderColumnSeparators(el, page, width, height); + return el; } @@ -2310,6 +2312,60 @@ export class DomPainter { } } + private renderColumnSeparators(pageEl: HTMLElement, page: Page, pageWidth: number, pageHeight: number): void { + if (!this.doc) return; + if (!page.margins) return; + + const leftMargin = page.margins.left ?? 0; + const rightMargin = page.margins.right ?? 0; + const topMargin = page.margins.top ?? 0; + const bottomMargin = page.margins.bottom ?? 0; + const contentWidth = pageWidth - leftMargin - rightMargin; + + // Prefer columnRegions (per-region configs for pages with continuous + // section breaks that change column layout mid-page). Fall back to a + // single region derived from page.columns so pages without mid-page + // changes keep working unchanged. + const regions = + page.columnRegions ?? + (page.columns + ? [ + { + yStart: topMargin, + yEnd: pageHeight - bottomMargin, + columns: page.columns, + }, + ] + : []); + + for (const region of regions) { + const { columns, yStart, yEnd } = region; + if (!columns.withSeparator) continue; + if (columns.count <= 1) continue; + + const columnWidth = (contentWidth - columns.gap * (columns.count - 1)) / columns.count; + // Given the separator will have 1px width, ensure column has a larger width. + if (columnWidth <= 1) continue; + + const regionHeight = yEnd - yStart; + if (regionHeight <= 0) continue; + + for (let i = 0; i < columns.count - 1; i++) { + const separatorX = leftMargin + (i + 1) * columnWidth + i * columns.gap + columns.gap / 2; + const separatorEl = this.doc.createElement('div'); + + separatorEl.style.position = 'absolute'; + separatorEl.style.left = `${separatorX}px`; + separatorEl.style.top = `${yStart}px`; + separatorEl.style.height = `${regionHeight}px`; + separatorEl.style.width = '1px'; + separatorEl.style.backgroundColor = '#000000'; + separatorEl.style.pointerEvents = 'none'; + pageEl.appendChild(separatorEl); + } + } + } + private renderDecorationsForPage(pageEl: HTMLElement, page: Page, pageIndex: number): void { if (this.isSemanticFlow) return; this.renderDecorationSection(pageEl, page, pageIndex, 'header'); @@ -2816,6 +2872,8 @@ export class DomPainter { }); this.renderDecorationsForPage(el, page, pageIndex); + this.renderColumnSeparators(el, page, pageSize.w, pageSize.h); + return { element: el, fragments: fragmentStates }; } diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index 415dbb87b2..65d5456486 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -903,6 +903,7 @@ describe('toFlowBlocks', () => { expect((contentBreak as FlowBlock).columns).toEqual({ count: 2, gap: 101.53333333333333, + withSeparator: false, widths: [72, 497.26666666666665], equalWidth: false, }); @@ -1077,7 +1078,7 @@ describe('toFlowBlocks', () => { expect(multiColumnBreak).toBeDefined(); expect((multiColumnBreak as FlowBlock).attrs?.requirePageBoundary).toBeUndefined(); // Gap is in pixels (0.5in = 48px @96DPI) - expect((multiColumnBreak as FlowBlock).columns).toEqual({ count: 2, gap: 48 }); + expect((multiColumnBreak as FlowBlock).columns).toEqual({ count: 2, gap: 48, withSeparator: false }); }); it('interprets missing w:num in w:cols as a single-column layout change', () => { @@ -1110,7 +1111,7 @@ describe('toFlowBlocks', () => { const allBreaks = getSectionBreaks(blocks, { includeFirst: true }); const tailBreak = allBreaks.find((b) => b.attrs?.sectionIndex === 0); expect(tailBreak).toBeDefined(); - expect((tailBreak as never).columns).toEqual({ count: 1, gap: 48 }); + expect((tailBreak as never).columns).toEqual({ count: 1, gap: 48, withSeparator: false }); }); describe('Regression tests for section property bug fixes', () => { @@ -1158,7 +1159,7 @@ describe('toFlowBlocks', () => { expect(firstBreak).toBeDefined(); expect(secondBreak).toBeDefined(); // Both have w:space="720" which means single column - expect((firstBreak as FlowBlock).columns).toEqual({ count: 1, gap: 48 }); + expect((firstBreak as FlowBlock).columns).toEqual({ count: 1, gap: 48, withSeparator: false }); expect((secondBreak as FlowBlock).type).toBe('continuous'); // Second sectPr }); @@ -1198,7 +1199,7 @@ describe('toFlowBlocks', () => { // Should emit the section break despite paragraph having content expect(contentBreak).toBeDefined(); - expect((contentBreak as FlowBlock).columns).toEqual({ count: 2, gap: 48 }); + expect((contentBreak as FlowBlock).columns).toEqual({ count: 2, gap: 48, withSeparator: false }); }); it('detects column changes from single to multi to single column', () => { diff --git a/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts b/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts index 37c60496b5..ac860ce7e6 100644 --- a/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts +++ b/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts @@ -756,7 +756,7 @@ describe('analysis', () => { footerPx: 50, pageSizePx: { w: 12240, h: 15840 }, orientation: 'landscape', - columnsPx: { count: 2, gap: 100 }, + columnsPx: { count: 2, gap: 100, withSeparator: false }, headerRefs: { default: 'header1' }, footerRefs: { default: 'footer1' }, numbering: { format: 'decimal', start: 1 }, @@ -770,7 +770,7 @@ describe('analysis', () => { margins: { header: 100, footer: 50 }, pageSize: { w: 12240, h: 15840 }, orientation: 'landscape', - columns: { count: 2, gap: 100 }, + columns: { count: 2, gap: 100, withSeparator: false }, headerRefs: { default: 'header1' }, footerRefs: { default: 'footer1' }, numbering: { format: 'decimal', start: 1 }, @@ -962,6 +962,32 @@ describe('analysis', () => { expect(result!.numbering).toEqual({ format: 'decimal', start: 5 }); }); + it('should have column separator flag set to true when present in extracted data', () => { + const bodySectPr: SectPrElement = { type: 'element', name: 'w:sectPr' }; + + vi.mocked(extractionModule.extractSectionData).mockReturnValue({ + titlePg: false, + columnsPx: { count: 2, gap: 48, withSeparator: true }, + }); + + const result = createFinalSectionFromBodySectPr(bodySectPr, 0, 10, 0); + + expect(result!.columns).toEqual({ count: 2, gap: 48, withSeparator: true }); + }); + + it('should have column separator flag set to false when present as "false" in extracted data', () => { + const bodySectPr: SectPrElement = { type: 'element', name: 'w:sectPr' }; + + vi.mocked(extractionModule.extractSectionData).mockReturnValue({ + titlePg: false, + columnsPx: { count: 2, gap: 48, withSeparator: false }, + }); + + const result = createFinalSectionFromBodySectPr(bodySectPr, 0, 10, 0); + + expect(result!.columns).toEqual({ count: 2, gap: 48, withSeparator: false }); + }); + it('should respect body section type from extracted data', () => { const bodySectPr: SectPrElement = { type: 'element', name: 'w:sectPr' }; diff --git a/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts b/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts index 7f3fa6beec..ce6a124e96 100644 --- a/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts +++ b/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts @@ -250,6 +250,7 @@ describe('extraction', () => { expect(result?.columnsPx).toEqual({ count: 2, gap: 48, // 720 twips = 0.5 inches = 48 pixels + withSeparator: false, }); }); @@ -281,6 +282,7 @@ describe('extraction', () => { expect(result?.columnsPx).toEqual({ count: 2, gap: 101.53333333333333, + withSeparator: false, widths: [72, 497.26666666666665], equalWidth: false, }); @@ -374,6 +376,129 @@ describe('extraction', () => { }); }); + // ==================== extractSectionData - column separator (w:sep) tests ==================== + describe('extractSectionData - column separator', () => { + it('should include separator when w:sep="1"', () => { + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:space': '720', 'w:sep': '1' }, + }, + ], + }, + }, + }, + }; + const result = extractSectionData(para); + + expect(result).not.toBeNull(); + expect(result?.columnsPx).toEqual({ + count: 2, + gap: 48, + withSeparator: true, + }); + }); + + it('should include separator when w:sep="true"', () => { + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:space': '720', 'w:sep': 'true' }, + }, + ], + }, + }, + }, + }; + const result = extractSectionData(para); + + expect(result?.columnsPx?.withSeparator).toBe(true); + }); + + it('should include separator when w:sep="on"', () => { + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:space': '720', 'w:sep': 'on' }, + }, + ], + }, + }, + }, + }; + const result = extractSectionData(para); + + expect(result?.columnsPx?.withSeparator).toBe(true); + }); + + it('should not include separator when w:sep is absent', () => { + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:space': '720' }, + }, + ], + }, + }, + }, + }; + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ count: 2, gap: 48, withSeparator: false }); + }); + + it('should not include separator when w:sep="0"', () => { + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:space': '720', 'w:sep': '0' }, + }, + ], + }, + }, + }, + }; + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ count: 2, gap: 48, withSeparator: false }); + }); + }); + // ==================== parseColumnGap Tests ==================== describe('parseColumnGap', () => { it('should return default 0.5 inches when gapTwips is undefined', () => { diff --git a/packages/layout-engine/pm-adapter/src/sections/extraction.ts b/packages/layout-engine/pm-adapter/src/sections/extraction.ts index ca2e9979ef..da98daeec3 100644 --- a/packages/layout-engine/pm-adapter/src/sections/extraction.ts +++ b/packages/layout-engine/pm-adapter/src/sections/extraction.ts @@ -6,6 +6,7 @@ import type { PMNode } from '../types.js'; import type { ParagraphProperties, SectionVerticalAlign } from './types.js'; +import type { ColumnLayout } from '@superdoc/contracts'; const TWIPS_PER_INCH = 1440; const PX_PER_INCH = 96; @@ -42,6 +43,15 @@ export function parseColumnGap(gapTwips: string | number | undefined): number { return Number.isFinite(gap) ? gap / TWIPS_PER_INCH : DEFAULT_COLUMN_GAP_INCHES; } +/** + * Parse presence of column separator from w:sep attribute (can be '1', 'true' or 'on'). + * @param rawValue - Raw value from w:sep attribute + * @returns Presence of column separator + */ +export function parseColumnSeparator(rawValue: string | number | undefined): boolean { + return rawValue === '1' || rawValue === 'true' || rawValue === 'on' || rawValue === 1; +} + type SectionType = 'continuous' | 'nextPage' | 'evenPage' | 'oddPage'; type Orientation = 'portrait' | 'landscape'; type HeaderRefType = Partial>; @@ -209,13 +219,12 @@ function extractPageNumbering(elements: SectionElement[]): /** * Extract columns from element. */ -function extractColumns( - elements: SectionElement[], -): { count: number; gap: number; widths?: number[]; equalWidth?: boolean } | undefined { +function extractColumns(elements: SectionElement[]): ColumnLayout | undefined { const cols = elements.find((el) => el?.name === 'w:cols'); if (!cols?.attributes) return undefined; const count = parseColumnCount(cols.attributes['w:num'] as string | number | undefined); + const withSeparator = parseColumnSeparator(cols.attributes['w:sep'] as string | number | undefined); const equalWidthRaw = cols.attributes['w:equalWidth']; const equalWidth = equalWidthRaw === '0' || equalWidthRaw === 0 || equalWidthRaw === false @@ -233,9 +242,10 @@ function extractColumns( .filter((widthTwips) => Number.isFinite(widthTwips) && widthTwips > 0) .map((widthTwips) => (widthTwips / 1440) * PX_PER_INCH); - const result = { + const result: ColumnLayout = { count, gap: gapInches * PX_PER_INCH, + withSeparator, ...(widths.length > 0 ? { widths } : {}), ...(equalWidth !== undefined ? { equalWidth } : {}), }; @@ -308,7 +318,7 @@ export function extractSectionData(para: PMNode): { type?: SectionType; pageSizePx?: { w: number; h: number }; orientation?: Orientation; - columnsPx?: { count: number; gap: number; widths?: number[]; equalWidth?: boolean }; + columnsPx?: ColumnLayout; titlePg?: boolean; headerRefs?: HeaderRefType; footerRefs?: HeaderRefType; diff --git a/packages/layout-engine/pm-adapter/src/sections/index.ts b/packages/layout-engine/pm-adapter/src/sections/index.ts index b21f849c6a..64b41423fb 100644 --- a/packages/layout-engine/pm-adapter/src/sections/index.ts +++ b/packages/layout-engine/pm-adapter/src/sections/index.ts @@ -17,7 +17,7 @@ export type { export { SectionType, DEFAULT_PARAGRAPH_SECTION_TYPE, DEFAULT_BODY_SECTION_TYPE } from './types.js'; // Extraction -export { extractSectionData, parseColumnCount, parseColumnGap } from './extraction.js'; +export { extractSectionData, parseColumnCount, parseColumnGap, parseColumnSeparator } from './extraction.js'; // Analysis export { diff --git a/packages/layout-engine/pm-adapter/src/sections/types.ts b/packages/layout-engine/pm-adapter/src/sections/types.ts index bc8498ffed..a4134b48d0 100644 --- a/packages/layout-engine/pm-adapter/src/sections/types.ts +++ b/packages/layout-engine/pm-adapter/src/sections/types.ts @@ -5,6 +5,8 @@ * Includes section ranges, signatures, and OOXML structures. */ +import type { ColumnLayout } from '@superdoc/contracts'; + /** * Section types in Word documents. * Controls how section breaks create new pages. @@ -72,7 +74,7 @@ export type SectionSignature = { orientation?: 'portrait' | 'landscape'; headerRefs?: Partial>; footerRefs?: Partial>; - columnsPx?: { count: number; gap: number; widths?: number[]; equalWidth?: boolean }; + columnsPx?: ColumnLayout; numbering?: { format?: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash'; start?: number; @@ -105,7 +107,7 @@ export interface SectionRange { } | null; pageSize: { w: number; h: number } | null; orientation: 'portrait' | 'landscape' | null; - columns: { count: number; gap: number; widths?: number[]; equalWidth?: boolean } | null; + columns: ColumnLayout | null; type: SectionType; titlePg: boolean; headerRefs?: Partial>; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/LayoutOptionParsing.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/LayoutOptionParsing.ts index 43ac3cd7b2..f9d2b6f467 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/LayoutOptionParsing.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/LayoutOptionParsing.ts @@ -20,20 +20,23 @@ export function inchesToPx(value: unknown): number | undefined { /** * Parses column layout configuration from raw input. * - * Extracts column count and gap spacing from various possible property names, + * Extracts column count, gap spacing, and separator presence from various possible property names, * normalizing to a standard ColumnLayout object. Returns undefined for single-column * layouts (count <= 1) since they don't require special column handling. * * @param raw - Raw column configuration object with properties like count, num, or numberOfColumns - * @returns ColumnLayout with count and gap, or undefined if not multi-column or invalid + * @returns ColumnLayout with count, gap and separator presence, or undefined if not multi-column + * or invalid * * @remarks * - Returns undefined if raw is not an object * - Accepts count from: 'count', 'num', or 'numberOfColumns' properties * - Returns undefined if count <= 1 (single column doesn't need layout) * - Accepts gap from: 'space' or 'gap' properties (converted from inches to pixels) - * - Gap defaults to 0 if not provided or invalid + * - Accepts separator presence from: 'withSeparator' boolean property * - Column count is floored to nearest integer and minimum of 1 + * - Gap defaults to 0 if not provided or invalid + * - Separator presence defaults to false if not provided or not a boolean */ export function parseColumns(raw: unknown): ColumnLayout | undefined { if (!raw || typeof raw !== 'object') return undefined; @@ -44,5 +47,6 @@ export function parseColumns(raw: unknown): ColumnLayout | undefined { } const count = Math.max(1, Math.floor(rawCount)); const gap = inchesToPx(columnSource.space ?? columnSource.gap) ?? 0; - return { count, gap }; + const withSeparator = typeof columnSource.withSeparator === 'boolean' ? columnSource.withSeparator : false; + return { count, gap, withSeparator }; } diff --git a/tests/visual/columns-with-line-separator.docx b/tests/visual/columns-with-line-separator.docx new file mode 100644 index 0000000000..51d07e47f0 Binary files /dev/null and b/tests/visual/columns-with-line-separator.docx differ