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
750 changes: 0 additions & 750 deletions .github/scripts/package-lock.json

This file was deleted.

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 @@ -1709,6 +1709,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
86 changes: 81 additions & 5 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
SectionMetadata,
ParagraphBlock,
ColumnLayout,
SectionBreakBlock,
} from '@superdoc/contracts';
import {
layoutDocument,
Expand All @@ -15,6 +16,7 @@
computeDisplayPageNumber,
resolvePageNumberTokens,
type NumberingContext,
SEMANTIC_PAGE_HEIGHT_PX,
} from '@superdoc/layout-engine';
import { remeasureParagraph } from './remeasure';
import { computeDirtyRegions } from './diff';
Expand Down Expand Up @@ -737,10 +739,19 @@
},
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);
const dirtyTime = performance.now() - dirtyStart;

Check warning on line 754 in packages/layout-engine/layout-bridge/src/incrementalLayout.ts

View workflow job for this annotation

GitHub Actions / validate

'dirtyTime' is assigned a value but never used. Allowed unused vars must match /^_/u

if (dirty.deletedBlockIds.length > 0) {
measureCache.invalidate(dirty.deletedBlockIds);
Expand All @@ -755,7 +766,15 @@
}

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 @@ -1041,8 +1060,11 @@
perfLog(`[Perf] 4.1.6 Pre-layout footers for height: ${(footerPreEnd - footerPreStart).toFixed(2)}ms`);
}

// In semantic mode, nextBlocks were already rewritten during pre-processing.
const blocksForLayout = nextBlocks;

const layoutStart = performance.now();
let layout = layoutDocument(nextBlocks, measures, {
let layout = layoutDocument(blocksForLayout, measures, {
...options,
headerContentHeights, // Pass header heights to prevent overlap (per-variant)
footerContentHeights, // Pass footer heights to prevent overlap (per-variant)
Expand All @@ -1061,7 +1083,7 @@
// Steps: paginate -> build numbering context -> resolve PAGE/NUMPAGES tokens
// -> remeasure affected blocks -> re-paginate -> repeat until stable
const maxIterations = 3;
let currentBlocks = nextBlocks;
let currentBlocks = blocksForLayout;
let currentMeasures = measures;
let iteration = 0;

Expand All @@ -1072,7 +1094,7 @@
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 @@ -1184,7 +1206,7 @@
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 @@ -1870,6 +1892,40 @@
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 },
};
});
}

/**
* Resolves the maximum measurement constraints (width and height) needed for measuring blocks
* across all sections in a document.
Expand Down Expand Up @@ -1935,6 +1991,26 @@
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
Loading
Loading