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
7 changes: 7 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1713,6 +1713,13 @@ export interface PositionMapping {
readonly maps: readonly unknown[];
}

/**
* Rendering flow mode.
* - `paginated`: discrete page surfaces
* - `semantic`: continuous flow surface
*/
export type FlowMode = 'paginated' | 'semantic';

export interface PainterDOM {
paint(layout: Layout, mount: HTMLElement, mapping?: PositionMapping): void;
/**
Expand Down
78 changes: 75 additions & 3 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
computeDisplayPageNumber,
resolvePageNumberTokens,
type NumberingContext,
SEMANTIC_PAGE_HEIGHT_PX,
} from '@superdoc/layout-engine';
import { remeasureParagraph } from './remeasure';
import { computeDirtyRegions } from './diff';
Expand Down Expand Up @@ -738,6 +739,15 @@ export async function incrementalLayout(
},
previousMeasures?: Measure[] | null,
): Promise<IncrementalLayoutResult> {
const isSemanticFlow = options.flowMode === 'semantic';

// In semantic mode, neutralize paginated-only inputs so downstream code
// doesn't need per-step guards.
if (isSemanticFlow) {
headerFooter = undefined;
nextBlocks = rewriteSectionBreaksForSemanticFlow(nextBlocks, options);
}

// Dirty region computation
const dirtyStart = performance.now();
const dirty = computeDirtyRegions(previousBlocks, nextBlocks);
Expand All @@ -763,7 +773,15 @@ export async function incrementalLayout(
}

const hasPreviousMeasures = Array.isArray(previousMeasures) && previousMeasures.length === previousBlocks.length;
const previousConstraints = hasPreviousMeasures ? resolveMeasurementConstraints(options, previousBlocks) : null;
// In semantic mode, the options-level semantic.contentWidth can change between
// renders (container resize) while the block content stays the same. Since
// previousConstraints is re-derived from the current options (not the options
// that produced the previous measures), it would incorrectly match the current
// constraints even when the previous measures were taken at a different width.
// Disable previous-pass measure reuse in semantic mode; the width-keyed
// measureCache still provides fast lookups for unchanged blocks.
const previousConstraints =
hasPreviousMeasures && !isSemanticFlow ? resolveMeasurementConstraints(options, previousBlocks) : null;
const canReusePreviousMeasures =
hasPreviousMeasures &&
previousConstraints?.measurementWidth === measurementWidth &&
Expand Down Expand Up @@ -1096,7 +1114,7 @@ export async function incrementalLayout(
let converged = true;

// Only run token resolution if feature flag is enabled
if (FeatureFlags.BODY_PAGE_TOKENS) {
if (!isSemanticFlow && FeatureFlags.BODY_PAGE_TOKENS) {
while (iteration < maxIterations) {
// Build numbering context from current layout
const sections = options.sectionMetadata ?? [];
Expand Down Expand Up @@ -1210,7 +1228,7 @@ export async function incrementalLayout(
let extraBlocks: FlowBlock[] | undefined;
let extraMeasures: Measure[] | undefined;
const footnotesInput = isFootnotesLayoutInput(options.footnotes) ? options.footnotes : null;
if (footnotesInput && footnotesInput.refs.length > 0 && footnotesInput.blocksById.size > 0) {
if (!isSemanticFlow && footnotesInput && footnotesInput.refs.length > 0 && footnotesInput.blocksById.size > 0) {
const gap = typeof footnotesInput.gap === 'number' && Number.isFinite(footnotesInput.gap) ? footnotesInput.gap : 2;
const topPadding =
typeof footnotesInput.topPadding === 'number' && Number.isFinite(footnotesInput.topPadding)
Expand Down Expand Up @@ -1919,6 +1937,40 @@ const DEFAULT_MARGINS = { top: 72, right: 72, bottom: 72, left: 72 };
export const normalizeMargin = (value: number | undefined, fallback: number): number =>
Number.isFinite(value) ? (value as number) : fallback;

/**
* Rewrites section break blocks so that `layoutDocument` uses the semantic page
* dimensions instead of the per-section DOCX page sizes. Without this, each
* section break carries its original narrow DOCX `pageSize` / `margins` /
* `columns`, and `layoutDocument` would switch `activePageSize` to those values
* — defeating the semantic flow's container-width–based layout.
*
* Only the block-level layout properties are overridden; everything else
* (numbering, header/footer refs, vAlign, orientation) is preserved.
*/
function rewriteSectionBreaksForSemanticFlow(blocks: FlowBlock[], options: LayoutOptions): FlowBlock[] {
const semanticPageSize = options.pageSize;
const semanticMargins = options.margins;
if (!semanticPageSize) return blocks;
if (!blocks.some((b) => b.kind === 'sectionBreak')) return blocks;

return blocks.map((block) => {
if (block.kind !== 'sectionBreak') return block;
const sb = block as SectionBreakBlock;
return {
...sb,
pageSize: { w: semanticPageSize.w, h: semanticPageSize.h },
margins: {
...sb.margins,
top: semanticMargins?.top,
right: semanticMargins?.right,
bottom: semanticMargins?.bottom,
left: semanticMargins?.left,
},
columns: { count: 1, gap: 0 },
};
});
}

/**
* Computes measurement constraints for each block based on its section's properties.
*
Expand Down Expand Up @@ -2048,6 +2100,26 @@ export function resolveMeasurementConstraints(
measurementWidth: number;
measurementHeight: number;
} {
if (options.flowMode === 'semantic') {
const semanticContentWidth = options.semantic?.contentWidth;
if (typeof semanticContentWidth === 'number' && Number.isFinite(semanticContentWidth) && semanticContentWidth > 0) {
const semanticTop = normalizeMargin(
options.semantic?.marginTop,
normalizeMargin(options.margins?.top, DEFAULT_MARGINS.top),
);
const semanticBottom = normalizeMargin(
options.semantic?.marginBottom,
normalizeMargin(options.margins?.bottom, DEFAULT_MARGINS.bottom),
);
const measurementHeight = Math.max(1, SEMANTIC_PAGE_HEIGHT_PX - (semanticTop + semanticBottom));
const measurementWidth = Math.max(1, Math.floor(semanticContentWidth));
return {
measurementWidth,
measurementHeight,
};
}
}

const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
const margins = {
top: normalizeMargin(options.margins?.top, DEFAULT_MARGINS.top),
Expand Down
2 changes: 1 addition & 1 deletion packages/layout-engine/layout-bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export {
export type { HeaderFooterBatch, DigitBucket } from './layoutHeaderFooter';
export { findWordBoundaries, findParagraphBoundaries } from './text-boundaries';
export type { BoundaryRange } from './text-boundaries';
export { incrementalLayout, measureCache } from './incrementalLayout';
export { incrementalLayout, measureCache, normalizeMargin } from './incrementalLayout';
export type { HeaderFooterLayoutResult, IncrementalLayoutResult } from './incrementalLayout';
// Re-export computeDisplayPageNumber from layout-engine for section-aware page numbering
export { computeDisplayPageNumber, type DisplayPageInfo } from '@superdoc/layout-engine';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it, vi } from 'vitest';

import { incrementalLayout } from '../src/incrementalLayout';

import type { FlowBlock, Measure, SectionBreakBlock } from '@superdoc/contracts';

const makeParagraph = (id: string, text: string): FlowBlock => ({
kind: 'paragraph',
id,
runs: [{ text, fontFamily: 'Arial', fontSize: 12 }],
});

const makeParagraphMeasure = (lineHeight: number, runLength: number, maxWidth: number): Measure => ({
kind: 'paragraph',
lines: [
{
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: runLength,
width: Math.min(maxWidth, runLength * 7),
ascent: lineHeight * 0.8,
descent: lineHeight * 0.2,
lineHeight,
maxWidth,
},
],
totalHeight: lineHeight,
});

describe('incrementalLayout semantic flow', () => {
it('rewrites section-break columns to single-column semantic width before layout', async () => {
const semanticMargins = { top: 24, right: 100, bottom: 36, left: 100 };
const semanticContentWidth = 600;
const semanticPageWidth = semanticContentWidth + semanticMargins.left + semanticMargins.right;

const firstSectionBreak: SectionBreakBlock = {
kind: 'sectionBreak',
id: 'sb-1',
type: 'continuous',
attrs: { isFirstSection: true, source: 'sectPr' },
// Intentionally narrow + multi-column: would reduce paragraph fragment width
// without semantic rewrite in incrementalLayout.
pageSize: { w: 320, h: 900 },
margins: { top: 12, right: 12, bottom: 12, left: 12 },
columns: { count: 2, gap: 24 },
};

const paragraph = makeParagraph('p-1', 'Semantic section rewrite keeps this paragraph full-width.');
const paragraphTextLength = paragraph.kind === 'paragraph' ? paragraph.runs[0].text.length : 1;

const measureBlock = vi.fn(async (block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => {
if (block.kind !== 'paragraph') {
throw new Error(`Unexpected block kind in test measure: ${block.kind}`);
}
return makeParagraphMeasure(20, paragraphTextLength, constraints.maxWidth);
});

const result = await incrementalLayout(
[],
null,
[firstSectionBreak, paragraph],
{
flowMode: 'semantic',
pageSize: { w: semanticPageWidth, h: 900 },
margins: semanticMargins,
semantic: {
contentWidth: semanticContentWidth,
marginTop: semanticMargins.top,
marginBottom: semanticMargins.bottom,
},
},
measureBlock,
);

const paragraphFragment = result.layout.pages
.flatMap((page) => page.fragments)
.find((fragment) => fragment.kind === 'para' && fragment.blockId === paragraph.id);

expect(paragraphFragment).toBeDefined();
expect(paragraphFragment?.width).toBe(semanticContentWidth);
});

it('skips header/footer layout work in semantic flow mode', async () => {
const paragraph = makeParagraph('body-1', 'Body content');
const headerParagraph = makeParagraph('header-1', 'Header content');

const measureBlock = vi.fn(async (block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => {
if (block.kind !== 'paragraph') {
throw new Error(`Unexpected block kind in test measure: ${block.kind}`);
}
const runLength = block.runs[0]?.text?.length ?? 1;
return makeParagraphMeasure(20, runLength, constraints.maxWidth);
});

const headerMeasure = vi.fn(async (block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => {
if (block.kind !== 'paragraph') {
throw new Error(`Unexpected header block kind in test measure: ${block.kind}`);
}
const runLength = block.runs[0]?.text?.length ?? 1;
return makeParagraphMeasure(20, runLength, constraints.maxWidth);
});

const result = await incrementalLayout(
[],
null,
[paragraph],
{
flowMode: 'semantic',
pageSize: { w: 800, h: 900 },
margins: { top: 40, right: 100, bottom: 40, left: 100 },
semantic: { contentWidth: 600, marginTop: 40, marginBottom: 40 },
},
measureBlock,
{
headerBlocks: { default: [headerParagraph] },
constraints: { width: 600, height: 80 },
measure: headerMeasure,
},
);

expect(result.headers).toBeUndefined();
expect(result.footers).toBeUndefined();
expect(headerMeasure).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,54 @@ describe('resolveMeasurementConstraints', () => {
});
});

describe('semantic flow constraints', () => {
it('uses semantic content width directly when provided', () => {
const options: LayoutOptions = {
flowMode: 'semantic',
pageSize: { w: 612, h: 792 },
margins: { top: 72, right: 72, bottom: 72, left: 72 },
semantic: {
contentWidth: 530,
marginTop: 40,
marginBottom: 50,
},
};

const result = resolveMeasurementConstraints(options);
expect(result.measurementWidth).toBe(530);
expect(result.measurementHeight).toBe(999910); // 1_000_000 - (40 + 50)
});

it('normalizes fractional semantic content width to match layout rounding', () => {
const options: LayoutOptions = {
flowMode: 'semantic',
pageSize: { w: 612, h: 792 },
margins: { top: 72, right: 72, bottom: 72, left: 72 },
semantic: {
contentWidth: 530.9,
marginTop: 40,
marginBottom: 50,
},
};

const result = resolveMeasurementConstraints(options);
expect(result.measurementWidth).toBe(530);
expect(result.measurementHeight).toBe(999910);
});

it('falls back to paginated constraints when semantic content width is missing', () => {
const options: LayoutOptions = {
flowMode: 'semantic',
pageSize: { w: 612, h: 792 },
margins: { top: 72, right: 72, bottom: 72, left: 72 },
};

const result = resolveMeasurementConstraints(options);
expect(result.measurementWidth).toBe(468);
expect(result.measurementHeight).toBe(648);
});
});

describe('column width calculations', () => {
it('handles zero gap in multi-column layout', () => {
const options: LayoutOptions = {
Expand Down
10 changes: 10 additions & 0 deletions packages/layout-engine/layout-engine/src/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
ColumnLayout,
FlowBlock,
FlowMode,
HeaderFooterLayout,
Layout,
Measure,
Expand All @@ -23,8 +24,17 @@ export type LayoutOptions = {
pageSize?: PageSize;
margins?: Margins;
columns?: ColumnLayout;
flowMode?: FlowMode;
semantic?: {
contentWidth?: number;
marginLeft?: number;
marginRight?: number;
marginTop?: number;
marginBottom?: number;
};
remeasureParagraph?: (block: ParagraphBlock, maxWidth: number, firstLineIndent?: number) => ParagraphMeasure;
};
export declare const SEMANTIC_PAGE_HEIGHT_PX = 1000000;
export type HeaderFooterConstraints = {
width: number;
height: number;
Expand Down
15 changes: 15 additions & 0 deletions packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
DrawingMeasure,
DrawingFragment,
SectionNumbering,
FlowMode,
} from '@superdoc/contracts';
import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js';
import { computeNextSectionPropsAtBreak } from './section-props';
Expand Down Expand Up @@ -62,6 +63,12 @@ type NormalizedColumns = ColumnLayout & { width: number };
*/
const DEFAULT_PARAGRAPH_LINE_HEIGHT_PX = 20;

/**
* Synthetic page height used in semantic flow mode to avoid pagination-driven clipping
* during measurement. A large finite value preserves stable measurement constraints.
*/
export const SEMANTIC_PAGE_HEIGHT_PX = 1_000_000;

/**
* Type guard to check if a fragment has a height property.
* Image, Drawing, and Table fragments all have a required height property.
Expand Down Expand Up @@ -419,6 +426,14 @@ export type LayoutOptions = {
pageSize?: PageSize;
margins?: Margins;
columns?: ColumnLayout;
flowMode?: FlowMode;
semantic?: {
contentWidth?: number;
marginLeft?: number;
marginRight?: number;
marginTop?: number;
marginBottom?: number;
};
remeasureParagraph?: (block: ParagraphBlock, maxWidth: number, firstLineIndent?: number) => ParagraphMeasure;
sectionMetadata?: SectionMetadata[];
/**
Expand Down
Loading
Loading