feat: add embedded font loading

This commit is contained in:
Steffen Becker committed 2026-07-11 14:07:36 +02:00
1 parent b8233e894e
commit 40c139dd2c
8 files changed
+212 -4

No files matched your search

+1
View File
@@ -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)
+13
View File
@@ -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')) {
+49
View File
@@ -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<void> {
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 */ }
}
}
+31
View File
@@ -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 <w:font> may reference regular/bold/italic/
// bold-italic .odttf binaries (obfuscated). Resolve r:id → package path, read + de-obfuscate.
function extractDocxFonts(files: Record<string, Uint8Array>): 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<string, RelInfo> {
const map = new Map<string, RelInfo>();
if (!bytes) return map;
+10
View File
@@ -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],
};
}
+27 -4
View File
@@ -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 <style:font-face>: 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<string, string>(Object.entries(NS).map(([alias, uri]) => [uri, alias]));
@@ -102,6 +112,7 @@ export class StyleResolver {
private styles = new Map<string, StyleEntry>();
private defaults = new Map<string, StyleEntry>(); // family → style:default-style
private fontFaces = new Map<string, string>(); // style:name → first font-family
private fontSources: EmbeddedFontSource[] = []; // embedded font binaries
private listStyles = new Map<string, Element>(); // style:name → text:list-style
private numberStyles = new Map<string, Element>(); // style:name → number:date/time-style
// Section styles kept as raw elements: <style:columns> 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 <svg:font-face-uri>); 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') {
+54
View File
@@ -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<IDBDatabase> {
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<T>(db: IDBDatabase, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise<T> {
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<void> {
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<EmbeddedFont[]> {
if (typeof indexedDB === 'undefined') return [];
try {
const db = await openDb();
const fonts = await request<EmbeddedFont[] | undefined>(db, 'readonly', (s) => s.get(KEY));
db.close();
return fonts ?? [];
} catch { return []; }
}
export async function clearEmbeddedFontStore(): Promise<void> {
if (typeof indexedDB === 'undefined') return;
try {
const db = await openDb();
await request(db, 'readwrite', (s) => s.delete(KEY));
db.close();
} catch { /* nothing to clear */ }
}
+27
View File
@@ -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));
});
});