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
1 change: 1 addition & 0 deletions e2e-tests/templates/vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "pnpm --filter superdoc build",
"build": "vite build",
"preview": "vite preview"
},
Expand Down
9 changes: 5 additions & 4 deletions packages/layout-engine/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
"description": "Shared layout contracts & engine interfaces for the SuperDoc layout engine pipeline.",
"type": "module",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
"types": "./dist/index.d.ts",
"source": "./src/index.ts",
"default": "./dist/index.js"
}
},
"scripts": {
Expand Down
5 changes: 4 additions & 1 deletion packages/layout-engine/contracts/src/pm-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const coercePmEnd = (run: unknown): number | undefined => {
* - Handles first/last run slicing based on line.fromChar and line.toChar
*/
export function computeLinePmRange(block: FlowBlock, line: Line): LinePmRange {
if (!line) return {};
if (block.kind !== 'paragraph') return {};

let pmStart: number | undefined;
Expand Down Expand Up @@ -149,7 +150,9 @@ export function computeFragmentPmRange(
let pmEnd: number | undefined;

for (let index = fromLine; index < toLine; index += 1) {
const range = computeLinePmRange(block, lines[index]);
const line = lines[index];
if (!line) continue;
const range = computeLinePmRange(block, line);
if (range.pmStart != null && pmStart == null) {
pmStart = range.pmStart;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/layout-engine/layout-bridge/src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type DirtyRegion = {
lastStableIndex: number;
insertedBlockIds: string[];
deletedBlockIds: string[];
stableBlockIds: Set<string>;
};

/**
Expand All @@ -85,6 +86,7 @@ export type DirtyRegion = {
export const computeDirtyRegions = (previous: FlowBlock[], next: FlowBlock[]): DirtyRegion => {
const prevMap = new Map(previous.map((block, index) => [block.id, { block, index }]));
const nextMap = new Map(next.map((block, index) => [block.id, { block, index }]));
const stableBlockIds = new Set<string>();

let firstDirtyIndex = next.length;
let lastStableIndex = -1;
Expand All @@ -97,6 +99,7 @@ export const computeDirtyRegions = (previous: FlowBlock[], next: FlowBlock[]): D

if (prevBlock.id === nextBlock.id && shallowEqual(prevBlock, nextBlock)) {
lastStableIndex = nextPointer;
stableBlockIds.add(prevBlock.id);
prevPointer += 1;
nextPointer += 1;
continue;
Expand Down Expand Up @@ -127,6 +130,7 @@ export const computeDirtyRegions = (previous: FlowBlock[], next: FlowBlock[]): D
lastStableIndex,
insertedBlockIds,
deletedBlockIds,
stableBlockIds,
};
};

Expand Down
49 changes: 46 additions & 3 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,44 +734,84 @@ export async function incrementalLayout(
constraints: HeaderFooterConstraints;
measure?: HeaderFooterMeasureFn;
},
previousMeasures?: Measure[] | null,
): Promise<IncrementalLayoutResult> {
const _perfStart = performance.now();
// Dirty region computation
const dirtyStart = performance.now();
const dirty = computeDirtyRegions(previousBlocks, nextBlocks);
const dirtyTime = performance.now() - dirtyStart;

if (dirty.deletedBlockIds.length > 0) {
measureCache.invalidate(dirty.deletedBlockIds);
}

// Perf summary emitted at the end of the function.

const { measurementWidth, measurementHeight } = resolveMeasurementConstraints(options, nextBlocks);

if (measurementWidth <= 0 || measurementHeight <= 0) {
throw new Error('incrementalLayout: invalid measurement constraints resolved from options');
}

const hasPreviousMeasures = Array.isArray(previousMeasures) && previousMeasures.length === previousBlocks.length;
const previousConstraints = hasPreviousMeasures ? resolveMeasurementConstraints(options, previousBlocks) : null;
const canReusePreviousMeasures =
hasPreviousMeasures &&
previousConstraints?.measurementWidth === measurementWidth &&
previousConstraints?.measurementHeight === measurementHeight;
const previousMeasuresById = canReusePreviousMeasures
? new Map(previousBlocks.map((block, index) => [block.id, previousMeasures![index]]))
: null;

const measureStart = performance.now();
const constraints = { maxWidth: measurementWidth, maxHeight: measurementHeight };
const measures: Measure[] = [];
let cacheHits = 0;
let cacheMisses = 0;
let reusedMeasures = 0;
let cacheLookupTime = 0;
let actualMeasureTime = 0;

for (const block of nextBlocks) {
if (block.kind === 'sectionBreak') {
measures.push({ kind: 'sectionBreak' });
continue;
}

if (canReusePreviousMeasures && dirty.stableBlockIds.has(block.id)) {
const previousMeasure = previousMeasuresById?.get(block.id);
if (previousMeasure) {
measures.push(previousMeasure);
reusedMeasures++;
continue;
}
}

// Time the cache lookup (includes hashRuns computation)
const lookupStart = performance.now();
const cached = measureCache.get(block, measurementWidth, measurementHeight);
cacheLookupTime += performance.now() - lookupStart;

if (cached) {
measures.push(cached);
cacheHits++;
continue;
}

// Time the actual DOM measurement
const measureBlockStart = performance.now();
const measurement = await measureBlock(block, constraints);
actualMeasureTime += performance.now() - measureBlockStart;

measureCache.set(block, measurementWidth, measurementHeight, measurement);
measures.push(measurement);
cacheMisses++;
}
const measureEnd = performance.now();
const totalMeasureTime = measureEnd - measureStart;

perfLog(
`[Perf] 4.1 Measure all blocks: ${(measureEnd - measureStart).toFixed(2)}ms (${cacheMisses} measured, ${cacheHits} cached)`,
`[Perf] 4.1 Measure all blocks: ${totalMeasureTime.toFixed(2)}ms (${cacheMisses} measured, ${cacheHits} cached, ${reusedMeasures} reused)`,
);

// Pre-layout headers to get their actual content heights BEFORE body layout.
Expand Down Expand Up @@ -1011,7 +1051,10 @@ export async function incrementalLayout(
remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent),
});
const layoutEnd = performance.now();
perfLog(`[Perf] 4.2 Layout document (pagination): ${(layoutEnd - layoutStart).toFixed(2)}ms`);
const layoutTime = layoutEnd - layoutStart;
perfLog(`[Perf] 4.2 Layout document (pagination): ${layoutTime.toFixed(2)}ms`);

const pageCount = layout.pages.length;

// Two-pass convergence loop for page number token resolution.
// Steps: paginate -> build numbering context -> resolve PAGE/NUMPAGES tokens
Expand Down
31 changes: 23 additions & 8 deletions packages/layout-engine/layout-bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -818,13 +818,29 @@ export function clickToPosition(
if (blockIndex !== -1) {
const measure = measures[blockIndex];
if (measure && measure.kind === 'paragraph') {
for (let li = fragment.fromLine; li < fragment.toLine; li++) {
const line = measure.lines[li];
const range = computeLinePmRange(blocks[blockIndex], line);
if (range.pmStart != null && range.pmEnd != null) {
if (domPos >= range.pmStart && domPos <= range.pmEnd) {
lineIndex = li;
break;
// Use fragment-specific remeasured lines when present to avoid index mismatches.
if (fragment.lines && fragment.lines.length > 0) {
for (let localIndex = 0; localIndex < fragment.lines.length; localIndex++) {
const line = fragment.lines[localIndex];
if (!line) continue;
const range = computeLinePmRange(blocks[blockIndex], line);
if (range.pmStart != null && range.pmEnd != null) {
if (domPos >= range.pmStart && domPos <= range.pmEnd) {
lineIndex = fragment.fromLine + localIndex;
break;
}
}
}
} else {
for (let li = fragment.fromLine; li < fragment.toLine; li++) {
const line = measure.lines[li];
if (!line) continue;
const range = computeLinePmRange(blocks[blockIndex], line);
if (range.pmStart != null && range.pmEnd != null) {
if (domPos >= range.pmStart && domPos <= range.pmEnd) {
lineIndex = li;
break;
}
}
}
}
Expand All @@ -844,7 +860,6 @@ export function clickToPosition(
}
}

// Position found but couldn't locate in fragments - still return it
logClickStage('log', 'success', {
pos: domPos,
usedMethod: 'DOM',
Expand Down
18 changes: 13 additions & 5 deletions packages/layout-engine/layout-bridge/test/performance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,19 @@ beforeAll(() => {

const describeIfRealCanvas = usingStub ? describe.skip : describe;

const LATENCY_TARGETS = {
p50: 420, // Relaxed for CI environments which are slower than local machines
p90: 480,
p99: 800,
};
const IS_CI = Boolean(process.env.CI);
const LATENCY_TARGETS = IS_CI
? {
// CI environments are slower and more variable; use generous buffers
p50: 500,
p90: 700,
p99: 1000,
}
: {
p50: 70,
p90: 80,
p99: 90,
};
const MIN_HIT_RATE = 0.95;

describeIfRealCanvas('incremental pipeline benchmarks', () => {
Expand Down
6 changes: 2 additions & 4 deletions packages/layout-engine/layout-bridge/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { defineConfig } from 'vitest/config';
import baseConfig from '../../../vitest.baseConfig';

const includeBench = process.env.VITEST_BENCH === 'true';

export default defineConfig({
...baseConfig,
test: {
environment: 'node',
include: includeBench ? ['test/**/performance*.test.ts'] : ['test/**/*.test.ts'],
exclude: includeBench ? [] : ['test/**/performance*.test.ts'],
include: ['test/**/*.test.ts'],
exclude: [],
globals: true,
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
import { computeAnchorX } from './floating-objects.js';

const spacingDebugEnabled = false;

/**
* Type definition for Word layout attributes attached to paragraph blocks.
* This is a subset of the WordParagraphLayoutOutput from @superdoc/word-layout.
Expand Down
5 changes: 5 additions & 0 deletions packages/layout-engine/painters/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const createDomPainter = (
setVirtualizationPins?: (pageIndices: number[] | null | undefined) => void;
setActiveComment?: (commentId: string | null) => void;
getActiveComment?: () => string | null;
onScroll?: () => void;
} => {
const painter = new DomPainter(options.blocks, options.measures, {
pageStyles: options.pageStyles,
Expand Down Expand Up @@ -156,5 +157,9 @@ export const createDomPainter = (
getActiveComment() {
return painter.getActiveComment();
},
// Trigger virtualization update when scroll container is external to the painter
onScroll() {
painter.onScroll();
},
};
};
Loading