diff --git a/packages/layout-engine/layout-bridge/src/cache.ts b/packages/layout-engine/layout-bridge/src/cache.ts index ac1016ea2f..f2efb115ff 100644 --- a/packages/layout-engine/layout-bridge/src/cache.ts +++ b/packages/layout-engine/layout-bridge/src/cache.ts @@ -658,14 +658,14 @@ export class MeasureCache { * @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) { @@ -692,14 +692,14 @@ export class MeasureCache { * @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)) { @@ -819,10 +819,13 @@ export class MeasureCache { * @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}`; } } diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index d035f504f8..dc304b661f 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -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 { + const fontSignature = fontRuntime?.fontSignature ?? ''; + const previousFontSignature = fontRuntime?.previousFontSignature ?? ''; const isSemanticFlow = options.flowMode === 'semantic'; // In semantic mode, neutralize paginated-only inputs so downstream code @@ -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 @@ -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) { @@ -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++; } @@ -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 @@ -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 @@ -1399,13 +1412,24 @@ export async function incrementalLayout( const measuresById = new Map(); 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); }), ); @@ -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); } @@ -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); } diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 6385a3065f..0a35e416ad 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -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 { 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; @@ -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; @@ -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 { const result: HeaderFooterBatchResult = {}; @@ -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 }; @@ -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; @@ -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(); for (let i = 0; i < clonedBlocks.length; i += 1) { diff --git a/packages/layout-engine/layout-bridge/test/cache.test.ts b/packages/layout-engine/layout-bridge/test/cache.test.ts index 2ed5e25ffc..f1a118470b 100644 --- a/packages/layout-engine/layout-bridge/test/cache.test.ts +++ b/packages/layout-engine/layout-bridge/test/cache.test.ts @@ -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 }); diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts index 48d8f1a22c..89863ae69a 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts @@ -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; @@ -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, + ), ), }; }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index 0b5272d4e9..7789a2e40c 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { createFontResolver } from '@superdoc/font-system'; import { resolveLayout } from './resolveLayout.js'; import type { Layout, @@ -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); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 786e293f84..1a1d499915 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -41,6 +41,12 @@ export type ResolveLayoutInput = { flowMode: FlowMode; blocks: FlowBlock[]; measures: Measure[]; + /** + * The document's font-mapping signature, folded into each block's paint-reuse version so a + * runtime `fonts.map` change repaints (the same way a font load busts reuse via the global + * epoch). Omitted/'' for default documents, leaving the version unchanged from before. + */ + fontSignature?: string; }; export function buildBlockMap(blocks: FlowBlock[], measures: Measure[]): Map { @@ -183,6 +189,7 @@ function computeBlockVersion( blockId: string, blockMap: Map, cache: Map, + fontSignature = '', ): string { const cached = cache.get(blockId); if (cached !== undefined) return cached; @@ -191,9 +198,13 @@ function computeBlockVersion( cache.set(blockId, 'missing'); return 'missing'; } + // Prepend the document's font-mapping signature so a `fonts.map` change busts paint reuse the + // same way a font load (getFontConfigVersion, folded inside deriveBlockVersion) does. The cache + // is per resolveLayout pass, so the signature is constant here; '' leaves the version unchanged. const version = deriveBlockVersion(entry.block); - cache.set(blockId, version); - return version; + const versioned = fontSignature ? `${fontSignature}|${version}` : version; + cache.set(blockId, versioned); + return versioned; } function applyPaintVersions(item: Extract, visualVersion: string): void { @@ -214,9 +225,10 @@ export function resolveFragmentItem( blockMap: Map, blockVersionCache: Map, story?: LayoutStoryLocator, + fontSignature = '', ): ResolvedPaintItem { const sdtContainerKey = resolveFragmentSdtContainerKey(fragment, blockMap); - const blockVer = computeBlockVersion(fragment.blockId, blockMap, blockVersionCache); + const blockVer = computeBlockVersion(fragment.blockId, blockMap, blockVersionCache, fontSignature); const version = fragmentSignature(fragment, blockVer); const layoutSourceIdentity = resolveFragmentLayoutIdentity(fragment, story); @@ -314,6 +326,7 @@ export function resolveFragmentItem( export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { const { layout, flowMode, blocks, measures } = input; + const fontSignature = input.fontSignature ?? ''; const blockMap = buildBlockMap(blocks, measures); const blockVersionCache = new Map(); @@ -326,7 +339,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { width: page.size?.w ?? layout.pageSize.w, height: page.size?.h ?? layout.pageSize.h, items: page.fragments.map((fragment, fragmentIndex) => - resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap, blockVersionCache), + resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap, blockVersionCache, undefined, fontSignature), ), margins: page.margins, footnoteReserved: page.footnoteReserved, @@ -348,7 +361,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { if (blocks.length > 0) { resolved.blockVersions = Object.fromEntries( - blocks.map((block) => [block.id, computeBlockVersion(block.id, blockMap, blockVersionCache)]), + blocks.map((block) => [block.id, computeBlockVersion(block.id, blockMap, blockVersionCache, fontSignature)]), ); } if (layout.layoutEpoch != null) { diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 3da3a58807..b635cbbf2f 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -305,6 +305,13 @@ function getCanvasContext(): CanvasRenderingContext2D { return canvasContext; } +/** + * Resolve a logical CSS font-family value to its physical render family. Threaded from the + * caller so measurement uses THIS document's resolver (honoring a per-document `fonts.map`); + * defaults to the global bundled map for callers without a document context. + */ +type ResolvePhysical = (cssFontFamily: string) => string; + /** * Build a CSS font string from Run styling properties * @@ -314,7 +321,10 @@ function getCanvasContext(): CanvasRenderingContext2D { * // Returns: { font: "italic bold 16px Arial", fontFamily: "Arial" } * ``` */ -function buildFontString(run: { fontFamily: string; fontSize: number; bold?: boolean; italic?: boolean }): { +function buildFontString( + run: { fontFamily: string; fontSize: number; bold?: boolean; italic?: boolean }, + resolvePhysical: ResolvePhysical = resolvePhysicalFamily, +): { font: string; fontFamily: string; } { @@ -325,9 +335,10 @@ function buildFontString(run: { fontFamily: string; fontSize: number; bold?: boo parts.push(`${run.fontSize}px`); // Resolve the logical family (e.g. "Calibri") to the physical render family - // (e.g. "Carlito") so text is MEASURED in the same font it is painted with. The - // measure cache keys on this font string, so the physical family is in the key. - const physicalFamily = resolvePhysicalFamily(run.fontFamily); + // (e.g. "Carlito") so text is MEASURED in the same font it is painted with, using THIS + // document's resolver so a per-document `fonts.map` is honored. The measure cache keys + // on this font string, so the physical family is in the key. + const physicalFamily = resolvePhysical(run.fontFamily); if (measurementConfig.mode === 'deterministic') { // Deterministic mode still flattens to one family for reproducible server-side @@ -701,6 +712,7 @@ function measureTabAlignmentGroup( runs: Run[], ctx: CanvasRenderingContext2D, decimalSeparator: string = '.', + resolvePhysical: ResolvePhysical = resolvePhysicalFamily, ): TabAlignmentGroupMeasure { const result: TabAlignmentGroupMeasure = { totalWidth: 0, @@ -731,7 +743,7 @@ function measureTabAlignmentGroup( const text = textRun.text || ''; if (text.length > 0) { - const { font } = buildFontString(textRun); + const { font } = buildFontString(textRun, resolvePhysical); const width = measureRunWidth(text, font, ctx, textRun, 0); // For decimal alignment, find the decimal position @@ -783,12 +795,15 @@ function measureTabAlignmentGroup( // Measure field annotation runs if (isFieldAnnotationRun(run)) { const fontSize = (run as { fontSize?: number }).fontSize ?? DEFAULT_FIELD_ANNOTATION_FONT_SIZE; - const { font } = buildFontString({ - fontFamily: (run as { fontFamily?: string }).fontFamily ?? 'Arial', - fontSize, - bold: (run as { bold?: boolean }).bold, - italic: (run as { italic?: boolean }).italic, - }); + const { font } = buildFontString( + { + fontFamily: (run as { fontFamily?: string }).fontFamily ?? 'Arial', + fontSize, + bold: (run as { bold?: boolean }).bold, + italic: (run as { italic?: boolean }).italic, + }, + resolvePhysical, + ); const textWidth = run.displayLabel ? measureRunWidth(run.displayLabel, font, ctx, run, 0) : 0; const pillWidth = textWidth + FIELD_ANNOTATION_PILL_PADDING; @@ -829,7 +844,11 @@ function measureTabAlignmentGroup( * // Result: { lines: [...], totalHeight: 19.2 } * ``` */ -export async function measureBlock(block: FlowBlock, constraints: number | MeasureConstraints): Promise { +export async function measureBlock( + block: FlowBlock, + constraints: number | MeasureConstraints, + resolvePhysical: ResolvePhysical = resolvePhysicalFamily, +): Promise { const normalized = normalizeConstraints(constraints); if (block.kind === 'drawing') { @@ -841,11 +860,11 @@ export async function measureBlock(block: FlowBlock, constraints: number | Measu } if (block.kind === 'list') { - return measureListBlock(block, normalized); + return measureListBlock(block, normalized, resolvePhysical); } if (block.kind === 'table') { - return measureTableBlock(block, normalized); + return measureTableBlock(block, normalized, resolvePhysical); } // Break blocks (sectionBreak, pageBreak, columnBreak) are pass-through measures @@ -861,10 +880,14 @@ export async function measureBlock(block: FlowBlock, constraints: number | Measu } // Paragraph/default - return measureParagraphBlock(block as ParagraphBlock, normalized.maxWidth); + return measureParagraphBlock(block as ParagraphBlock, normalized.maxWidth, resolvePhysical); } -async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): Promise { +async function measureParagraphBlock( + block: ParagraphBlock, + maxWidth: number, + resolvePhysical: ResolvePhysical = resolvePhysicalFamily, +): Promise { const ctx = getCanvasContext(); const wordLayout: WordParagraphLayoutOutput | undefined = block.attrs?.wordLayout as | WordParagraphLayoutOutput @@ -890,7 +913,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P bold: wordLayout.marker.run.bold, italic: wordLayout.marker.run.italic, }; - const { font: markerFont } = buildFontString(markerRun); + const { font: markerFont } = buildFontString(markerRun, resolvePhysical); const markerText = wordLayout.marker.markerText ?? ''; const glyphWidth = markerText ? measureText(markerText, markerFont, ctx) : 0; const gutter = @@ -1004,7 +1027,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P bold: marker.run?.bold ?? false, italic: marker.run?.italic ?? false, }; - const { font: markerFont } = buildFontString(markerRun); + const { font: markerFont } = buildFontString(markerRun, resolvePhysical); return measureText(markerText, markerFont, ctx); }, ); @@ -1053,7 +1076,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P if (!dropCapDescriptor.run || !dropCapDescriptor.run.text || !dropCapDescriptor.lines) { console.warn('Invalid drop cap descriptor - missing required fields:', dropCapDescriptor); } else { - const dropCapMeasured = measureDropCap(ctx, dropCapDescriptor, spacing); + const dropCapMeasured = measureDropCap(ctx, dropCapDescriptor, spacing, resolvePhysical); dropCapMeasure = dropCapMeasured; // Update the descriptor with measured dimensions @@ -1417,6 +1440,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P const keptText = sliceText.slice(0, Math.max(0, sliceText.length - trimCount)); const { font } = buildFontString( lastRun as { fontFamily: string; fontSize: number; bold?: boolean; italic?: boolean }, + resolvePhysical, ); const fullWidth = measureRunWidth(sliceText, font, ctx, lastRun, sliceStart); const keptWidth = keptText.length > 0 ? measureRunWidth(keptText, font, ctx, lastRun, sliceStart) : 0; @@ -1672,7 +1696,13 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P // to properly align ALL content until the next tab or end of line if (stop.val === 'end' || stop.val === 'center' || stop.val === 'decimal') { // Measure all content from the next run until the next tab or end of paragraph - const groupMeasure = measureTabAlignmentGroup(runIndex + 1, runsToProcess, ctx, decimalSeparator); + const groupMeasure = measureTabAlignmentGroup( + runIndex + 1, + runsToProcess, + ctx, + decimalSeparator, + resolvePhysical, + ); if (groupMeasure.totalWidth > 0) { // Calculate the aligned starting X position based on total group width @@ -2048,7 +2078,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P } if (isEmptySdtPlaceholderRun(run)) { - const placeholderFont = buildFontString(run).font; + const placeholderFont = buildFontString(run, resolvePhysical).font; const placeholderText = applyTextTransform(EMPTY_SDT_PLACEHOLDER_TEXT, run); const measuredPlaceholderWidth = getMeasuredTextWidth( placeholderText, @@ -2128,7 +2158,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P // Handle text runs lastFontSize = run.fontSize; hasSeenTextRun = true; - const { font } = buildFontString(run); + const { font } = buildFontString(run, resolvePhysical); const tabSegments = run.text.split('\t'); let charPosInRun = 0; @@ -2856,7 +2886,11 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P }; } -async function measureTableBlock(block: TableBlock, constraints: MeasureConstraints): Promise { +async function measureTableBlock( + block: TableBlock, + constraints: MeasureConstraints, + resolvePhysical: ResolvePhysical = resolvePhysicalFamily, +): Promise { const maxWidth = typeof constraints === 'number' ? constraints : constraints.maxWidth; const workingInput = buildAutoFitWorkingGridInput(block, { maxWidth }); const columnWidths = await resolveRuntimeTableColumnWidths(block, workingInput); @@ -2968,7 +3002,7 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai for (let blockIndex = 0; blockIndex < cellBlocks.length; blockIndex++) { const block = cellBlocks[blockIndex]; - const measure = await measureBlock(block, { maxWidth: contentWidth, maxHeight: Infinity }); + const measure = await measureBlock(block, { maxWidth: contentWidth, maxHeight: Infinity }, resolvePhysical); blockMeasures.push(measure); // Get height from different measure types const blockHeight = 'totalHeight' in measure ? measure.totalHeight : 'height' in measure ? measure.height : 0; @@ -3379,7 +3413,11 @@ function normalizeConstraints(constraints: number | MeasureConstraints): Measure return constraints; } -async function measureListBlock(block: ListBlock, constraints: MeasureConstraints): Promise { +async function measureListBlock( + block: ListBlock, + constraints: MeasureConstraints, + resolvePhysical: ResolvePhysical = resolvePhysicalFamily, +): Promise { const ctx = getCanvasContext(); const items = []; let totalHeight = 0; @@ -3405,14 +3443,14 @@ async function measureListBlock(block: ListBlock, constraints: MeasureConstraint italic: marker.run.italic, letterSpacing: marker.run.letterSpacing, }; - const { font: markerFont } = buildFontString(markerFontRun); + const { font: markerFont } = buildFontString(markerFontRun, resolvePhysical); markerTextWidth = marker.markerText ? measureText(marker.markerText, markerFont, ctx) : 0; markerWidth = 0; indentLeft = (wordLayout as WordParagraphLayoutOutput).indentLeftPx ?? 0; } else { // Fallback: legacy behavior for backwards compatibility const markerFontRun = getPrimaryRun(item.paragraph); - const { font: markerFont } = buildFontString(markerFontRun); + const { font: markerFont } = buildFontString(markerFontRun, resolvePhysical); const markerText = item.marker.text ?? ''; markerTextWidth = markerText ? measureText(markerText, markerFont, ctx) : 0; indentLeft = resolveIndentLeft(item); @@ -3423,7 +3461,7 @@ async function measureListBlock(block: ListBlock, constraints: MeasureConstraint // Account for both indentLeft and marker width so paragraph text wraps correctly const paragraphWidth = Math.max(1, constraints.maxWidth - indentLeft - markerWidth); - const paragraphMeasure = await measureParagraphBlock(item.paragraph, paragraphWidth); + const paragraphMeasure = await measureParagraphBlock(item.paragraph, paragraphWidth, resolvePhysical); totalHeight += paragraphMeasure.totalHeight; items.push({ @@ -3709,16 +3747,20 @@ const measureDropCap = ( ctx: CanvasRenderingContext2D, descriptor: DropCapDescriptor, spacing?: ParagraphSpacing, + resolvePhysical: ResolvePhysical = resolvePhysicalFamily, ): { width: number; height: number; lines: number; mode: 'drop' | 'margin' } => { const { run, lines, mode } = descriptor; // Build font string for the drop cap run - const { font } = buildFontString({ - fontFamily: run.fontFamily, - fontSize: run.fontSize, - bold: run.bold, - italic: run.italic, - }); + const { font } = buildFontString( + { + fontFamily: run.fontFamily, + fontSize: run.fontSize, + bold: run.bold, + italic: run.italic, + }, + resolvePhysical, + ); // Measure the text width ctx.font = font; diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index 3588e05cd8..c08a9fe954 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -104,6 +104,13 @@ export type DomPainterOptions = { showFormattingMarks?: boolean; /** Built-in SDT chrome rendering mode. */ contentControlsChrome?: 'default' | 'none'; + /** + * Per-document logical->physical font resolver (a CSS-stack resolver). The painter paints each + * run in the family this returns - e.g. Carlito for Calibri - the SAME family measurement used, + * so glyph advances match the laid-out positions. Set per painter instance (per document) so two + * editors can map one logical family differently. Defaults to the global bundled resolver. + */ + resolvePhysical?: (cssFontFamily: string) => string; }; export type DomPainterHandle = { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 910e320008..afc5546920 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -243,6 +243,8 @@ type PainterOptions = { showFormattingMarks?: boolean; /** Built-in SDT chrome rendering mode. */ contentControlsChrome?: 'default' | 'none'; + /** Per-document logical->physical font resolver; see DomPainterOptions.resolvePhysical. */ + resolvePhysical?: (cssFontFamily: string) => string; }; type FragmentDomState = { @@ -3814,6 +3816,8 @@ export class DomPainter { layoutEpoch: this.layoutEpoch, showFormattingMarks: this.showFormattingMarks, contentControlsChrome: this.contentControlsChrome, + // Per-document font resolver (undefined => applyRunStyles falls back to the global default). + resolvePhysical: this.options.resolvePhysical, pendingTooltips: this.pendingTooltips, getNextLinkId: () => `superdoc-link-${++this.linkIdCounter}`, applySdtDataset, diff --git a/packages/layout-engine/painters/dom/src/runs/render-run.ts b/packages/layout-engine/painters/dom/src/runs/render-run.ts index 12a1c62fc0..d1f96045b1 100644 --- a/packages/layout-engine/painters/dom/src/runs/render-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/render-run.ts @@ -27,7 +27,7 @@ const renderEmptySdtPlaceholderRun = (run: TextRun, renderContext: RunRenderCont if (run.pmStart != null) elem.dataset.pmStart = String(run.pmStart); if (run.pmEnd != null) elem.dataset.pmEnd = String(run.pmEnd); renderContext.applySdtDataset(elem, run.sdt); - applyRunStyles(elem, run); + applyRunStyles(elem, run, false, renderContext.resolvePhysical); return elem; }; diff --git a/packages/layout-engine/painters/dom/src/runs/text-run.ts b/packages/layout-engine/painters/dom/src/runs/text-run.ts index be6f31e935..73979457f4 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -60,7 +60,12 @@ const applyRunVerticalPositioning = (element: HTMLElement, run: TextRun): void = * inline colors are now applied to all runs (including links) to * ensure OOXML hyperlink character styles appear correctly. */ -export const applyRunStyles = (element: HTMLElement, run: Run, _isLink = false): void => { +export const applyRunStyles = ( + element: HTMLElement, + run: Run, + _isLink = false, + resolvePhysical: (cssFontFamily: string) => string = resolvePhysicalFamily, +): void => { if ( run.kind === 'tab' || run.kind === 'image' || @@ -74,8 +79,10 @@ export const applyRunStyles = (element: HTMLElement, run: Run, _isLink = false): } // Paint the physical render family (e.g. Carlito for Calibri) - the same family the - // text was measured in, so glyph advances match the laid-out positions. - element.style.fontFamily = resolvePhysicalFamily(run.fontFamily); + // text was measured in, so glyph advances match the laid-out positions. The resolver is the + // per-document one (passed by the caller from the render context), so two editors that map a + // logical family differently paint different physical families. Defaults to the global bundled. + element.style.fontFamily = resolvePhysical(run.fontFamily); element.style.fontSize = `${run.fontSize}px`; if (run.bold) element.style.fontWeight = 'bold'; if (run.italic) element.style.fontStyle = 'italic'; @@ -200,7 +207,7 @@ export const renderTextRun = ( } // Pass isLink flag to skip applying inline color/decoration styles for links - applyRunStyles(elem as HTMLElement, run, isActiveLink); + applyRunStyles(elem as HTMLElement, run, isActiveLink, renderContext.resolvePhysical); const dirAttr = resolveRunDirectionAttribute({ runText: run.text, effectiveText, diff --git a/packages/layout-engine/painters/dom/src/runs/types.ts b/packages/layout-engine/painters/dom/src/runs/types.ts index c28d93c6bb..e276e7d91c 100644 --- a/packages/layout-engine/painters/dom/src/runs/types.ts +++ b/packages/layout-engine/painters/dom/src/runs/types.ts @@ -26,6 +26,8 @@ export type RunRenderContext = { layoutEpoch: number; showFormattingMarks: boolean; contentControlsChrome: 'default' | 'none'; + /** Per-document logical->physical font resolver. Undefined => global bundled default. */ + resolvePhysical?: (cssFontFamily: string) => string; pendingTooltips: WeakMap; getNextLinkId: () => string; applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts index 1228456dc2..9924615fdc 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts @@ -10,6 +10,7 @@ import { } from '@superdoc/layout-bridge'; import type { HeaderFooterLayoutResult, HeaderFooterConstraints } from '@superdoc/layout-bridge'; import { measureBlock } from '@superdoc/measuring-dom'; +import type { FontResolver } from '@superdoc/font-system'; export type HeaderFooterPerRidLayoutInput = { headerBlocks?: unknown; @@ -37,6 +38,10 @@ export async function layoutPerRIdHeaderFooters( headerLayoutsByRId: Map; footerLayoutsByRId: Map; }, + // The calling document's resolver. Per-rId header/footer measurement reads through it (and + // folds its signature into the shared cache) so multi-section documents stay isolated under + // a `fonts.map`. Omitted (undefined) => the global default resolver, preserving prior behavior. + fontResolver?: FontResolver, ): Promise { deps.headerLayoutsByRId.clear(); deps.footerLayoutsByRId.clear(); @@ -67,6 +72,7 @@ export async function layoutPerRIdHeaderFooters( constraints, pageResolver, deps.headerLayoutsByRId, + fontResolver, ); await layoutWithPerSectionConstraints( 'footer', @@ -75,6 +81,7 @@ export async function layoutPerRIdHeaderFooters( constraints, pageResolver, deps.footerLayoutsByRId, + fontResolver, ); } else { // Single-section or uniform margins: use original single-constraint path @@ -87,6 +94,7 @@ export async function layoutPerRIdHeaderFooters( constraints, pageResolver, deps.headerLayoutsByRId, + fontResolver, ); await layoutBlocksByRId( 'footer', @@ -95,6 +103,7 @@ export async function layoutPerRIdHeaderFooters( constraints, pageResolver, deps.footerLayoutsByRId, + fontResolver, ); } } @@ -110,9 +119,15 @@ async function layoutBlocksByRId( constraints: Constraints, pageResolver: (pageNumber: number) => { displayText: string; totalPages: number }, layoutsByRId: Map, + fontResolver?: FontResolver, ): Promise { if (!blocksByRId || referencedRIds.size === 0) return; + // Bind the per-document resolver into the measure callback, and derive its signature for the + // (cross-document) header/footer cache key. Undefined resolver => global default + '' signature. + const resolvePhysical = fontResolver ? (css: string) => fontResolver.resolvePhysicalFamily(css) : undefined; + const fontSignature = fontResolver?.signature ?? ''; + for (const [rId, blocks] of blocksByRId) { if (!referencedRIds.has(rId)) continue; if (!blocks || blocks.length === 0) continue; @@ -121,11 +136,12 @@ async function layoutBlocksByRId( const batchResult = await layoutHeaderFooterWithCache( { default: blocks }, constraints, - (block: FlowBlock, c: { maxWidth: number; maxHeight: number }) => measureBlock(block, c), + (block: FlowBlock, c: { maxWidth: number; maxHeight: number }) => measureBlock(block, c, resolvePhysical), undefined, undefined, pageResolver, kind, + fontSignature, ); if (batchResult.default) { @@ -210,8 +226,14 @@ async function layoutWithPerSectionConstraints( fallbackConstraints: Constraints, pageResolver: (pageNumber: number) => { displayText: string; totalPages: number }, layoutsByRId: Map, + fontResolver?: FontResolver, ): Promise { if (!blocksByRId) return; + + // See layoutBlocksByRId: bind the per-document resolver + derive its cache signature. + const resolvePhysical = fontResolver ? (css: string) => fontResolver.resolvePhysicalFamily(css) : undefined; + const fontSignature = fontResolver?.signature ?? ''; + const groups = buildSectionAwareHeaderFooterMeasurementGroups( kind, blocksByRId, @@ -228,11 +250,12 @@ async function layoutWithPerSectionConstraints( const batchResult = await layoutHeaderFooterWithCache( { default: blocks }, group.sectionConstraints, - (block: FlowBlock, c: { maxWidth: number; maxHeight: number }) => measureBlock(block, c), + (block: FlowBlock, c: { maxWidth: number; maxHeight: number }) => measureBlock(block, c, resolvePhysical), undefined, undefined, pageResolver, kind, + fontSignature, ); if (batchResult.default) { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index cd1f83370e..ecf4a8acc4 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -177,7 +177,7 @@ import type { } from '@superdoc/layout-bridge'; import { measureBlock } from '@superdoc/measuring-dom'; -import { resolvePhysicalFamilies, type FontResolutionRecord, type FontLoadSummary } from '@superdoc/font-system'; +import { createFontResolver, type FontResolutionRecord, type FontLoadSummary } from '@superdoc/font-system'; import { installBundledSubstitutes } from '@superdoc/font-system/bundled'; import { FontReadinessGate } from './fonts/FontReadinessGate'; import { planRequiredFontFaces } from './fonts/font-load-planner'; @@ -503,6 +503,12 @@ export class PresentationEditor extends EventEmitter { #hiddenHostWrapper: HTMLElement; #layoutOptions: LayoutEngineOptions; #layoutState: LayoutState = { blocks: [], measures: [], layout: null, bookmarks: new Map() }; + /** + * The font-mapping signature `#layoutState.measures` were produced with. Travels with the + * measures so the next render can tell incrementalLayout whether a mapping change since the + * prior pass invalidates previous-measure reuse (that reuse fast path bypasses the cache key). + */ + #layoutFontSignature = ''; #layoutLookupBlocks: FlowBlock[] = []; #layoutLookupMeasures: Measure[] = []; /** Cache for incremental toFlowBlocks conversion */ @@ -531,6 +537,21 @@ export class PresentationEditor extends EventEmitter { #selectionSync = new SelectionSyncCoordinator(); /** Load-before-measure gate: awaits required fonts before measurement, reflows on late load. */ #fontGate: FontReadinessGate | null = null; + /** + * This document's logical->physical font resolver. Per-instance (per document) so two + * editors can map the same logical family differently without leaking. Planner, gate, report, + * text MEASURE (body, footnotes, header/footer, per-rId header/footer) and text PAINT resolve + * through THIS instance, and its signature keys every measure cache AND every paint-reuse + * version, so two documents with different mappings can never share a measure or reuse each + * other's painted DOM. (Field-annotation pills are the one font-bearing path NOT resolved here: + * their line-layout measure + paint still use the logical family, exactly as on main. Unifying + * them changes pill rendering, so it lands with the `fonts.map` PR, not this foundation.) + * This is the per-document isolation foundation the customer write API + * (`fonts.map`/`add`/`preload`) builds on; PR1 wires the seam with no public mutators yet, so + * the signature stays '' and the resolver is seeded with the same bundled map - behavior- + * preserving by construction (resolved families, cache keys, and paint versions are unchanged). + */ + readonly #fontResolver = createFontResolver(); /** Layout blocks for the current render, stashed so the gate's planner reads the live set. */ #fontPlanBlocks: FlowBlock[] | null = null; /** Dedup key for `fonts-changed`: epoch + per-face load status. Null until the first emit. */ @@ -886,6 +907,7 @@ export class PresentationEditor extends EventEmitter { initBudgetMs: HEADER_FOOTER_INIT_BUDGET_MS, defaultPageSize: DEFAULT_PAGE_SIZE, defaultMargins: DEFAULT_MARGINS, + getFontSignature: () => this.#fontResolver.signature, }); this.#headerFooterSession.setHoverElements({ hoverOverlay: this.#hoverOverlay, @@ -966,10 +988,11 @@ export class PresentationEditor extends EventEmitter { // rendered document uses, from the planner walking the current layout blocks. The // gate awaits these - so bold/italic load before measure and declared-but-unused // fonts are not fetched. Reads the blocks stashed just before each gate await. - getRequiredFaces: () => planRequiredFontFaces(this.#fontPlanBlocks), - // Fallback family path (used only if getRequiredFaces is unavailable): wait on the - // resolved PHYSICAL families (Calibri -> Carlito). - resolveFamilies: resolvePhysicalFamilies, + getRequiredFaces: () => planRequiredFontFaces(this.#fontPlanBlocks, this.#fontResolver), + // The document's resolver: the gate derives the family-path resolution from it and + // resolves its report through it (load + diagnostics). Measure and paint resolve through + // the same instance, so load, measure, paint, and diagnostics all agree. + fontResolver: this.#fontResolver, // Register the bundled substitute pack (Carlito) into the document's registry the // first time it resolves, so the substitute is available with no manual setup. onRegistryResolved: (registry) => @@ -3224,6 +3247,7 @@ export class PresentationEditor extends EventEmitter { flowMode: this.#layoutOptions.flowMode ?? 'paginated', blocks, measures, + fontSignature: this.#fontResolver.signature, }); const isSemanticFlow = this.#layoutOptions.flowMode === 'semantic'; @@ -5030,10 +5054,11 @@ export class PresentationEditor extends EventEmitter { // header/footer descriptors against the new converter and rerender so the // importer tab matches the collaborator tab without waiting for an edit. const handleDocumentReplaced = () => { - // A new document reuses this gate, so drop the old document's pending late-load reflow - // and required-face state - otherwise a flush armed under the old document fires a - // spurious full reflow against the new one. + // A new document reuses this gate AND this resolver, so drop the old document's pending + // late-load reflow + required-face state and its runtime font mappings - otherwise a + // flush armed under the old document reflows the new one, or a prior `fonts.map` leaks in. this.#fontGate?.resetForDocumentChange(); + this.#fontResolver.reset(); this.#refreshHeaderFooterStructureThenRerender({ purgeCachedEditors: true }); }; this.#editor.on('documentReplaced', handleDocumentReplaced); @@ -6702,6 +6727,14 @@ export class PresentationEditor extends EventEmitter { const previousBlocks = this.#layoutState.blocks; const previousLayout = this.#layoutState.layout; const previousMeasures = this.#layoutState.measures; + // Per-document font context for this render: bind the resolver into the measure callback + // (so measurement uses THIS document's physical substitutes) and capture its signature for + // the measure-cache keys. previousFontSignature is the signature the prior measures were + // produced with - if it differs, incrementalLayout must not reuse them (the reuse fast + // path bypasses the cache key). PR1 has no public `fonts.map`, so the signature stays ''. + const resolvePhysical = (css: string): string => this.#fontResolver.resolvePhysicalFamily(css); + const fontSignature = this.#fontResolver.signature; + const previousFontSignature = this.#layoutFontSignature; let layout: Layout; let measures: Measure[]; @@ -6752,9 +6785,11 @@ export class PresentationEditor extends EventEmitter { previousLayout, blocksForLayout, layoutOptions, - (block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => measureBlock(block, constraints), + (block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => + measureBlock(block, constraints, resolvePhysical), headerFooterInput ?? undefined, previousMeasures, + { fontSignature, previousFontSignature }, ); const incrementalLayoutEnd = perfNow(); perfLog(`[Perf] incrementalLayout: ${(incrementalLayoutEnd - incrementalLayoutStart).toFixed(2)}ms`); @@ -6794,6 +6829,7 @@ export class PresentationEditor extends EventEmitter { flowMode: this.#layoutOptions.flowMode ?? 'paginated', blocks: bodyBlocksForPaint, measures: bodyMeasuresForPaint, + fontSignature, }); headerLayouts = result.headers; @@ -6822,6 +6858,9 @@ export class PresentationEditor extends EventEmitter { } const anchorMap = computeAnchorMapFromHelper(bookmarks, layout, blocksForLayout); this.#layoutState = { blocks: blocksForLayout, measures, layout, bookmarks, anchorMap }; + // Record the signature these measures were produced with, so the next render can gate + // previous-measure reuse on whether the mapping changed (see #layoutFontSignature). + this.#layoutFontSignature = fontSignature; this.#layoutLookupBlocks = resolveBlocks; this.#layoutLookupMeasures = resolveMeasures; @@ -6966,6 +7005,9 @@ export class PresentationEditor extends EventEmitter { pageGap: this.#layoutState.layout?.pageGap ?? effectiveGap, showFormattingMarks: this.#layoutOptions.showFormattingMarks ?? false, contentControlsChrome: this.#layoutOptions.contentControlsChrome ?? 'default', + // Paint each run in THIS document's physical substitute - the same family measurement used - + // so two editors that map a logical family differently never paint each other's font. + resolvePhysical: (css: string): string => this.#fontResolver.resolvePhysicalFamily(css), }); // Pass the current zoom so virtualization accounts for the CSS transform scale @@ -8094,7 +8136,7 @@ export class PresentationEditor extends EventEmitter { sectionMetadata: SectionMetadata[], ): Promise { if (this.#headerFooterSession) { - await this.#headerFooterSession.layoutPerRId(headerFooterInput, layout, sectionMetadata); + await this.#headerFooterSession.layoutPerRId(headerFooterInput, layout, sectionMetadata, this.#fontResolver); } } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts index 2586213dec..57ac0f6979 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts @@ -10,6 +10,7 @@ import { type FontLoadSummary, type FontLoadStatus, type FontResolutionRecord, + type FontResolver, } from '@superdoc/font-system'; export type { FontLoadSummary } from '@superdoc/font-system'; @@ -55,6 +56,14 @@ export interface FontReadinessGateOptions { * Defaults to identity when not provided; the editor wires `resolvePhysicalFamilies`. */ resolveFamilies?: (families: string[]) => string[]; + /** + * The document's font resolver. When provided, `resolveFamilies` defaults to it and the + * report resolves through it, so the gate honors a per-document `fonts.map`. Text measure and + * paint resolve through the same instance, so load, measure, paint, and diagnostics agree for + * text runs. (Field-annotation pills still measure/paint the logical family - pre-existing on + * main; unified in the `fonts.map` PR.) + */ + fontResolver?: FontResolver; /** Per-font load budget before a face is treated as timed out. */ timeoutMs?: number; /** Explicit registry override (tests). Normally derived from the font environment. */ @@ -97,6 +106,7 @@ export class FontReadinessGate { readonly #getDocumentFonts: () => string[]; readonly #getRequiredFaces: (() => FontFaceRequest[]) | null; readonly #resolveFamilies: (families: string[]) => string[]; + readonly #fontResolver: FontResolver | null; readonly #requestReflow: () => void; readonly #getFontEnvironment: () => FontEnvironment | null; readonly #registryOverride: FontRegistry | null; @@ -124,7 +134,11 @@ export class FontReadinessGate { constructor(options: FontReadinessGateOptions) { this.#getDocumentFonts = options.getDocumentFonts; this.#getRequiredFaces = options.getRequiredFaces ?? null; - this.#resolveFamilies = options.resolveFamilies ?? ((families) => families); + this.#fontResolver = options.fontResolver ?? null; + const resolver = this.#fontResolver; + this.#resolveFamilies = + options.resolveFamilies ?? + (resolver ? (families) => resolver.resolvePhysicalFamilies(families) : (families) => families); this.#requestReflow = options.requestReflow; this.#getFontEnvironment = options.getFontEnvironment ?? defaultFontEnvironment; this.#registryOverride = options.registry ?? null; @@ -167,7 +181,7 @@ export class FontReadinessGate { } catch { return []; } - return buildFontReport(logical, this.#resolveContext().registry); + return buildFontReport(logical, this.#resolveContext().registry, this.#fontResolver ?? undefined); } /** diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts index 26189c5ec8..0da6e90637 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/font-load-planner.ts @@ -1,4 +1,4 @@ -import { resolvePrimaryPhysicalFamily, type FontFaceRequest } from '@superdoc/font-system'; +import { resolvePrimaryPhysicalFamily, type FontFaceRequest, type FontResolver } from '@superdoc/font-system'; import type { FlowBlock, ParagraphBlock, TableBlock, ListBlock, Run } from '@superdoc/contracts'; /** @@ -13,12 +13,16 @@ import type { FlowBlock, ParagraphBlock, TableBlock, ListBlock, Run } from '@sup * * This walks the layout input (`blocksForLayout`) - which exists BEFORE measurement and * already carries each run's `fontFamily` + `bold`/`italic` - and emits the deduped set of - * physical face requests. It resolves logical -> physical with `resolvePrimaryPhysicalFamily`, - * the SAME primary resolution measure and paint use, so the planned set cannot disagree - * with what is actually measured/painted. Declared-font diagnostics stay separate - * (`getDocumentFonts()` / `getReport()`); this feeds loading only. + * physical face requests. It resolves logical -> physical with the DOCUMENT'S resolver - the + * same instance measure and paint will use once they are threaded onto it - so the planned/ + * loaded set cannot disagree with what is actually measured/painted, including a per-document + * `fonts.map`. Declared-font diagnostics stay separate (`getDocumentFonts()` / `getReport()`); + * this feeds loading only. */ +/** Resolve a logical family to its bare physical face name, per the document's resolver. */ +type ResolvePrimary = (family: string) => string; + /** Anything that carries a measurable text font: a run, a list marker run, etc. */ interface FontBearing { fontFamily?: unknown; @@ -31,9 +35,9 @@ function faceKey(req: FontFaceRequest): string { } /** Collect a face request from any font-bearing object into the deduped map. */ -function collect(out: Map, node: FontBearing | null | undefined): void { +function collect(out: Map, node: FontBearing | null | undefined, resolve: ResolvePrimary): void { if (!node || typeof node.fontFamily !== 'string' || !node.fontFamily) return; - const family = resolvePrimaryPhysicalFamily(node.fontFamily); + const family = resolve(node.fontFamily); if (!family) return; const req: FontFaceRequest = { family, @@ -44,7 +48,7 @@ function collect(out: Map, node: FontBearing | null | u if (!out.has(key)) out.set(key, req); } -function collectRuns(out: Map, runs: Run[] | undefined): void { +function collectRuns(out: Map, runs: Run[] | undefined, resolve: ResolvePrimary): void { if (!runs) return; // Duck-typed on fontFamily so every font-bearing run kind is covered (text, // fieldAnnotation, dropCap, ...) - missing one would silently measure against fallback. @@ -53,55 +57,59 @@ function collectRuns(out: Map, runs: Run[] | undefined) // A field annotation with no explicit font is measured against 'Arial' by the measurer // (its buildFontString default), so plan that face rather than skip the fontless run. if (run.kind === 'fieldAnnotation' && (typeof bearing.fontFamily !== 'string' || !bearing.fontFamily)) { - collect(out, { ...bearing, fontFamily: 'Arial' }); + collect(out, { ...bearing, fontFamily: 'Arial' }, resolve); } else { - collect(out, bearing); + collect(out, bearing, resolve); } } } -function collectParagraph(out: Map, paragraph: ParagraphBlock | undefined): void { +function collectParagraph( + out: Map, + paragraph: ParagraphBlock | undefined, + resolve: ResolvePrimary, +): void { if (!paragraph) return; - collectRuns(out, paragraph.runs); + collectRuns(out, paragraph.runs, resolve); // The word-layout list marker glyph ("1.", "•") is measured with its OWN run font // (attrs.wordLayout.marker.run, used by the measurer's buildFontString), which can be a // different family/weight/style than the item text - so it must be planned too. - collect(out, paragraph.attrs?.wordLayout?.marker?.run as FontBearing | undefined); + collect(out, paragraph.attrs?.wordLayout?.marker?.run as FontBearing | undefined, resolve); // A drop cap is measured from attrs.dropCapDescriptor.run (measureDropCap) with its own, // often distinct and large, font; the cap text is moved out of `runs`, so plan it here. - collect(out, paragraph.attrs?.dropCapDescriptor?.run as FontBearing | undefined); + collect(out, paragraph.attrs?.dropCapDescriptor?.run as FontBearing | undefined, resolve); } -function collectTable(out: Map, table: TableBlock): void { +function collectTable(out: Map, table: TableBlock, resolve: ResolvePrimary): void { for (const row of table.rows) { for (const cell of row.cells) { - collectParagraph(out, cell.paragraph); - if (cell.blocks) for (const b of cell.blocks) collectBlock(out, b as FlowBlock); + collectParagraph(out, cell.paragraph, resolve); + if (cell.blocks) for (const b of cell.blocks) collectBlock(out, b as FlowBlock, resolve); } } } -function collectList(out: Map, list: ListBlock): void { +function collectList(out: Map, list: ListBlock, resolve: ResolvePrimary): void { for (const item of list.items) { // collectParagraph covers the item text AND any word-layout marker font on the // paragraph's attrs. The ListBlock-level `item.marker` (ListMarker) carries no font of // its own - that glyph is measured with the paragraph font, already collected here. - collectParagraph(out, item.paragraph); + collectParagraph(out, item.paragraph, resolve); } } -function collectBlock(out: Map, block: FlowBlock): void { +function collectBlock(out: Map, block: FlowBlock, resolve: ResolvePrimary): void { switch (block.kind) { case 'paragraph': // Via collectParagraph (not collectRuns) so a top-level paragraph's word-layout // marker run font is collected too, not just its text runs. - collectParagraph(out, block); + collectParagraph(out, block, resolve); break; case 'table': - collectTable(out, block); + collectTable(out, block, resolve); break; case 'list': - collectList(out, block); + collectList(out, block, resolve); break; default: // image/drawing/section/page/column breaks carry no measurable text font. @@ -113,10 +121,17 @@ function collectBlock(out: Map, block: FlowBlock): void * The deduped physical face requests the given layout blocks actually render. The caller * passes every block this render measures - body, notes, header/footer, and (in paginated * mode) footnotes - so each measured face is planned; this function only walks what it is - * given. + * given. A `resolver` (the document's) maps logical -> physical so the planned faces match + * measure/paint; without one it falls back to the shared bundled map. */ -export function planRequiredFontFaces(blocks: readonly FlowBlock[] | null | undefined): FontFaceRequest[] { +export function planRequiredFontFaces( + blocks: readonly FlowBlock[] | null | undefined, + resolver?: FontResolver, +): FontFaceRequest[] { + const resolve: ResolvePrimary = resolver + ? (family) => resolver.resolvePrimaryPhysicalFamily(family) + : resolvePrimaryPhysicalFamily; const out = new Map(); - if (blocks) for (const block of blocks) collectBlock(out, block); + if (blocks) for (const block of blocks) collectBlock(out, block, resolve); return [...out.values()]; } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 2fb8cc5aa1..784658b086 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -47,6 +47,7 @@ import { } from '../../header-footer/HeaderFooterRegistry.js'; import { initHeaderFooterRegistry } from '../../header-footer/HeaderFooterRegistryInit.js'; import { layoutPerRIdHeaderFooters } from '../../header-footer/HeaderFooterPerRidLayout.js'; +import type { FontResolver } from '@superdoc/font-system'; import { extractIdentifierFromConverter, getHeaderFooterType, @@ -251,6 +252,11 @@ export type HeaderFooterSessionManagerOptions = { header?: number; footer?: number; }; + /** + * Reads the owning document's current font-mapping signature, folded into header/footer + * paint-reuse versions so a runtime `fonts.map` change repaints them. Omitted => '' (default). + */ + getFontSignature?: () => string; }; /** @@ -374,9 +380,13 @@ function storyIdFromHeaderFooterLayoutKey(key: string): string { return key.replace(/::s\d+$/, ''); } -function resolveResult(result: HeaderFooterLayoutResult, storyId?: string | null): ResolvedHeaderFooterLayout { +function resolveResult( + result: HeaderFooterLayoutResult, + storyId?: string | null, + fontSignature = '', +): ResolvedHeaderFooterLayout { const story = buildHeaderFooterStory(result.kind, storyId ?? String(result.type)); - return resolveHeaderFooterLayout(result.layout, result.blocks, result.measures, story); + return resolveHeaderFooterLayout(result.layout, result.blocks, result.measures, story, fontSignature); } function shiftResolvedPaintItemY(item: ResolvedPaintItem, yOffset: number): ResolvedPaintItem { @@ -576,7 +586,7 @@ export class HeaderFooterSessionManager { /** Set header layout results */ set headerLayoutResults(results: HeaderFooterLayoutResult[] | null) { this.#headerLayoutResults = results; - this.#resolvedHeaderLayouts = results ? results.map((result) => resolveResult(result)) : null; + this.#resolvedHeaderLayouts = results ? results.map((result) => this.#resolveResult(result)) : null; } /** Footer layout results */ @@ -587,7 +597,7 @@ export class HeaderFooterSessionManager { /** Set footer layout results */ set footerLayoutResults(results: HeaderFooterLayoutResult[] | null) { this.#footerLayoutResults = results; - this.#resolvedFooterLayouts = results ? results.map((result) => resolveResult(result)) : null; + this.#resolvedFooterLayouts = results ? results.map((result) => this.#resolveResult(result)) : null; } /** Header layouts by rId */ @@ -698,8 +708,8 @@ export class HeaderFooterSessionManager { ): void { this.#headerLayoutResults = headerResults; this.#footerLayoutResults = footerResults; - this.#resolvedHeaderLayouts = headerResults ? headerResults.map((result) => resolveResult(result)) : null; - this.#resolvedFooterLayouts = footerResults ? footerResults.map((result) => resolveResult(result)) : null; + this.#resolvedHeaderLayouts = headerResults ? headerResults.map((result) => this.#resolveResult(result)) : null; + this.#resolvedFooterLayouts = footerResults ? footerResults.map((result) => this.#resolveResult(result)) : null; } /** @@ -1648,6 +1658,11 @@ export class HeaderFooterSessionManager { }; } + /** resolveResult, with this document's font signature folded into the paint-reuse versions. */ + #resolveResult(result: HeaderFooterLayoutResult, storyId?: string | null): ResolvedHeaderFooterLayout { + return resolveResult(result, storyId, this.#options.getFontSignature?.() ?? ''); + } + /** * Layout per-rId header/footers for multi-section documents. */ @@ -1655,20 +1670,27 @@ export class HeaderFooterSessionManager { headerFooterInput: HeaderFooterInput, layout: Layout, sectionMetadata: SectionMetadata[], + fontResolver?: FontResolver, ): Promise { - await layoutPerRIdHeaderFooters(headerFooterInput, layout, sectionMetadata, { - headerLayoutsByRId: this.#headerLayoutsByRId, - footerLayoutsByRId: this.#footerLayoutsByRId, - }); + await layoutPerRIdHeaderFooters( + headerFooterInput, + layout, + sectionMetadata, + { + headerLayoutsByRId: this.#headerLayoutsByRId, + footerLayoutsByRId: this.#footerLayoutsByRId, + }, + fontResolver, + ); // Rebuild resolved maps aligned 1:1 with the raw rId maps. this.#resolvedHeaderByRId.clear(); for (const [key, result] of this.#headerLayoutsByRId) { - this.#resolvedHeaderByRId.set(key, resolveResult(result, storyIdFromHeaderFooterLayoutKey(key))); + this.#resolvedHeaderByRId.set(key, this.#resolveResult(result, storyIdFromHeaderFooterLayoutKey(key))); } this.#resolvedFooterByRId.clear(); for (const [key, result] of this.#footerLayoutsByRId) { - this.#resolvedFooterByRId.set(key, resolveResult(result, storyIdFromHeaderFooterLayoutKey(key))); + this.#resolvedFooterByRId.set(key, this.#resolveResult(result, storyIdFromHeaderFooterLayoutKey(key))); } } @@ -2324,7 +2346,7 @@ export class HeaderFooterSessionManager { ); } - const freshResolvedLayout = resolveResult(result, storyId); + const freshResolvedLayout = this.#resolveResult(result, storyId); const freshPage = freshResolvedLayout.pages.find((page) => page.number === slotPageNumber); const freshItems = freshPage?.items; if (freshItems && freshItems.length === fragments.length) { diff --git a/shared/font-system/src/index.ts b/shared/font-system/src/index.ts index 8347a3d434..79cc89dfd7 100644 --- a/shared/font-system/src/index.ts +++ b/shared/font-system/src/index.ts @@ -29,6 +29,7 @@ export type { export { SETTLED_STATUSES, isSettled } from './types'; export type { FontResolution, FontResolutionReason } from './resolver'; +export { FontResolver, createFontResolver } from './resolver'; export { resolveFontFamily, resolvePhysicalFamily, diff --git a/shared/font-system/src/report.ts b/shared/font-system/src/report.ts index ba79e07c6d..2e5c36d9b3 100644 --- a/shared/font-system/src/report.ts +++ b/shared/font-system/src/report.ts @@ -1,4 +1,4 @@ -import { resolveFontFamily, type FontResolutionReason } from './resolver'; +import { resolveFontFamily, type FontResolutionReason, type FontResolver } from './resolver'; import type { FontRegistry } from './registry'; import { isSettled, type FontLoadStatus } from './types'; @@ -42,13 +42,19 @@ export interface FontResolutionRecord { * upgraded `onFontsResolved` payload are thin wrappers over this - they must not compute * resolution independently, or the report could disagree with what actually painted. */ -export function buildFontReport(logicalFamilies: Iterable, registry: FontRegistry): FontResolutionRecord[] { +export function buildFontReport( + logicalFamilies: Iterable, + registry: FontRegistry, + resolver?: FontResolver, +): FontResolutionRecord[] { const seen = new Set(); const report: FontResolutionRecord[] = []; for (const logical of logicalFamilies) { if (!logical || seen.has(logical)) continue; seen.add(logical); - const { physicalFamily, reason } = resolveFontFamily(logical); + // Resolve through the document's resolver so the report reflects its per-document + // `fonts.map`; fall back to the shared bundled map for callers without a context. + const { physicalFamily, reason } = resolver ? resolver.resolveFontFamily(logical) : resolveFontFamily(logical); const loadStatus = registry.getStatus(physicalFamily); report.push({ logicalFamily: logical, diff --git a/shared/font-system/src/resolver.test.ts b/shared/font-system/src/resolver.test.ts index 0f50c870b8..bd3f0c46a6 100644 --- a/shared/font-system/src/resolver.test.ts +++ b/shared/font-system/src/resolver.test.ts @@ -4,6 +4,7 @@ import { resolvePhysicalFamily, resolvePrimaryPhysicalFamily, resolvePhysicalFamilies, + createFontResolver, } from './index'; describe('font resolver', () => { @@ -61,3 +62,108 @@ describe('font resolver', () => { ]); }); }); + +describe('FontResolver (per-document context)', () => { + it('is seeded with the bundled clean-clone map', () => { + const resolver = createFontResolver(); + expect(resolver.resolvePrimaryPhysicalFamily('Calibri')).toBe('Carlito'); + expect(resolver.resolvePhysicalFamily('Arial, sans-serif')).toBe('Liberation Sans, sans-serif'); + expect(resolver.version).toBe(0); + }); + + it('map() overrides the bundled default and reports custom_mapping', () => { + const resolver = createFontResolver(); + resolver.map('Georgia', 'Gelasio'); + expect(resolver.resolvePrimaryPhysicalFamily('Georgia, serif')).toBe('Gelasio'); + expect(resolver.resolveFontFamily('Georgia')).toEqual({ + logicalFamily: 'Georgia', + physicalFamily: 'Gelasio', + reason: 'custom_mapping', + }); + // An override beats the bundled map for the same logical family. + resolver.map('Calibri', 'MyCalibri'); + expect(resolver.resolvePrimaryPhysicalFamily('Calibri')).toBe('MyCalibri'); + }); + + it('version bumps on each distinct mapping change, not on no-ops', () => { + const resolver = createFontResolver(); + resolver.map('Georgia', 'Gelasio'); + expect(resolver.version).toBe(1); + resolver.map('Georgia', 'Gelasio'); // same -> no bump + expect(resolver.version).toBe(1); + resolver.unmap('Georgia'); + expect(resolver.version).toBe(2); + resolver.unmap('Georgia'); // absent -> no bump + expect(resolver.version).toBe(2); + expect(resolver.resolvePrimaryPhysicalFamily('Georgia')).toBe('Georgia'); // reverted to identity + }); + + it('isolates mappings per instance: two documents map the same logical family differently', () => { + const docA = createFontResolver(); + const docB = createFontResolver(); + docA.map('Georgia', 'Gelasio'); + docB.map('Georgia', 'Tinos'); + + expect(docA.resolvePrimaryPhysicalFamily('Georgia')).toBe('Gelasio'); + expect(docB.resolvePrimaryPhysicalFamily('Georgia')).toBe('Tinos'); + // A document with no override still gets the bundled default, unaffected by the others. + expect(createFontResolver().resolvePrimaryPhysicalFamily('Georgia')).toBe('Georgia'); + expect(docA.resolvePrimaryPhysicalFamily('Calibri')).toBe('Carlito'); // bundled map intact + }); + + it('signature is stable, order-independent, and distinguishes different mappings at the same version', () => { + const empty = createFontResolver(); + expect(empty.signature).toBe(''); // default docs share cache safely + + const docA = createFontResolver(); + docA.map('Georgia', 'Gelasio'); + const docB = createFontResolver(); + docB.map('Georgia', 'Tinos'); + // Same version (1), DIFFERENT mappings -> signatures MUST differ (else measure/paint collide). + expect(docA.version).toBe(docB.version); + expect(docA.signature).not.toBe(docB.signature); + + // Order-independent: the same set of mappings yields the same signature regardless of insertion order. + const x = createFontResolver(); + x.map('Georgia', 'Gelasio'); + x.map('Arial', 'MyArial'); + const y = createFontResolver(); + y.map('Arial', 'MyArial'); + y.map('Georgia', 'Gelasio'); + expect(x.signature).toBe(y.signature); + + // Identical mapping -> identical signature (safe cross-document cache sharing). + const z = createFontResolver(); + z.map('Georgia', 'Gelasio'); + expect(z.signature).toBe(docA.signature); + }); + + it('reset() drops all overrides (document swap) and reverts to the bundled-only map', () => { + const resolver = createFontResolver(); + resolver.map('Georgia', 'Gelasio'); + resolver.map('Calibri', 'MyCalibri'); + expect(resolver.signature).not.toBe(''); + + resolver.reset(); + expect(resolver.signature).toBe(''); // back to default identity + expect(resolver.resolvePrimaryPhysicalFamily('Georgia')).toBe('Georgia'); // override gone + expect(resolver.resolvePrimaryPhysicalFamily('Calibri')).toBe('Carlito'); // bundled default restored + expect(resolver.version).toBe(3); // 2 maps + 1 reset + + const before = resolver.version; + resolver.reset(); // already empty -> no-op, no version bump + expect(resolver.version).toBe(before); + }); + + it('trims the physical family and ignores empty/whitespace mappings', () => { + const resolver = createFontResolver(); + resolver.map('Georgia', ' Gelasio '); + expect(resolver.resolvePrimaryPhysicalFamily('Georgia')).toBe('Gelasio'); // trimmed + expect(resolver.version).toBe(1); + resolver.map('Georgia', 'Gelasio'); // same after trim -> no bump + expect(resolver.version).toBe(1); + resolver.map('Tahoma', ' '); // whitespace-only physical -> ignored + expect(resolver.resolvePrimaryPhysicalFamily('Tahoma')).toBe('Tahoma'); + expect(resolver.version).toBe(1); + }); +}); diff --git a/shared/font-system/src/resolver.ts b/shared/font-system/src/resolver.ts index a6b15c4da6..e3bd16701c 100644 --- a/shared/font-system/src/resolver.ts +++ b/shared/font-system/src/resolver.ts @@ -13,17 +13,23 @@ * builds via `toCssFontFamily`, e.g. "Calibri, sans-serif" - so resolution applies * to the PRIMARY family and keeps the remaining fallbacks ("Carlito, sans-serif"). * - * Ships the five verified clean clones (Calibri->Carlito, Cambria->Caladea, - * Arial->Liberation Sans, Times New Roman->Liberation Serif, Courier New->Liberation - * Mono) - each proven to match Word's painted line breaks. Becomes customer-configurable - * in T7; this module stays the single source of the map. + * Resolution is a {@link FontResolver} INSTANCE, not a global: each document gets its + * own so two editors on one page can map the same logical family differently (a + * customer `fonts.map`) without leaking across documents - the same per-document + * isolation the registry already has per `FontFaceSet`. Every instance is seeded with + * the five verified clean clones (Calibri->Carlito, Cambria->Caladea, Arial->Liberation + * Sans, Times New Roman->Liberation Serif, Courier New->Liberation Mono). The + * module-level `resolve*` functions delegate to a shared default instance for callers + * that have no document context (and for backward compatibility). */ export type FontResolutionReason = /** No substitute is known; the requested family is used as-is. */ | 'as_requested' /** Replaced by a bundled metric-compatible clone. */ - | 'bundled_substitute'; + | 'bundled_substitute' + /** Replaced by a runtime mapping set on this document's resolver (customer `fonts.map`). */ + | 'custom_mapping'; export interface FontResolution { /** The family the document asked for (preserved for toolbar/export). */ @@ -64,58 +70,151 @@ function splitStack(cssFontFamily: string): string[] { .filter(Boolean); } -/** The physical family for a bare logical name, or the name itself if unmapped. */ -function physicalFor(bareFamily: string): { physical: string; mapped: boolean } { - const physical = BUNDLED_SUBSTITUTES[normalizeFamilyKey(bareFamily)]; - return physical ? { physical, mapped: true } : { physical: bareFamily, mapped: false }; +/** + * Per-document logical -> physical font resolver. Seeded with the bundled clean-clone + * map; also holds per-instance runtime overrides (a customer `fonts.map`). Because each + * document owns its instance, two documents can map the same logical family to different + * physical families without interfering. Its {@link signature} (NOT the numeric + * {@link version}) is the identity measure-cache keys and paint reuse signatures must fold in, + * so two documents at the same version with different mappings never collide. + */ +export class FontResolver { + /** Normalized logical family -> physical family. Takes precedence over the bundled map. */ + readonly #overrides = new Map(); + #version = 0; + + /** + * Map a logical family to a physical render family for this document, overriding the + * bundled default (e.g. "Georgia" -> "Gelasio", or a customer family -> their font). + * The physical family must be one the registry can load. + */ + map(logicalFamily: string, physicalFamily: string): void { + const key = normalizeFamilyKey(logicalFamily); + // The physical name is the bare family the registry loads and CSS renders, so trim + // surrounding whitespace (" Gelasio " and "Gelasio" must be one mapping, not two). + const physical = physicalFamily?.trim(); + if (!key || !physical) return; + if (this.#overrides.get(key) === physical) return; + this.#overrides.set(key, physical); + this.#version += 1; + } + + /** Remove a runtime mapping; the family reverts to its bundled default (or identity). */ + unmap(logicalFamily: string): void { + if (this.#overrides.delete(normalizeFamilyKey(logicalFamily))) this.#version += 1; + } + + /** + * Drop all runtime overrides, reverting to the bundled-only map. Call on a document swap + * (the same editor instance is reused, so the prior document's `fonts.map` must not leak + * into the next). Bumps {@link version} only if something was actually cleared. + */ + reset(): void { + if (this.#overrides.size === 0) return; + this.#overrides.clear(); + this.#version += 1; + } + + /** Monotonic version; bumps on every mapping change. A lightweight "did it change" signal. */ + get version(): number { + return this.#version; + } + + /** + * Stable content signature of this resolver's runtime mappings - the deterministic, + * order-independent serialization of its overrides. This (NOT {@link version}) is what + * measure-cache keys and paint reuse signatures must fold in: two documents can both be at + * version 1 with DIFFERENT mappings (Georgia->Gelasio vs Georgia->Tinos), and a numeric + * version would collide; their signatures differ. Empty (no overrides) is `''`, so all + * default documents share cache safely because they resolve identically. + */ + get signature(): string { + if (this.#overrides.size === 0) return ''; + // JSON of sorted [logical, physical] pairs: deterministic and collision-safe even when a + // font name contains punctuation (a delimited "logical=physical|..." form would not be). + return JSON.stringify([...this.#overrides.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); + } + + /** The physical family + why, for a bare logical name. Overrides beat the bundled map. */ + #physicalFor(bareFamily: string): { physical: string; reason: FontResolutionReason } { + const key = normalizeFamilyKey(bareFamily); + const override = this.#overrides.get(key); + if (override) return { physical: override, reason: 'custom_mapping' }; + const bundled = BUNDLED_SUBSTITUTES[key]; + if (bundled) return { physical: bundled, reason: 'bundled_substitute' }; + return { physical: bareFamily, reason: 'as_requested' }; + } + + /** + * Structured resolution of a logical family (or CSS stack) to its bare physical render + * family. The primary (first) family drives the result; this is what the load gate + * awaits and what diagnostics report. + */ + resolveFontFamily(logicalFamily: string): FontResolution { + const parts = splitStack(logicalFamily); + const primary = parts[0] ?? logicalFamily; + const { physical, reason } = this.#physicalFor(primary); + return { logicalFamily, physicalFamily: physical, reason }; + } + + /** + * Resolve a CSS font-family value for MEASURE and PAINT: swap the primary family to its + * physical substitute and keep the original fallbacks. "Calibri, sans-serif" -> + * "Carlito, sans-serif"; "Calibri" -> "Carlito". An unmapped value is returned unchanged. + */ + resolvePhysicalFamily(cssFontFamily: string): string { + if (!cssFontFamily) return cssFontFamily; + const parts = splitStack(cssFontFamily); + if (parts.length === 0) return cssFontFamily; + const { physical, reason } = this.#physicalFor(parts[0]); + if (reason === 'as_requested') return cssFontFamily; + return [physical, ...parts.slice(1)].join(', '); + } + + /** + * The bare physical family the load gate must await - the primary family resolved to its + * substitute. "Calibri, sans-serif" -> "Carlito"; "Calibri" -> "Carlito". + */ + resolvePrimaryPhysicalFamily(family: string): string { + const parts = splitStack(family); + const primary = parts[0] ?? family; + return this.#physicalFor(primary).physical; + } + + /** The deduped set of physical face families a set of logical families needs loaded. */ + resolvePhysicalFamilies(families: Iterable): string[] { + const out = new Set(); + for (const family of families) { + if (family) out.add(this.resolvePrimaryPhysicalFamily(family)); + } + return [...out]; + } +} + +/** Create a per-document resolver seeded with the bundled clean-clone map. */ +export function createFontResolver(): FontResolver { + return new FontResolver(); } /** - * Structured resolution of a logical family (or CSS stack) to its bare physical - * render family. The primary (first) family drives the result; this is what the - * load gate awaits and what diagnostics report. + * Shared default resolver for callers without a document context. Document rendering + * threads its OWN {@link FontResolver} (so per-document `map` stays isolated); these + * module functions delegate here and preserve the prior global behavior. */ +const defaultResolver = new FontResolver(); + export function resolveFontFamily(logicalFamily: string): FontResolution { - const parts = splitStack(logicalFamily); - const primary = parts[0] ?? logicalFamily; - const { physical, mapped } = physicalFor(primary); - return { - logicalFamily, - physicalFamily: physical, - reason: mapped ? 'bundled_substitute' : 'as_requested', - }; + return defaultResolver.resolveFontFamily(logicalFamily); } -/** - * Resolve a CSS font-family value for MEASURE and PAINT: swap the primary family - * to its physical substitute and keep the original fallbacks. - * "Calibri, sans-serif" -> "Carlito, sans-serif"; "Calibri" -> "Carlito". - * An unmapped value is returned unchanged. - */ export function resolvePhysicalFamily(cssFontFamily: string): string { - if (!cssFontFamily) return cssFontFamily; - const parts = splitStack(cssFontFamily); - if (parts.length === 0) return cssFontFamily; - const { physical, mapped } = physicalFor(parts[0]); - if (!mapped) return cssFontFamily; - return [physical, ...parts.slice(1)].join(', '); + return defaultResolver.resolvePhysicalFamily(cssFontFamily); } -/** - * The bare physical family the load gate must await - the primary family resolved - * to its substitute. "Calibri, sans-serif" -> "Carlito"; "Calibri" -> "Carlito". - */ export function resolvePrimaryPhysicalFamily(family: string): string { - const parts = splitStack(family); - const primary = parts[0] ?? family; - return physicalFor(primary).physical; + return defaultResolver.resolvePrimaryPhysicalFamily(family); } -/** The deduped set of physical face families a set of logical families needs loaded. */ export function resolvePhysicalFamilies(families: Iterable): string[] { - const out = new Set(); - for (const family of families) { - if (family) out.add(resolvePrimaryPhysicalFamily(family)); - } - return [...out]; + return defaultResolver.resolvePhysicalFamilies(families); }