feat(fonts): face-scoped font substitution so single-face clones are safe (SD-3372)#3634
Merged
caio-pizzol merged 8 commits intoJun 5, 2026
Conversation
…safe (SD-3372) The resolver mapped logical->physical by family only, so a single-face substitute (a customer fonts.map to a Regular-only font, or a bundled single-face clone) routed bold/italic runs to it and the browser faux-synthesized the missing face, drifting advances and breaking the line-break guarantee that is the point of a metric clone. Resolution is now face-aware: a substitute applies only when it provides the run's weight/style (consulted from the registry's bundled + fonts.add() faces), else the logical family passes through, reported fallback_face_absent. One stored FontPlan per render drives load (requiredFaces), diagnostics (usedFaces), measure/paint resolution, and cache identity (effectiveSignature), replacing resolver.signature at every rendered-layout cache site since face availability can change resolution without changing the family map. resolver.signature now only detects map changes. Additive public change: FontResolutionRecord.face (optional) + FontResolutionReason 'fallback_face_absent'. Stacks on #3626 + #3631; no font files shipped here.
…sd-3372-featfonts-face-scoped-font-substitution-single-face-safe # Conflicts: # packages/layout-engine/measuring/dom/src/index.ts # packages/layout-engine/painters/dom/src/runs/field-annotation-run.ts # packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts # packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts # shared/font-system/src/index.ts # shared/font-system/src/resolver.ts
…ary family
effectiveSignature was a ';'-joined delimited string of 'logical|weight|style=>physical|reason' entries; a font family is a free ST_String that may contain ';', '|', or '=>', so distinct resolution sets could serialize to the same key and cause wrong cache reuse. Serialize collision-safe JSON tuples instead (empty stays '' for the shared-cache fast path), matching FontResolver.signature.
primaryFamily now strips surrounding quotes like the resolver's normalizeFamilyKey, so a quoted primary ('"Calibri"') and its bare form collapse to one used face / report row / signature entry instead of two.
…correct Bundled face metadata was registered only when a real document.fonts existed, but hasFace (the face-availability oracle the face-aware resolver consults) reads that metadata. So an editor with no font set (SSR/jsdom, some iframe/embedded timings) saw hasFace=false for every bundled face and disabled all bundled substitutes - even Calibri Regular stopped resolving to Carlito. Install the pack whenever a registry is resolved (metadata only; loading still needs a font set), guarded per registry instance so it installs once.
…-load' fonts-changed folds the render plan's effectiveSignature into its dedup key, so the set of rendered faces changing from ordinary editing (e.g. the first Bold of a family) emits an event - but it defaulted to source: 'late-load' even though nothing loaded. Consumers filtering on 'late-load' got spurious load signals on typing. The font-config epoch bumps on a real late load and on a config mutation but NOT on editing, so distinguish them: epoch bump => 'late-load', key change with no epoch bump => new 'render-change'.
…+ realistic test The report keyed 'missing' purely on the per-face load status, and the single-face-substitute test asserted loadStatus 'unloaded' / missing false for the pass-through Bold face. That state cannot occur in production: the planner awaits the pass-through (Georgia 700), and document.fonts.load resolves only registered faces (never system fonts), so the awaited pass-through always settles to 'fallback_used'. Make 'missing' reason-based for fallback_face_absent (the substitute lacks the face, so it is not faithfully rendered - deterministic, not probe-dependent) and rewrite the test to model the real awaited-pass-through path (fallback_used, missing true). getMissingFonts now correctly lists a family whose Bold has no faithful substitute.
…titution-single-face-safe
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
field-annotation-run.test.ts still asserted the family-only resolvePhysical call shape; add the face arg ({ weight, style }) and widen the makeContext/mock types. PresentationEditor.test.ts resolved measureBlock's FontMeasureContext but still called resolvePhysical with no face (TypeError reading 'weight'); pass the face. Builds on a84ab05. Test-only.
Contributor
|
🎉 This PR is included in superdoc-cli v0.16.0 The release is available on GitHub release |
Contributor
|
🎉 This PR is included in superdoc-sdk v1.15.0 |
Contributor
|
🎉 This PR is included in @superdoc-dev/mcp v0.11.0 The release is available on GitHub release |
Contributor
|
🎉 This PR is included in superdoc v1.39.0 The release is available on GitHub release |
Contributor
|
🎉 This PR is included in @superdoc-dev/react v1.10.0 The release is available on GitHub release |
Contributor
|
🎉 This PR is included in vscode-ext v2.11.0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes font resolution face-aware so a single-face substitute is never faux-styled onto a weight/style it lacks. Today the resolver maps logical->physical by family only, so a customer
fonts.mapto a Regular-only font (or a bundled single-face clone) would route bold/italic runs to it and the browser would synthesize a faux face, drifting advances and breaking the line-break guarantee that is the point of a metric clone. Resolver safety only; no new fonts ship here.fonts.add()faces); otherwise the logical family passes through, reportedfallback_face_absentand never faux-styled.FontPlanper render is the single source for load (requiredFaces), diagnostics (usedFaces), measure/paint resolution, and cache identity (effectiveSignature). The effective signature replacesresolver.signatureat every rendered-layout cache site, because face availability can change a face's resolution for an unchanged family map;resolver.signaturenow only detects map changes (inDocumentFontController).FontResolutionRecord.face(optional) +FontResolutionReasongainsfallback_face_absent. The report now has per-face rows; declared-but-unused fonts stay as family-level rows (faceundefined).Stacked on #3631; do not open to main.
Review: confirm no
resolver.signatureremains in a measure/resolve/reuse cache path (it should only key map-change detection).Verified:
pnpm check:typesclean;pnpm check:public:superdocgreen (13 stages, no export-snapshot growth). Unit + behavior/layout/visual run in CI.