From 28470dc16fb25caf087e65d0b8a17dd6fa564bbe Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 2 Jun 2026 16:52:33 -0300 Subject: [PATCH 01/11] feat(fonts): add bundled font substitutes and readiness reporting Render common Word fonts the browser lacks (Calibri, Cambria, Arial, Times New Roman, Courier New) with metric-compatible open substitutes loaded before measurement, via a manifest-driven asset provider that emits font files as separate assets instead of inlining them into JS. Adds a read-only fonts diagnostics surface (superdoc.fonts.*, fonts-changed / onReport). Runtime asset base defaults to /fonts/; a configurable/script-relative base is a follow-up. Excludes Aptos, the write API, embedded fonts, and legal sign-off. --- .../layout-resolved/package.json | 3 +- .../layout-resolved/src/versionSignature.ts | 4 + .../layout-engine/measuring/dom/package.json | 1 + .../layout-engine/measuring/dom/src/index.ts | 34 +- .../layout-engine/painters/dom/package.json | 1 + .../dom/src/paragraph/block-version.ts | 4 + .../painters/dom/src/runs/text-run.ts | 10 +- packages/super-editor/package.json | 1 + .../src/editors/v1/core/Editor.ts | 6 + .../presentation-editor/PresentationEditor.ts | 140 ++++++++ .../fonts/FontReadinessGate.test.ts | 185 +++++++++++ .../fonts/FontReadinessGate.ts | 298 ++++++++++++++++++ .../src/editors/v1/core/types/EditorConfig.ts | 4 + .../src/editors/v1/core/types/EditorEvents.ts | 38 ++- packages/super-editor/src/index.ts | 9 + packages/superdoc/scripts/ensure-types.cjs | 48 +++ .../superdoc/scripts/type-surface.config.cjs | 11 + packages/superdoc/src/core/SuperDoc.test.js | 135 ++++++++ packages/superdoc/src/core/SuperDoc.ts | 82 ++++- packages/superdoc/src/core/types/index.ts | 40 +++ packages/superdoc/src/public/index.ts | 3 + .../superdoc/vite-plugin-bundled-fonts.mjs | 54 ++++ packages/superdoc/vite.config.cdn.js | 3 +- packages/superdoc/vite.config.js | 2 + pnpm-lock.yaml | 24 ++ shared/font-system/assets/Apache-2.0.txt | 204 ++++++++++++ shared/font-system/assets/Caladea-Bold.woff2 | Bin 0 -> 18424 bytes .../assets/Caladea-BoldItalic.woff2 | Bin 0 -> 19368 bytes .../font-system/assets/Caladea-Italic.woff2 | Bin 0 -> 19640 bytes .../font-system/assets/Caladea-Regular.woff2 | Bin 0 -> 18860 bytes shared/font-system/assets/Carlito-Bold.woff2 | Bin 0 -> 198592 bytes .../assets/Carlito-BoldItalic.woff2 | Bin 0 -> 207128 bytes .../font-system/assets/Carlito-Italic.woff2 | Bin 0 -> 201992 bytes .../font-system/assets/Carlito-Regular.woff2 | Bin 0 -> 190548 bytes shared/font-system/assets/LICENSES.md | 22 ++ .../assets/LiberationMono-Bold.woff2 | Bin 0 -> 123088 bytes .../assets/LiberationMono-BoldItalic.woff2 | Bin 0 -> 117004 bytes .../assets/LiberationMono-Italic.woff2 | Bin 0 -> 114660 bytes .../assets/LiberationMono-Regular.woff2 | Bin 0 -> 125840 bytes .../assets/LiberationSans-Bold.woff2 | Bin 0 -> 148312 bytes .../assets/LiberationSans-BoldItalic.woff2 | Bin 0 -> 150948 bytes .../assets/LiberationSans-Italic.woff2 | Bin 0 -> 153236 bytes .../assets/LiberationSans-Regular.woff2 | Bin 0 -> 146812 bytes .../assets/LiberationSerif-Bold.woff2 | Bin 0 -> 140928 bytes .../assets/LiberationSerif-BoldItalic.woff2 | Bin 0 -> 147036 bytes .../assets/LiberationSerif-Italic.woff2 | Bin 0 -> 147624 bytes .../assets/LiberationSerif-Regular.woff2 | Bin 0 -> 146580 bytes shared/font-system/assets/OFL.txt | 95 ++++++ shared/font-system/package.json | 35 ++ .../scripts/verify-bundled-metrics.py | 78 +++++ shared/font-system/src/bundled-manifest.ts | 58 ++++ shared/font-system/src/bundled.ts | 55 ++++ shared/font-system/src/epoch.test.ts | 22 ++ shared/font-system/src/epoch.ts | 29 ++ shared/font-system/src/index.ts | 47 +++ shared/font-system/src/registry.test.ts | 216 +++++++++++++ shared/font-system/src/registry.ts | 266 ++++++++++++++++ shared/font-system/src/report.test.ts | 63 ++++ shared/font-system/src/report.ts | 59 ++++ shared/font-system/src/resolver.test.ts | 63 ++++ shared/font-system/src/resolver.ts | 121 +++++++ shared/font-system/src/types.ts | 92 ++++++ shared/font-system/tsconfig.json | 13 + shared/font-system/vitest.config.mjs | 11 + .../snapshots/superdoc-root-exports.json | 17 +- .../snapshots/superdoc-root-exports.md | 22 +- .../snapshots/superdoc-super-editor.txt | 5 + .../consumer-typecheck/src/fonts-read-apis.ts | 44 +++ 68 files changed, 2756 insertions(+), 21 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts create mode 100644 packages/superdoc/vite-plugin-bundled-fonts.mjs create mode 100644 shared/font-system/assets/Apache-2.0.txt create mode 100644 shared/font-system/assets/Caladea-Bold.woff2 create mode 100644 shared/font-system/assets/Caladea-BoldItalic.woff2 create mode 100644 shared/font-system/assets/Caladea-Italic.woff2 create mode 100644 shared/font-system/assets/Caladea-Regular.woff2 create mode 100644 shared/font-system/assets/Carlito-Bold.woff2 create mode 100644 shared/font-system/assets/Carlito-BoldItalic.woff2 create mode 100644 shared/font-system/assets/Carlito-Italic.woff2 create mode 100644 shared/font-system/assets/Carlito-Regular.woff2 create mode 100644 shared/font-system/assets/LICENSES.md create mode 100644 shared/font-system/assets/LiberationMono-Bold.woff2 create mode 100644 shared/font-system/assets/LiberationMono-BoldItalic.woff2 create mode 100644 shared/font-system/assets/LiberationMono-Italic.woff2 create mode 100644 shared/font-system/assets/LiberationMono-Regular.woff2 create mode 100644 shared/font-system/assets/LiberationSans-Bold.woff2 create mode 100644 shared/font-system/assets/LiberationSans-BoldItalic.woff2 create mode 100644 shared/font-system/assets/LiberationSans-Italic.woff2 create mode 100644 shared/font-system/assets/LiberationSans-Regular.woff2 create mode 100644 shared/font-system/assets/LiberationSerif-Bold.woff2 create mode 100644 shared/font-system/assets/LiberationSerif-BoldItalic.woff2 create mode 100644 shared/font-system/assets/LiberationSerif-Italic.woff2 create mode 100644 shared/font-system/assets/LiberationSerif-Regular.woff2 create mode 100644 shared/font-system/assets/OFL.txt create mode 100644 shared/font-system/package.json create mode 100644 shared/font-system/scripts/verify-bundled-metrics.py create mode 100644 shared/font-system/src/bundled-manifest.ts create mode 100644 shared/font-system/src/bundled.ts create mode 100644 shared/font-system/src/epoch.test.ts create mode 100644 shared/font-system/src/epoch.ts create mode 100644 shared/font-system/src/index.ts create mode 100644 shared/font-system/src/registry.test.ts create mode 100644 shared/font-system/src/registry.ts create mode 100644 shared/font-system/src/report.test.ts create mode 100644 shared/font-system/src/report.ts create mode 100644 shared/font-system/src/resolver.test.ts create mode 100644 shared/font-system/src/resolver.ts create mode 100644 shared/font-system/src/types.ts create mode 100644 shared/font-system/tsconfig.json create mode 100644 shared/font-system/vitest.config.mjs create mode 100644 tests/consumer-typecheck/src/fonts-read-apis.ts 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/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 25ac8c50f9..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'; @@ -7,7 +8,10 @@ import type { RunRenderContext, TrackedChangesRenderConfig } from './types.js'; import { applyRunDataAttributes } from './hash.js'; import { applyLinkAttributes, applyLinkDataset, buildLinkRenderData, enhanceAccessibility } from './links.js'; import { setTextContentWithFormattingSpaceMarks } from './formatting-marks.js'; -import { normalizeRtlDateTokenForWordParity, resolveRunDirectionAttribute } from '../features/inline-direction/index.js'; +import { + normalizeRtlDateTokenForWordParity, + resolveRunDirectionAttribute, +} from '../features/inline-direction/index.js'; const DEFAULT_SUPERSCRIPT_RAISE_RATIO = 0.33; const DEFAULT_SUBSCRIPT_LOWER_RATIO = 0.14; @@ -69,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 f149189f37..764c50c030 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 @@ -132,6 +132,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, @@ -472,6 +476,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). @@ -882,6 +892,37 @@ 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), + 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); } @@ -2780,6 +2821,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. */ @@ -4239,6 +4363,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) { @@ -6472,6 +6598,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..f78d88d195 --- /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. Identity until + * the resolver lands (T1); then this becomes the logical->physical map so the gate waits on + * the real substitute (e.g. Calibri -> Carlito). + */ + 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/types/EditorConfig.ts b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts index 4487329bfd..8bed873c61 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -7,6 +7,7 @@ import type { Mark as EditorMark } from '../Mark.js'; import type { EditorRenderer } from '../renderers/EditorRenderer.js'; import type { FontsResolvedPayload, + FontsChangedPayload, Comment, CommentsPayload, CommentLocationsPayload, @@ -591,6 +592,9 @@ export interface EditorOptions { /** Called when all fonts used in the document are determined */ onFontsResolved?: ((payload: FontsResolvedPayload) => void) | null; + /** Called with the authoritative substitution + load-aware font report once it settles and on change. */ + onFontsChanged?: ((payload: FontsChangedPayload) => void) | null; + /** Handler for image uploads - async (file) => url */ handleImageUpload?: ((file: File) => Promise) | null; diff --git a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts index 034b42607b..023213f545 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts @@ -3,18 +3,49 @@ import type { Editor } from '../Editor.js'; import type { DefaultEventMap } from '../EventEmitter.js'; import type { PartChangedEvent } from '../parts/types.js'; import type { DocumentProtectionState, StoryLocator } from '@superdoc/document-api'; +import type { FontResolutionRecord, FontLoadSummary } from '@superdoc/font-system'; /** Source of a protection state change. */ export type ProtectionChangeSource = 'init' | 'local-mutation' | 'remote-part-sync'; /** - * Payload for fonts-resolved events + * Payload for `fonts-resolved` events. + * + * LEGACY / EARLY signal: emitted once at editor init, before any font has loaded, from + * `converter.getDocumentFonts()` + a browser `canRenderFont()` probe. It answers "which + * font names did the document declare, and which can't this browser render natively" - it + * is NOT substitution- or load-aware. `unsupportedFonts` will list families (Calibri, + * Arial, ...) that in fact render faithfully through a bundled substitute. For the + * authoritative, load-settled picture listen to `fonts-changed` instead. Kept unchanged + * for backward compatibility. */ export interface FontsResolvedPayload { documentFonts: string[]; unsupportedFonts: string[]; } +/** + * Payload for `fonts-changed` events: the authoritative, substitution- and load-aware + * font report for the current document. Emitted after the load-before-measure gate + * settles (`source: 'initial'`), again when a face arrives after a timed-out first paint + * (`'late-load'`), and on a runtime font config change (`'config-change'`, with the write + * API). `version` is the document's font-config epoch; it increases on every change. + * + * `documentFonts` are the document's DECLARED logical families (font table + theme + + * defaults), deduped - not only the fonts visible on screen. (A separate rendered-fonts + * view may follow.) `resolutions` maps each to its physical render family, the reason, + * and its load status; `missingFonts` are the declared families with no faithful render + * font loaded (the substitution-aware replacement for the legacy `unsupportedFonts`). + */ +export interface FontsChangedPayload { + documentFonts: string[]; + resolutions: FontResolutionRecord[]; + missingFonts: string[]; + loadSummary: FontLoadSummary; + source: 'initial' | 'late-load' | 'config-change'; + version: number; +} + /** * A structured element within a comment body. * @@ -232,9 +263,12 @@ export interface EditorEventMap extends DefaultEventMap { /** Called when list definitions change */ 'list-definitions-change': [ListDefinitionsPayload]; - /** Called when all fonts used in the document are determined */ + /** Called once at init with declared font names + a native-render probe (legacy/early). */ 'fonts-resolved': [FontsResolvedPayload]; + /** Called with the authoritative substitution + load-aware font report once it settles and on change. */ + 'fonts-changed': [FontsChangedPayload]; + /** Called when active content control changes to a new control (or A -> B). */ contentControlFocus: [ContentControlFocusPayload]; diff --git a/packages/super-editor/src/index.ts b/packages/super-editor/src/index.ts index 0d300ddf91..23b3ab33a5 100644 --- a/packages/super-editor/src/index.ts +++ b/packages/super-editor/src/index.ts @@ -62,6 +62,7 @@ export type { CommentsPayload, CommentLocationsPayload, FontsResolvedPayload, + FontsChangedPayload, PaginationPayload, ListDefinitionsPayload, TrackedChangesChangedPayload, @@ -69,6 +70,14 @@ export type { EditorEventMap, } from './editors/v1/core/types/EditorEvents.js'; +// Font report types (used to type `fonts-changed` payloads + the fonts read API) +export type { + FontResolutionRecord, + FontResolutionReason, + FontLoadStatus, + FontLoadSummary, +} from '@superdoc/font-system'; + // Parts system types (used by partChanged event handler) export type { PartChangedEvent, PartId, PartSectionId } from './editors/v1/core/parts/types.js'; diff --git a/packages/superdoc/scripts/ensure-types.cjs b/packages/superdoc/scripts/ensure-types.cjs index cab2a574a8..6d812b342b 100644 --- a/packages/superdoc/scripts/ensure-types.cjs +++ b/packages/superdoc/scripts/ensure-types.cjs @@ -109,6 +109,54 @@ const SHARED_COMMON_DTS_TARGETS = typeSurface.sharedCommonDtsTargets; console.log(`[ensure-types] ✓ Emitted ${SHARED_COMMON_DTS_TARGETS.length} shared/common declarations`); } +// Emit @superdoc/font-system public declarations into the published dist tree. Its font +// report types surface on `superdoc.fonts` / `fonts-changed`. Like shared/common, adding +// it to vite-plugin-dts would shift the common-ancestor, so emit it standalone from the +// package's `index.ts` (which has no asset imports). The relocation rule rewrites bare +// `@superdoc/font-system` specifiers in other dist files to `shared/font-system/src/index.d.ts`. +{ + const { spawnSync: _spawnSync } = require('node:child_process'); + const tscBin = path.join(repoRoot, 'node_modules', '.bin', 'tsc'); + const fontSystemSrc = path.join(repoRoot, 'shared/font-system/src'); + const fontSystemDistDir = path.join(distRoot, 'shared/font-system/src'); + fs.mkdirSync(fontSystemDistDir, { recursive: true }); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'superdoc-ensure-types-fs-')); + const tempTsconfig = path.join(tempDir, 'tsconfig.font-system.json'); + fs.writeFileSync( + tempTsconfig, + `${JSON.stringify( + { + compilerOptions: { + declaration: true, + emitDeclarationOnly: true, + skipLibCheck: true, + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + types: [], + lib: ['ES2022', 'DOM'], + outDir: fontSystemDistDir, + rootDir: fontSystemSrc, + }, + files: [path.join(fontSystemSrc, 'index.ts')], + }, + null, + 2, + )}\n`, + ); + let tscResult; + try { + tscResult = _spawnSync(tscBin, ['-p', tempTsconfig], { stdio: 'inherit' }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + if (tscResult.status !== 0) { + console.error('[ensure-types] tsc failed emitting @superdoc/font-system declarations'); + process.exit(1); + } + console.log('[ensure-types] ✓ Emitted @superdoc/font-system declarations'); +} + // SD-2978: the package advertises CJS runtime entry points for `.`, `./types`, // and `./super-editor`. Node16/NodeNext TypeScript consumers resolving those // entries through `require` need CJS declaration entry points (`.d.cts`) so the diff --git a/packages/superdoc/scripts/type-surface.config.cjs b/packages/superdoc/scripts/type-surface.config.cjs index 0664ca006e..e2d9beae74 100644 --- a/packages/superdoc/scripts/type-surface.config.cjs +++ b/packages/superdoc/scripts/type-surface.config.cjs @@ -197,6 +197,17 @@ const relocations = [ viteIncludes: [], // emitted via sharedCommonDtsTargets tsc-postbuild tsconfigIncludes: [], }, + // The font report types (FontResolutionRecord, FontLoadStatus, FontLoadSummary, ...) + // surface on `superdoc.fonts` / `fonts-changed`. font-system lives in shared/ like + // @superdoc/common, so it is emitted standalone via tsc-postbuild (ensure-types.cjs) + // rather than vite includes, which would shift the dts common-ancestor. + { + pkg: '@superdoc/font-system', + distEntry: 'shared/font-system/src/index.d.ts', + matchSubpaths: true, + viteIncludes: [], // emitted via the font-system tsc-postbuild in ensure-types + tsconfigIncludes: [], + }, ]; /** diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 45ad4fd677..db129091b3 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -853,6 +853,141 @@ describe('SuperDoc core', () => { expect(readySpy).toHaveBeenCalledTimes(1); }); + describe('fonts-changed relay', () => { + const makeEmitterEditor = (overrides = {}) => { + const listeners = {}; + return { + options: { documentId: 'doc-1' }, + on: vi.fn((event, cb) => { + (listeners[event] ||= []).push(cb); + }), + emit: (event, payload) => (listeners[event] || []).forEach((cb) => cb(payload)), + ...overrides, + }; + }; + const makeInstance = async () => { + createAppHarness(); + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + onException: vi.fn(), + }); + await flushMicrotasks(); + return instance; + }; + + it('relays an editor fonts-changed event up to the SuperDoc surface', async () => { + const instance = await makeInstance(); + const editor = makeEmitterEditor(); + const received = []; + instance.on('fonts-changed', (p) => received.push(p)); + instance.broadcastEditorCreate(editor); + const payload = { + documentFonts: ['Calibri'], + resolutions: [], + missingFonts: [], + loadSummary: { loaded: 1, failed: 0, timedOut: 0, fallbackUsed: 0, results: [] }, + source: 'late-load', + version: 1, + }; + editor.emit('fonts-changed', payload); + expect(received).toContainEqual(payload); + }); + + it('replays the presentation editor cached report when the relay subscribes after emission', async () => { + const instance = await makeInstance(); + const cached = { + documentFonts: ['Aptos'], + resolutions: [], + missingFonts: ['Aptos'], + loadSummary: { loaded: 0, failed: 0, timedOut: 0, fallbackUsed: 1, results: [] }, + source: 'initial', + version: 0, + }; + const editor = makeEmitterEditor({ presentationEditor: { getLastFontsChangedPayload: () => cached } }); + const received = []; + instance.on('fonts-changed', (p) => received.push(p)); + instance.broadcastEditorCreate(editor); + expect(received).toContainEqual(cached); + }); + + it('does not throw for an editor without .on, and wires the relay at most once', async () => { + const instance = await makeInstance(); + expect(() => instance.broadcastEditorCreate({})).not.toThrow(); + + const editor = makeEmitterEditor(); + instance.broadcastEditorCreate(editor); + instance.broadcastEditorCreate(editor); + const fontsChangedSubscriptions = editor.on.mock.calls.filter((call) => call[0] === 'fonts-changed').length; + expect(fontsChangedSubscriptions).toBe(1); + }); + + it('fonts.onReport delivers the current report immediately, then streams changes until unsubscribed', async () => { + const instance = await makeInstance(); + const initial = { + documentFonts: ['Calibri'], + resolutions: [], + missingFonts: [], + loadSummary: { loaded: 1, failed: 0, timedOut: 0, fallbackUsed: 0, results: [] }, + source: 'initial', + version: 0, + }; + const editor = makeEmitterEditor({ presentationEditor: { getLastFontsChangedPayload: () => initial } }); + instance.broadcastEditorCreate(editor); // delivers `initial`, caching it on the instance + + const received = []; + const unsubscribe = instance.fonts.onReport((p) => received.push(p)); + expect(received).toEqual([initial]); // immediate snapshot, even though we subscribed late + + const next = { ...initial, source: 'late-load', version: 1, missingFonts: ['Aptos'] }; + editor.emit('fonts-changed', next); + expect(received).toEqual([initial, next]); // streamed + + unsubscribe(); + editor.emit('fonts-changed', { ...initial, version: 2 }); + expect(received).toHaveLength(2); // silent after unsubscribe + }); + + it('fonts.onReport never replays a prior editor report after an active-editor switch', async () => { + const instance = await makeInstance(); + const reportA = { + documentFonts: ['Calibri'], + resolutions: [], + missingFonts: [], + loadSummary: { loaded: 1, failed: 0, timedOut: 0, fallbackUsed: 0, results: [] }, + source: 'initial', + version: 0, + }; + const editorA = makeEmitterEditor({ presentationEditor: { getLastFontsChangedPayload: () => reportA } }); + instance.activeEditor = editorA; + instance.broadcastEditorCreate(editorA); // delivers reportA + populates the instance cache + + // Switch the active editor to B, which has not produced a report yet. + const editorB = makeEmitterEditor({ presentationEditor: { getLastFontsChangedPayload: () => null } }); + instance.activeEditor = editorB; + + const received = []; + instance.fonts.onReport((p) => received.push(p)); + expect(received).toEqual([]); // B has no report yet -> deliver nothing, never the stale A + + const reportB = { + documentFonts: ['Cambria'], + resolutions: [], + missingFonts: [], + loadSummary: { loaded: 1, failed: 0, timedOut: 0, fallbackUsed: 0, results: [] }, + source: 'initial', + version: 0, + }; + instance.broadcastEditorCreate(editorB); + editorB.emit('fonts-changed', reportB); + expect(received).toEqual([reportB]); // only B, via the subscription + }); + }); + it('uses visible search model in SuperDoc.search()', async () => { createAppHarness(); diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index 680cb0f3dc..4a68eb8203 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -87,12 +87,20 @@ import type { SuperDocLockedPayload, SuperDocReadyPayload, SuperDocState, + SuperDocFontsApi, SurfaceHandle, SurfaceRequest, UpgradeToCollaborationOptions, User, } from './types/index.js'; -import type { Comment, FontsResolvedPayload, ListDefinitionsPayload, PresentationEditor } from '@superdoc/super-editor'; +import type { + Comment, + FontsResolvedPayload, + FontsChangedPayload, + FontResolutionRecord, + ListDefinitionsPayload, + PresentationEditor, +} from '@superdoc/super-editor'; import type * as Y from 'yjs'; // `Whiteboard` is already imported as a value above (line 19); reuse it // as a type here without a separate `import type` declaration. @@ -141,6 +149,7 @@ interface SuperDocEventMap { 'editor-update': [EditorUpdateEvent]; 'content-error': [SuperDocContentErrorPayload]; 'fonts-resolved': [FontsResolvedPayload]; + 'fonts-changed': [FontsChangedPayload]; 'pagination-update': [SuperDocPaginationPayload]; 'list-definitions-change': [ListDefinitionsPayload]; 'comments-update': [SuperDocCommentsUpdatePayload]; @@ -860,6 +869,7 @@ export class SuperDoc extends EventEmitter { this.#onConfig('list-definitions-change', this.config.onListDefinitionsChange); this.#onConfig('pagination-update', this.config.onPaginationUpdate); this.#onConfig('fonts-resolved', this.config.onFontsResolved); + this.#onConfig('fonts-changed', this.config.onFontsChanged); } /** @@ -1488,9 +1498,41 @@ export class SuperDoc extends EventEmitter { broadcastEditorCreate(editor: Editor) { this.readyEditors++; this.broadcastReady(); + this.#wireFontsChangedRelay(editor); this.emit('editorCreate', { editor: createDeprecatedEditorProxy(editor) }); } + /** Editors whose `fonts-changed` we already relay, so a repeated create wires once. */ + #fontsRelayEditors = new WeakSet(); + + /** + * Relay an editor's authoritative `fonts-changed` up to the SuperDoc surface, so + * `superdoc.on('fonts-changed')` / `onFontsChanged` fire without the legacy + * `fonts-resolved` SuperDoc.vue listener-transport. Two robustness rules the happy + * path missed: (1) guard `editor.on` - test stubs and pre-layout editors lack it; + * (2) the PresentationEditor may have emitted its first report BEFORE this relay + * subscribed (a fast or swapped document), so replay the cached payload once on wire, + * matching what `superdoc.fonts.getReport()` returns for the active document. Wired at + * most once per editor (a create can fire twice). + */ + #wireFontsChangedRelay(editor: Editor): void { + if (!editor || typeof editor.on !== 'function') return; + if (this.#fontsRelayEditors.has(editor)) return; + this.#fontsRelayEditors.add(editor); + editor.on('fonts-changed', (payload) => this.#deliverFontsChanged(payload)); + const cached = editor.presentationEditor?.getLastFontsChangedPayload?.(); + if (cached) this.#deliverFontsChanged(cached); + } + + /** Last font report delivered on this instance, so `fonts.onReport` can replay it. */ + #lastFontsChangedPayload: FontsChangedPayload | null = null; + + /** Cache then emit a font report, so a later `onReport` subscriber gets the current one. */ + #deliverFontsChanged(payload: FontsChangedPayload): void { + this.#lastFontsChangedPayload = payload; + this.emit('fonts-changed', payload); + } + /** * Triggered when an editor is destroyed */ @@ -1510,6 +1552,44 @@ export class SuperDoc extends EventEmitter { (console.debug ? console.debug : console.log)('🦋 🦸‍♀️ [superdoc]', ...args); } + #fontsApi: SuperDocFontsApi | null = null; + + /** + * Read-only font surface: the substitution- and load-aware report for the active + * editor's document. Pulls on demand (the same report streams via `fonts-changed`). + * Stable identity; the closures always read the current `activeEditor`. Returns empty + * arrays when no editor is active or layout mode is off. + */ + get fonts(): SuperDocFontsApi { + if (!this.#fontsApi) { + this.#fontsApi = { + getReport: () => this.activeEditor?.presentationEditor?.getFontReport() ?? [], + getMissingFonts: () => this.activeEditor?.presentationEditor?.getMissingFonts() ?? [], + getDocumentFonts: () => + (this.activeEditor?.presentationEditor?.getFontReport() ?? []).map((record) => record.logicalFamily), + onReport: (callback) => { + // Snapshot-then-subscribe: the report may already have resolved (it fires during + // load, before a consumer subscribes - and a document swap creates a fresh editor), + // so deliver the current one immediately, then stream future changes. The active + // editor is the source of truth for "current": use ITS cached report so a snapshot + // matches `getReport()` for the active document. The instance-level cache can hold a + // PRIOR document's payload after an active-editor switch, so it is only a fallback + // for when no editor is active. When an active editor exists but has not produced a + // report yet, deliver nothing (the subscription catches its first one) rather than + // replaying a stale prior-editor payload. Returns an unsubscribe. + const activeEditor = this.activeEditor; + const current = activeEditor + ? (activeEditor.presentationEditor?.getLastFontsChangedPayload?.() ?? null) + : (this.#lastFontsChangedPayload ?? null); + if (current) callback(current); + this.on('fonts-changed', callback); + return () => this.off('fonts-changed', callback); + }, + }; + } + return this.#fontsApi; + } + /** * Set the active editor * @param editor The editor to set as active diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 948c010df2..af681548aa 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -29,6 +29,8 @@ import type { Comment, FontConfig, FontsResolvedPayload, + FontsChangedPayload, + FontResolutionRecord, ListDefinitionsPayload, ProofingProvider, User, @@ -66,6 +68,32 @@ export type NavigableAddress = SuperEditorNavigableAddress; * `#assignUserColor()` after `#init`. */ export type { User } from '@superdoc/super-editor'; +export type { FontResolutionRecord, FontsChangedPayload } from '@superdoc/super-editor'; + +/** + * Read-only font surface on a SuperDoc instance (`superdoc.fonts`). The authoritative, + * substitution- and load-aware answer to "what fonts does this document use and did + * SuperDoc render them faithfully", pulled on demand. The same report streams via the + * `fonts-changed` event / `onFontsChanged`. All three reflect the active editor; they + * return empty arrays when no editor is active. The write surface (add/map/preload) is + * deferred. {@link getReport} and {@link getDocumentFonts} cover the document's DECLARED + * fonts (font table + theme + defaults), not only fonts visible on screen. + */ +export interface SuperDocFontsApi { + /** Per-font report: requested logical family -> physical render family, reason, load status, export family, missing. */ + getReport(): FontResolutionRecord[]; + /** Declared families with no faithful render font loaded (the substitution-aware truth). */ + getMissingFonts(): string[]; + /** The document's declared logical font families, deduped. */ + getDocumentFonts(): string[]; + /** + * Observe the font report: invokes `callback` immediately with the current report (if one + * has resolved) and then on every change. Use this rather than `on('fonts-changed')` when + * you may subscribe after the report resolved or after a document swap - it delivers the + * current state regardless of timing. Returns an unsubscribe function. + */ + onReport(callback: (payload: FontsChangedPayload) => void): () => void; +} /** * Internal post-`#init` shape of the active user. Extends the public @@ -1835,8 +1863,20 @@ export interface Config { * Callback fired after the editor reports `fonts-resolved`. The payload * contains `documentFonts` and `unsupportedFonts` arrays so hosts can fall * back, warn, or block printing on unsupported faces. + * + * LEGACY/EARLY: this fires once before fonts load and is not substitution-aware + * (`unsupportedFonts` over-reports families that render via a bundled substitute). + * For the authoritative, load-settled picture use {@link onFontsChanged}. */ onFontsResolved?: (payload: FontsResolvedPayload) => void; + /** + * Callback fired with the authoritative substitution + load-aware font report: once + * after the load-before-measure gate settles (`source: 'initial'`), again when a face + * arrives after a timed-out first paint (`'late-load'`). Each payload carries the full + * per-font `resolutions`, the genuinely `missingFonts`, and a `loadSummary`. Also + * available to pull on demand via `superdoc.fonts.getReport()`. + */ + onFontsChanged?: (payload: FontsChangedPayload) => void; } /** diff --git a/packages/superdoc/src/public/index.ts b/packages/superdoc/src/public/index.ts index e4ede65fde..b639636d46 100644 --- a/packages/superdoc/src/public/index.ts +++ b/packages/superdoc/src/public/index.ts @@ -105,6 +105,7 @@ export type { SuperDocExceptionEditorPayload } from '../core/types/index.js'; export type { SuperDocExceptionPayload } from '../core/types/index.js'; export type { SuperDocExceptionRestorePayload } from '../core/types/index.js'; export type { SuperDocExceptionStorePayload } from '../core/types/index.js'; +export type { SuperDocFontsApi } from '../core/types/index.js'; export type { SuperDocLayoutEngineOptions } from '../core/types/index.js'; export type { SuperDocLockedPayload } from '../core/types/index.js'; export type { SuperDocReadyPayload } from '../core/types/index.js'; @@ -169,6 +170,8 @@ export type { ExportFormat } from '@superdoc/super-editor'; export type { ExportOptions } from '@superdoc/super-editor'; export type { FieldValue } from '@superdoc/super-editor'; export type { FontConfig } from '@superdoc/super-editor'; +export type { FontResolutionRecord } from '@superdoc/super-editor'; +export type { FontsChangedPayload } from '@superdoc/super-editor'; export type { FontsResolvedPayload } from '@superdoc/super-editor'; export type { ImageDeselectedEvent } from '@superdoc/super-editor'; export type { ImageSelectedEvent } from '@superdoc/super-editor'; diff --git a/packages/superdoc/vite-plugin-bundled-fonts.mjs b/packages/superdoc/vite-plugin-bundled-fonts.mjs new file mode 100644 index 0000000000..35dc5ae8d5 --- /dev/null +++ b/packages/superdoc/vite-plugin-bundled-fonts.mjs @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The bundled metric-compatible substitute pack lives in @superdoc/font-system as raw +// assets. This plugin serves them at /fonts/* in dev and emits them as SEPARATE output +// assets (dist/fonts/*) in build - so the font bytes are never inlined into the JS +// bundle (Vite lib mode otherwise base64-inlines imported assets, which busts the size +// budget). The provider registers `url(/fonts/)` faces against this same path. +const here = path.dirname(fileURLToPath(import.meta.url)); +const ASSETS_DIR = path.resolve(here, '../../shared/font-system/assets'); +const URL_PREFIX = '/fonts/'; + +const contentType = (file) => { + if (file.endsWith('.woff2')) return 'font/woff2'; + if (file.endsWith('.md')) return 'text/markdown; charset=utf-8'; + return 'text/plain; charset=utf-8'; +}; + +export default function bundledFontsPlugin() { + return { + name: 'superdoc-bundled-fonts', + + // Dev: serve the pack (.woff2 + license texts) at /fonts/. + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = req.url || ''; + if (!url.startsWith(URL_PREFIX)) return next(); + const name = path.basename(url.split('?')[0]); + const file = path.join(ASSETS_DIR, name); + // Confine to the assets dir; fall through for anything else. + if (path.dirname(file) !== ASSETS_DIR || !fs.existsSync(file)) return next(); + res.setHeader('Content-Type', contentType(name)); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-cache'); + fs.createReadStream(file).pipe(res); + }); + }, + + // Build: emit each asset as a separate output file under fonts/ (explicit emitFile, + // so it is NOT subject to lib-mode asset inlining). + generateBundle() { + if (!fs.existsSync(ASSETS_DIR)) { + this.warn(`[bundled-fonts] assets dir not found: ${ASSETS_DIR}`); + return; + } + for (const name of fs.readdirSync(ASSETS_DIR)) { + const full = path.join(ASSETS_DIR, name); + if (!fs.statSync(full).isFile()) continue; + this.emitFile({ type: 'asset', fileName: `fonts/${name}`, source: fs.readFileSync(full) }); + } + }, + }; +} diff --git a/packages/superdoc/vite.config.cdn.js b/packages/superdoc/vite.config.cdn.js index d069c53d72..162d562ad8 100644 --- a/packages/superdoc/vite.config.cdn.js +++ b/packages/superdoc/vite.config.cdn.js @@ -3,6 +3,7 @@ import { defineConfig } from 'vite'; import { version } from './package.json'; import { getAliases } from './vite.config.js'; import layeredCssPlugin from './vite-plugin-layered-css.mjs'; +import bundledFontsPlugin from './vite-plugin-bundled-fonts.mjs'; // Standalone browser bundle for CDN /