Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,7 @@ export type SectionBreakBlock = {
count: number;
gap: number;
equalWidth?: boolean;
withSeparator?: boolean;
};
/**
* Vertical alignment of content within the section's pages.
Expand Down Expand Up @@ -1295,6 +1296,7 @@ export type FlowBlock =
export type ColumnLayout = {
count: number;
gap: number;
withSeparator?: boolean;
};

/** A measured line within a block, output by the measurer. */
Expand Down Expand Up @@ -1499,6 +1501,12 @@ export type Page = {
* Sections are 0-indexed, matching the sectionIndex in SectionMetadata.
*/
sectionIndex?: number;
/**
* Column layout configuration for this page.
* Used by the renderer to draw column separator lines when
* `withSeparator` is set to true.
*/
columns?: ColumnLayout;
};

/** A paragraph fragment positioned on a page. */
Expand Down
25 changes: 18 additions & 7 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,21 +130,23 @@ const normalizeColumnsForFootnotes = (input: ColumnLayout | undefined, contentWi
const gap = Math.max(0, input?.gap ?? 0);
const totalGap = gap * (count - 1);
const width = (contentWidth - totalGap) / count;
const withSeparator = input?.withSeparator ?? false;

if (!Number.isFinite(width) || width <= COLUMN_EPSILON) {
return {
count: 1,
gap: 0,
width: Math.max(0, contentWidth),
withSeparator,
};
}

return { count, gap, width };
return { count, gap, width, withSeparator };
};

const resolveSectionColumnsByIndex = (options: LayoutOptions, blocks?: FlowBlock[]): Map<number, ColumnLayout> => {
const result = new Map<number, ColumnLayout>();
let activeColumns: ColumnLayout = options.columns ?? { count: 1, gap: 0 };
let activeColumns: ColumnLayout = options.columns ?? { count: 1, gap: 0, withSeparator: false };
Copy link
Contributor

Choose a reason for hiding this comment

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

this fallback { count: 1, gap: 0, withSeparator: false } shows up 6 times across incrementalLayout.ts and index.ts. there's already a SINGLE_COLUMN_DEFAULT constant in section-breaks.ts that represents the same thing, but it's not exported.

if you export it and reuse it here, the next field added won't require hunting down 6 scattered literals -- which is exactly the situation you ran into when adding withSeparator.


if (blocks && blocks.length > 0) {
for (const block of blocks) {
Expand All @@ -153,7 +155,11 @@ const resolveSectionColumnsByIndex = (options: LayoutOptions, blocks?: FlowBlock
const sectionIndex =
typeof sectionIndexRaw === 'number' && Number.isFinite(sectionIndexRaw) ? sectionIndexRaw : result.size;
if (block.columns) {
activeColumns = { count: block.columns.count, gap: block.columns.gap };
activeColumns = {
count: block.columns.count,
gap: block.columns.gap,
withSeparator: block.columns.withSeparator,
};
}
result.set(sectionIndex, { ...activeColumns });
}
Expand Down Expand Up @@ -183,7 +189,8 @@ 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 ?? { count: 1, gap: 0, withSeparator: false };
const normalized = normalizeColumnsForFootnotes(columnsConfig, contentWidth);
result.set(pageIndex, { ...normalized, left: marginLeft, contentWidth });
}
Expand Down Expand Up @@ -256,7 +263,7 @@ const resolveFootnoteMeasurementWidth = (options: LayoutOptions, blocks?: FlowBl
left: normalizeMargin(options.margins?.left, DEFAULT_MARGINS.left),
};
let width = pageSize.w - (margins.left + margins.right);
let activeColumns: ColumnLayout = options.columns ?? { count: 1, gap: 0 };
let activeColumns: ColumnLayout = options.columns ?? { count: 1, gap: 0, withSeparator: false };
let activePageSize = pageSize;
let activeMargins = { ...margins };

Expand All @@ -277,7 +284,11 @@ const resolveFootnoteMeasurementWidth = (options: LayoutOptions, blocks?: FlowBl
left: normalizeMargin(block.margins?.left, activeMargins.left),
};
if (block.columns) {
activeColumns = { count: block.columns.count, gap: block.columns.gap };
activeColumns = {
count: block.columns.count,
gap: block.columns.gap,
withSeparator: block.columns.withSeparator,
};
}
const w = resolveColumnWidth();
if (w > 0 && w < width) width = w;
Expand Down Expand Up @@ -1452,7 +1463,7 @@ export async function incrementalLayout(
);
const pageContentWidth = pageSize.w - (marginLeft + marginRight);
const fallbackColumns = normalizeColumnsForFootnotes(
options.columns ?? { count: 1, gap: 0 },
options.columns ?? { count: 1, gap: 0, withSeparator: false },
pageContentWidth,
);
const columns = pageColumns.get(pageIndex) ?? {
Expand Down
38 changes: 38 additions & 0 deletions packages/layout-engine/layout-engine/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,44 @@ 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('applies spacing before and after paragraphs', () => {
const spacingBlock: FlowBlock = {
kind: 'paragraph',
Expand Down
32 changes: 26 additions & 6 deletions packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options

// Track active and pending columns
let activeColumns = options.columns ?? { count: 1, gap: 0 };
let pendingColumns: { count: number; gap: number } | null = null;
let pendingColumns: { count: number; gap: number; withSeparator?: boolean } | null = null;

// Track active and pending orientation
let activeOrientation: 'portrait' | 'landscape' | null = null;
Expand Down Expand Up @@ -822,11 +822,15 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
// Update columns - if section has columns, use them; if undefined, reset to single column.
// In OOXML, absence of <w:cols> means single column (default).
if (block.columns) {
next.activeColumns = { count: block.columns.count, gap: block.columns.gap };
next.activeColumns = {
count: block.columns.count,
gap: block.columns.gap,
withSeparator: block.columns.withSeparator,
};
next.pendingColumns = null;
} else {
// No columns specified = reset to single column (OOXML default)
next.activeColumns = { count: 1, gap: 0 };
next.activeColumns = { count: 1, gap: 0, withSeparator: false };
next.pendingColumns = null;
}
// Schedule section refs for first section (will be applied on first page creation)
Expand Down Expand Up @@ -932,7 +936,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
}
// Helper to get column config: use block.columns if defined, otherwise reset to single column (OOXML default)
const getColumnConfig = () =>
block.columns ? { count: block.columns.count, gap: block.columns.gap } : { count: 1, gap: 0 };
block.columns
? { count: block.columns.count, gap: block.columns.gap, withSeparator: block.columns.withSeparator }
: { count: 1, gap: 0, withSeparator: false };

if (block.attrs?.requirePageBoundary) {
next.pendingColumns = getColumnConfig();
Expand Down Expand Up @@ -972,6 +978,11 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
if (activeOrientation) {
page.orientation = activeOrientation;
}

if (activeColumns.count > 1) {
Copy link
Contributor

Choose a reason for hiding this comment

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

heads up on an edge case here: page.columns is set from activeColumns, which is the raw config from the section properties. but further down in the pipeline, there's a normalization step (normalizeColumns) that checks whether columns actually fit on the page -- if the gap is so large that columns would have zero width, it silently falls back to single-column layout. since page.columns was already set before that check, the renderer would still draw separators even though the page is actually rendering as one column.

unlikely to hit in real documents, but the fix is straightforward -- either set page.columns after normalization, or check the normalized count in the renderer.

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 @@ -1307,7 +1318,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
const advanceColumn = paginator.advanceColumn;

// Start a new mid-page region with different column configuration
const startMidPageRegion = (state: PageState, newColumns: { count: number; gap: number }): void => {
const startMidPageRegion = (
state: PageState,
newColumns: { count: number; gap: number; withSeparator?: boolean },
): void => {
// Record the boundary at current Y position
const boundary: ConstraintBoundary = {
y: state.cursorY,
Expand Down Expand Up @@ -2288,7 +2302,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,
};
}

Expand Down Expand Up @@ -2498,19 +2515,22 @@ function normalizeColumns(input: ColumnLayout | undefined, contentWidth: number)
const gap = Math.max(0, input?.gap ?? 0);
const totalGap = gap * (count - 1);
const width = (contentWidth - totalGap) / count;
const withSeparator = input?.withSeparator ?? false;

if (width <= COLUMN_EPSILON) {
return {
count: 1,
gap: 0,
width: contentWidth,
withSeparator,
};
}

return {
count,
gap,
width,
withSeparator,
};
}

Expand Down
6 changes: 3 additions & 3 deletions packages/layout-engine/layout-engine/src/paginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export type NormalizedColumns = ColumnLayout & { width: number };

export type ConstraintBoundary = {
y: number;
columns: { count: number; gap: number };
columns: { count: number; gap: number; withSeparator?: boolean };
Copy link
Contributor

Choose a reason for hiding this comment

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

you'll notice the type { count: number; gap: number; withSeparator?: boolean } is written out inline about 14 times across these files. we actually have a ColumnLayout type in @superdoc/contracts that's the exact same shape -- and it's already imported in this file.

using it in the files you touched would mean the next person adding a field (like you did with withSeparator) only has to update it in one place.

};

export type PageState = {
Expand All @@ -27,7 +27,7 @@ export type PaginatorOptions = {
getActiveFooterDistance(): number;
getActivePageSize(): { w: number; h: number };
getDefaultPageSize(): { w: number; h: number };
getActiveColumns(): { count: number; gap: number };
getActiveColumns(): { count: number; gap: number; withSeparator?: boolean };
getCurrentColumns(): NormalizedColumns;
createPage(number: number, pageMargins: PageMargins, pageSizeOverride?: { w: number; h: number }): Page;
onNewPage?: (state: PageState) => void;
Expand All @@ -37,7 +37,7 @@ export function createPaginator(opts: PaginatorOptions) {
const states: PageState[] = [];
const pages: Page[] = [];

const getActiveColumnsForState = (state: PageState): { count: number; gap: number } => {
const getActiveColumnsForState = (state: PageState): { count: number; gap: number; withSeparator?: boolean } => {
if (state.activeConstraintIndex >= 0 && state.constraintBoundaries[state.activeConstraintIndex]) {
return state.constraintBoundaries[state.activeConstraintIndex].columns;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/layout-engine/layout-engine/src/section-breaks.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ export type SectionState = {
activeColumns: {
count: number;
gap: number;
withSeparator?: boolean;
};
pendingColumns: {
count: number;
gap: number;
withSeparator?: boolean;
} | null;
activeOrientation: 'portrait' | 'landscape' | null;
pendingOrientation: 'portrait' | 'landscape' | null;
Expand Down
21 changes: 15 additions & 6 deletions packages/layout-engine/layout-engine/src/section-breaks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ export type SectionState = {
pendingFooterDistance: number | null;
activePageSize: { w: number; h: number };
pendingPageSize: { w: number; h: number } | null;
activeColumns: { count: number; gap: number };
pendingColumns: { count: number; gap: number } | null;
activeColumns: { count: number; gap: number; withSeparator?: boolean };
pendingColumns: { count: number; gap: number; withSeparator?: boolean } | null;
activeOrientation: 'portrait' | 'landscape' | null;
pendingOrientation: 'portrait' | 'landscape' | null;
hasAnyPages: boolean;
Expand All @@ -29,18 +29,27 @@ export type BreakDecision = {
};

/** Default single-column configuration per OOXML spec (absence of w:cols element) */
const SINGLE_COLUMN_DEFAULT: Readonly<{ count: number; gap: number }> = { count: 1, gap: 0 };
const SINGLE_COLUMN_DEFAULT: Readonly<{ count: number; gap: number; withSeparator?: boolean }> = {
count: 1,
gap: 0,
};

/**
* Get the column configuration for a section break.
* Returns the explicit column config if defined, otherwise returns single-column default.
* Per OOXML spec, absence of <w:cols> 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: { count: number; gap: number } | undefined): { count: number; gap: number } {
return blockColumns ? { count: blockColumns.count, gap: blockColumns.gap } : { ...SINGLE_COLUMN_DEFAULT };
function getColumnConfig(blockColumns: { count: number; gap: number; withSeparator?: boolean } | undefined): {
count: number;
gap: number;
withSeparator?: boolean;
} {
return blockColumns
? { count: blockColumns.count, gap: blockColumns.gap, withSeparator: blockColumns.withSeparator }
: { ...SINGLE_COLUMN_DEFAULT };
}

/**
Expand Down
32 changes: 32 additions & 0 deletions packages/layout-engine/layout-engine/src/section-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
Loading