Skip to content

feat(fonts): face-scoped font substitution so single-face clones are safe (SD-3372)#3634

Merged
caio-pizzol merged 8 commits into
mainfrom
caio/sd-3372-featfonts-face-scoped-font-substitution-single-face-safe
Jun 5, 2026
Merged

feat(fonts): face-scoped font substitution so single-face clones are safe (SD-3372)#3634
caio-pizzol merged 8 commits into
mainfrom
caio/sd-3372-featfonts-face-scoped-font-substitution-single-face-safe

Conversation

@caio-pizzol

Copy link
Copy Markdown
Contributor

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.map to 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.

  • A substitute applies only when it provides the run's weight/style (consulted from the registry's bundled + fonts.add() faces); otherwise the logical family passes through, reported fallback_face_absent and never faux-styled.
  • One stored FontPlan per render is the single source for load (requiredFaces), diagnostics (usedFaces), measure/paint resolution, and cache identity (effectiveSignature). The effective signature replaces resolver.signature at every rendered-layout cache site, because face availability can change a face's resolution for an unchanged family map; resolver.signature now only detects map changes (in DocumentFontController).
  • Measure, paint, field-annotation pills, and per-rId header/footer all resolve face-aware.
  • Additive public surface: FontResolutionRecord.face (optional) + FontResolutionReason gains fallback_face_absent. The report now has per-face rows; declared-but-unused fonts stay as family-level rows (face undefined).
  • Bacasime / Caprasimo (and any single-face font) are NOT shipped here.

Stacked on #3631; do not open to main.

Review: confirm no resolver.signature remains in a measure/resolve/reuse cache path (it should only key map-change detection).
Verified: pnpm check:types clean; pnpm check:public:superdoc green (13 stages, no export-snapshot growth). Unit + behavior/layout/visual run in CI.

…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.
@caio-pizzol caio-pizzol requested a review from a team as a code owner June 4, 2026 05:01
@linear-code

linear-code Bot commented Jun 4, 2026

Copy link
Copy Markdown

SD-3372

…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.
Base automatically changed from caio/fonts-write-api to main June 4, 2026 23:13
@codecov-commenter

Copy link
Copy Markdown

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.
@caio-pizzol caio-pizzol merged commit 9b724bf into main Jun 5, 2026
70 checks passed
@caio-pizzol caio-pizzol deleted the caio/sd-3372-featfonts-face-scoped-font-substitution-single-face-safe branch June 5, 2026 00:15
@superdoc-bot

superdoc-bot Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in superdoc-cli v0.16.0

The release is available on GitHub release

@superdoc-bot

superdoc-bot Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in superdoc-sdk v1.15.0

@superdoc-bot

superdoc-bot Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in @superdoc-dev/mcp v0.11.0

The release is available on GitHub release

@superdoc-bot

superdoc-bot Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in superdoc v1.39.0

The release is available on GitHub release

@superdoc-bot

superdoc-bot Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in @superdoc-dev/react v1.10.0

The release is available on GitHub release

@superdoc-bot

superdoc-bot Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in vscode-ext v2.11.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants