From 40c139dd2c65ccb0520ba261e82e2cc9feabe7f3 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 11 Jul 2026 14:07:36 +0200 Subject: [PATCH] feat: add embedded font loading --- CHANGELOG.md | 1 + src/App.svelte | 13 +++++++ src/lib/fonts/embeddedFonts.ts | 49 +++++++++++++++++++++++++ src/lib/import/docx.ts | 31 ++++++++++++++++ src/lib/import/odt.ts | 10 ++++++ src/lib/import/styleResolver.ts | 31 +++++++++++++--- src/lib/storage/embeddedFontStore.ts | 54 ++++++++++++++++++++++++++++ tests/unit/deobfuscate-odttf.test.ts | 27 ++++++++++++++ 8 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 src/lib/fonts/embeddedFonts.ts create mode 100644 src/lib/storage/embeddedFontStore.ts create mode 100644 tests/unit/deobfuscate-odttf.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c756908..abf89f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ - ODT export via odf-kit with extensive content.xml / styles.xml post-processing (custom node types, tables with borders, images, fonts and colors); filename derived from the first heading (fallback `document.odt`) - ODT import / open existing file: parses content.xml / styles.xml directly, adopts the file's margins and orientation, and reports graceful-degradation warnings - Opens `.ott` templates (OpenDocument Text Template): read like an `.odt`, but as a new untitled document — the first Save writes a fresh `.odt` and never overwrites the template (Word/LibreOffice behavior) +- Embedded font loading: fonts embedded in an opened `.odt`/`.docx` (Word `.odttf` de-obfuscated) are registered via the FontFace API so text renders in its real face even when the font isn't installed; persisted per-document in IndexedDB so it survives a reload. Fonts the document only names but neither embeds nor installs are still flagged as substituted - Word (.docx) export and import — round-trips the editor's formatting (text, fonts, lists, tables, images, headers/footers, page geometry) and opens real Word documents - PDF export — Raster (pixel-exact copy of the editor with a selectable text layer) and Vector (crisp, fonts embedded, via the browser print dialog) - Print (printer button / Ctrl+P) — opens the browser print dialog with a pixel-exact raster of the document (tables, headers/footers and page breaks intact) diff --git a/src/App.svelte b/src/App.svelte index 53136b6..d6f0b63 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -32,6 +32,8 @@ import { withShortcut } from './lib/i18n/shortcut'; import { localizeImportMessage } from './lib/i18n/importMessages'; import { unavailableFonts } from './lib/utils/fontDetect'; + import { registerEmbeddedFonts, clearEmbeddedFonts } from './lib/fonts/embeddedFonts'; + import { saveEmbeddedFonts, loadEmbeddedFonts, clearEmbeddedFontStore } from './lib/storage/embeddedFontStore'; let editor: Editor | null = $state(null); let tick: number = $state(0); @@ -327,6 +329,8 @@ hfDistances = { ...DEFAULT_HF_DISTANCES }; documentName = ''; fileHandle = null; + clearEmbeddedFonts(); + void clearEmbeddedFontStore(); editor.commands.focus(); } @@ -361,6 +365,11 @@ return; } + // Register the document's embedded fonts (and persist them for next reload) before + // rendering, so its text shows in the right face and isn't flagged as missing below. + await registerEmbeddedFonts(result.fonts); + void saveEmbeddedFonts(result.fonts); + editor.commands.setContent(result.content); // onUpdate fires → autosave // Adopt the opened file's name as the document name (drives the save filename). if (sourceName) documentName = stripOdtExtension(sourceName).replace(/\.docx$/i, ''); @@ -565,6 +574,10 @@ } onMount(() => { + // Re-register the restored document's embedded fonts so it renders in the right face; + // FontFace load fires 'loadingdone', which Editor.svelte re-paginates on. + void loadEmbeddedFonts().then(registerEmbeddedFonts); + function onKeydown(e: KeyboardEvent) { // Ctrl/Cmd+S → Save (suppress the browser's save-page dialog). if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 's' || e.key === 'S')) { diff --git a/src/lib/fonts/embeddedFonts.ts b/src/lib/fonts/embeddedFonts.ts new file mode 100644 index 0000000..9e32224 --- /dev/null +++ b/src/lib/fonts/embeddedFonts.ts @@ -0,0 +1,49 @@ +// Fonts embedded in an opened .odt/.docx, registered via the browser FontFace API so +// text renders in its original face even when the font isn't installed locally. +export interface EmbeddedFont { + family: string; + weight: 'normal' | 'bold'; + style: 'normal' | 'italic'; + data: Uint8Array; +} + +// De-obfuscate a Word `.odttf`: the first 32 bytes are XORed with the 16-byte fontKey +// GUID in reverse. In-place; returns the same array. No/short key ⇒ left untouched. +export function deobfuscateOdttf(data: Uint8Array, fontKey: string): Uint8Array { + const hex = fontKey.replace(/[^0-9a-fA-F]/g, ''); + if (hex.length < 32) return data; + const key = new Uint8Array(16); + for (let i = 0; i < 16; i++) key[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + key.reverse(); + const n = Math.min(32, data.length); + for (let i = 0; i < n; i++) data[i] ^= key[i % 16]; + return data; +} + +// The faces this module added to document.fonts, tracked so a new document's set can +// replace the previous one (only the current document's fonts stay registered). +let registered: FontFace[] = []; + +export function clearEmbeddedFonts(): void { + if (typeof document !== 'undefined' && document.fonts) { + for (const face of registered) { + try { document.fonts.delete(face); } catch { /* already gone */ } + } + } + registered = []; +} + +// Register each font under its CSS family; an unreadable/blocked one is skipped so the +// rest still load. No-op without FontFace (jsdom/tests). +export async function registerEmbeddedFonts(fonts: EmbeddedFont[]): Promise { + if (typeof FontFace === 'undefined' || typeof document === 'undefined' || !document.fonts) return; + clearEmbeddedFonts(); + for (const f of fonts) { + try { + const face = new FontFace(f.family, f.data as BufferSource, { weight: f.weight, style: f.style }); + await face.load(); + document.fonts.add(face); + registered.push(face); + } catch { /* skip an unreadable/blocked font */ } + } +} diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 477f3a6..8921eda 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -13,6 +13,7 @@ import { formatFromCm, type PageFormat } from '../storage/pageFormat'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; import type { HfDoc } from '../storage/headerFooter'; import type { OdtImportResult } from './odt'; +import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts'; // .docx → TipTap JSON, inverting export/docx.ts. Editor-expressible OOXML becomes its // native node/mark/attr; values matching the editor's defaults are suppressed so round @@ -139,10 +140,40 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = headerDistanceCm: sect.header ? sect.headerDistCm : null, footerDistanceCm: sect.footer ? sect.footerDistCm : null, language: documentLanguage(stylesDoc, warnings), + fonts: extractDocxFonts(files), warnings: [...warnings], }; } +// Fonts embedded via word/fontTable.xml: each may reference regular/bold/italic/ +// bold-italic .odttf binaries (obfuscated). Resolve r:id → package path, read + de-obfuscate. +function extractDocxFonts(files: Record): EmbeddedFont[] { + const ftBytes = files['word/fontTable.xml']; + if (!ftBytes) return []; + let doc: Document; + try { doc = parseXml(strFromU8(ftBytes)); } catch { return []; } + const rels = parseRels(files['word/_rels/fontTable.xml.rels']); + const embeds: [string, 'normal' | 'bold', 'normal' | 'italic'][] = [ + ['embedRegular', 'normal', 'normal'], ['embedBold', 'bold', 'normal'], + ['embedItalic', 'normal', 'italic'], ['embedBoldItalic', 'bold', 'italic'], + ]; + const out: EmbeddedFont[] = []; + for (const font of Array.from(doc.getElementsByTagNameNS(W, 'font'))) { + const family = font.getAttributeNS(W, 'name'); + if (!family) continue; + for (const [tag, weight, style] of embeds) { + const el = fc(font, tag); + const rel = el ? rels.get(el.getAttributeNS(R, 'id') ?? '') : undefined; + if (!el || !rel || rel.external) continue; + const data = files['word/' + rel.target.replace(/^\/+/, '')]; + if (!data) continue; + const key = el.getAttributeNS(W, 'fontKey'); + out.push({ family, weight, style, data: key ? deobfuscateOdttf(data, key) : data }); + } + } + return out; +} + function parseRels(bytes: Uint8Array | undefined): Map { const map = new Map(); if (!bytes) return map; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index bf59e57..bd82ba1 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -11,6 +11,7 @@ import type { Orientation } from '../storage/pageOrientation'; import type { PageFormat } from '../storage/pageFormat'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; import type { HfDoc } from '../storage/headerFooter'; +import type { EmbeddedFont } from '../fonts/embeddedFonts'; // .odt → TipTap JSON, inverting export/odt.ts. Editor-expressible content becomes its // native node/mark/attr; values matching the editor's defaults are suppressed so round @@ -39,6 +40,8 @@ export interface OdtImportResult { // Document spell-check language; NO_LANGUAGE when the file's language has no // bundled dictionary; null when the file declares none. language: DocumentLanguage | null; + // Fonts embedded in the package, to register via FontFace so they render. + fonts: EmbeddedFont[]; warnings: string[]; } @@ -343,6 +346,12 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = const geometry = resolver.pageGeometry(); const edge = resolver.edgeDistancesCm(); + const fonts: EmbeddedFont[] = []; + for (const s of resolver.embeddedFontSources()) { + const data = files[s.href]; + if (data) fonts.push({ family: s.family, weight: s.weight, style: s.style, data }); + } + const odfLang = resolver.documentLanguage(); let language: DocumentLanguage | null = null; if (odfLang) { @@ -366,6 +375,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = headerDistanceCm: hf.header ? edge?.top ?? null : null, footerDistanceCm: hf.footer ? edge?.bottom ?? null : null, language, + fonts, warnings: [...warnings], }; } diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 45f87eb..3b1863b 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -16,8 +16,18 @@ export const NS = { draw: 'urn:oasis:names:tc:opendocument:xmlns:drawing:1.0', xlink: 'http://www.w3.org/1999/xlink', number: 'urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0', + loext: 'urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0', } as const; +// One embedded-font binary referenced from a : the CSS family runs use, +// the package href of the font file, and the face's weight/style. +export interface EmbeddedFontSource { + family: string; + href: string; + weight: 'normal' | 'bold'; + style: 'normal' | 'italic'; +} + // Property keys are stored as "alias:localName" for the namespaces we care // about; attributes from other namespaces are dropped. const ALIAS_BY_URI = new Map(Object.entries(NS).map(([alias, uri]) => [uri, alias])); @@ -102,6 +112,7 @@ export class StyleResolver { private styles = new Map(); private defaults = new Map(); // family → style:default-style private fontFaces = new Map(); // style:name → first font-family + private fontSources: EmbeddedFontSource[] = []; // embedded font binaries private listStyles = new Map(); // style:name → text:list-style private numberStyles = new Map(); // style:name → number:date/time-style // Section styles kept as raw elements: is a child element of @@ -131,15 +142,27 @@ export class StyleResolver { for (const el of Array.from(container.children)) { if (el.namespaceURI !== NS.style || el.localName !== 'font-face') continue; const name = el.getAttributeNS(NS.style, 'name'); + if (!name) continue; + // svg:font-family may be a quoted list: 'Liberation Serif', 'Times New Roman' const family = el.getAttributeNS(NS.svg, 'font-family'); - if (name && family) { - // svg:font-family may be a quoted list: 'Liberation Serif', 'Times New Roman' - const first = family.split(',')[0].trim().replace(/^['"]|['"]$/g, ''); - if (first) this.fontFaces.set(name, first); + const first = family ? family.split(',')[0].trim().replace(/^['"]|['"]$/g, '') : ''; + if (first) this.fontFaces.set(name, first); + const cssFamily = first || name; + for (const uri of Array.from(el.getElementsByTagNameNS(NS.svg, 'font-face-uri'))) { + const href = uri.getAttributeNS(NS.xlink, 'href'); + if (!href) continue; + const weight = uri.getAttributeNS(NS.loext, 'font-weight') === 'bold' ? 'bold' : 'normal'; + const style = uri.getAttributeNS(NS.loext, 'font-style') === 'italic' ? 'italic' : 'normal'; + this.fontSources.push({ family: cssFamily, href, weight, style }); } } } + // Embedded font binaries (one per ); importOdt maps href → bytes. + embeddedFontSources(): readonly EmbeddedFontSource[] { + return this.fontSources; + } + private scanContainer(container: Element): void { for (const el of Array.from(container.children)) { if (el.namespaceURI === NS.style && el.localName === 'style') { diff --git a/src/lib/storage/embeddedFontStore.ts b/src/lib/storage/embeddedFontStore.ts new file mode 100644 index 0000000..d8ca6bb --- /dev/null +++ b/src/lib/storage/embeddedFontStore.ts @@ -0,0 +1,54 @@ +import type { EmbeddedFont } from '../fonts/embeddedFonts'; + +// The current document's embedded fonts, persisted in IndexedDB (not localStorage, whose +// ~5 MB quota is already tight for images). One record, replaced on each open, so storage +// stays bounded to the open document; on app start it is re-registered via FontFace. +const DB_NAME = 'odf-editor-fonts'; +const STORE = 'fonts'; +const KEY = 'current'; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function request(db: IDBDatabase, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise { + return new Promise((resolve, reject) => { + const req = run(db.transaction(STORE, mode).objectStore(STORE)); + req.onsuccess = () => resolve(req.result as T); + req.onerror = () => reject(req.error); + }); +} + +export async function saveEmbeddedFonts(fonts: EmbeddedFont[]): Promise { + if (typeof indexedDB === 'undefined') return; + try { + const db = await openDb(); + if (fonts.length) await request(db, 'readwrite', (s) => s.put(fonts, KEY)); + else await request(db, 'readwrite', (s) => s.delete(KEY)); + db.close(); + } catch { /* storage disabled/full — fidelity just won't persist across reloads */ } +} + +export async function loadEmbeddedFonts(): Promise { + if (typeof indexedDB === 'undefined') return []; + try { + const db = await openDb(); + const fonts = await request(db, 'readonly', (s) => s.get(KEY)); + db.close(); + return fonts ?? []; + } catch { return []; } +} + +export async function clearEmbeddedFontStore(): Promise { + if (typeof indexedDB === 'undefined') return; + try { + const db = await openDb(); + await request(db, 'readwrite', (s) => s.delete(KEY)); + db.close(); + } catch { /* nothing to clear */ } +} diff --git a/tests/unit/deobfuscate-odttf.test.ts b/tests/unit/deobfuscate-odttf.test.ts new file mode 100644 index 0000000..876a4c5 --- /dev/null +++ b/tests/unit/deobfuscate-odttf.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { deobfuscateOdttf } from '../../src/lib/fonts/embeddedFonts'; + +const GUID = '{01020304-0506-0708-090A-0B0C0D0E0F10}'; +// The XOR key is the GUID's 16 bytes in reverse order. +const REVERSED_KEY = [0x10, 0x0f, 0x0e, 0x0d, 0x0c, 0x0b, 0x0a, 0x09, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01]; + +describe('deobfuscateOdttf', () => { + it('XORs the first 32 bytes with the reversed fontKey GUID', () => { + const data = new Uint8Array([...REVERSED_KEY, ...REVERSED_KEY, 0xaa, 0xbb]); + deobfuscateOdttf(data, GUID); + expect(Array.from(data.slice(0, 32))).toEqual(new Array(32).fill(0)); + expect(Array.from(data.slice(32))).toEqual([0xaa, 0xbb]); + }); + + it('is symmetric (obfuscation and de-obfuscation are the same XOR)', () => { + const original = Uint8Array.from({ length: 40 }, (_, i) => (i * 7) & 0xff); + const roundTrip = deobfuscateOdttf(deobfuscateOdttf(original.slice(), GUID), GUID); + expect(Array.from(roundTrip)).toEqual(Array.from(original)); + }); + + it('leaves data untouched when the key has fewer than 32 hex chars', () => { + const data = Uint8Array.from({ length: 32 }, (_, i) => i); + deobfuscateOdttf(data, 'not-a-guid'); + expect(Array.from(data)).toEqual(Array.from({ length: 32 }, (_, i) => i)); + }); +});