Skip to content
Merged
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
3 changes: 3 additions & 0 deletions packages/layout-engine/contracts/src/column-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down Expand Up @@ -62,6 +63,7 @@ export function normalizeColumnLayout(
count: 1,
gap: 0,
width: Math.max(0, contentWidth),
...(input?.withSeparator !== undefined ? { withSeparator: input.withSeparator } : {}),
};
}

Expand All @@ -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,
};
}
46 changes: 42 additions & 4 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -987,10 +987,7 @@ export type SectionBreakBlock = {
even?: string;
odd?: string;
};
columns?: {
count: number;
gap: number;
widths?: number[];
columns?: ColumnLayout & {
equalWidth?: boolean;
};
/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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. */
Expand Down
5 changes: 3 additions & 2 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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) ?? {
Expand Down
94 changes: 94 additions & 0 deletions packages/layout-engine/layout-engine/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
55 changes: 52 additions & 3 deletions packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
ColumnLayout,
ColumnRegion,
FlowBlock,
Fragment,
HeaderFooterLayout,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1074,6 +1080,11 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
if (activeOrientation) {
page.orientation = activeOrientation;
}

if (activeColumns.count > 1) {
Comment thread
caio-pizzol marked this conversation as resolved.
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;
Expand Down Expand Up @@ -2527,14 +2538,50 @@ 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,
// Note: columns here reflects the effective default for subsequent pages
// 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,
};
}

Expand Down Expand Up @@ -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';
15 changes: 6 additions & 9 deletions packages/layout-engine/layout-engine/src/section-breaks.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SectionBreakBlock } from '@superdoc/contracts';
import type { ColumnLayout, SectionBreakBlock } from '@superdoc/contracts';

export type SectionState = {
activeTopMargin: number;
activeBottomMargin: number;
Expand All @@ -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;
Expand All @@ -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.
Expand All @@ -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.
*/
Expand Down
Loading
Loading