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
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
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
17 changes: 12 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,18 @@

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
? {
p50: 300, // CI is typically slower and more variable
p90: 400,
p99: 600,
}
: {
p50: 70,
p90: 80,
p99: 90,
};
const MIN_HIT_RATE = 0.95;

describeIfRealCanvas('incremental pipeline benchmarks', () => {
Expand Down Expand Up @@ -61,7 +68,7 @@
);
}
expect(result.actualPages).toBe(result.targetPages);
expect(result.latency.p50).toBeLessThanOrEqual(LATENCY_TARGETS.p50);

Check failure on line 71 in packages/layout-engine/layout-bridge/test/performance.test.ts

View workflow job for this annotation

GitHub Actions / run-unit-tests

test/performance.test.ts > incremental pipeline benchmarks > meets latency and cache targets across document sizes

AssertionError: expected 311.0662975000005 to be less than or equal to 300 ❯ test/performance.test.ts:71:34 ❯ test/performance.test.ts:50:13
expect(result.latency.p90).toBeLessThanOrEqual(LATENCY_TARGETS.p90);
expect(result.latency.p99).toBeLessThanOrEqual(LATENCY_TARGETS.p99);
if (result.targetPages >= 10) {
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,
},
});
3 changes: 1 addition & 2 deletions packages/layout-engine/layout-engine/src/layout-paragraph.ts
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 Expand Up @@ -796,7 +795,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para

// Store remeasured lines in fragment so renderer can use them.
// This is needed because the original measure has different line breaks.
if (didRemeasureForColumnWidth) {
if (didRemeasureForColumnWidth || didRemeasureForFloats) {
fragment.lines = lines.slice(fromLine, slice.toLine);
}

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();
},
};
};
80 changes: 63 additions & 17 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,7 @@ export class DomPainter {
private virtualPaddingTop: number | null = null; // px; computed from mount if not provided
private topSpacerEl: HTMLElement | null = null;
private bottomSpacerEl: HTMLElement | null = null;
private virtualPagesEl: HTMLElement | null = null;
private virtualGapSpacers: HTMLElement[] = [];
private virtualPinnedPages: number[] = [];
private virtualMountedKey = '';
Expand Down Expand Up @@ -1065,8 +1066,8 @@ export class DomPainter {
applyStyles(mount, containerStyles);

if (this.virtualEnabled) {
// Override container gap for consistent spacer math
mount.style.gap = `${this.virtualGap}px`;
// Keep container gap at 0 so spacers don't introduce extra offsets.
mount.style.gap = '0px';
this.renderVirtualized(layout, mount);
this.currentLayout = layout;
this.changedBlocks.clear();
Expand Down Expand Up @@ -1096,7 +1097,7 @@ export class DomPainter {
this.currentLayout = layout;

// First-time init or mount changed
const needsInit = !this.topSpacerEl || !this.bottomSpacerEl || this.mount !== mount;
const needsInit = !this.topSpacerEl || !this.bottomSpacerEl || !this.virtualPagesEl || this.mount !== mount;
if (needsInit) {
this.ensureVirtualizationSetup(mount);
}
Expand All @@ -1121,7 +1122,16 @@ export class DomPainter {
this.configureSpacerElement(this.topSpacerEl, 'top');
this.configureSpacerElement(this.bottomSpacerEl, 'bottom');

// Create and configure pages container (handles the inter-page gap)
this.virtualPagesEl = this.doc.createElement('div');
this.virtualPagesEl.style.display = 'flex';
this.virtualPagesEl.style.flexDirection = 'column';
this.virtualPagesEl.style.alignItems = 'center';
this.virtualPagesEl.style.width = '100%';
this.virtualPagesEl.style.gap = `${this.virtualGap}px`;

mount.appendChild(this.topSpacerEl);
mount.appendChild(this.virtualPagesEl);
mount.appendChild(this.bottomSpacerEl);

// Bind scroll and resize handlers
Expand Down Expand Up @@ -1207,8 +1217,19 @@ export class DomPainter {
return 0;
}

/**
* Public method to trigger virtualization window update on scroll.
* Call this from external scroll handlers when the scroll container
* is different from the painter's mount element.
*/
public onScroll(): void {
if (this.virtualEnabled) {
this.updateVirtualWindow();
}
}

private updateVirtualWindow(): void {
if (!this.mount || !this.topSpacerEl || !this.bottomSpacerEl || !this.currentLayout) return;
if (!this.mount || !this.topSpacerEl || !this.bottomSpacerEl || !this.virtualPagesEl || !this.currentLayout) return;
const layout = this.currentLayout;
const N = layout.pages.length;
if (N === 0) {
Expand Down Expand Up @@ -1294,18 +1315,21 @@ export class DomPainter {
newState.element.dataset.pageIndex = String(i);
// Ensure virtualization uses page margin 0
applyStyles(newState.element, pageStyles(pageSize.w, pageSize.h, this.getEffectivePageStyles()));
this.mount.insertBefore(newState.element, this.bottomSpacerEl);
this.virtualPagesEl.appendChild(newState.element);
this.pageIndexToState.set(i, newState);
} else {
// Patch in place
this.patchPage(existing, page, pageSize);
}
}

// Ensure top spacer is first and bottom spacer is last.
// Ensure top spacer is first, pages container is in the middle, and bottom spacer is last.
if (this.mount.firstChild !== this.topSpacerEl) {
this.mount.insertBefore(this.topSpacerEl, this.mount.firstChild);
}
if (this.virtualPagesEl.parentElement !== this.mount) {
this.mount.insertBefore(this.virtualPagesEl, this.bottomSpacerEl);
}
this.mount.appendChild(this.bottomSpacerEl);

// Ensure mounted pages are ordered (with gap spacers) before bottom spacer.
Expand All @@ -1320,10 +1344,10 @@ export class DomPainter {
this.topOfIndex(idx) - this.topOfIndex(prevIndex) - this.virtualHeights[prevIndex] - this.virtualGap * 2;
gap.style.height = `${Math.max(0, Math.floor(gapHeight))}px`;
this.virtualGapSpacers.push(gap);
this.mount.insertBefore(gap, this.bottomSpacerEl);
this.virtualPagesEl.appendChild(gap);
}
const state = this.pageIndexToState.get(idx)!;
this.mount.insertBefore(state.element, this.bottomSpacerEl);
this.virtualPagesEl.appendChild(state.element);
prevIndex = idx;
}

Expand Down Expand Up @@ -1440,9 +1464,10 @@ export class DomPainter {
pageNumberText: page.numberText,
};

const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup);
const fragments = this.dedupeFragments(page);
const sdtBoundaries = computeSdtBoundaries(fragments, this.blockLookup);

page.fragments.forEach((fragment, index) => {
fragments.forEach((fragment, index) => {
const sdtBoundary = sdtBoundaries.get(index);
el.appendChild(this.renderFragment(fragment, contextBase, sdtBoundary));
});
Expand Down Expand Up @@ -1687,6 +1712,7 @@ export class DomPainter {
this.pageIndexToState.clear();
this.topSpacerEl = null;
this.bottomSpacerEl = null;
this.virtualPagesEl = null;
this.onScrollHandler = null;
this.onWindowScrollHandler = null;
this.onResizeHandler = null;
Expand Down Expand Up @@ -1747,7 +1773,8 @@ export class DomPainter {

const existing = new Map(state.fragments.map((frag) => [frag.key, frag]));
const nextFragments: FragmentDomState[] = [];
const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup);
const fragments = this.dedupeFragments(page);
const sdtBoundaries = computeSdtBoundaries(fragments, this.blockLookup);

const contextBase: FragmentRenderContext = {
pageNumber: page.number,
Expand All @@ -1756,7 +1783,7 @@ export class DomPainter {
pageNumberText: page.numberText,
};

page.fragments.forEach((fragment, index) => {
fragments.forEach((fragment, index) => {
const key = fragmentKey(fragment);
const current = existing.get(key);
const sdtBoundary = sdtBoundaries.get(index);
Expand Down Expand Up @@ -1883,9 +1910,9 @@ export class DomPainter {
section: 'body',
};

const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup);

const fragments: FragmentDomState[] = page.fragments.map((fragment, index) => {
const fragments = this.dedupeFragments(page);
const sdtBoundaries = computeSdtBoundaries(fragments, this.blockLookup);
const fragmentStates: FragmentDomState[] = fragments.map((fragment, index) => {
const sdtBoundary = sdtBoundaries.get(index);
const fragmentEl = this.renderFragment(fragment, contextBase, sdtBoundary);
el.appendChild(fragmentEl);
Expand All @@ -1899,7 +1926,27 @@ export class DomPainter {
});

this.renderDecorationsForPage(el, page);
return { element: el, fragments };
return { element: el, fragments: fragmentStates };
}

private dedupeFragments(page: Page): Fragment[] {
const fragments = page.fragments;
if (fragments.length <= 1) return fragments;
const seen = new Set<string>();
const result: Fragment[] = [];

for (let i = fragments.length - 1; i >= 0; i -= 1) {
const fragment = fragments[i];
const key = fragmentKey(fragment);
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(fragment);
}
result.reverse();

return result;
}

private getEffectivePageStyles(): PageStyles | undefined {
Expand Down Expand Up @@ -2001,7 +2048,6 @@ export class DomPainter {
// Use fragment.lines if available (set when paragraph was remeasured for narrower column).
// Otherwise, fall back to slicing from the original measure.
const lines = fragment.lines ?? measure.lines.slice(fragment.fromLine, fragment.toLine);

applyParagraphBlockStyles(fragmentEl, block.attrs);
const { shadingLayer, borderLayer } = createParagraphDecorationLayers(this.doc, fragment.width, block.attrs);
if (shadingLayer) {
Expand Down
Loading
Loading