feat(fonts): render embedded DOCX fonts as document-owned registry faces#3643
Conversation
Reads a font's OS/2 table to decide whether a DOCX-embedded font may be registered for rendering (fsType licensing) and which weight/style FACE it is (usWeightClass + fsSelection italic bit), so embedded fonts can become first-class registry faces with the correct FaceKey instead of filename inference. Restricted-License fonts (fsType bit 1) are non-embeddable; a parse failure returns null so callers skip conservatively. Foundation for registering embedded fonts as providers, stacked on the registered_face ladder.
…aces Adds SuperConverter.getEmbeddedFontFaces(), the architecturally correct replacement for the legacy @font-face CSS injection: it extracts + deobfuscates the embedded fonts and classifies each via the OS/2 parser (weight/style + fsType licensing), returning structured faces with the deobfuscated bytes and the fontTable relationship id. The converter no longer needs to mint object URLs or inject <style>; the document font controller will register these as first-class registry faces (skipping non-embeddable or unreadable ones) so they flow through the registered_face provider ladder. The legacy getFontFaceImportString stays until the registry path renders embedded fonts.
Adds FontRegistry.registerOwnedFace(descriptor) -> disposer for embedded DOCX fonts. The registry is shared per FontFaceSet across editors, so a key-based delete would let one document's swap drop another document's (or a fonts.add) face for the same family|weight|style. Instead each call creates a distinct managed face and returns a disposer that removes EXACTLY that face; a face key collapses (hasFace flips false) only when its last owner releases. Tracks all faces per key in #facesByKey (replacing the one-per-key #managedFaces); register() and registerOwnedFace() share #addManagedFace/#removeManagedFace. Binary embedded faces are never de-duped (two documents can embed the same family with different subset bytes). Returns null with no DOM FontFace constructor so the resolver falls through to the bundled substitute. OwnedFaceDescriptor exported.
DocumentFontController.applyEmbeddedFaces(faces) registers each embeddable embedded face (from SuperConverter.getEmbeddedFontFaces) as a document-owned registry face before the first measure, so the resolver's registered_face rung renders the document's real font instead of the bundled substitute - no resolver special-casing. Non-embeddable faces (Restricted-License or unreadable OS/2) are skipped and the substitute renders them. Holds a release handle per face and frees them on reset() (document swap) and dispose() (teardown), so a document's embedded fonts never leak into the next or into another editor sharing the FontFaceSet; re-applying replaces the prior set. Like other config-time registrations it invalidates the shared measure caches without a reflow or event. Also corrects the getEmbeddedFontFaces @returns (source is an ArrayBuffer, not a Uint8Array).
…lifecycle PresentationEditor now registers the document's embedded fonts through the controller at config time - on initial load and after a document swap - before the first font plan, so the resolver renders the real embedded font. The swap path already resets the controller (releasing the prior document's faces) before re-applying; teardown disposes it. Reads getEmbeddedFontFaces through a narrow structural cast (not on the converter's typed surface, same pattern as getDocumentFonts). The legacy <style> object-URL injection still runs in parallel for now; removing it is deferred to a follow-up until CI confirms the registry path renders embedded fonts (no-removal-before-test).
Covers SuperConverter.getEmbeddedFontFaces with a synthetic fontTable.xml + rels and obfuscated SFNT fixtures (XOR round-trip via the same operation deobfuscateFont runs): OS/2-derived weight/style wins over the embed name; Restricted-License fsType is extracted but flagged not embeddable; an unreadable OS/2 table falls back to the embed-name face and not-embeddable; a missing relationship and a non-embedded entry are skipped; the deobfuscated source is a fresh ArrayBuffer that round-trips the SFNT version word; and the pooled converter bytes are not mutated (the slice fix).
…ical family The FontFaceSet is shared per page, so registering an embedded font under its logical name (Calibri) let two documents that both embed Calibri cross-render each other's subset bytes - handle-based release fixed cleanup ownership but not RENDER ownership. Each document now registers its embedded faces under a unique physical family (e.g. __superdoc_embedded_3__0_Calibri) and binds logical->physical in its own resolver via mapEmbedded; the face-aware ladder resolves that to registered_face and the paint/measure seam swaps the primary to it, so a document always renders its OWN bytes. The logical name is kept for export and the public font report (the alias is used only for the load-status lookup). reset()/dispose() drop the bindings and release the faces; the signature folds embedded bindings in (document-distinct cache keys) but is unchanged when there are no embedded fonts. A fonts.add real face still resolves to the logical name (physical === logical), so that path is untouched.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1641f364be
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…gistration # Conflicts: # shared/font-system/src/registry.test.ts # shared/font-system/src/report.test.ts # shared/font-system/src/resolver.ts
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e95971fcb3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
… generation The legacy getFontFaceImportString path injected embedded faces with no embedding check, leaking Restricted-License or unreadable fonts under their logical family even though the registry path skips them. Gate it on the same parseEmbeddingPolicy rule, and skip a malformed embed (missing relationship/bytes/deobfuscation) instead of aborting the whole method, matching the registry path. Fold a per-apply generation into the embedded physical family name. A same-controller document swap clears the per-family index, so without it the next document re-mints the prior name and aliases its in-flight face on the shared registry. Update the DocumentFontController fake to report the bundled clone pack as loadable: after the resolver began gating bundled_substitute on hasFace(clone), the fake (which omits the pack) resolved Calibri to identity instead of Carlito.
|
🎉 This PR is included in superdoc-cli v0.16.0 The release is available on GitHub release |
|
🎉 This PR is included in superdoc-sdk v1.15.0 |
|
🎉 This PR is included in @superdoc-dev/mcp v0.11.0 The release is available on GitHub release |
|
🎉 This PR is included in superdoc v1.39.0 The release is available on GitHub release |
|
🎉 This PR is included in @superdoc-dev/react v1.10.0 The release is available on GitHub release |
|
🎉 This PR is included in vscode-ext v2.11.0 |
DOCX files can embed their own fonts; SuperDoc was injecting them as global
@font-faceCSS with object URLs that were never revoked, weight/style guessed from filenames, and the embedding permission never checked. This makes each embedded font a first-class registry face: the converter deobfuscates the bytes and reads the OS/2 table for the real weight/style and embedding permission, and the document controller registers each allowed face so the resolver renders the document's real font instead of the bundled substitute.The legacy
<style>injection still runs in parallel as a temporary safety net; removing it is a follow-up once CI confirms the registry path renders embedded fonts.Review: the legacy
<style>injection is intentionally left for now (removal tracked as a follow-up);__superdoc_embedded_*family names are internal render aliases, not public API.Stacked on #3640.