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
15 changes: 9 additions & 6 deletions packages/layout-engine/layout-bridge/src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,14 +658,14 @@ export class MeasureCache<T> {
* @param height - The height dimension for cache key
* @returns The cached value or undefined
*/
public get(block: FlowBlock | null | undefined, width: number, height: number): T | undefined {
public get(block: FlowBlock | null | undefined, width: number, height: number, fontSignature = ''): T | undefined {
// Safety: Validate block exists and has required properties before accessing
// This prevents invalid cache keys from null/undefined blocks
if (!block || !block.id) {
return undefined;
}

const key = this.composeKey(block, width, height);
const key = this.composeKey(block, width, height, fontSignature);
const value = this.cache.get(key);

if (value !== undefined) {
Expand All @@ -692,14 +692,14 @@ export class MeasureCache<T> {
* @param height - The height dimension for cache key
* @param value - The value to cache
*/
public set(block: FlowBlock | null | undefined, width: number, height: number, value: T): void {
public set(block: FlowBlock | null | undefined, width: number, height: number, value: T, fontSignature = ''): void {
// Safety: Validate block exists and has required properties before caching
// This prevents invalid cache keys and silent failures
if (!block || !block.id) {
return;
}

const key = this.composeKey(block, width, height);
const key = this.composeKey(block, width, height, fontSignature);

// If key already exists, delete it first (will be re-added at end)
if (this.cache.has(key)) {
Expand Down Expand Up @@ -819,10 +819,13 @@ export class MeasureCache<T> {
* @param height - Height dimension (will be clamped to [0, MAX_DIMENSION])
* @returns Cache key string
*/
private composeKey(block: FlowBlock, width: number, height: number): string {
private composeKey(block: FlowBlock, width: number, height: number, fontSignature: string): string {
const safeWidth = Number.isFinite(width) ? Math.max(0, Math.min(Math.floor(width), MAX_DIMENSION)) : 0;
const safeHeight = Number.isFinite(height) ? Math.max(0, Math.min(Math.floor(height), MAX_DIMENSION)) : 0;
const hash = hashRuns(block);
return `${block.id}@${safeWidth}x${safeHeight}:${hash}`;
// The font signature (the document resolver's mapping identity) is part of the key so two
// documents with identical block content but different `fonts.map` cannot reuse each other's
// measure. Appended AFTER the block.id prefix so invalidate(blockIds) prefix-matching holds.
return `${block.id}@${safeWidth}x${safeHeight}:${hash}#${fontSignature}`;
}
}
34 changes: 30 additions & 4 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,15 @@ export async function incrementalLayout(
measure?: HeaderFooterMeasureFn;
},
previousMeasures?: Measure[] | null,
// Narrow runtime context (deliberately NOT on LayoutOptions): the document resolver's mapping
// signature, plus the signature the previous measures were taken with. The resolver itself
// rides the measureBlock callback; only the signature is needed here - for the measure-cache
// keys (so two documents with different `fonts.map` cannot share a measure) and to invalidate
// previous-measure reuse when this document's mapping changed since the prior render.
fontRuntime?: { fontSignature?: string; previousFontSignature?: string },
): Promise<IncrementalLayoutResult> {
const fontSignature = fontRuntime?.fontSignature ?? '';
const previousFontSignature = fontRuntime?.previousFontSignature ?? '';
const isSemanticFlow = options.flowMode === 'semantic';

// In semantic mode, neutralize paginated-only inputs so downstream code
Expand Down Expand Up @@ -852,6 +860,9 @@ export async function incrementalLayout(
hasPreviousMeasures && !isSemanticFlow ? resolveMeasurementConstraints(options, previousBlocks) : null;
const canReusePreviousMeasures =
hasPreviousMeasures &&
// A mapping change (different signature) makes the prior measures stale even for unchanged
// blocks; this reuse path bypasses the measure-cache key, so it must check the signature too.
fontSignature === previousFontSignature &&
previousConstraints?.measurementWidth === measurementWidth &&
previousConstraints?.measurementHeight === measurementHeight;
const previousPerSectionConstraints = canReusePreviousMeasures
Expand Down Expand Up @@ -900,7 +911,7 @@ export async function incrementalLayout(

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

if (cached) {
Expand All @@ -914,7 +925,7 @@ export async function incrementalLayout(
const measurement = await measureBlock(block, sectionConstraints);
actualMeasureTime += performance.now() - measureBlockStart;

measureCache.set(block, blockMeasureWidth, blockMeasureHeight, measurement);
measureCache.set(block, blockMeasureWidth, blockMeasureHeight, measurement, fontSignature);
measures.push(measurement);
cacheMisses++;
}
Expand Down Expand Up @@ -1066,6 +1077,7 @@ export async function incrementalLayout(
HEADER_PRELAYOUT_PLACEHOLDER_PAGE_COUNT,
undefined, // No page resolver needed for height calculation
'header',
fontSignature,
);

// Extract actual content heights from each variant
Expand Down Expand Up @@ -1170,6 +1182,7 @@ export async function incrementalLayout(
FOOTER_PRELAYOUT_PLACEHOLDER_PAGE_COUNT,
undefined, // No page resolver needed for height calculation
'footer',
fontSignature,
);

// Extract actual content heights from each variant
Expand Down Expand Up @@ -1399,13 +1412,24 @@ export async function incrementalLayout(
const measuresById = new Map<string, Measure>();
await Promise.all(
blocks.map(async (block) => {
const cached = measureCache.get(block, footnoteConstraints.maxWidth, footnoteConstraints.maxHeight);
const cached = measureCache.get(
block,
footnoteConstraints.maxWidth,
footnoteConstraints.maxHeight,
fontSignature,
);
if (cached) {
measuresById.set(block.id, cached);
return;
}
const measurement = await measureBlock(block, footnoteConstraints);
measureCache.set(block, footnoteConstraints.maxWidth, footnoteConstraints.maxHeight, measurement);
measureCache.set(
block,
footnoteConstraints.maxWidth,
footnoteConstraints.maxHeight,
measurement,
fontSignature,
);
measuresById.set(block.id, measurement);
}),
);
Expand Down Expand Up @@ -2719,6 +2743,7 @@ export async function incrementalLayout(
FeatureFlags.HEADER_FOOTER_PAGE_TOKENS ? undefined : numberingCtx.totalPages, // Fallback for backward compat
pageResolver, // Use page resolver for section-aware numbering
'header',
fontSignature,
);
headers = serializeHeaderFooterResults('header', headerLayouts);
}
Expand All @@ -2731,6 +2756,7 @@ export async function incrementalLayout(
FeatureFlags.HEADER_FOOTER_PAGE_TOKENS ? undefined : numberingCtx.totalPages, // Fallback for backward compat
pageResolver, // Use page resolver for section-aware numbering
'footer',
fontSignature,
);
footers = serializeHeaderFooterResults('footer', footerLayouts);
}
Expand Down
17 changes: 12 additions & 5 deletions packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,14 @@ export class HeaderFooterLayoutCache {
blocks: FlowBlock[],
constraints: { width: number; height: number },
measureBlock: MeasureResolver,
// The document resolver's mapping signature. This cache is a cross-document singleton, so the
// signature must key it - otherwise two documents that map the same logical header font
// differently would share one measure. Defaults to '' (no overrides => all default docs share).
fontSignature: string = '',
): Promise<Measure[]> {
const measures: Measure[] = [];
for (const block of blocks) {
const cached = this.cache.get(block, constraints.width, constraints.height);
const cached = this.cache.get(block, constraints.width, constraints.height, fontSignature);
if (cached) {
measures.push(cached);
continue;
Expand All @@ -164,7 +168,7 @@ export class HeaderFooterLayoutCache {
maxWidth: constraints.width,
maxHeight: constraints.height,
});
this.cache.set(block, constraints.width, constraints.height, measurement);
this.cache.set(block, constraints.width, constraints.height, measurement, fontSignature);
measures.push(measurement);
}
return measures;
Expand Down Expand Up @@ -217,6 +221,9 @@ export async function layoutHeaderFooterWithCache(
totalPages?: number,
pageResolver?: PageResolver,
kind?: 'header' | 'footer',
// The calling document's font-mapping signature, forwarded to the (cross-document) measure cache
// so header/footer measures cannot leak between documents with different mappings. '' = default.
fontSignature: string = '',
): Promise<HeaderFooterBatchResult> {
const result: HeaderFooterBatchResult = {};

Expand All @@ -233,7 +240,7 @@ export async function layoutHeaderFooterWithCache(
// Resolve page number tokens BEFORE measurement
resolveHeaderFooterTokens(clonedBlocks, 1, numPages);

const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock);
const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock, fontSignature);
const layout = layoutHeaderFooter(clonedBlocks, measures, constraints, kind);

result[type] = { blocks: clonedBlocks, measures, layout };
Expand All @@ -256,7 +263,7 @@ export async function layoutHeaderFooterWithCache(
// Fast path: if variant has no page tokens, create one layout for all pages
const hasTokens = hasPageTokens(blocks);
if (!hasTokens) {
const measures = await cache.measureBlocks(blocks, constraints, measureBlock);
const measures = await cache.measureBlocks(blocks, constraints, measureBlock, fontSignature);
const layout = layoutHeaderFooter(blocks, measures, constraints, kind);
result[type] = { blocks, measures, layout };
continue;
Expand Down Expand Up @@ -300,7 +307,7 @@ export async function layoutHeaderFooterWithCache(
resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText);

// Measure and layout
const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock);
const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock, fontSignature);
const pageLayout = layoutHeaderFooter(clonedBlocks, measures, constraints, kind);
const measuresById = new Map<string, Measure>();
for (let i = 0; i < clonedBlocks.length; i += 1) {
Expand Down
25 changes: 25 additions & 0 deletions packages/layout-engine/layout-bridge/test/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,31 @@ describe('MeasureCache', () => {
expect(cache.get(item, 400, 600)).toBeUndefined();
});

it('does not share a measure between two documents that map the same block differently', () => {
const item = block('0-paragraph', 'hello');
// Document A measured this block under its font mapping (signature "docA").
cache.set(item, 400, 600, { totalHeight: 20 }, 'docA');
// Document B - identical block content, different mapping - must NOT reuse A's measure.
expect(cache.get(item, 400, 600, 'docB')).toBeUndefined();
// Document A still reuses its own measure.
expect(cache.get(item, 400, 600, 'docA')?.totalHeight).toBe(20);
});

it('shares a measure when signatures match (default documents use the empty signature)', () => {
const item = block('0-paragraph', 'hello');
cache.set(item, 400, 600, { totalHeight: 20 });
// Omitting the signature is the same as '' on both sides, so default documents share cache.
expect(cache.get(item, 400, 600)?.totalHeight).toBe(20);
expect(cache.get(item, 400, 600, '')?.totalHeight).toBe(20);
});

it('invalidates by block id even when a font signature is part of the key', () => {
const item = block('0-paragraph', 'hello');
cache.set(item, 400, 600, { totalHeight: 20 }, 'docA');
cache.invalidate(['0-paragraph']);
expect(cache.get(item, 400, 600, 'docA')).toBeUndefined();
});

it('clears all entries', () => {
const item = block('0-paragraph', 'hello');
cache.set(item, 400, 600, { totalHeight: 20 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export function resolveHeaderFooterLayout(
blocks: FlowBlock[],
measures: Measure[],
story?: LayoutStoryLocator,
// Folded into each header/footer block's paint-reuse version (see resolveLayout). '' for default.
fontSignature = '',
): ResolvedHeaderFooterLayout {
const pages: ResolvedHeaderFooterPage[] = layout.pages.map((page) => {
const pageBlocks = page.blocks ?? blocks;
Expand All @@ -32,7 +34,15 @@ export function resolveHeaderFooterLayout(
displayNumber: page.displayNumber,
numberText: page.numberText,
items: page.fragments.map((fragment, fragmentIndex) =>
resolveFragmentItem(fragment, fragmentIndex, page.number - 1, blockMap, blockVersionCache, story),
resolveFragmentItem(
fragment,
fragmentIndex,
page.number - 1,
blockMap,
blockVersionCache,
story,
fontSignature,
),
),
};
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import { createFontResolver } from '@superdoc/font-system';
import { resolveLayout } from './resolveLayout.js';
import type {
Layout,
Expand Down Expand Up @@ -158,6 +159,54 @@ describe('resolveLayout', () => {
expect(result.blockVersions?.p1).not.toBe(result.blockVersions?.p2);
});

describe('per-document font-mapping isolation (paint reuse versions)', () => {
const layout: Layout = {
pageSize: { w: 800, h: 1000 },
pages: [
{
number: 1,
fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 0, width: 468 }],
},
],
} as any;
const blocks: FlowBlock[] = [
{ kind: 'paragraph', id: 'p1', runs: [{ text: 'visible', fontFamily: 'Georgia', fontSize: 12 }] } as any,
];
const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any];

it('two documents mapping the same family differently get different paint-reuse versions', () => {
// Two real per-document resolvers, same logical family mapped to different physical fonts.
const docA = createFontResolver();
docA.map('Georgia', 'Gelasio');
const docB = createFontResolver();
docB.map('Georgia', 'Tinos');

const rA = resolveLayout({ layout, flowMode: 'paginated', blocks, measures, fontSignature: docA.signature });
const rB = resolveLayout({ layout, flowMode: 'paginated', blocks, measures, fontSignature: docB.signature });

// Identical logical content; different mappings must not reuse each other's painted DOM.
expect(rA.blockVersions?.p1).not.toBe(rB.blockVersions?.p1);
});

it('an empty signature is byte-identical to omitting it (default documents share paint reuse)', () => {
const omitted = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
const empty = resolveLayout({ layout, flowMode: 'paginated', blocks, measures, fontSignature: '' });
expect(empty.blockVersions?.p1).toBe(omitted.blockVersions?.p1);
});

it('identical mappings yield identical versions (cache-shareable across same-mapping documents)', () => {
const docA = createFontResolver();
docA.map('Georgia', 'Gelasio');
const docB = createFontResolver();
docB.map('Georgia', 'Gelasio');
expect(docA.signature).toBe(docB.signature);

const rA = resolveLayout({ layout, flowMode: 'paginated', blocks, measures, fontSignature: docA.signature });
const rB = resolveLayout({ layout, flowMode: 'paginated', blocks, measures, fontSignature: docB.signature });
expect(rA.blockVersions?.p1).toBe(rB.blockVersions?.p1);
});
});

it('defaults pageGap to 0 when layout.pageGap is undefined', () => {
const result = resolveLayout({ layout: baseLayout, flowMode: 'paginated', blocks: [], measures: [] });
expect(result.pageGap).toBe(0);
Expand Down
Loading
Loading