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 dee82e2cfe..1766f961b4 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 @@ -187,7 +187,7 @@ import { } from '@superdoc/font-system'; import { installBundledSubstitutes } from '@superdoc/font-system/bundled'; import { FontReadinessGate } from './fonts/FontReadinessGate'; -import { DocumentFontController } from './fonts/DocumentFontController'; +import { DocumentFontController, type EmbeddedFontFace } from './fonts/DocumentFontController'; import { planFontFaces, type FontPlan } from './fonts/font-load-planner'; import type { FontsChangedPayload } from '../types/EditorEvents'; import type { FontFamilyConfig } from '../types/EditorConfig'; @@ -1057,6 +1057,7 @@ export class PresentationEditor extends EventEmitter { }, }); this.#fontController.applyInitialConfig(this.#options.fontAssets); + this.#applyEmbeddedDocumentFonts(); if (typeof this.#options.disableContextMenu === 'boolean') { this.setContextMenuDisabled(this.#options.disableContextMenu); } @@ -3167,6 +3168,20 @@ export class PresentationEditor extends EventEmitter { await this.#fontController.preload(families); } + /** + * Register the current document's embedded fonts (from the converter) as document-owned registry + * faces, so the resolver's `registered_face` rung renders the real embedded font instead of the + * bundled substitute. Runs at config time - initial load and after a document swap - BEFORE the + * first font plan; the controller skips non-embeddable faces and releases these on the next swap + * (`reset`) / teardown (`dispose`). `getEmbeddedFontFaces` is not on the converter's typed surface, + * so it is read through a narrow structural cast (same pattern as `getDocumentFonts`). + */ + #applyEmbeddedDocumentFonts(): void { + const converter = (this.#editor as Editor & { converter?: { getEmbeddedFontFaces?: () => EmbeddedFontFace[] } }) + .converter; + this.#fontController.applyEmbeddedFaces(converter?.getEmbeddedFontFaces?.()); + } + /** * Drop this editor's cached blocks + measures and schedule a full document re-layout. The * font-readiness gate calls this (via its requestReflow option) for both a late font load and a @@ -5338,6 +5353,9 @@ export class PresentationEditor extends EventEmitter { // here states the intent and starts the swap from a clean signature. this.#layoutFontSignature = ''; this.#fontController.applyInitialConfig(this.#options.fontAssets); + // Register the NEW document's embedded fonts (the swap's `reset()` released the old ones), before + // the rerender below runs the first font plan for this document. + this.#applyEmbeddedDocumentFonts(); this.#resetFontReportStateForDocumentChange(); this.#refreshHeaderFooterStructureThenRerender({ purgeCachedEditors: true }); }; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.test.ts index b1dcee301b..00bea42bea 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createFontResolver, type FontFaceRequest, type RegisterFaceResult } from '@superdoc/font-system'; import type { FontRegistry } from '@superdoc/font-system'; -import { DocumentFontController } from './DocumentFontController'; +import { DocumentFontController, type EmbeddedFontFace } from './DocumentFontController'; import type { FontReadinessGate } from './FontReadinessGate'; class FakeRegistry { @@ -25,6 +25,33 @@ class FakeRegistry { return { family: input.family, status: 'unloaded', changed: true }; } + readonly ownedRegistered: Array<{ family: string; weight: string; style: string }> = []; + readonly ownedReleased: Array<{ family: string; weight: string; style: string }> = []; + + /** Model the document-owned binary face path: each call registers a distinct face and returns a + * disposer that releases exactly it. Reflected in `hasFace` so the resolver's `registered_face` + * rung sees it. (Refcounting a shared key is the real registry's job, covered in registry.test.ts; + * these controller tests use distinct families.) */ + registerOwnedFace(input: { + family: string; + source: ArrayBuffer | ArrayBufferView; + weight: '400' | '700'; + style: 'normal' | 'italic'; + }): (() => boolean) | null { + const record = { family: input.family, weight: input.weight, style: input.style }; + this.ownedRegistered.push(record); + const key = `${input.family.toLowerCase()}|${input.weight}|${input.style}`; + this.#sources.set(key, 'owned'); + let released = false; + return () => { + if (released) return false; + released = true; + this.ownedReleased.push(record); + this.#sources.delete(key); + return true; + }; + } + async awaitFaceRequests(requests: Iterable): Promise<[]> { this.awaited.push([...requests]); return []; @@ -39,8 +66,9 @@ class FakeRegistry { } } -function makeController() { - const registry = new FakeRegistry(); +function makeController(sharedRegistry?: FakeRegistry) { + // Pass a shared registry to model two editors on one FontFaceSet (the real per-document sharing). + const registry = sharedRegistry ?? new FakeRegistry(); const notifyDocumentFontConfigChanged = vi.fn(); const invalidateCachesForConfigRegistration = vi.fn(); const onDocumentFontConfigApplied = vi.fn(); @@ -318,4 +346,192 @@ describe('DocumentFontController', () => { expect(resolver.signature).toBe(''); expect(notifyDocumentFontConfigChanged).toHaveBeenCalledTimes(3); }); + + describe('applyEmbeddedFaces (embedded DOCX fonts)', () => { + const embed = (over: Partial & { family: string }): EmbeddedFontFace => ({ + source: new ArrayBuffer(8), + weight: '400', + style: 'normal', + fsType: 0, + embeddable: true, + relationshipId: 'rId1', + ...over, + }); + + // The bundled substitute pack (Carlito, Caladea, Liberation Sans/Serif/Mono) is registered in + // production by installBundledSubstitutes, mirroring BUNDLED_MANIFEST. After #3653 the resolver only + // takes the bundled_substitute rung when the clone face is loadable (hasFace-gated), so a fake that + // omits the pack resolves e.g. Calibri to identity instead of Carlito. Report the clones as present - + // all are four-face, so they supply every weight/style - mirroring resolver.test.ts's clone-aware + // hasFace. Deliberately NOT pushed into registry.registered, which other tests assert on. + const BUNDLED_CLONE_FAMILIES = new Set([ + 'carlito', + 'caladea', + 'liberation sans', + 'liberation serif', + 'liberation mono', + ]); + const hasFaceOf = (registry: FakeRegistry) => (f: string, w: '400' | '700', s: 'normal' | 'italic') => + registry.hasFace(f, w, s) || BUNDLED_CLONE_FAMILIES.has(f.trim().toLowerCase()); + const regular = { weight: '400', style: 'normal' } as const; + + it('registers embeddable faces under a unique physical family, skips restricted, invalidates without reflow/event', () => { + const { + controller, + resolver, + registry, + invalidateCachesForConfigRegistration, + notifyDocumentFontConfigChanged, + onDocumentFontConfigApplied, + } = makeController(); + const hasFace = hasFaceOf(registry); + + controller.applyEmbeddedFaces([ + embed({ family: 'Calibri', weight: '400' }), + embed({ family: 'Calibri', weight: '700', relationshipId: 'rId2' }), + // Restricted-License (fsType bit 1) / unreadable OS/2: not embeddable -> skipped. + embed({ family: 'SecretFont', fsType: 0x0002, embeddable: false, relationshipId: 'rId3' }), + ]); + + // Both Calibri faces register under ONE document-unique physical family - never the shared + // logical name "Calibri" (which would let another document render these bytes). + const phys = resolver.resolveFace('Calibri', regular, hasFace).physicalFamily; + expect(phys).toMatch(/^__superdoc_embedded_\d+__\d+_\d+_Calibri$/); + expect(registry.ownedRegistered).toEqual([ + { family: phys, weight: '400', style: 'normal' }, + { family: phys, weight: '700', style: 'normal' }, + ]); + expect(registry.hasFace(phys, '700', 'normal')).toBe(true); + expect(registry.hasFace('Calibri', '400', 'normal')).toBe(false); // logical name NOT in the shared set + expect(registry.hasFace('SecretFont', '400', 'normal')).toBe(false); // restricted never registered + // Config-time registration: clears the shared measure caches, but no reflow/event. + expect(invalidateCachesForConfigRegistration).toHaveBeenCalledTimes(1); + expect(onDocumentFontConfigApplied).not.toHaveBeenCalled(); + expect(notifyDocumentFontConfigChanged).not.toHaveBeenCalled(); + }); + + it('resolves the logical family to the unique physical via registered_face, and paint swaps to it', () => { + const { controller, resolver, registry } = makeController(); + const hasFace = hasFaceOf(registry); + + // Before: Calibri falls back to the bundled substitute (Carlito). + expect(resolver.resolveFace('Calibri', regular, hasFace).physicalFamily).toBe('Carlito'); + + controller.applyEmbeddedFaces([embed({ family: 'Calibri' })]); + + const resolved = resolver.resolveFace('Calibri', regular, hasFace); + expect(resolved.reason).toBe('registered_face'); + expect(resolved.physicalFamily).not.toBe('Calibri'); // NOT the shared logical name + expect(resolved.physicalFamily).toMatch(/^__superdoc_embedded_\d+__\d+_\d+_Calibri$/); + // The paint/measure seam swaps the primary to the unique physical family (fallbacks preserved). + expect(resolver.resolvePhysicalFamilyForFace('Calibri, serif', regular, hasFace)).toBe( + `${resolved.physicalFamily}, serif`, + ); + }); + + it('releases embedded faces and resolver bindings on reset (document swap)', () => { + const { controller, resolver, registry } = makeController(); + const hasFace = hasFaceOf(registry); + controller.applyEmbeddedFaces([ + embed({ family: 'Calibri', weight: '400' }), + embed({ family: 'Calibri', weight: '700' }), + ]); + const phys = resolver.resolveFace('Calibri', regular, hasFace).physicalFamily; + expect(registry.hasFace(phys, '400', 'normal')).toBe(true); + + controller.reset(); + + expect(registry.ownedReleased).toEqual([ + { family: phys, weight: '400', style: 'normal' }, + { family: phys, weight: '700', style: 'normal' }, + ]); + expect(registry.hasFace(phys, '400', 'normal')).toBe(false); + // Resolver reverts: Calibri falls back to the bundled substitute again. + expect(resolver.resolveFace('Calibri', regular, hasFace).physicalFamily).toBe('Carlito'); + }); + + it('releases embedded faces and resolver bindings on dispose (teardown)', () => { + const { controller, resolver, registry } = makeController(); + const hasFace = hasFaceOf(registry); + controller.applyEmbeddedFaces([embed({ family: 'Calibri' })]); + const phys = resolver.resolveFace('Calibri', regular, hasFace).physicalFamily; + + controller.dispose(); + + expect(registry.ownedReleased).toEqual([{ family: phys, weight: '400', style: 'normal' }]); + expect(resolver.resolveFace('Calibri', regular, hasFace).physicalFamily).toBe('Carlito'); + }); + + it('replaces the prior embedded set on re-apply (releases old, binds new)', () => { + const { controller, resolver, registry } = makeController(); + const hasFace = hasFaceOf(registry); + controller.applyEmbeddedFaces([embed({ family: 'Calibri' })]); + const calibriPhys = resolver.resolveFace('Calibri', regular, hasFace).physicalFamily; + + controller.applyEmbeddedFaces([embed({ family: 'Cambria' })]); + + expect(registry.ownedReleased).toEqual([{ family: calibriPhys, weight: '400', style: 'normal' }]); + expect(resolver.resolveFace('Calibri', regular, hasFace).physicalFamily).toBe('Carlito'); // released -> bundled + const cambria = resolver.resolveFace('Cambria', regular, hasFace); + expect(cambria.reason).toBe('registered_face'); + expect(cambria.physicalFamily).toMatch(/^__superdoc_embedded_\d+__\d+_\d+_Cambria$/); + }); + + it('does nothing (no invalidate) when there are no embeddable faces', () => { + const { controller, resolver, registry, invalidateCachesForConfigRegistration } = makeController(); + const hasFace = hasFaceOf(registry); + controller.applyEmbeddedFaces([embed({ family: 'SecretFont', embeddable: false })]); + + expect(registry.ownedRegistered).toHaveLength(0); + expect(invalidateCachesForConfigRegistration).not.toHaveBeenCalled(); + expect(resolver.resolveFace('SecretFont', regular, hasFace).reason).toBe('as_requested'); + }); + + it('gives each document a distinct physical family for the SAME logical name (render isolation)', () => { + // Two editors on ONE FontFaceSet, both embedding "Calibri" with different bytes. The unique + // physical families keep render ownership document-scoped: neither cleanup nor matching crosses. + const registry = new FakeRegistry(); + const docA = makeController(registry); + const docB = makeController(registry); + const hasFace = hasFaceOf(registry); + + docA.controller.applyEmbeddedFaces([embed({ family: 'Calibri' })]); + docB.controller.applyEmbeddedFaces([embed({ family: 'Calibri' })]); // same logical family, other doc + + const physA = docA.resolver.resolveFace('Calibri', regular, hasFace).physicalFamily; + const physB = docB.resolver.resolveFace('Calibri', regular, hasFace).physicalFamily; + expect(physA).not.toBe(physB); // distinct physical families for the same logical "Calibri" + + docA.controller.reset(); + + expect(registry.hasFace(physA, '400', 'normal')).toBe(false); // doc A released + expect(registry.hasFace(physB, '400', 'normal')).toBe(true); // doc B intact + expect(docB.resolver.resolveFace('Calibri', regular, hasFace).reason).toBe('registered_face'); + }); + + it('mints a fresh physical family across a same-controller document swap (no in-flight name reuse)', () => { + // One controller, two documents in sequence with reset() between (the real same-controller swap). + // The namespace is fixed per controller and reset() clears the per-family index, so WITHOUT a + // per-apply generation the second document would re-mint the first's physical name and alias its + // (possibly still in-flight) face on the shared registry. The generation keeps the names distinct. + const { controller, resolver, registry } = makeController(); + const hasFace = hasFaceOf(registry); + + controller.applyEmbeddedFaces([embed({ family: 'Calibri' })]); + const physFirst = resolver.resolveFace('Calibri', regular, hasFace).physicalFamily; + + controller.reset(); // document swap: releases the face, clears the index + controller.applyEmbeddedFaces([embed({ family: 'Calibri' })]); + const physSecond = resolver.resolveFace('Calibri', regular, hasFace).physicalFamily; + + expect(physSecond).not.toBe(physFirst); // generation bumped: never a reused name + // Same controller -> same namespace; the GENERATION segment is what differs (not the namespace, + // unlike the cross-controller test above). Pin that so a regression to namespace-only uniqueness fails. + const namespaceOf = (phys: string) => phys.match(/^(__superdoc_embedded_\d+__)/)?.[1]; + expect(namespaceOf(physSecond)).toBe(namespaceOf(physFirst)); + // Only the second document's face is live in the shared registry; the first was released on swap. + expect(registry.hasFace(physFirst, '400', 'normal')).toBe(false); + expect(registry.hasFace(physSecond, '400', 'normal')).toBe(true); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.ts index 67668d5dcb..f4de192b55 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/DocumentFontController.ts @@ -24,6 +24,26 @@ export interface DocumentFontControllerDeps { scheduleMicrotask?: (callback: () => void) => void; } +/** + * A document's embedded font face, as extracted by the converter (`SuperConverter.getEmbeddedFontFaces`): + * the deobfuscated bytes plus the OS/2-derived face axis and raw `fsType` licensing. The controller + * registers each {@link embeddable} face under a document-unique physical family (binding the logical + * family to it in the resolver) so the `registered_face` rung renders the document's real font instead + * of the bundled substitute; it skips faces that are not embeddable (Restricted-License, or an + * unreadable OS/2 table - no proof the license permits embedding). + */ +export interface EmbeddedFontFace { + family: string; + /** Deobfuscated SFNT bytes (an ArrayBuffer from `deobfuscateFont`). */ + source: ArrayBuffer | ArrayBufferView; + weight: '400' | '700'; + style: 'normal' | 'italic'; + /** Raw OS/2 `fsType`, or null when the table was unreadable. Preserved for diagnostics/policy. */ + fsType: number | null; + embeddable: boolean; + relationshipId: string; +} + /** * Normalize a public font source (a plain URL like '/fonts/Gelasio.woff2') to the CSS `url(...)` * source the FontFace constructor expects. An already-`url(...)` value is left unchanged. @@ -32,6 +52,22 @@ function toCssFontSource(url: string): string { return /^\s*url\(/i.test(url) ? url : `url(${JSON.stringify(url)})`; } +/** + * Monotonic per-page counter giving each document controller a distinct embedded-font namespace, so + * the document-unique physical families two controllers mint never collide in the shared FontFaceSet. + */ +let embeddedDocumentCounter = 0; +function nextEmbeddedNamespace(): string { + embeddedDocumentCounter += 1; + return `__superdoc_embedded_${embeddedDocumentCounter}__`; +} + +/** Reduce a family name to a CSS-identifier-safe token (no spaces/punctuation) for use inside a + * physical family name, so the painted `font-family` is a single valid unquoted token. */ +function sanitizeFamilyToken(family: string): string { + return family.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'font'; +} + /** * The single writer for a document's font state: `map`/`unmap` change the resolver, `add` * registers customer faces through the registry, and `preload` loads them. Runtime @@ -58,6 +94,30 @@ export class DocumentFontController { * the caches would otherwise keep stale fallback widths for a now-loadable family. */ #runtimeAvailabilityChanged = false; + /** + * Release handles for THIS document's embedded faces, in registration order. The registry is shared + * per FontFaceSet across editors, so cleanup must release exactly the faces this document registered + * (each disposer removes one specific face); a document swap / teardown calls them all. Emptied on + * release; replaced wholesale on each {@link applyEmbeddedFaces}. + */ + readonly #embeddedDisposers: Array<() => void> = []; + /** + * This document's embedded-font namespace, unique per controller, so its physical families never + * collide with another editor's in the shared FontFaceSet. + */ + readonly #embeddedNamespace = nextEmbeddedNamespace(); + /** + * Normalized logical family -> this document's unique physical family for it. Dedupes the faces of + * one embedded family (e.g. Calibri regular + bold) onto a single physical family. Cleared on release. + */ + readonly #embeddedPhysical = new Map(); + /** + * Per-apply generation, bumped on each {@link applyEmbeddedFaces} that registers a set. Folded into + * the physical family name: a document swap clears {@link #embeddedPhysical} (resetting the index), so + * without the generation the next document's first embedded family would re-mint the previous one's + * physical name and collide on the shared registry's in-flight load/status key. + */ + #embeddedGeneration = 0; constructor(deps: DocumentFontControllerDeps) { this.#resolver = deps.resolver; @@ -92,12 +152,14 @@ export class DocumentFontController { */ reset(): void { this.#cancelPendingRuntimeReflow(); + this.#releaseEmbeddedFaces(); this.#resolver.reset(); } - /** Cancel pending runtime font work on editor teardown. */ + /** Cancel pending runtime font work and release this document's embedded faces on editor teardown. */ dispose(): void { this.#cancelPendingRuntimeReflow(); + this.#releaseEmbeddedFaces(); } /** @@ -118,6 +180,70 @@ export class DocumentFontController { if (registered) this.#getGate()?.invalidateCachesForConfigRegistration(); } + /** + * Register the document's embedded fonts (from `SuperConverter.getEmbeddedFontFaces`) as first-class + * registry faces, BEFORE the first layout measure. Each {@link EmbeddedFontFace.embeddable} face is + * registered under a DOCUMENT-UNIQUE physical family (e.g. `__superdoc_embedded_3__1_0_Calibri`), and the + * logical family is bound to it in this document's resolver, so the `registered_face` rung renders the + * document's real font instead of the bundled substitute (Carlito) - with no resolver special-casing. + * The FontFaceSet is shared per page, so registering under the logical name would let another document + * render these bytes; the unique physical name keeps render ownership document-scoped while export and + * the font report keep the logical name. Non-embeddable faces (Restricted-License, or an unreadable + * OS/2 table) are skipped: the bundled substitute renders them. + * + * Document-scoped: the controller holds a release handle per registered face and frees them on + * {@link reset} (document swap) / {@link dispose} (teardown), so this document's fonts never leak into + * the next or into another editor sharing the FontFaceSet. Re-applying replaces the prior embedded + * set. Like a config-time registration it invalidates the shared measure caches (a registration does + * not move the resolver signature that would otherwise bust them) but does NOT reflow or emit - the + * first/next render measures fresh against the now-registered face. + */ + applyEmbeddedFaces(faces: EmbeddedFontFace[] | null | undefined): void { + // Replace any prior embedded set (idempotent re-apply): release before re-registering so a repeated + // call cannot double-register or strand handles. + this.#releaseEmbeddedFaces(); + if (!faces?.length) return; + // No registry (no DOM / headless): the document still renders with bundled substitutes. Don't throw + // here - unlike the user-invoked `fonts.add`, this runs automatically on every document load. + const registry = this.#getGate()?.resolveRegistry(); + if (!registry) return; + // New generation per registering apply, so this document's physical families never reuse the prior + // document's names (which would alias the shared registry's in-flight load/status on a swap). + this.#embeddedGeneration += 1; + let registered = false; + for (const face of faces) { + if (!face?.embeddable) continue; + const physicalFamily = this.#physicalFamilyFor(face.family); + const release = registry.registerOwnedFace({ + family: physicalFamily, + source: face.source, + weight: face.weight, + style: face.style, + }); + if (release) { + this.#embeddedDisposers.push(release); + // Bind logical -> this document's unique physical family so the resolver renders THIS document's + // bytes (registered_face). Idempotent across the family's faces (regular + bold share it). + this.#resolver.mapEmbedded(face.family, physicalFamily); + registered = true; + } + } + if (registered) this.#getGate()?.invalidateCachesForConfigRegistration(); + } + + /** This document's unique physical family for a logical embedded family, assigned once per family + * (its faces share it). The per-apply generation + per-family index keep it unique across BOTH a + * document swap (generation) and names that sanitize alike within one document (index). */ + #physicalFamilyFor(logicalFamily: string): string { + const key = logicalFamily.trim().toLowerCase(); + let physical = this.#embeddedPhysical.get(key); + if (!physical) { + physical = `${this.#embeddedNamespace}${this.#embeddedGeneration}_${this.#embeddedPhysical.size}_${sanitizeFamilyToken(logicalFamily)}`; + this.#embeddedPhysical.set(key, physical); + } + return physical; + } + /** * Register custom physical font faces (e.g. a customer's Gelasio woff2s) so they become loadable * and mappable. Registers only - it does NOT map (call {@link map} for that). Idempotent per @@ -244,6 +370,16 @@ export class DocumentFontController { this.#runtimeReflowQueued = false; this.#runtimeReflowToken += 1; } + + /** Release every embedded face this document registered (each disposer removes one specific face) and + * drop the resolver bindings, so neither the FontFaceSet nor the resolver retains this document's + * embedded fonts. Safe to call repeatedly; the disposers are idempotent. */ + #releaseEmbeddedFaces(): void { + for (const release of this.#embeddedDisposers) release(); + this.#embeddedDisposers.length = 0; + this.#embeddedPhysical.clear(); + this.#resolver.clearEmbedded(); + } } function defaultScheduleMicrotask(callback: () => void): void { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js index cb8ee0b0f2..247b9a3700 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js @@ -12,6 +12,7 @@ import { normalizeDuplicateBlockIdentitiesInContent } from './v2/importer/normal import { preProcessPageFieldsOnly } from './field-references/preProcessPageFieldsOnly.js'; import { carbonCopy } from '../utilities/carbonCopy.js'; import { deobfuscateFont, getArrayBufferFromUrl, computeCrc32Hex } from './helpers.js'; +import { parseEmbeddingPolicy } from '@superdoc/font-system'; import { baseNumbering } from './v2/exporter/helpers/base-list.definitions.js'; import { DEFAULT_CUSTOM_XML, DEFAULT_DOCX_DEFS } from './exporter-docx-defs.js'; import { @@ -1007,14 +1008,33 @@ class SuperConverter { let styleString = ''; for (const font of fontsToInclude) { const filePath = elements.find((el) => el.attributes.Id === font.attributes['r:id'])?.attributes?.Target; - if (!filePath) return; + // Skip a font with a missing relationship/binary/deobfuscation rather than aborting the whole + // method (one malformed embedded font must not drop every other font's @font-face). Matches the + // registry path (getEmbeddedFontFaces), which already `continue`s past the same cases. + if (!filePath) continue; const fontUint8Array = this.fonts[`word/${filePath}`]; - const fontBuffer = fontUint8Array?.buffer; - if (!fontBuffer) return; + if (!fontUint8Array?.buffer) continue; + // Copy the EXACT view bytes into a fresh buffer BEFORE deobfuscating. deobfuscateFont XORs its + // input IN PLACE; passing `this.fonts[...].buffer` would mutate the shared embedded-font bytes, + // so the later registry extraction (getEmbeddedFontFaces) would double-XOR and corrupt the face. + // The byteOffset/byteLength slice also avoids XORing the wrong bytes when the Uint8Array is a + // view into a larger pooled buffer. getEmbeddedFontFaces already copies the same way. + const fontBuffer = fontUint8Array.buffer.slice( + fontUint8Array.byteOffset, + fontUint8Array.byteOffset + fontUint8Array.byteLength, + ); const ttfBuffer = deobfuscateFont(fontBuffer, font.attributes['w:fontKey']); - if (!ttfBuffer) return; + if (!ttfBuffer) continue; + + // Honor the embedding policy here too. The registry path (getEmbeddedFontFaces) skips a face whose + // OS/2 fsType forbids embedding (or is unreadable), but this legacy @font-face injection would + // otherwise still emit the restricted bytes under the logical family. Reuse the SAME rule + // (parseEmbeddingPolicy; a null/unreadable policy is conservatively not embeddable) so the policy + // gate has one source of truth; skip just this face rather than aborting the rest. + const policy = parseEmbeddingPolicy(ttfBuffer); + if (!(policy ? policy.embeddable : false)) continue; // Convert to a blob and inject @font-face const blob = new Blob([ttfBuffer], { type: 'font/ttf' }); @@ -1046,6 +1066,82 @@ class SuperConverter { }; } + /** + * Extract the document's embedded fonts as structured, deobfuscated faces for first-class registry + * registration (the architecturally correct replacement for the legacy `@font-face` CSS injection in + * {@link getFontFaceImportString}). The converter only extracts + deobfuscates + classifies; it does + * NOT create object URLs, inject `