diff --git a/demos/nextjs-ssr/.gitignore b/demos/nextjs-ssr/.gitignore index 5ef6a52078..938a89d2f4 100644 --- a/demos/nextjs-ssr/.gitignore +++ b/demos/nextjs-ssr/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +public/fonts/ diff --git a/demos/nextjs-ssr/copy-fonts.mjs b/demos/nextjs-ssr/copy-fonts.mjs new file mode 100644 index 0000000000..7131366e61 --- /dev/null +++ b/demos/nextjs-ssr/copy-fonts.mjs @@ -0,0 +1,21 @@ +// Copy SuperDoc's bundled metric-compatible font substitutes into public/fonts/ so they +// are served at /fonts/ (the default asset base). Without this, the bundled .woff2 404 +// and SuperDoc paginates against a browser fallback. Runs as `predev`/`prebuild`. +// +// A real Next.js consumer would copy from `node_modules/@superdoc/font-system/assets/` +// (or set `fonts.assetBaseUrl` to wherever they serve them); this example copies from the +// workspace build for a self-contained demo. +import { cpSync, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = resolve(here, '../../packages/superdoc/dist/fonts'); +const dst = resolve(here, 'public/fonts'); + +if (existsSync(src)) { + cpSync(src, dst, { recursive: true }); + console.log('[nextjs-ssr] copied bundled fonts -> public/fonts/'); +} else { + console.warn(`[nextjs-ssr] bundled fonts not found at ${src}; run \`pnpm build:superdoc\` first`); +} diff --git a/demos/nextjs-ssr/package.json b/demos/nextjs-ssr/package.json index bc007a9d68..bf700a6d05 100644 --- a/demos/nextjs-ssr/package.json +++ b/demos/nextjs-ssr/package.json @@ -3,7 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { + "predev": "node copy-fonts.mjs", "dev": "next dev --turbopack", + "prebuild": "node copy-fonts.mjs", "build": "next build", "start": "next start", "lint": "next lint" diff --git a/examples/getting-started/cdn/setup.mjs b/examples/getting-started/cdn/setup.mjs index 379b069be1..05d72b85fb 100644 --- a/examples/getting-started/cdn/setup.mjs +++ b/examples/getting-started/cdn/setup.mjs @@ -2,7 +2,7 @@ // so `index.html` is self-contained and can be served with `npx serve .`. // Run before `dev` or the Playwright smoke test. -import { copyFileSync, existsSync } from 'node:fs'; +import { copyFileSync, cpSync, existsSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -19,12 +19,19 @@ const assets = [ [sampleSource, resolve(here, 'test_file.docx')], ]; +// The bundled metric-compatible substitutes ship as separate assets under dist/fonts/. +// The CDN build auto-detects `./fonts/` relative to superdoc.min.js, so the example must +// serve them beside the script (here/fonts/) or every .woff2 404s. +const fontsSrc = resolve(dist, 'fonts'); +const fontsDst = resolve(here, 'fonts'); + const missing = assets.filter(([src]) => !existsSync(src)); -if (missing.length) { +if (missing.length || !existsSync(fontsSrc)) { console.error('[cdn-example/setup] Build the SuperDoc bundle first:'); console.error(' pnpm --filter superdoc build'); console.error('Missing files:'); for (const [src] of missing) console.error(' ' + src); + if (!existsSync(fontsSrc)) console.error(' ' + fontsSrc + ' (bundled font assets)'); process.exit(1); } @@ -32,3 +39,6 @@ for (const [src, dst] of assets) { copyFileSync(src, dst); console.log('[cdn-example/setup] copied', dst.replace(here + '/', '')); } + +cpSync(fontsSrc, fontsDst, { recursive: true }); +console.log('[cdn-example/setup] copied fonts/ (bundled substitute pack)'); diff --git a/examples/getting-started/laravel/.gitignore b/examples/getting-started/laravel/.gitignore index 0e36e1deb7..cd23a749ae 100644 --- a/examples/getting-started/laravel/.gitignore +++ b/examples/getting-started/laravel/.gitignore @@ -22,3 +22,4 @@ composer.lock /.nova /.vscode /.zed +/public/fonts/ diff --git a/examples/getting-started/laravel/copy-fonts.mjs b/examples/getting-started/laravel/copy-fonts.mjs new file mode 100644 index 0000000000..0312c4beb8 --- /dev/null +++ b/examples/getting-started/laravel/copy-fonts.mjs @@ -0,0 +1,20 @@ +// Copy SuperDoc's bundled metric-compatible font substitutes into public/fonts/ so Laravel +// serves them at /fonts/ (the default asset base). Without this, the bundled .woff2 404 and +// SuperDoc paginates against a browser fallback. Runs before `vite build` in `start`. +// +// A real Laravel app would copy from `node_modules/@superdoc/font-system/assets/` (or set +// `fonts.assetBaseUrl`); this example copies from the workspace build for a self-contained demo. +import { cpSync, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = resolve(here, '../../../packages/superdoc/dist/fonts'); +const dst = resolve(here, 'public/fonts'); + +if (existsSync(src)) { + cpSync(src, dst, { recursive: true }); + console.log('[laravel] copied bundled fonts -> public/fonts/'); +} else { + console.warn(`[laravel] bundled fonts not found at ${src}; run \`pnpm build:superdoc\` first`); +} diff --git a/examples/getting-started/laravel/package.json b/examples/getting-started/laravel/package.json index 0d831de6fd..dd9782d57f 100644 --- a/examples/getting-started/laravel/package.json +++ b/examples/getting-started/laravel/package.json @@ -3,9 +3,9 @@ "private": true, "type": "module", "scripts": { - "build": "vite build", - "dev": "concurrently \"php artisan serve --host=0.0.0.0 --port=8000\" \"vite\"", - "start": "vite build && php artisan serve --host=127.0.0.1 --port=8000" + "build": "node copy-fonts.mjs && vite build", + "dev": "node copy-fonts.mjs && concurrently \"php artisan serve --host=0.0.0.0 --port=8000\" \"vite\"", + "start": "node copy-fonts.mjs && vite build && php artisan serve --host=127.0.0.1 --port=8000" }, "dependencies": { "concurrently": "^9.0.0", diff --git a/packages/layout-engine/layout-resolved/package.json b/packages/layout-engine/layout-resolved/package.json index c0311c1dcc..94fa1cd7d3 100644 --- a/packages/layout-engine/layout-resolved/package.json +++ b/packages/layout-engine/layout-resolved/package.json @@ -20,7 +20,8 @@ }, "dependencies": { "@superdoc/common": "workspace:*", - "@superdoc/contracts": "workspace:*" + "@superdoc/contracts": "workspace:*", + "@superdoc/font-system": "workspace:*" }, "devDependencies": { "tsup": "catalog:", diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 89d56326f8..3f8a79fc25 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -22,6 +22,7 @@ import { type TextRun, type VectorShapeDrawing, } from '@superdoc/contracts'; +import { getFontConfigVersion } from '@superdoc/font-system'; import { hashParagraphBorders } from './paragraphBorderHash.js'; import { hashCellBorders, @@ -343,6 +344,9 @@ export const deriveBlockVersion = (block: FlowBlock): string => { return [ textRun.text ?? '', textRun.fontFamily, + // Font epoch: busts paint reuse when a font loads/changes (the resolved physical + // family is the same, only its availability changed - logical family alone can't see it). + getFontConfigVersion(), textRun.fontSize, textRun.bold ? 1 : 0, textRun.italic ? 1 : 0, diff --git a/packages/layout-engine/measuring/dom/package.json b/packages/layout-engine/measuring/dom/package.json index 1fa8891956..7b5359ee9a 100644 --- a/packages/layout-engine/measuring/dom/package.json +++ b/packages/layout-engine/measuring/dom/package.json @@ -25,6 +25,7 @@ "dependencies": { "@superdoc/contracts": "workspace:*", "@superdoc/common": "workspace:*", + "@superdoc/font-system": "workspace:*", "@superdoc/font-utils": "workspace:*", "@superdoc/geometry-utils": "workspace:*", "@superdoc/word-layout": "workspace:*" diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 6e6aa87412..3da3a58807 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -78,6 +78,7 @@ import { import { resolveListTextStartPx, type MinimalMarker } from '@superdoc/common/list-marker-utils'; import { calculateRotatedBounds, normalizeRotation } from '@superdoc/geometry-utils'; import { toCssFontFamily } from '@superdoc/font-utils'; +import { resolvePhysicalFamily } from '@superdoc/font-system'; export { installNodeCanvasPolyfill } from './setup.js'; import { clearMeasurementCache, getMeasuredTextWidth, setCacheSize } from './measurementCache.js'; import { getFontMetrics, clearFontMetricsCache, type FontInfo } from './fontMetricsCache.js'; @@ -88,6 +89,7 @@ import type { FixedLayoutResult } from './fixed-table-columns.js'; import { buildAutoFitTableResultCacheKey, buildTableCellContentMetricsCacheKey, + clearTableAutoFitMeasurementCaches, getCachedAutoFitTableResult, type TableAutoFitContentMetricsResult, measureTableAutoFitContentMetrics, @@ -95,6 +97,27 @@ import { } from './table-autofit-metrics.js'; export { clearFontMetricsCache }; +export { clearTableAutoFitMeasurementCaches }; + +/** + * Clear every font-dependent text-measurement cache owned by `measuring/dom`: + * text advance widths, font ascent/descent metrics, and AutoFit cell metrics. + * + * Call this when the set of available fonts changes (a face finishes loading, + * or a substitution/mapping is added) so the next measurement pass re-measures + * with the correct font instead of reusing results taken against a fallback. + * The caller is also responsible for clearing the layout-bridge block-measure + * cache (`measureCache.clear()`), which holds derived block measures. + */ +export function clearTextMeasurementCaches(): void { + clearMeasurementCache(); + clearFontMetricsCache(); + clearTableAutoFitMeasurementCaches(); + // Drop the persistent measuring canvas. A 2D context caches its font resolution: once it + // measured a family while the font was absent (falling back), it keeps using the fallback + // even after the font loads. A fresh context re-resolves to the now-available font. + canvasContext = null; +} const { computeTabStops } = Engines; @@ -301,19 +324,26 @@ function buildFontString(run: { fontFamily: string; fontSize: number; bold?: boo if (run.bold) parts.push('bold'); 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); + if (measurementConfig.mode === 'deterministic') { + // Deterministic mode still flattens to one family for reproducible server-side + // measurement; per-font resolution here is follow-up T1 work (browser mode first). parts.push( measurementConfig.fonts.fallbackStack.length > 0 ? measurementConfig.fonts.fallbackStack.join(', ') : measurementConfig.fonts.deterministicFamily, ); } else { - parts.push(run.fontFamily); + parts.push(physicalFamily); } return { font: parts.join(' '), - fontFamily: run.fontFamily, + fontFamily: physicalFamily, }; } diff --git a/packages/layout-engine/painters/dom/package.json b/packages/layout-engine/painters/dom/package.json index 3410206a3f..c5803b5042 100644 --- a/packages/layout-engine/painters/dom/package.json +++ b/packages/layout-engine/painters/dom/package.json @@ -20,6 +20,7 @@ "@superdoc/common": "workspace:*", "@superdoc/contracts": "workspace:*", "@superdoc/dom-contract": "workspace:*", + "@superdoc/font-system": "workspace:*", "@superdoc/font-utils": "workspace:*", "@superdoc/preset-geometry": "workspace:*", "@superdoc/url-validation": "workspace:*" diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index de1a88bfda..249856c9da 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -3200,7 +3200,11 @@ describe('DomPainter', () => { expect(placeholder?.dataset.placeholderText).toBe('Click or tap here to enter text'); expect(placeholder?.dataset.pmStart).toBe('4'); expect(placeholder?.dataset.pmEnd).toBe('4'); - expect(placeholder?.style.fontFamily).toBe('Arial'); + // Painted with the resolved PHYSICAL family (Arial -> Liberation Sans), like all + // painted text - the placeholder chrome goes through the same paint path. The logical + // family is preserved for export, not in painted DOM. Quoted because the serialized + // CSS value wraps a multi-word family name. + expect(placeholder?.style.fontFamily).toBe('"Liberation Sans"'); expect(placeholder?.style.fontSize).toBe('16px'); expect(fragment?.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('0px'); expect(fragment?.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('220px'); diff --git a/packages/layout-engine/painters/dom/src/paragraph/block-version.ts b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts index 212210b2a3..8aa8d0a9b5 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/block-version.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts @@ -1,5 +1,6 @@ import type { ImageRun, ParagraphAttrs, ParagraphBlock, TextRun, TrackedChangeMeta } from '@superdoc/contracts'; import { getParagraphInlineDirection } from '@superdoc/contracts'; +import { getFontConfigVersion } from '@superdoc/font-system'; import { hashParagraphBorders } from '../paragraph-hash-utils.js'; import { getRunBooleanProp, @@ -150,6 +151,9 @@ export const deriveParagraphBlockVersion = ( return [ textRun.text ?? '', textRun.fontFamily, + // Font epoch: busts block paint reuse when a font loads/changes (logical family + // alone cannot see a substitute becoming available after first paint). + getFontConfigVersion(), textRun.fontSize, textRun.bold ? 1 : 0, textRun.italic ? 1 : 0, 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 d9927998c1..be6f31e935 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -1,5 +1,6 @@ import type { FlowRunLink, Run, TextRun } from '@superdoc/contracts'; import { normalizeBaselineShift, resolveBaseFontSizeForVerticalText } from '@superdoc/contracts'; +import { resolvePhysicalFamily } from '@superdoc/font-system'; import { assertPmPositions } from '../pm-position-validation.js'; import type { FragmentRenderContext } from '../renderer.js'; import { BROWSER_DEFAULT_FONT_SIZE } from '../styles.js'; @@ -72,7 +73,9 @@ export const applyRunStyles = (element: HTMLElement, run: Run, _isLink = false): return; } - element.style.fontFamily = run.fontFamily; + // 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); element.style.fontSize = `${run.fontSize}px`; if (run.bold) element.style.fontWeight = 'bold'; if (run.italic) element.style.fontStyle = 'italic'; diff --git a/packages/super-editor/package.json b/packages/super-editor/package.json index d7b820bbc2..22e1b658d2 100644 --- a/packages/super-editor/package.json +++ b/packages/super-editor/package.json @@ -119,6 +119,7 @@ "@superdoc/contracts": "workspace:*", "@superdoc/dom-contract": "workspace:*", "@superdoc/document-api": "workspace:*", + "@superdoc/font-system": "workspace:*", "@superdoc/font-utils": "workspace:*", "@superdoc/layout-bridge": "workspace:*", "@superdoc/layout-resolved": "workspace:*", diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 6f06e6dd1f..c4569c1115 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -1095,6 +1095,9 @@ export class Editor extends EventEmitter { this.on('comment-positions', this.options.onCommentLocationsUpdate!); this.on('list-definitions-change', this.options.onListDefinitionsChange!); this.on('fonts-resolved', this.options.onFontsResolved!); + // Emitted unconditionally by PresentationEditor, so only register a real callback - + // a bare `this.on('fonts-changed', undefined)` would make `emit` call undefined. + if (this.options.onFontsChanged) this.on('fonts-changed', this.options.onFontsChanged); this.on('exception', this.options.onException!); this.on('pointerDown', this.options.onPointerDown!); this.#trackContentControlPointer(); @@ -1527,6 +1530,9 @@ export class Editor extends EventEmitter { this.on('comment-positions', this.options.onCommentLocationsUpdate!); this.on('list-definitions-change', this.options.onListDefinitionsChange!); this.on('fonts-resolved', this.options.onFontsResolved!); + // Emitted unconditionally by PresentationEditor, so only register a real callback - + // a bare `this.on('fonts-changed', undefined)` would make `emit` call undefined. + if (this.options.onFontsChanged) this.on('fonts-changed', this.options.onFontsChanged); this.on('exception', this.options.onException!); this.on('pointerDown', this.options.onPointerDown!); this.#trackContentControlPointer(); 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 dad4441c10..d3d20b16ed 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,6 +177,10 @@ import type { } from '@superdoc/layout-bridge'; import { measureBlock } from '@superdoc/measuring-dom'; +import { resolvePhysicalFamilies, type FontResolutionRecord, type FontLoadSummary } from '@superdoc/font-system'; +import { installBundledSubstitutes } from '@superdoc/font-system/bundled'; +import { FontReadinessGate } from './fonts/FontReadinessGate'; +import type { FontsChangedPayload } from '../types/EditorEvents'; import type { ColumnLayout, FlowBlock, @@ -524,6 +528,12 @@ export class PresentationEditor extends EventEmitter { #pendingMapping: Mapping | null = null; #isRerendering = false; #selectionSync = new SelectionSyncCoordinator(); + /** Load-before-measure gate: awaits required fonts before measurement, reflows on late load. */ + #fontGate: FontReadinessGate | null = null; + /** Dedup key for `fonts-changed`: epoch + per-face load status. Null until the first emit. */ + #lastFontsChangedKey: string | null = null; + /** Last emitted `fonts-changed` payload, so a late relay subscriber can replay it. */ + #lastFontsChangedPayload: FontsChangedPayload | null = null; /** * When true, the next selection render scrolls the caret/selection head into view. * Only set for user-initiated actions (keyboard/mouse selection, image click, zoom). @@ -934,6 +944,41 @@ export class PresentationEditor extends EventEmitter { // Add reference back to PresentationEditor for event handler detection (this.#editor as Editor & { _presentationEditor?: PresentationEditor })._presentationEditor = this; this.#syncHiddenEditorA11yAttributes(); + this.#fontGate = new FontReadinessGate({ + getDocumentFonts: () => { + const converter = (this.#editor as Editor & { converter?: { getDocumentFonts?: () => string[] } }).converter; + return converter?.getDocumentFonts?.() ?? []; + }, + requestReflow: () => { + // A font finished loading (or the resolution changed). Incremental layout reuses + // this editor's previousMeasures for unchanged blocks, so clearing the global + // measurement caches alone will not re-measure. Drop the cached blocks + measures + // to force a full re-measure, then schedule a DOCUMENT re-layout - #scheduleRerender + // with the pending-change flag, not #selectionSync.requestRender (selection-only). + this.#layoutState = { ...this.#layoutState, blocks: [], measures: [], layout: null }; + this.#pendingDocChange = true; + this.#scheduleRerender(); + }, + // Wait on the resolved PHYSICAL families (Calibri -> Carlito), so the gate holds + // measurement until the substitute that measure + paint will use has loaded. + resolveFamilies: resolvePhysicalFamilies, + // 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) => + installBundledSubstitutes(registry, { + assetBaseUrl: this.#options.fontAssets?.assetBaseUrl, + resolveAssetUrl: this.#options.fontAssets?.resolveAssetUrl, + }), + getFontEnvironment: () => { + // Bind the registry and the watched font set to THIS editor's document, so an + // editor inside an iframe awaits and listens on the same FontFaceSet. + const ownerDoc = this.#visibleHost?.ownerDocument ?? (typeof document !== 'undefined' ? document : null); + const view = ownerDoc?.defaultView ?? (typeof window !== 'undefined' ? window : null); + const fontSet = ownerDoc?.fonts ?? null; + const FontFaceCtor = view?.FontFace ?? (typeof FontFace !== 'undefined' ? FontFace : null); + return fontSet && FontFaceCtor ? { fontSet, FontFaceCtor } : null; + }, + }); if (typeof this.#options.disableContextMenu === 'boolean') { this.setContextMenuDisabled(this.#options.disableContextMenu); } @@ -2840,6 +2885,89 @@ export class PresentationEditor extends EventEmitter { }; } + /** + * Per-font resolution report for the current document: for each DECLARED (logical) + * font, the physical family SuperDoc rendered, why, its load status, and the family + * export preserves. The observable answer to "what font did SuperDoc actually use". + * + * Scope: this is a DOCUMENT-font report - it covers every family the document declares + * (font table + theme + defaults via `converter.getDocumentFonts()`), not only fonts + * currently visible on screen. A family declared but never painted still appears. A + * separate rendered-fonts view (only what is on screen) may follow. Surfaced publicly + * as `superdoc.fonts.getReport()`. + */ + getFontReport(): FontResolutionRecord[] { + return this.#fontGate?.getReport() ?? []; + } + + /** + * Declared families with no faithful render font loaded (substitution-aware): the + * subset of {@link getFontReport} where `missing` is true - genuinely absent fonts + * such as Aptos with no metric-compatible clone. The accurate replacement for the + * legacy `fonts-resolved.unsupportedFonts` probe. Surfaced as + * `superdoc.fonts.getMissingFonts()`. + */ + getMissingFonts(): string[] { + return this.getFontReport() + .filter((record) => record.missing) + .map((record) => record.logicalFamily); + } + + /** + * Emit `fonts-changed` on the hidden editor when the resolved/loaded font picture + * actually changed since the last emit, so consumers see one event per real change + * rather than one per render. The dedup key is the font epoch plus each required face's + * load status (cheap; from the gate's last summary). The full report is built only when + * we emit. First emit is `source: 'initial'`; an epoch bump (a late load) is + * `'late-load'`. Never throws - font reporting must not break layout. + */ + #emitFontsChangedIfChanged(summary: FontLoadSummary | null): void { + const gate = this.#fontGate; + if (!gate) return; + const version = gate.fontConfigVersion; + const statusKey = summary + ? summary.results + .map((result) => `${result.family}:${result.status}`) + .sort() + .join(',') + : ''; + const key = `${version}|${statusKey}`; + if (key === this.#lastFontsChangedKey) return; + const isInitial = this.#lastFontsChangedKey === null; + this.#lastFontsChangedKey = key; + + let resolutions: FontResolutionRecord[]; + try { + resolutions = gate.getReport(); + } catch { + return; + } + const payload: FontsChangedPayload = { + documentFonts: resolutions.map((record) => record.logicalFamily), + resolutions, + missingFonts: resolutions.filter((record) => record.missing).map((record) => record.logicalFamily), + loadSummary: summary ?? { loaded: 0, failed: 0, timedOut: 0, fallbackUsed: 0, results: [] }, + source: isInitial ? 'initial' : 'late-load', + version, + }; + this.#lastFontsChangedPayload = payload; + try { + this.#editor.emit('fonts-changed', payload); + } catch { + /* font reporting must never break layout */ + } + } + + /** + * The last `fonts-changed` payload this editor emitted, or null if none yet. Lets a + * SuperDoc relay that subscribed after the emission replay the current report, so the + * active document's authoritative report is always delivered even when the relay + * attaches late (e.g. a document swap). + */ + getLastFontsChangedPayload(): FontsChangedPayload | null { + return this.#lastFontsChangedPayload; + } + /** * Expose the current layout engine options. */ @@ -4299,6 +4427,8 @@ export class PresentationEditor extends EventEmitter { this.#postPaintPipeline.destroy(); this.#proofingManager?.dispose(); this.#proofingManager = null; + this.#fontGate?.dispose(); + this.#fontGate = null; // Cancel pending cursor awareness update if (this.#cursorUpdateTimer !== null) { @@ -6572,6 +6702,20 @@ export class PresentationEditor extends EventEmitter { let extraMeasures: Measure[] | undefined; let resolveBlocks: FlowBlock[] = blocksForLayout; let resolveMeasures: Measure[] = previousMeasures; + // Load-before-measure gate (T3): wait for the fonts this document needs so the first + // measurement pass uses real metrics instead of a fallback that would reflow on load. + // Bounded by a per-font timeout; resolves to the cached summary once fonts are stable; + // never throws, so font readiness can never block layout. + try { + const fontSummary = (await this.#fontGate?.ensureReadyForMeasure()) ?? null; + // Now that the gate has settled, the font report reflects real load status. Emit + // the authoritative `fonts-changed` once the picture first resolves and whenever it + // changes (a late-load bumps the gate epoch and re-renders through here). + this.#emitFontsChangedIfChanged(fontSummary); + } catch { + /* font readiness must never break layout */ + } + const headerFooterInput = this.#buildHeaderFooterInput(); try { const incrementalLayoutStart = perfNow(); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts new file mode 100644 index 0000000000..2a0189ef77 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { FontLoadResult, FontLoadStatus, FontRegistry } from '@superdoc/font-system'; +import { FontReadinessGate, type FontEnvironment } from './FontReadinessGate'; + +/** Minimal FontFace constructor stand-in for the environment (unused when a registry is injected). */ +class FakeFontFace { + constructor(public readonly family: string) {} + load(): Promise { + return Promise.resolve(this); + } +} +const fakeCtor = FakeFontFace as unknown as FontEnvironment['FontFaceCtor']; + +/** Structural fake of the slice of FontRegistry the gate uses. */ +class FakeRegistry { + readonly statuses = new Map(); + readonly available = new Set(); + readonly awaitCalls: string[][] = []; + + getStatus(family: string): FontLoadStatus { + return this.statuses.get(family) ?? 'unloaded'; + } + isAvailable(family: string): boolean { + return this.available.has(family); + } + async awaitFaces(families: Iterable): Promise { + const unique = [...new Set(families)]; + this.awaitCalls.push(unique); + return unique.map((family) => ({ family, status: this.getStatus(family) })); + } + asRegistry(): FontRegistry { + return this as unknown as FontRegistry; + } +} + +/** Fake FontFaceSet that lets the test fire `loadingdone` by hand. */ +class FakeFontSet { + readonly handlers: Record void>> = {}; + addEventListener(type: string, cb: (event?: unknown) => void): void { + (this.handlers[type] ??= []).push(cb); + } + removeEventListener(type: string, cb: (event?: unknown) => void): void { + this.handlers[type] = (this.handlers[type] ?? []).filter((h) => h !== cb); + } + fire(type: string, event?: unknown): void { + (this.handlers[type] ?? []).forEach((h) => h(event)); + } + asFontSet(): FontFaceSet { + return this as unknown as FontFaceSet; + } +} + +const calibriToCarlito = (families: string[]) => families.map((f) => (f === 'Calibri' ? 'Carlito' : f)); + +describe('FontReadinessGate', () => { + let registry: FakeRegistry; + let fontSet: FakeFontSet; + let requestReflow: ReturnType; + let invalidateCaches: ReturnType; + + beforeEach(() => { + registry = new FakeRegistry(); + fontSet = new FakeFontSet(); + requestReflow = vi.fn(); + invalidateCaches = vi.fn(); + }); + + function makeGate(documentFonts: string[]) { + return new FontReadinessGate({ + registry: registry.asRegistry(), + getDocumentFonts: () => documentFonts, + resolveFamilies: calibriToCarlito, + requestReflow, + invalidateCaches, + getFontEnvironment: () => ({ fontSet: fontSet.asFontSet(), FontFaceCtor: fakeCtor }), + timeoutMs: 1000, + }); + } + + it('awaits the resolved physical family, not the logical one', async () => { + registry.statuses.set('Carlito', 'loaded'); + registry.available.add('Carlito'); + const gate = makeGate(['Calibri']); + + const summary = await gate.ensureReadyForMeasure(); + + expect(registry.awaitCalls).toEqual([['Carlito']]); // resolver seam: Calibri -> Carlito + expect(summary.loaded).toBe(1); + }); + + it('skips re-awaiting when the required set is unchanged and already loaded', async () => { + registry.statuses.set('Carlito', 'loaded'); + registry.available.add('Carlito'); + const gate = makeGate(['Calibri']); + + await gate.ensureReadyForMeasure(); + await gate.ensureReadyForMeasure(); + + expect(registry.awaitCalls).toHaveLength(1); // fast path on the second pass + }); + + it('summarizes a timed-out first paint', async () => { + registry.statuses.set('Carlito', 'timed_out'); + const gate = makeGate(['Calibri']); + + const summary = await gate.ensureReadyForMeasure(); + + expect(summary.timedOut).toBe(1); + expect(summary.loaded).toBe(0); + }); + + it('reflows once when a required face loads after a timed-out first paint', async () => { + registry.statuses.set('Carlito', 'timed_out'); + const gate = makeGate(['Calibri']); + await gate.ensureReadyForMeasure(); + + // Carlito finishes loading after first paint. + registry.statuses.set('Carlito', 'loaded'); + registry.available.add('Carlito'); + fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); + + expect(invalidateCaches).toHaveBeenCalledTimes(1); + expect(requestReflow).toHaveBeenCalledTimes(1); + expect(gate.fontConfigVersion).toBe(1); + }); + + it('does not reflow again on a second loadingdone for the same face (no loop)', async () => { + registry.statuses.set('Carlito', 'timed_out'); + const gate = makeGate(['Calibri']); + await gate.ensureReadyForMeasure(); + + registry.statuses.set('Carlito', 'loaded'); + registry.available.add('Carlito'); + fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); + fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); + + expect(invalidateCaches).toHaveBeenCalledTimes(1); + expect(requestReflow).toHaveBeenCalledTimes(1); + }); + + it('does not reflow when a loaded face was already available at first measure', async () => { + registry.statuses.set('Carlito', 'loaded'); + registry.available.add('Carlito'); + const gate = makeGate(['Calibri']); + await gate.ensureReadyForMeasure(); + + fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); + + expect(requestReflow).not.toHaveBeenCalled(); + }); + + it('notifyFontConfigChanged bumps the epoch, invalidates, and reflows', () => { + const gate = makeGate(['Calibri']); + + gate.notifyFontConfigChanged(); + + expect(gate.fontConfigVersion).toBe(1); + expect(invalidateCaches).toHaveBeenCalledTimes(1); + expect(requestReflow).toHaveBeenCalledTimes(1); + }); + + it('exposes the last summary as diagnostics', async () => { + registry.statuses.set('Carlito', 'loaded'); + registry.available.add('Carlito'); + const gate = makeGate(['Calibri']); + + await gate.ensureReadyForMeasure(); + + expect(gate.getDiagnostics()).toMatchObject({ loaded: 1, results: [{ family: 'Carlito', status: 'loaded' }] }); + }); + + it('never rejects when getDocumentFonts throws', async () => { + const gate = new FontReadinessGate({ + registry: registry.asRegistry(), + getDocumentFonts: () => { + throw new Error('converter unavailable'); + }, + requestReflow, + invalidateCaches, + getFontEnvironment: () => ({ fontSet: fontSet.asFontSet(), FontFaceCtor: fakeCtor }), + }); + + await expect(gate.ensureReadyForMeasure()).resolves.toMatchObject({ loaded: 0 }); + }); +}); 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 new file mode 100644 index 0000000000..a4e95136a6 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts @@ -0,0 +1,298 @@ +import { + getFontRegistryFor, + bumpFontConfigVersion, + buildFontReport, + DEFAULT_FONT_LOAD_TIMEOUT_MS, + type FontRegistry, + type FontLoadResult, + type FontLoadSummary, + type FontResolutionRecord, +} from '@superdoc/font-system'; + +export type { FontLoadSummary } from '@superdoc/font-system'; +import { clearTextMeasurementCaches } from '@superdoc/measuring-dom'; +import { measureCache } from '@superdoc/layout-bridge'; + +/** + * The font set the gate operates on plus the constructor for new managed faces. + * Both come from the SAME window/document so the registry it derives and the set + * it watches for load events are the same object - the iframe-safe pairing. The + * ctor is the host window's `FontFace` (the structural cast to the registry's + * face type is confined to {@link FontReadinessGate} so callers pass it directly). + */ +export interface FontEnvironment { + fontSet: FontFaceSet; + FontFaceCtor: typeof FontFace; +} + +export interface FontReadinessGateOptions { + /** Logical font families the current document uses. Cheap to call per render. */ + getDocumentFonts: () => string[]; + /** Trigger a re-measure + re-layout + repaint (PresentationEditor's immediate render). */ + requestReflow: () => void; + /** + * The font set + face constructor for this editor's document. Resolved lazily (the host + * document is not available at construction). The registry is derived from this same + * `fontSet`, so awaits and load-event watching always target one set. Defaults to the + * ambient `document.fonts`. + */ + getFontEnvironment?: () => FontEnvironment | null; + /** + * Map logical families to the physical families that must actually load: the + * logical->physical map so the gate waits on the real substitute (e.g. Calibri -> Carlito). + * Defaults to identity when not provided; the editor wires `resolvePhysicalFamilies`. + */ + resolveFamilies?: (families: string[]) => string[]; + /** Per-font load budget before a face is treated as timed out. */ + timeoutMs?: number; + /** Explicit registry override (tests). Normally derived from the font environment. */ + registry?: FontRegistry; + /** + * Called once when the registry is first resolved (a real font set is available). + * The editor uses this to install the bundled substitute pack into the registry, so + * the gate stays pack-agnostic and never imports the font assets itself. + */ + onRegistryResolved?: (registry: FontRegistry) => void; + /** + * Cache invalidation hook. Defaults to clearing every font-dependent measurement cache + * (text/font-metric/table caches + the block-measure cache). Injectable for tests. + */ + invalidateCaches?: () => void; +} + +/** + * Load-before-measure gate. + * + * The layout pipeline measures text with the canvas `measureText` API, which silently + * substitutes a fallback when the requested font has not loaded yet. That makes first + * paint paginate against the wrong metrics and then reflow once the font arrives. This + * gate closes that window: before each measurement pass it awaits the specific faces the + * current document needs (not a global `document.fonts.ready`), and if a required face + * finishes after a timed-out first paint it invalidates the measurement caches and + * reflows exactly once. + * + * It owns no font loading itself - that is `@superdoc/font-system`. It owns the editor-side + * lifecycle: when to wait, when to invalidate, when to reflow. + */ +export class FontReadinessGate { + readonly #getDocumentFonts: () => string[]; + readonly #resolveFamilies: (families: string[]) => string[]; + readonly #requestReflow: () => void; + readonly #getFontEnvironment: () => FontEnvironment | null; + readonly #registryOverride: FontRegistry | null; + readonly #onRegistryResolved: ((registry: FontRegistry) => void) | null; + readonly #timeoutMs: number; + readonly #invalidateCaches: () => void; + + /** Resolved once a real font set is available: the watched set + its registry, paired. */ + #context: { fontSet: FontFaceSet | null; registry: FontRegistry } | null = null; + + #fontConfigVersion = 0; + #requiredSignature = ''; + #requiredFamilies = new Set(); + /** Families observed available, so the late-load handler fires at most once per face. */ + readonly #seenAvailable = new Set(); + #lastSummary: FontLoadSummary | null = null; + #loadingDoneHandler: ((event: FontFaceSetLoadEvent) => void) | null = null; + + constructor(options: FontReadinessGateOptions) { + this.#getDocumentFonts = options.getDocumentFonts; + this.#resolveFamilies = options.resolveFamilies ?? ((families) => families); + this.#requestReflow = options.requestReflow; + this.#getFontEnvironment = options.getFontEnvironment ?? defaultFontEnvironment; + this.#registryOverride = options.registry ?? null; + this.#onRegistryResolved = options.onRegistryResolved ?? null; + this.#timeoutMs = options.timeoutMs ?? DEFAULT_FONT_LOAD_TIMEOUT_MS; + this.#invalidateCaches = options.invalidateCaches ?? defaultInvalidate; + } + + /** + * Font-config epoch. Increments whenever the available-font picture changes (a late + * load, or a runtime add/map). The resolver (T1) will fold this into the per-fragment + * paint signature so paint reuse busts on a font change, not just measurement. + */ + get fontConfigVersion(): number { + return this.#fontConfigVersion; + } + + /** Most recent readiness summary, for diagnostics and the public DX surface (later). */ + getDiagnostics(): FontLoadSummary | null { + return this.#lastSummary; + } + + /** + * Per-font resolution report for the current document: requested -> rendered -> reason + * -> load status -> export family. The observable answer to "what did SuperDoc render + * and is it faithful". Built through the shared resolver + registry, so it reflects + * exactly what was measured and painted - not an independent computation. + */ + getReport(): FontResolutionRecord[] { + let logical: string[] = []; + try { + logical = this.#getDocumentFonts(); + } catch { + return []; + } + return buildFontReport(logical, this.#resolveContext().registry); + } + + /** + * Await the faces the current document needs, then return their outcomes. Safe and + * cheap to call on every render: when the required set is unchanged and already fully + * loaded it returns the cached summary without awaiting. Never rejects - font readiness + * must not break layout. + */ + async ensureReadyForMeasure(): Promise { + const registry = this.#resolveContext().registry; + + let required: string[]; + try { + required = [...new Set(this.#resolveFamilies(this.#getDocumentFonts()).filter(Boolean))]; + } catch { + return this.#lastSummary ?? emptySummary(); + } + + const signature = required.slice().sort().join('|'); + const unchangedAndLoaded = + signature === this.#requiredSignature && required.every((family) => registry.getStatus(family) === 'loaded'); + if (unchangedAndLoaded && this.#lastSummary) { + return this.#lastSummary; + } + + this.#requiredSignature = signature; + this.#requiredFamilies = new Set(required); + this.#ensureSubscribed(); + + let results: FontLoadResult[] = []; + try { + results = required.length ? await registry.awaitFaces(required, { timeoutMs: this.#timeoutMs }) : []; + } catch { + results = []; + } + + for (const result of results) { + if (result.status === 'loaded') this.#seenAvailable.add(result.family); + } + this.#lastSummary = summarize(results); + return this.#lastSummary; + } + + /** + * Signal that the font configuration changed at runtime (customer add/map, T7). Bumps + * the epoch, invalidates measurement caches, and reflows so the new mapping takes effect. + */ + notifyFontConfigChanged(): void { + this.#fontConfigVersion += 1; + bumpFontConfigVersion(); // bump the global epoch so measure/paint reuse signatures bust + this.#seenAvailable.clear(); + this.#requiredSignature = ''; + this.#invalidateCaches(); + this.#requestReflow(); + } + + /** Remove the late-load listener. Call on editor teardown. */ + dispose(): void { + const fontSet = this.#context?.fontSet ?? null; + if (fontSet && this.#loadingDoneHandler && typeof fontSet.removeEventListener === 'function') { + fontSet.removeEventListener('loadingdone', this.#loadingDoneHandler); + } + this.#loadingDoneHandler = null; + } + + /** Resolve (and cache) the watched font set + its paired registry. */ + #resolveContext(): { fontSet: FontFaceSet | null; registry: FontRegistry } { + if (this.#context && this.#context.fontSet) return this.#context; + const env = this.#getFontEnvironment(); + const fontSet = env?.fontSet ?? null; + const registry = + this.#registryOverride ?? + getFontRegistryFor( + fontSet as unknown as FontSetLikeArg, + (env?.FontFaceCtor ?? null) as unknown as FontFaceCtorArg, + ); + this.#context = { fontSet, registry }; + // Let the editor install the bundled substitute pack into the registry once a real + // font set exists. Kept out of the gate so the gate never imports the font assets. + if (fontSet && this.#onRegistryResolved) { + try { + this.#onRegistryResolved(registry); + } catch { + /* font setup must not break layout */ + } + } + return this.#context; + } + + #ensureSubscribed(): void { + if (this.#loadingDoneHandler) return; + const fontSet = this.#resolveContext().fontSet; + if (!fontSet || typeof fontSet.addEventListener !== 'function') return; + const handler = (event: FontFaceSetLoadEvent) => this.#onLoadingDone(event); + fontSet.addEventListener('loadingdone', handler); + this.#loadingDoneHandler = handler; + } + + #onLoadingDone(event: FontFaceSetLoadEvent): void { + // A required face that the last measure could not use just finished loading -> that + // paint used a fallback, so invalidate and reflow. We key off the faces the event + // actually reports as loaded (reliable), NOT FontFaceSet.check() (which lies for + // unregistered bare families). The seen-set fires this once per face; never a loop. + const loadedKeys = new Set((event?.fontfaces ?? []).map((face) => normalizeFamilyKey(face.family))); + if (loadedKeys.size === 0) return; + let changed = false; + for (const family of this.#requiredFamilies) { + if (this.#seenAvailable.has(family)) continue; + if (loadedKeys.has(normalizeFamilyKey(family))) { + this.#seenAvailable.add(family); + changed = true; + } + } + if (!changed) return; + this.#fontConfigVersion += 1; + bumpFontConfigVersion(); // bump the global epoch so measure/paint reuse signatures bust + this.#invalidateCaches(); + this.#requestReflow(); + } +} + +/** Lowercase + strip surrounding quotes so a required family matches a loaded FontFace.family. */ +function normalizeFamilyKey(family: string): string { + return family + .trim() + .replace(/^["']|["']$/g, '') + .toLowerCase(); +} + +/** The font-system registry accepts a structural font set + face ctor; the DOM types satisfy them. */ +type FontSetLikeArg = Parameters[0]; +type FontFaceCtorArg = Parameters[1]; + +function summarize(results: FontLoadResult[]): FontLoadSummary { + const summary = emptySummary(); + summary.results = results; + for (const result of results) { + if (result.status === 'loaded') summary.loaded += 1; + else if (result.status === 'failed') summary.failed += 1; + else if (result.status === 'timed_out') summary.timedOut += 1; + else if (result.status === 'fallback_used') summary.fallbackUsed += 1; + } + return summary; +} + +function emptySummary(): FontLoadSummary { + return { loaded: 0, failed: 0, timedOut: 0, fallbackUsed: 0, results: [] }; +} + +function defaultFontEnvironment(): FontEnvironment | null { + const doc = (globalThis as { document?: Document }).document ?? null; + const view = doc?.defaultView ?? null; + const fontSet = doc?.fonts ?? null; + const ctor = view?.FontFace ?? (typeof FontFace !== 'undefined' ? FontFace : null); + if (!fontSet || !ctor) return null; + return { fontSet, FontFaceCtor: ctor }; +} + +function defaultInvalidate(): void { + clearTextMeasurementCaches(); + measureCache.clear(); +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index d7c5eb78e8..c179c8859d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -2936,9 +2936,16 @@ describe('PresentationEditor', () => { }); it('emits headerFooterEditBlocked when keyboard shortcut has no matching region', async () => { - const layoutNoHeaders = buildLayoutResult(); - layoutNoHeaders.headers = []; - mockIncrementalLayout.mockResolvedValueOnce(layoutNoHeaders); + // Header/footer regions are derived from the resolved layout's PAGES + // (HeaderFooterSessionManager.rebuildRegions builds one region per page), not + // from the headers[] array. To exercise the "no matching region" path the + // resolved layout must have no page 0 at all; emptying headers[] still leaves a + // per-page region and takes the activation path instead. With no pages, + // getRegionForPage('header', 0) returns null deterministically, independent of + // render timing. + const layoutNoPages = buildLayoutResult(); + layoutNoPages.layout.pages = []; + mockIncrementalLayout.mockResolvedValueOnce(layoutNoPages); const blockedSpy = vi.fn(); @@ -2955,7 +2962,9 @@ describe('PresentationEditor', () => { new KeyboardEvent('keydown', { ctrlKey: true, altKey: true, code: 'KeyH', bubbles: true }), ); - expect(blockedSpy).toHaveBeenCalledWith(expect.objectContaining({ reason: 'missingRegion' })); + await vi.waitFor(() => + expect(blockedSpy).toHaveBeenCalledWith(expect.objectContaining({ reason: 'missingRegion' })), + ); }); it('returns false without emitting an error when an unqualified bookmark is not found', async () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index 4067e3f4c4..206df195de 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts @@ -6,7 +6,7 @@ */ import type { Editor } from '../Editor.js'; -import type { CollaborationProvider } from '../types/EditorConfig.js'; +import type { CollaborationProvider, FontsConfig } from '../types/EditorConfig.js'; import type { TrackedChangesMode, FlowBlock, @@ -205,6 +205,12 @@ export type PresentationEditorOptions = ConstructorParameters[0] * Layout-specific configuration consumed by PresentationEditor. */ layoutEngineOptions?: LayoutEngineOptions; + /** + * Font system configuration (the SuperDoc-level `fonts` config). Currently the served + * location of the bundled substitute pack: `assetBaseUrl` / `resolveAssetUrl`. Threaded + * here (not the legacy untyped `EditorConfig.fonts`) so the asset config is typed. + */ + fontAssets?: FontsConfig; /** * Document mode for the editor. Determines editability and tracked changes behavior. * @default 'editing' diff --git a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts index 4487329bfd..c7dc9194d0 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -7,11 +7,32 @@ import type { Mark as EditorMark } from '../Mark.js'; import type { EditorRenderer } from '../renderers/EditorRenderer.js'; import type { FontsResolvedPayload, + FontsChangedPayload, Comment, CommentsPayload, CommentLocationsPayload, ListDefinitionsPayload, } from './EditorEvents.js'; +import type { FontAssetUrlResolver } from '@superdoc/font-system'; + +/** + * Configuration for SuperDoc's font system. Currently the served location of the bundled + * metric-compatible substitute pack; the resolver/registry/gate path is unaffected. + */ +export interface FontsConfig { + /** + * Base URL the bundled font `.woff2` are served from, e.g. `/fonts/` or + * `https://cdn.example.com/superdoc-fonts/v1/`. Required for npm/SSR/framework deploys + * that serve the assets from a non-root path; the CDN `