diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index e63e4e95d2..ad0264f11f 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.lineBetween !== undefined ? { lineBetween: columns.lineBetween } : {}), } : { count: 1, gap: 0 }; } @@ -70,6 +71,7 @@ export function normalizeColumnLayout( gap, ...(widths.length > 0 ? { widths } : {}), ...(input?.equalWidth !== undefined ? { equalWidth: input.equalWidth } : {}), + ...(input?.lineBetween !== undefined ? { lineBetween: input.lineBetween } : {}), width, }; } diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index e7a7d1b2f3..c5f317ec05 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -990,6 +990,7 @@ export type SectionBreakBlock = { gap: number; widths?: number[]; equalWidth?: boolean; + lineBetween?: boolean; }; /** * Vertical alignment of content within the section's pages. @@ -1478,6 +1479,7 @@ export type ColumnLayout = { gap: number; widths?: number[]; equalWidth?: boolean; + lineBetween?: boolean; }; /** A measured line within a block, output by the measurer. */ @@ -1698,6 +1700,11 @@ export type Page = { * where headers/footers don't affect vertical alignment. */ baseMargins?: { top: number; bottom: number }; + /** + * Column layout for this page, if multi-column. + * Carried from the active section so the painter can render column separator lines. + */ + columns?: ColumnLayout; /** * Index of the section this page belongs to. * Used for section-aware page numbering and header/footer selection. diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 3391ef0918..71ebd63e44 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -1071,6 +1071,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options bottom: activeSectionBaseBottomMargin, }; } + // Carry column layout to the page for the painter (e.g. inter-column separator lines) + if (activeColumns && activeColumns.count > 1) { + page.columns = activeColumns; + } return page; }; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 69276efb20..1fbc855732 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2227,9 +2227,61 @@ export class DomPainter { ); }); this.renderDecorationsForPage(el, page, pageIndex); + this.renderColumnSeparators(el, page, width, height); + return el; } + /** + * Append vertical separator lines between columns when the page's column + * layout has `lineBetween` enabled (OOXML ``). + * + * Shared by `renderPage` (book/horizontal flow) and `createPageState` + * (vertical/virtualized flow) so the feature shows up in both paths. + * + * Safe to call unconditionally: it no-ops when the flag is off, when there + * are fewer than 2 columns, or when margins/document are missing. + */ + private renderColumnSeparators(el: HTMLElement, page: Page, width: number, height: number): void { + if (!page.columns?.lineBetween || page.columns.count <= 1 || !page.margins || !this.doc) { + return; + } + const cols = page.columns; + const margins = page.margins; + const pageHeight = page.size?.h ?? height; + const contentTop = margins.top; + const contentBottom = pageHeight - margins.bottom; + const lineHeight = contentBottom - contentTop; + + if (lineHeight <= 0) return; + + const colWidths = Array.isArray(cols.widths) && cols.widths.length > 0 ? cols.widths : null; + for (let c = 0; c < cols.count - 1; c++) { + let lineX: number; + if (colWidths) { + let x = margins.left; + for (let j = 0; j <= c; j++) { + x += colWidths[j] ?? 0; + if (j < c) x += cols.gap; + } + lineX = x + cols.gap / 2; + } else { + const colWidth = (width - margins.left - margins.right - (cols.count - 1) * cols.gap) / cols.count; + lineX = margins.left + (c + 1) * colWidth + (c + 0.5) * cols.gap; + } + + const line = this.doc.createElement('div'); + line.style.position = 'absolute'; + line.style.left = `${lineX}px`; + line.style.top = `${contentTop}px`; + line.style.width = '0px'; + line.style.height = `${lineHeight}px`; + line.style.borderLeft = '1px solid #000'; + line.style.pointerEvents = 'none'; + el.appendChild(line); + } + } + /** * Render a ruler element for a page. * @@ -2811,6 +2863,7 @@ 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/sections/extraction.ts b/packages/layout-engine/pm-adapter/src/sections/extraction.ts index ca2e9979ef..6ee7570c48 100644 --- a/packages/layout-engine/pm-adapter/src/sections/extraction.ts +++ b/packages/layout-engine/pm-adapter/src/sections/extraction.ts @@ -211,7 +211,7 @@ function extractPageNumbering(elements: SectionElement[]): */ function extractColumns( elements: SectionElement[], -): { count: number; gap: number; widths?: number[]; equalWidth?: boolean } | undefined { +): { count: number; gap: number; widths?: number[]; equalWidth?: boolean; lineBetween?: boolean } | undefined { const cols = elements.find((el) => el?.name === 'w:cols'); if (!cols?.attributes) return undefined; @@ -233,11 +233,17 @@ function extractColumns( .filter((widthTwips) => Number.isFinite(widthTwips) && widthTwips > 0) .map((widthTwips) => (widthTwips / 1440) * PX_PER_INCH); + const sepAttr = cols.attributes['w:sep']; + const hasSepChild = Array.isArray(cols.elements) && cols.elements.some((child) => child?.name === 'w:sep'); + const lineBetween = + sepAttr === '1' || sepAttr === 1 || sepAttr === true || sepAttr === 'true' || hasSepChild ? true : undefined; + const result = { count, gap: gapInches * PX_PER_INCH, ...(widths.length > 0 ? { widths } : {}), ...(equalWidth !== undefined ? { equalWidth } : {}), + ...(lineBetween !== undefined ? { lineBetween } : {}), }; return result; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/section-properties.js b/packages/super-editor/src/editors/v1/core/super-converter/section-properties.js index ba444f9609..653e9a2cee 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/section-properties.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/section-properties.js @@ -87,6 +87,17 @@ export function getSectPrColumns(sectPr) { result.gap = twipsToInches(a['w:space']); } + // w:sep = line between columns (attribute on w:cols) + if (a['w:sep'] === '1' || a['w:sep'] === 'true') { + result.lineBetween = true; + } + + // Also check for w:sep child element (alternative OOXML form) + const sepChild = cols.elements?.find((el) => el?.name === 'w:sep'); + if (sepChild) { + result.lineBetween = true; + } + return Object.keys(result).length > 0 ? result : undefined; }