From 39f90e62d045a1867bf6d97ef130a8f83935393c Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 15:16:10 -0300 Subject: [PATCH 01/10] feat(fonts): per-document FontResolver (foundation for runtime mapping) Replace the global frozen logical->physical map with a FontResolver INSTANCE so two editors on one page can map the same logical family differently without leaking - the same per-document isolation the registry already has per FontFaceSet. Each instance is seeded with the five bundled clean clones and holds per-instance runtime overrides (map/unmap) with a version epoch for reuse-busting. Behavior-preserving: the module-level resolve* functions delegate to a shared default instance, so every current caller (measure, paint, planner, gate, report) is unchanged. Threading the per-document instance through those call sites is the next step. --- shared/font-system/src/index.ts | 1 + shared/font-system/src/resolver.test.ts | 89 ++++++++++++ shared/font-system/src/resolver.ts | 177 ++++++++++++++++++------ 3 files changed, 222 insertions(+), 45 deletions(-) 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/resolver.test.ts b/shared/font-system/src/resolver.test.ts index 0f50c870b8..e2b8b3a779 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,91 @@ 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('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..3954b79b54 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,139 @@ 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. A {@link version} bumps on every mapping change + * so measure/paint reuse signatures can fold it in and bust stale reuse. + */ +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; + } + + /** 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); } From 1fbc870190dca9f8a1661b4b8afba29b0f06a113 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 15:40:00 -0300 Subject: [PATCH 02/10] feat(fonts): thread the per-document resolver through planner, gate, and report PresentationEditor now owns one FontResolver per document and passes it to the planner (planRequiredFontFaces), the gate (which derives the family-path resolution from it and resolves its report through it), and buildFontReport. So load, family resolution, and diagnostics all go through the same per-document instance instead of the global default. Behavior-preserving: with no runtime overrides the resolver matches the bundled map, and the threaded params are optional (callers without a document context still use the global functions). Measure and paint still use the global resolver + cache keys; threading those (with the resolver signature in the measure cache key and paint reuse signature) is next. --- .../presentation-editor/PresentationEditor.ts | 16 +++-- .../fonts/FontReadinessGate.ts | 16 ++++- .../fonts/font-load-planner.ts | 66 +++++++++++-------- shared/font-system/src/report.ts | 12 +++- 4 files changed, 74 insertions(+), 36 deletions(-) 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..39a14dbfe0 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'; @@ -531,6 +531,12 @@ 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, + * measure, paint, and report all resolve through THIS instance. + */ + 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. */ @@ -966,10 +972,10 @@ 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, so load, measure, paint, and diagnostics 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) => 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..fd35836bc8 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,12 @@ 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` and stays + * consistent with measure/paint (which use the same instance). + */ + 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 +104,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 +132,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 +179,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..804279231a 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,15 @@ 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 use), 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 +34,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 +47,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 +56,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 +120,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/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, From 2dcb0eb438d0e4832f833d7969c02341a53a08c4 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 15:46:44 -0300 Subject: [PATCH 03/10] feat(fonts): resolver reset on document swap; correct signature-vs-version docs - Add FontResolver.reset() and call it from documentReplaced alongside the gate reset. The resolver is per PresentationEditor instance, so a reused editor must drop the prior document's runtime mappings or a fonts.map would leak into the next document. - Fix the class JSDoc: the cache/reuse identity is the stable signature, not the numeric version (two docs at the same version with different mappings must not collide). --- .../presentation-editor/PresentationEditor.ts | 7 ++++--- shared/font-system/src/resolver.test.ts | 17 +++++++++++++++++ shared/font-system/src/resolver.ts | 16 ++++++++++++++-- 3 files changed, 35 insertions(+), 5 deletions(-) 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 39a14dbfe0..12e126f721 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 @@ -5036,10 +5036,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); diff --git a/shared/font-system/src/resolver.test.ts b/shared/font-system/src/resolver.test.ts index e2b8b3a779..bd3f0c46a6 100644 --- a/shared/font-system/src/resolver.test.ts +++ b/shared/font-system/src/resolver.test.ts @@ -138,6 +138,23 @@ describe('FontResolver (per-document context)', () => { 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 '); diff --git a/shared/font-system/src/resolver.ts b/shared/font-system/src/resolver.ts index 3954b79b54..819dfa6595 100644 --- a/shared/font-system/src/resolver.ts +++ b/shared/font-system/src/resolver.ts @@ -74,8 +74,9 @@ function splitStack(cssFontFamily: string): string[] { * 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. A {@link version} bumps on every mapping change - * so measure/paint reuse signatures can fold it in and bust stale reuse. + * physical families without interfering. Its {@link signature} (NOT the numeric + * {@link version}) is the identity measure-cache keys and paint reuse signatures 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. */ @@ -103,6 +104,17 @@ export class FontResolver { 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; From 829890fcd820df379f6d0fb3acde7aa6d75bb057 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 16:03:00 -0300 Subject: [PATCH 04/10] docs(fonts): correct resolver comments to match the partial state Measure and paint still use the global resolver; only planner, gate, and report read the per-document instance so far. Reword the #fontResolver field and the gate fontResolver option so they do not claim measure/paint consistency before that threading lands. --- .../v1/core/presentation-editor/PresentationEditor.ts | 9 ++++++--- .../core/presentation-editor/fonts/FontReadinessGate.ts | 5 +++-- .../core/presentation-editor/fonts/font-load-planner.ts | 9 +++++---- shared/font-system/src/resolver.ts | 2 +- 4 files changed, 15 insertions(+), 10 deletions(-) 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 12e126f721..4e7e8f6157 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 @@ -533,8 +533,10 @@ export class PresentationEditor extends EventEmitter { #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, - * measure, paint, and report all resolve through THIS instance. + * editors can map the same logical family differently without leaking. Planner, gate, and + * report resolve through THIS instance today; threading measure + paint (and folding the + * resolver signature into their cache/reuse keys) is the remaining step before runtime + * `fonts.map` is safe - until then measure/paint still use the global resolver. */ readonly #fontResolver = createFontResolver(); /** Layout blocks for the current render, stashed so the gate's planner reads the live set. */ @@ -974,7 +976,8 @@ export class PresentationEditor extends EventEmitter { // fonts are not fetched. Reads the blocks stashed just before each gate await. 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, so load, measure, paint, and diagnostics agree. + // resolves its report through it (load + diagnostics). Measure/paint do not read it + // yet - threading them is the remaining step before load/measure/paint fully 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. 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 fd35836bc8..b0f03d66a5 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 @@ -58,8 +58,9 @@ export interface FontReadinessGateOptions { 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` and stays - * consistent with measure/paint (which use the same instance). + * report resolves through it, so the gate honors a per-document `fonts.map`. (Measure and + * paint do not yet read this instance; threading them is the remaining step, after which + * load, measure, paint, and diagnostics all agree.) */ fontResolver?: FontResolver; /** Per-font load budget before a face is treated as timed out. */ 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 804279231a..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 @@ -13,10 +13,11 @@ 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 the DOCUMENT'S resolver (the - * same instance measure and paint use), 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. + * 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. */ diff --git a/shared/font-system/src/resolver.ts b/shared/font-system/src/resolver.ts index 819dfa6595..e3bd16701c 100644 --- a/shared/font-system/src/resolver.ts +++ b/shared/font-system/src/resolver.ts @@ -75,7 +75,7 @@ function splitStack(cssFontFamily: string): string[] { * 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 fold in, + * {@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 { From 5f58b642b99eb8fc65791b63321b7a2a9e3027be Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 16:23:25 -0300 Subject: [PATCH 05/10] feat(fonts): thread the document resolver through measure (buildFontString) measureBlock now accepts a resolvePhysical function and threads it through measureParagraphBlock / measureListBlock / measureTableBlock / measureTabAlignmentGroup / measureDropCap to buildFontString - including the table path's recursive measureBlock call for cell content - so a document's text is MEASURED with its own resolver (honoring a per-document fonts.map) instead of the global one. Defaults to the global bundled map, so callers that pass nothing are unchanged; the body/header-footer callers that bind the document resolver, plus the measure-cache font signature, come next. Verified: measuring-dom tsc error profile is identical to baseline (no new errors vs the pre-existing implicit-any/never backlog). --- .../layout-engine/measuring/dom/src/index.ts | 110 ++++++++++++------ 1 file changed, 76 insertions(+), 34 deletions(-) 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; From e8d8ea6051edcbf0314138be16c5309accc22939 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 16:47:38 -0300 Subject: [PATCH 06/10] feat(fonts): key measure caches by the per-document resolver signature Thread the document FontResolver and its mapping signature through every measure path - body, footnotes, header/footer, and per-rId header/footer - so two editors that map the same logical family differently cannot share a cached measure. The signature rides a narrow incremental-layout runtime option (not the exported LayoutOptions) and keys each measure cache; the resolver itself rides the measure callback. previousMeasures reuse bypasses the cache key, so it is now gated on the signature matching the prior pass. PresentationEditor binds its resolver into the measure callback and records the signature its measures were produced with. Behavior-preserving: no public fonts.map yet, so the signature stays '' and every path resolves through the bundled map as before. Paint is next. --- .../layout-engine/layout-bridge/src/cache.ts | 15 ++++---- .../layout-bridge/src/incrementalLayout.ts | 34 ++++++++++++++++--- .../layout-bridge/src/layoutHeaderFooter.ts | 17 +++++++--- .../header-footer/HeaderFooterPerRidLayout.ts | 27 +++++++++++++-- .../presentation-editor/PresentationEditor.ts | 33 ++++++++++++++---- .../HeaderFooterSessionManager.ts | 16 ++++++--- 6 files changed, 115 insertions(+), 27 deletions(-) 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/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 4e7e8f6157..48755d8f6f 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 @@ -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 */ @@ -533,10 +539,12 @@ export class PresentationEditor extends EventEmitter { #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, and - * report resolve through THIS instance today; threading measure + paint (and folding the - * resolver signature into their cache/reuse keys) is the remaining step before runtime - * `fonts.map` is safe - until then measure/paint still use the global resolver. + * editors can map the same logical family differently without leaking. Planner, gate, report, + * and MEASURE (body, footnotes, header/footer, and per-rId header/footer) all resolve through + * THIS instance, and its signature keys every measure cache so two documents with different + * mappings cannot share a measure. PAINT is the remaining global path; folding the resolver + * into the paint render context + reuse signature is the last step before a runtime + * `fonts.map` is fully isolated. */ readonly #fontResolver = createFontResolver(); /** Layout blocks for the current render, stashed so the gate's planner reads the live set. */ @@ -6712,6 +6720,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[]; @@ -6762,9 +6778,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`); @@ -6832,6 +6850,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; @@ -8104,7 +8125,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/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 2fb8cc5aa1..a96e62304b 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, @@ -1655,11 +1656,18 @@ 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(); From 69be0f6f5da7a6a07d3b61bc3dd5dee40b8a3d26 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 17:05:02 -0300 Subject: [PATCH 07/10] feat(fonts): paint each document through its own resolver The painter and the paint-reuse versioning now resolve fonts through the document's per-instance FontResolver instead of the global one, completing the per-document isolation the measure path already has. The painter is per document, so its resolvePhysical comes from painter options (PresentationEditor binds its resolver); text runs paint the family it returns, the same one measurement used. The resolver's signature is folded into every block's paint-reuse version - body via resolveLayout, header/footer via the session - so a future fonts.map change repaints the way a font load does today. Behavior-preserving: no public fonts.map yet, so the signature stays '' and both paint output and reuse versions are byte-identical to before. --- .../src/resolveHeaderFooter.ts | 12 ++++++- .../layout-resolved/src/resolveLayout.ts | 23 ++++++++++--- .../layout-engine/painters/dom/src/index.ts | 7 ++++ .../painters/dom/src/renderer.ts | 4 +++ .../painters/dom/src/runs/render-run.ts | 2 +- .../painters/dom/src/runs/text-run.ts | 15 ++++++--- .../painters/dom/src/runs/types.ts | 2 ++ .../presentation-editor/PresentationEditor.ts | 17 +++++++--- .../HeaderFooterSessionManager.ts | 32 +++++++++++++------ 9 files changed, 89 insertions(+), 25 deletions(-) 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.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 786e293f84..d86903a05c 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 versions byte-identical to 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/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/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 48755d8f6f..65f776f186 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 @@ -540,11 +540,12 @@ export class PresentationEditor extends EventEmitter { /** * 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, - * and MEASURE (body, footnotes, header/footer, and per-rId header/footer) all resolve through - * THIS instance, and its signature keys every measure cache so two documents with different - * mappings cannot share a measure. PAINT is the remaining global path; folding the resolver - * into the paint render context + reuse signature is the last step before a runtime - * `fonts.map` is fully isolated. + * MEASURE (body, footnotes, header/footer, per-rId header/footer), and PAINT all resolve + * through THIS instance. 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. 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 every path is byte-identical to the prior global behavior. */ readonly #fontResolver = createFontResolver(); /** Layout blocks for the current render, stashed so the gate's planner reads the live set. */ @@ -902,6 +903,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, @@ -3241,6 +3243,7 @@ export class PresentationEditor extends EventEmitter { flowMode: this.#layoutOptions.flowMode ?? 'paginated', blocks, measures, + fontSignature: this.#fontResolver.signature, }); const isSemanticFlow = this.#layoutOptions.flowMode === 'semantic'; @@ -6822,6 +6825,7 @@ export class PresentationEditor extends EventEmitter { flowMode: this.#layoutOptions.flowMode ?? 'paginated', blocks: bodyBlocksForPaint, measures: bodyMeasuresForPaint, + fontSignature, }); headerLayouts = result.headers; @@ -6997,6 +7001,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 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 a96e62304b..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 @@ -252,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; }; /** @@ -375,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 { @@ -577,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 */ @@ -588,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 */ @@ -699,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; } /** @@ -1649,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. */ @@ -1672,11 +1686,11 @@ export class HeaderFooterSessionManager { // 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))); } } @@ -2332,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) { From 9eb4f2d651b72dbec14a66075453197e257537f9 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 17:10:27 -0300 Subject: [PATCH 08/10] test(fonts): prove per-document isolation at the measure and paint seams Two documents that map one logical family to different physical fonts must not share a cached measure or reuse each other's painted DOM. The MeasureCache test keys two signatures against one block; the resolveLayout test drives two real resolvers (Georgia->Gelasio vs Georgia->Tinos) and asserts their block paint-reuse versions differ. Both also assert the empty signature is byte-identical to omitting it, locking in the behavior-preserving default. --- .../layout-bridge/test/cache.test.ts | 25 ++++++++++ .../layout-resolved/src/resolveLayout.test.ts | 49 +++++++++++++++++++ 2 files changed, 74 insertions(+) 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/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); From 8c253836e07cc6b3f68762c6079d7e7e4edafb5e Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 17:20:52 -0300 Subject: [PATCH 09/10] docs(fonts): correct stale resolver comments to match the completed threading Two comments still said measure and paint do not read the per-document resolver - true when written in the planner/gate phase, false now that both resolve through it (a stale comment is a prompt-surface bug). Also soften two "byte-identical to the prior global behavior" claims to "behavior-preserving by construction": the signature is always '' and the resolver shares the bundled map, so resolution is unchanged, though that is not golden-tested. --- .../layout-engine/layout-resolved/src/resolveLayout.ts | 2 +- .../v1/core/presentation-editor/PresentationEditor.ts | 7 ++++--- .../v1/core/presentation-editor/fonts/FontReadinessGate.ts | 5 ++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index d86903a05c..1a1d499915 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -44,7 +44,7 @@ export type ResolveLayoutInput = { /** * 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 versions byte-identical to before. + * epoch). Omitted/'' for default documents, leaving the version unchanged from before. */ fontSignature?: string; }; 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 65f776f186..bdbafb21f0 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 @@ -545,7 +545,8 @@ export class PresentationEditor extends EventEmitter { * so two documents with different mappings can never share a measure or reuse each other's * painted DOM. 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 every path is byte-identical to the prior global behavior. + * 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. */ @@ -986,8 +987,8 @@ export class PresentationEditor extends EventEmitter { // fonts are not fetched. Reads the blocks stashed just before each gate await. 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/paint do not read it - // yet - threading them is the remaining step before load/measure/paint fully agree. + // 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. 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 b0f03d66a5..71490e3d27 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 @@ -58,9 +58,8 @@ export interface FontReadinessGateOptions { 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`. (Measure and - * paint do not yet read this instance; threading them is the remaining step, after which - * load, measure, paint, and diagnostics all agree.) + * report resolves through it, so the gate honors a per-document `fonts.map`. Measure and + * paint resolve through the same instance, so load, measure, paint, and diagnostics all agree. */ fontResolver?: FontResolver; /** Per-font load budget before a face is treated as timed out. */ From e320cf2225e63d4731257fafc3f50241ee7da58b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 18:18:25 -0300 Subject: [PATCH 10/10] docs(fonts): note field-annotation pills as the one unresolved paint path The resolver docs claimed measure and paint all resolve through the per-document instance. That over-claims: field-annotation pills measure (line-layout path) and paint with the logical family - pre-existing on main, unchanged here. Tighten the #fontResolver and gate JSDocs to scope the claim to text runs and name the pill exception, deferring the fix to the fonts.map PR (where unifying it is an intended rendering change). --- .../v1/core/presentation-editor/PresentationEditor.ts | 11 +++++++---- .../presentation-editor/fonts/FontReadinessGate.ts | 6 ++++-- 2 files changed, 11 insertions(+), 6 deletions(-) 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 bdbafb21f0..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 @@ -540,10 +540,13 @@ export class PresentationEditor extends EventEmitter { /** * 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, - * MEASURE (body, footnotes, header/footer, per-rId header/footer), and PAINT all resolve - * through THIS instance. 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. This is the per-document isolation foundation the customer write API + * 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). 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 71490e3d27..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 @@ -58,8 +58,10 @@ export interface FontReadinessGateOptions { 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`. Measure and - * paint resolve through the same instance, so load, measure, paint, and diagnostics all agree. + * 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. */