From 0474f70d227461bb00c07dee4c99dbb7f968343a Mon Sep 17 00:00:00 2001 From: mioupa Date: Tue, 9 Jun 2026 22:39:40 +0900 Subject: [PATCH 1/5] feat: use custom fonts via in-browser upload Let users add their own font files (.ttf/.otf/.ttc) directly in the task pane, without forking the repo or running a custom web server. Addresses #64. - registry/font-name.ts: parse the OpenType `name` table to detect the family and subfamily (style) names, so the UI shows what to type in `#set text(font: ...)` and different weights of one family are kept apart. - registry/user-fonts.ts: hold uploaded font bytes, persist them in IndexedDB (keyed per face) so they survive a reload, and expose the bytes for the compiler. - typst.ts: include user fonts in loadFonts() and add reloadCompilerFonts() to re-init the compiler when fonts change. - font-ui.ts + powerpoint.html + styles: a "Custom fonts" panel to add, list (with a style badge) and remove fonts; the preview refreshes live. Co-Authored-By: Claude Opus 4.8 --- web/powerpoint.html | 11 ++ web/src/constants.ts | 4 + web/src/font-ui.ts | 144 +++++++++++++++++++++++ web/src/main.ts | 6 + web/src/registry/font-name.ts | 204 ++++++++++++++++++++++++++++++++ web/src/registry/user-fonts.ts | 209 +++++++++++++++++++++++++++++++++ web/src/typst.ts | 12 +- web/styles/main.css | 131 ++++++++++++++++++++- 8 files changed, 719 insertions(+), 2 deletions(-) create mode 100644 web/src/font-ui.ts create mode 100644 web/src/registry/font-name.ts create mode 100644 web/src/registry/user-fonts.ts diff --git a/web/powerpoint.html b/web/powerpoint.html index 7fd72b3..6c01f82 100644 --- a/web/powerpoint.html +++ b/web/powerpoint.html @@ -87,6 +87,17 @@ > +
+ + Custom fonts + + +
    +
    +
    diff --git a/web/src/constants.ts b/web/src/constants.ts index 2f2e237..c74b80e 100644 --- a/web/src/constants.ts +++ b/web/src/constants.ts @@ -61,6 +61,9 @@ export const DOM_IDS = { ABOUT_LINK: "aboutLink", ABOUT_MODAL: "aboutModal", ABOUT_MODAL_CLOSE: "aboutModalClose", + FONTS_DETAILS: "fontsDetails", + FONTS_INPUT: "fontsInput", + FONTS_LIST: "fontsList", } as const; /** @@ -73,6 +76,7 @@ export const STORAGE_KEYS = { MATH_MODE: "typstMathMode", PREAMBLE: "typstPreamble", PREAMBLE_OPEN: "typstPreambleOpen", + FONTS_OPEN: "typstFontsOpen", THEME: "typstTheme", } as const; diff --git a/web/src/font-ui.ts b/web/src/font-ui.ts new file mode 100644 index 0000000..d0e7748 --- /dev/null +++ b/web/src/font-ui.ts @@ -0,0 +1,144 @@ +/** + * UI wiring for the "Custom fonts" panel: uploading font files, listing the + * loaded fonts, and removing them. Adding or removing a font re-initializes + * the Typst compiler and refreshes the preview. + */ + +import { DOM_IDS, STORAGE_KEYS } from "./constants.js"; +import { getDetailsElement, getHTMLElement, getInputElement } from "./utils/dom.js"; +import { getStoredValue, storeValue } from "./utils/storage.js"; +import { + addFontFromFile, + getUserFonts, + removeFont, + type UserFont, +} from "./registry/user-fonts.js"; +import { reloadCompilerFonts } from "./typst.js"; +import { updatePreview } from "./preview.js"; +import { setStatus } from "./ui.js"; + +/** + * Initializes the custom-fonts panel: restores its open state, renders the + * current fonts, and wires up the file input. + */ +export function setupFontsPanel() { + const details = getDetailsElement(DOM_IDS.FONTS_DETAILS); + details.open = getStoredValue(STORAGE_KEYS.FONTS_OPEN) === "true"; + details.addEventListener("toggle", () => { + storeValue(STORAGE_KEYS.FONTS_OPEN, details.open.toString()); + }); + + const input = getInputElement(DOM_IDS.FONTS_INPUT); + input.addEventListener("change", () => { + void handleFilesSelected(input); + }); + + renderFontsList(); +} + +/** + * Reads the selected files, adds them as user fonts, reloads the compiler and + * refreshes the preview. + */ +async function handleFilesSelected(input: HTMLInputElement) { + const files = Array.from(input.files ?? []); + if (files.length === 0) { + return; + } + // Reset so selecting the same file again still fires a change event. + input.value = ""; + + setStatus(`Loading ${files.length.toString()} font(s)…`); + const added: string[] = []; + try { + for (const file of files) { + const font = await addFontFromFile(file); + added.push(font.key); + } + } catch (error) { + console.error("Failed to add custom font:", error); + setStatus("Could not read that font file.", true); + return; + } + + await reloadCompilerFonts(); + renderFontsList(); + await updatePreview(); + setStatus(`Added: ${added.join(", ")}`); +} + +/** + * Removes a font, reloads the compiler and refreshes the preview. + */ +async function handleRemove(key: string) { + await removeFont(key); + await reloadCompilerFonts(); + renderFontsList(); + await updatePreview(); + setStatus(""); +} + +/** + * Renders the list of loaded custom fonts. + */ +function renderFontsList() { + const list = getHTMLElement(DOM_IDS.FONTS_LIST); + list.innerHTML = ""; + + const fonts = getUserFonts(); + if (fonts.length === 0) { + const empty = document.createElement("li"); + empty.className = "fonts-empty"; + empty.textContent = "No custom fonts yet."; + list.appendChild(empty); + return; + } + + for (const font of fonts) { + list.appendChild(createFontRow(font)); + } +} + +/** + * Builds a single list row for a loaded font. + */ +function createFontRow(font: UserFont): HTMLLIElement { + const row = document.createElement("li"); + row.className = "fonts-item"; + + const info = document.createElement("div"); + info.className = "fonts-item-info"; + + const familyRow = document.createElement("div"); + familyRow.className = "fonts-item-family-row"; + + const family = document.createElement("code"); + family.className = "fonts-item-family"; + family.textContent = font.family; + family.title = `Use in Typst: #set text(font: "${font.family}")`; + familyRow.appendChild(family); + + if (font.subfamily) { + const style = document.createElement("span"); + style.className = "fonts-item-style"; + style.textContent = font.subfamily; + familyRow.appendChild(style); + } + + const fileName = document.createElement("span"); + fileName.className = "fonts-item-file"; + fileName.textContent = font.fileName; + + info.append(familyRow, fileName); + + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "fonts-item-remove"; + remove.textContent = "✕"; + remove.title = `Remove ${font.family}`; + remove.setAttribute("aria-label", `Remove ${font.family}`); + remove.onclick = () => void handleRemove(font.key); + + row.append(info, remove); + return row; +} diff --git a/web/src/main.ts b/web/src/main.ts index 6d08de2..808d3ec 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -4,6 +4,8 @@ import { setupPreviewListeners, updateButtonState } from "./preview.js"; import { initializeDarkMode, setupDarkModeToggle } from "./theme.js"; import { handleSelectionChange } from "./selection.js"; import { generateFromFile, initializeDropzone } from "./file/file.js"; +import { loadStoredFonts } from "./registry/user-fonts.js"; +import { setupFontsPanel } from "./font-ui.js"; import { DOM_IDS } from "./constants.js"; import { getHTMLElement } from "./utils/dom.js"; @@ -51,6 +53,9 @@ await Office.onReady(async (info) => { return; } + // Load persisted custom fonts before the compiler is initialized so they are + // available for the very first preview. + await loadStoredFonts(); await initTypst(); initializeDarkMode(); @@ -61,6 +66,7 @@ await Office.onReady(async (info) => { initializeDropzone(); setupEventListeners(); setupPreviewListeners(); + setupFontsPanel(); updateButtonState(); Office.context.document.addHandlerAsync( diff --git a/web/src/registry/font-name.ts b/web/src/registry/font-name.ts new file mode 100644 index 0000000..940c9be --- /dev/null +++ b/web/src/registry/font-name.ts @@ -0,0 +1,204 @@ +/** + * Minimal OpenType/TrueType `name` table reader. + * + * Extracts the human-readable family and subfamily (style) names from raw font + * bytes so the UI can tell the user exactly what to type in + * `#set text(font: "...")` and so different weights of the same family can be + * told apart. This is a best-effort helper: if the format is unsupported (e.g. + * WOFF, which stores compressed tables) it returns `null` fields and the caller + * falls back to the file name. + * + * Reference: https://learn.microsoft.com/en-us/typography/opentype/spec/name + */ + +// Name IDs we care about, in order of preference within each kind. +const NAME_ID_FONT_FAMILY = 1; +const NAME_ID_FONT_SUBFAMILY = 2; +const NAME_ID_TYPOGRAPHIC_FAMILY = 16; +const NAME_ID_TYPOGRAPHIC_SUBFAMILY = 17; + +/** + * Family and subfamily (style) names read from a font. + */ +export interface FontNames { + /** e.g. "Noto Sans JP" — what the user types in `#set text(font: ...)`. */ + family: string | null; + /** e.g. "Bold", "Regular", "SemiBold Italic". */ + subfamily: string | null; +} + +/** + * Reads the family and subfamily names from raw font bytes. + * + * @param data Raw font file bytes (TTF/OTF, or TTC collection). + */ +export function readFontNames(data: Uint8Array): FontNames { + try { + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const sfntOffset = resolveSfntOffset(view); + if (sfntOffset === null) { + return { family: null, subfamily: null }; + } + const nameTableOffset = findNameTable(view, sfntOffset); + if (nameTableOffset === null) { + return { family: null, subfamily: null }; + } + return parseNameTable(view, nameTableOffset); + } catch { + return { family: null, subfamily: null }; + } +} + +/** + * Resolves the offset of the sfnt table directory, transparently unwrapping a + * TrueType Collection (`ttcf`) by pointing at its first font. Returns `null` + * for formats we cannot parse directly (e.g. WOFF/WOFF2). + */ +function resolveSfntOffset(view: DataView): number | null { + const tag = view.getUint32(0); + // 'ttcf' collection: read the offset of the first contained font. + if (tag === 0x74746366) { + return view.getUint32(12); + } + // 0x00010000 (TrueType), 'OTTO' (CFF), 'true', 'typ1'. + if ( + tag === 0x00010000 + || tag === 0x4f54544f + || tag === 0x74727565 + || tag === 0x74797031 + ) { + return 0; + } + // 'wOFF' / 'wOF2' and anything else: unsupported here. + return null; +} + +/** + * Scans the table directory for the `name` table and returns its offset. + */ +function findNameTable(view: DataView, sfntOffset: number): number | null { + const numTables = view.getUint16(sfntOffset + 4); + const recordsStart = sfntOffset + 12; + const NAME_TAG = 0x6e616d65; // 'name' + + for (let i = 0; i < numTables; i++) { + const record = recordsStart + i * 16; + if (view.getUint32(record) === NAME_TAG) { + return view.getUint32(record + 8); + } + } + return null; +} + +/** + * A name-table candidate, kept alongside whether it is an English record so we + * can prefer English family names over localized ones. + */ +interface NameCandidate { + value: string; + isEnglish: boolean; +} + +/** + * Parses the `name` table, returning the best available family and subfamily. + * + * Preference order: typographic names (IDs 16/17) over the basic family/style + * names (IDs 1/2), and an English record over a localized one within the same + * ID. + */ +function parseNameTable(view: DataView, tableOffset: number): FontNames { + const count = view.getUint16(tableOffset + 2); + const stringStorage = tableOffset + view.getUint16(tableOffset + 4); + const recordsStart = tableOffset + 6; + + let typographicFamily: NameCandidate | null = null; + let fontFamily: NameCandidate | null = null; + let typographicSubfamily: NameCandidate | null = null; + let fontSubfamily: NameCandidate | null = null; + + for (let i = 0; i < count; i++) { + const record = recordsStart + i * 12; + const platformId = view.getUint16(record); + const languageId = view.getUint16(record + 4); + const nameId = view.getUint16(record + 6); + + const length = view.getUint16(record + 8); + const offset = stringStorage + view.getUint16(record + 10); + const value = decodeNameString(view, offset, length, platformId); + if (!value) { + continue; + } + + const candidate: NameCandidate = { value, isEnglish: isEnglish(platformId, languageId) }; + switch (nameId) { + case NAME_ID_TYPOGRAPHIC_FAMILY: + typographicFamily = preferEnglish(typographicFamily, candidate); + break; + case NAME_ID_FONT_FAMILY: + fontFamily = preferEnglish(fontFamily, candidate); + break; + case NAME_ID_TYPOGRAPHIC_SUBFAMILY: + typographicSubfamily = preferEnglish(typographicSubfamily, candidate); + break; + case NAME_ID_FONT_SUBFAMILY: + fontSubfamily = preferEnglish(fontSubfamily, candidate); + break; + } + } + + return { + family: (typographicFamily ?? fontFamily)?.value ?? null, + subfamily: (typographicSubfamily ?? fontSubfamily)?.value ?? null, + }; +} + +/** + * Keeps the existing candidate unless we don't have one yet, or the new one is + * English and the existing one is not. + */ +function preferEnglish(current: NameCandidate | null, next: NameCandidate): NameCandidate { + if (!current) { + return next; + } + if (next.isEnglish && !current.isEnglish) { + return next; + } + return current; +} + +/** + * Whether a name record is in English. Windows uses language ID 0x0409, + * Macintosh uses 0, and Unicode (platform 0) records have no language. + */ +function isEnglish(platformId: number, languageId: number): boolean { + if (platformId === 3) { + return languageId === 0x0409; + } + if (platformId === 1) { + return languageId === 0; + } + return true; +} + +/** + * Decodes a name string. Windows (platform 3) and Unicode (platform 0) records + * are UTF-16BE; Macintosh (platform 1) records are treated as Latin-1. + */ +function decodeNameString( + view: DataView, offset: number, length: number, platformId: number, +): string { + if (platformId === 1) { + let result = ""; + for (let i = 0; i < length; i++) { + result += String.fromCharCode(view.getUint8(offset + i)); + } + return result; + } + + // UTF-16BE (platform 0 = Unicode, platform 3 = Windows). + let result = ""; + for (let i = 0; i + 1 < length; i += 2) { + result += String.fromCharCode(view.getUint16(offset + i)); + } + return result; +} diff --git a/web/src/registry/user-fonts.ts b/web/src/registry/user-fonts.ts new file mode 100644 index 0000000..89ef080 --- /dev/null +++ b/web/src/registry/user-fonts.ts @@ -0,0 +1,209 @@ +/** + * Management of user-provided (custom) fonts. + * + * Fonts are uploaded by the user in the browser, kept in memory so the Typst + * compiler can use them, and persisted in IndexedDB so they survive a reload + * of the task pane. Everything happens client-side; no server or fork needed. + */ + +import { debug } from "../utils/logger.js"; +import { readFontNames } from "./font-name.js"; + +/** + * A custom font provided by the user. One entry corresponds to one font face + * (e.g. "Noto Sans JP" Regular and "Noto Sans JP" Bold are two entries that + * share the same family but differ by subfamily). + */ +export interface UserFont { + /** Unique key per face: `family` plus `subfamily`, or the file name. */ + key: string; + /** Family name to use in `#set text(font: "...")`. */ + family: string; + /** Style within the family, e.g. "Regular", "Bold". May be empty. */ + subfamily: string; + /** Original file name, shown in the UI. */ + fileName: string; + /** Raw font bytes handed to the Typst compiler. */ + data: Uint8Array; +} + +const DB_NAME = "pptypst-user-fonts"; +const DB_VERSION = 1; +const STORE_NAME = "fonts"; + +let fonts: UserFont[] = []; + +/** + * @returns the currently loaded user fonts. + */ +export function getUserFonts(): readonly UserFont[] { + return fonts; +} + +/** + * @returns the raw bytes of all user fonts, for handing to `loadFonts`. + */ +export function getUserFontData(): Uint8Array[] { + return fonts.map(font => font.data); +} + +/** + * Opens (and lazily creates) the IndexedDB database. + */ +function openDb(): Promise { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "key" }); + } + }; + return awaitRequest(request, "open"); +} + +/** + * Wraps an IDBRequest in a promise. + */ +function awaitRequest(request: IDBRequest, label: string): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve(request.result); + }; + request.onerror = () => { + reject(request.error ?? new Error(`IndexedDB ${label} failed`)); + }; + }); +} + +/** + * Wraps an IDBTransaction completion in a promise. + */ +function awaitTransaction(tx: IDBTransaction, label: string): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => { + resolve(); + }; + tx.onerror = () => { + reject(tx.error ?? new Error(`IndexedDB ${label} failed`)); + }; + }); +} + +interface StoredFont { + key: string; + family: string; + subfamily?: string; + fileName: string; + data: ArrayBuffer; +} + +/** + * Loads previously persisted fonts from IndexedDB into memory. + * + * Must be awaited before the first compiler initialization so persisted fonts + * are available for the initial preview. Failures are non-fatal: the add-in + * still works, just without persisted fonts. + */ +export async function loadStoredFonts(): Promise { + try { + const db = await openDb(); + const request = db.transaction(STORE_NAME, "readonly") + .objectStore(STORE_NAME) + .getAll() as IDBRequest; + const stored = await awaitRequest(request, "read"); + db.close(); + + fonts = stored.map(font => ({ + key: font.key, + family: font.family, + subfamily: font.subfamily ?? "", + fileName: font.fileName, + data: new Uint8Array(font.data), + })); + debug(`Loaded ${fonts.length.toString()} persisted custom font(s)`); + } catch (error) { + console.warn("Could not load persisted custom fonts:", error); + fonts = []; + } +} + +/** + * Persists a single font to IndexedDB. + */ +async function persistFont(font: UserFont): Promise { + try { + const db = await openDb(); + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).put({ + key: font.key, + family: font.family, + subfamily: font.subfamily, + fileName: font.fileName, + // Store a standalone ArrayBuffer copy (not a view into a larger buffer). + data: font.data.slice().buffer, + } satisfies StoredFont); + await awaitTransaction(tx, "write"); + db.close(); + } catch (error) { + console.warn("Could not persist custom font:", error); + } +} + +/** + * Removes a single font from IndexedDB. + */ +async function unpersistFont(key: string): Promise { + try { + const db = await openDb(); + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).delete(key); + await awaitTransaction(tx, "delete"); + db.close(); + } catch (error) { + console.warn("Could not remove persisted custom font:", error); + } +} + +/** + * Adds a font from an uploaded file: reads its bytes, detects the family name, + * stores it in memory and persists it. + * + * @param file The font file selected by the user. + * @returns the added font (caller is responsible for reloading the compiler). + */ +export async function addFontFromFile(file: File): Promise { + const data = new Uint8Array(await file.arrayBuffer()); + const names = readFontNames(data); + const family = names.family ?? stripExtension(file.name); + const subfamily = names.subfamily ?? ""; + + // Key per face so multiple weights/styles of the same family coexist + // (e.g. "Noto Sans JP" Regular and Bold). Re-adding the same face replaces it. + const key = subfamily ? `${family} ${subfamily}` : family; + + const font: UserFont = { key, family, subfamily, fileName: file.name, data }; + + fonts = fonts.filter(existing => existing.key !== key); + fonts.push(font); + + await persistFont(font); + debug(`Added custom font "${key}" from ${file.name}`); + return font; +} + +/** + * Removes a font by its key from memory and IndexedDB. + */ +export async function removeFont(key: string): Promise { + fonts = fonts.filter(font => font.key !== key); + await unpersistFont(key); + debug(`Removed custom font "${key}"`); +} + +/** + * Drops the file extension from a file name for use as a fallback family label. + */ +function stripExtension(fileName: string): string { + const dot = fileName.lastIndexOf("."); + return dot > 0 ? fileName.slice(0, dot) : fileName; +} diff --git a/web/src/typst.ts b/web/src/typst.ts index e06ae29..96b5076 100644 --- a/web/src/typst.ts +++ b/web/src/typst.ts @@ -25,6 +25,7 @@ import typstCompilerWasm from "@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts // @ts-expect-error WASM module import import typstRendererWasm from "@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm?url"; import { registryRequest } from "./registry/registry"; +import { getUserFontData } from "./registry/user-fonts.js"; import { TypstSource } from "./payload.js"; let compiler: typstWeb.TypstCompiler; @@ -38,6 +39,15 @@ export async function initTypst() { await initRenderer(); } +/** + * Re-initializes the compiler so the current set of user fonts (see + * {@link getUserFontData}) takes effect. Call this after adding or removing a + * custom font. The renderer is unaffected and is left untouched. + */ +export async function reloadCompilerFonts() { + await initCompiler(); +} + /** * Initializes the Typst compiler. * @@ -53,7 +63,7 @@ async function initCompiler() { beforeBuild: [ disableDefaultFontAssets(), // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - loadFonts([mathFontUrl]), + loadFonts([mathFontUrl, ...getUserFontData()]), ...cachedFontInitOptions().beforeBuild, withAccessModel(accessModel), withPackageRegistry( diff --git a/web/styles/main.css b/web/styles/main.css index 8def53c..93a9d9c 100644 --- a/web/styles/main.css +++ b/web/styles/main.css @@ -378,4 +378,133 @@ button { background: #2196f33d; color: #64b5f6; } -} \ No newline at end of file +} +/* Custom fonts panel */ +.fonts-panel { + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-secondary); + overflow: hidden; +} + +.fonts-summary { + padding: 8px 10px; + cursor: pointer; + list-style: none; + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + user-select: none; + + &::-webkit-details-marker { + display: none; + } +} + +.fonts-summary:hover { + background: color-mix(in srgb, var(--bg-secondary) 88%, var(--text-primary) 12%); +} + +.fonts-upload { + display: block; + margin: 0 10px 8px; + padding: 8px; + border: 1px dashed var(--border-color); + border-radius: 4px; + text-align: center; + cursor: pointer; + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; +} + +.fonts-upload:hover { + background: color-mix(in srgb, var(--bg-secondary) 88%, var(--text-primary) 12%); +} + +.fonts-input { + display: none; +} + +.fonts-list { + list-style: none; + margin: 0 10px 10px; + padding: 0; +} + +.fonts-empty { + color: var(--text-secondary); + font-size: 12px; + padding: 2px 0; +} + +.fonts-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + margin-bottom: 6px; + background: var(--bg-tertiary); +} + +.fonts-item-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.fonts-item-family-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.fonts-item-family { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fonts-item-style { + flex: 0 0 auto; + font-size: 10px; + font-weight: 600; + padding: 1px 5px; + border-radius: 3px; + background: color-mix(in srgb, var(--button-bg) 18%, transparent); + color: var(--button-bg); +} + +.fonts-item-file { + font-size: 11px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fonts-item-remove { + flex: 0 0 auto; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + line-height: 1; +} + +.fonts-item-remove:hover { + background: var(--error-color); + color: #fff; +} From 402b9ef85e1a177c1d8df06f60c3fd3553902da1 Mon Sep 17 00:00:00 2001 From: mioupa Date: Wed, 10 Jun 2026 01:13:42 +0900 Subject: [PATCH 2/5] refactor: apply fonts via compiler.setFonts instead of re-init Per maintainer feedback on #64: avoid re-initializing the whole compiler when fonts change. Instead, fetch the base fonts once (bundled math font + Typst's default text/cjk/emoji assets, via the existing cached fetcher) and, on every font change, rebuild a font resolver from those in-memory bytes plus the user fonts and hand it to compiler.setFonts(...). This re-loads neither the WASM module nor any font over the network. User fonts are inserted before the cjk/emoji assets so they keep fallback priority. Test mocks gain createTypstFontBuilder, compiler.setFonts and a no-op _resolveAssets so no fonts are fetched from the CDN during tests. Co-Authored-By: Claude Opus 4.8 --- tests/_support/browser-mocks/typst-options.ts | 5 ++ tests/_support/browser-mocks/typst.ts | 20 +++++ web/src/registry/font-cache.ts | 18 +---- web/src/typst.ts | 81 ++++++++++++++++--- 4 files changed, 98 insertions(+), 26 deletions(-) diff --git a/tests/_support/browser-mocks/typst-options.ts b/tests/_support/browser-mocks/typst-options.ts index f2fc060..aa0ee9c 100644 --- a/tests/_support/browser-mocks/typst-options.ts +++ b/tests/_support/browser-mocks/typst-options.ts @@ -7,3 +7,8 @@ export const loadFonts = option("loadFonts"); export const preloadFontAssets = option("preloadFontAssets"); export const withAccessModel = option("withAccessModel"); export const withPackageRegistry = option("withPackageRegistry"); + +// Return no default asset URLs in tests so no fonts are fetched from the CDN. +export function _resolveAssets(): string[] { + return []; +} diff --git a/tests/_support/browser-mocks/typst.ts b/tests/_support/browser-mocks/typst.ts index 68578be..526186b 100644 --- a/tests/_support/browser-mocks/typst.ts +++ b/tests/_support/browser-mocks/typst.ts @@ -31,6 +31,26 @@ export function createTypstCompiler() { typstMockState.compileCalls.push(options); return Promise.resolve({ diagnostics: [], result: new Uint8Array([1, 2, 3]) }); }, + setFonts() { + // no-op in tests + }, + }; +} + +export function createTypstFontBuilder() { + return { + init() { + return Promise.resolve(); + }, + getFontInfo() { + return Promise.resolve({}); + }, + addFontData() { + return Promise.resolve(); + }, + build(cb: (_resolver: unknown) => Promise) { + return cb({}); + }, }; } diff --git a/web/src/registry/font-cache.ts b/web/src/registry/font-cache.ts index 18119e4..1eb343b 100644 --- a/web/src/registry/font-cache.ts +++ b/web/src/registry/font-cache.ts @@ -5,14 +5,13 @@ * https://github.com/Myriad-Dreamin/typst.ts/blob/2a8b32d8cca70cc4d105fef074d2f35fc7546450/templates/compiler-wasm-cjs/src/cached-font-middleware.cts#L1-L52 */ -import { preloadFontAssets } from "@myriaddreamin/typst.ts/dist/esm/options.init.mjs"; - const FONT_CACHE_NAME = "typst-font-assets-v1"; /** - * A fetch wrapper that caches font assets in the browser's Cache API. + * A fetch wrapper that caches font assets in the browser's Cache API, so the + * (large) default font assets are downloaded at most once per browser. */ -async function cachedFetch(input: RequestInfo | URL, init?: RequestInit): Promise { +export async function cachedFetch(input: RequestInfo | URL, init?: RequestInit): Promise { const request = input instanceof Request ? input : new Request(input, init); if (!("caches" in globalThis) || request.method.toUpperCase() !== "GET") { @@ -44,14 +43,3 @@ async function cachedFetch(input: RequestInfo | URL, init?: RequestInit): Promis return response; } - -export function cachedFontInitOptions() { - return { - beforeBuild: [ - preloadFontAssets({ - assets: ["text", "cjk", "emoji"], - fetcher: cachedFetch, - }), - ], - }; -} diff --git a/web/src/typst.ts b/web/src/typst.ts index 96b5076..9e82318 100644 --- a/web/src/typst.ts +++ b/web/src/typst.ts @@ -6,16 +6,20 @@ */ import type * as typstWeb from "@myriaddreamin/typst.ts"; -import { createTypstCompiler, createTypstRenderer } from "@myriaddreamin/typst.ts"; +import { + createTypstCompiler, + createTypstFontBuilder, + createTypstRenderer, +} from "@myriaddreamin/typst.ts"; import { disableDefaultFontAssets, - loadFonts, + _resolveAssets, withPackageRegistry, withAccessModel, } from "@myriaddreamin/typst.ts/dist/esm/options.init.mjs"; import { NodeFetchPackageRegistry } from "@myriaddreamin/typst.ts/dist/esm/fs/package.node.mjs"; import { MemoryAccessModel } from "@myriaddreamin/typst.ts/dist/esm/fs/memory.mjs"; -import { cachedFontInitOptions } from "./registry/font-cache"; +import { cachedFetch } from "./registry/font-cache"; // @ts-expect-error ?url import import mathFontUrl from "/math-font.ttf?url"; @@ -31,25 +35,39 @@ import { TypstSource } from "./payload.js"; let compiler: typstWeb.TypstCompiler; let renderer: typstWeb.TypstRenderer; +// Base font bytes, fetched once and reused to rebuild the font set whenever the +// user adds or removes a custom font: the bundled math font plus Typst's +// default text/cjk/emoji assets. +let mathFontData: Uint8Array | null = null; +let assetFontData: Uint8Array[] = []; + /** * Initializes both the Typst compiler and renderer. */ export async function initTypst() { await initCompiler(); + await loadBaseFontData(); + await applyFonts(); await initRenderer(); } /** - * Re-initializes the compiler so the current set of user fonts (see - * {@link getUserFontData}) takes effect. Call this after adding or removing a - * custom font. The renderer is unaffected and is left untouched. + * Rebuilds the compiler's font set from the base fonts plus the current user + * fonts (see {@link getUserFontData}) and hands it over via + * `compiler.setFonts(...)`. Call this after adding or removing a custom font. + * + * Unlike a full re-init, this neither reloads the WASM module nor re-downloads + * any fonts; it only rebuilds the font resolver from in-memory bytes. */ export async function reloadCompilerFonts() { - await initCompiler(); + await applyFonts(); } /** - * Initializes the Typst compiler. + * Initializes the Typst compiler. No fonts are loaded here; they are supplied + * via {@link applyFonts} using `setFonts`. `disableDefaultFontAssets()` is kept + * only to satisfy typst.ts's requirement that at least one font loader is + * present in `beforeBuild`. * * See also https://myriad-dreamin.github.io/typst.ts/cookery/guide/all-in-one.html#label-Initializing%20using%20the%20low-level%20API * And https://github.com/Myriad-Dreamin/typst.ts/blob/2a8b32d8cca70cc4d105fef074d2f35fc7546450/templates/compiler-wasm-cjs/src/main.package.cts#L20-L39 @@ -62,9 +80,6 @@ async function initCompiler() { getModule: () => typstCompilerWasm, beforeBuild: [ disableDefaultFontAssets(), - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - loadFonts([mathFontUrl, ...getUserFontData()]), - ...cachedFontInitOptions().beforeBuild, withAccessModel(accessModel), withPackageRegistry( new NodeFetchPackageRegistry(accessModel, registryRequest), @@ -74,6 +89,50 @@ async function initCompiler() { console.log("Typst compiler initialized"); } +/** + * Fetches the base font bytes once (bundled math font + Typst's default + * text/cjk/emoji assets), via the cached fetcher so assets are downloaded at + * most once and then served from the browser cache. + */ +async function loadBaseFontData() { + if (mathFontData) { + return; + } + const fetchBytes = async (url: string) => + new Uint8Array(await (await cachedFetch(url)).arrayBuffer()); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + mathFontData = await fetchBytes(mathFontUrl); + const assetUrls = _resolveAssets({ assets: ["text", "cjk", "emoji"] }); + assetFontData = await Promise.all(assetUrls.map(fetchBytes)); +} + +/** + * Builds a font resolver containing the base fonts plus the user fonts and sets + * it on the compiler. User fonts are inserted before the CJK/emoji assets so + * they win font fallback (matching the previous `loadFonts()` ordering). + */ +async function applyFonts() { + const builder = createTypstFontBuilder(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + await builder.init({ getModule: () => typstCompilerWasm }); + + if (mathFontData) { + await builder.addFontData(mathFontData); + } + for (const data of getUserFontData()) { + await builder.addFontData(data); + } + for (const data of assetFontData) { + await builder.addFontData(data); + } + + await builder.build((resolver) => { + compiler.setFonts(resolver); + return Promise.resolve(); + }); +} + /** * Initializes the Typst renderer. * From 771896acef66e737c9858ab28e6e7a59a5c2ee14 Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 11 Jun 2026 16:34:57 +0900 Subject: [PATCH 3/5] test: add Playwright coverage for custom fonts Covers the custom-font feature end-to-end (with the Typst compiler mocked): adding a font file, keeping multiple weights of one family as separate faces, removing a font, and persistence across a reload (IndexedDB). Uses a tiny synthetic test font generated in-memory (a minimal sfnt with just a `name` table) so the family/subfamily detection and UI can be exercised without committing a large binary fixture. Co-Authored-By: Claude Opus 4.8 --- tests/_support/font-fixture.ts | 70 ++++++++++++++++++++++++++++++++++ tests/fonts.spec.ts | 59 ++++++++++++++++++++++++++++ tests/pages/powerpoint-page.ts | 52 +++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 tests/_support/font-fixture.ts create mode 100644 tests/fonts.spec.ts diff --git a/tests/_support/font-fixture.ts b/tests/_support/font-fixture.ts new file mode 100644 index 0000000..f8164b0 --- /dev/null +++ b/tests/_support/font-fixture.ts @@ -0,0 +1,70 @@ +/** + * Generates a minimal, tiny sfnt (TrueType) font in memory for tests. + * + * The font is not meant to be rendered (the Typst compiler is mocked in tests); + * it only carries a valid `name` table so that the add-in's family/subfamily + * detection and the Custom fonts UI can be exercised against a real font file + * without committing a large binary fixture. + */ + +/** Encodes a string as big-endian UTF-16 (the encoding used by Windows name records). */ +function utf16be(value: string): Buffer { + const buffer = Buffer.alloc(value.length * 2); + for (let i = 0; i < value.length; i++) { + buffer.writeUInt16BE(value.charCodeAt(i), i * 2); + } + return buffer; +} + +/** + * Builds a minimal font whose `name` table reports the given family and + * subfamily (style) names. + */ +export function makeTestFont(family: string, subfamily: string): Buffer { + const records = [ + { nameId: 1, value: utf16be(family) }, // Font Family + { nameId: 2, value: utf16be(subfamily) }, // Font Subfamily + ]; + + // --- name table --- + const nameHeaderSize = 6; + const stringStorageOffset = nameHeaderSize + records.length * 12; + const strings = Buffer.concat(records.map(record => record.value)); + const nameTable = Buffer.alloc(stringStorageOffset + strings.length); + + nameTable.writeUInt16BE(0, 0); // format 0 + nameTable.writeUInt16BE(records.length, 2); // count + nameTable.writeUInt16BE(stringStorageOffset, 4); // string storage offset + + let recordOffset = nameHeaderSize; + let stringOffset = 0; + for (const record of records) { + nameTable.writeUInt16BE(3, recordOffset); // platformID: Windows + nameTable.writeUInt16BE(1, recordOffset + 2); // encodingID: Unicode BMP + nameTable.writeUInt16BE(0x0409, recordOffset + 4); // languageID: English (US) + nameTable.writeUInt16BE(record.nameId, recordOffset + 6); + nameTable.writeUInt16BE(record.value.length, recordOffset + 8); // length + nameTable.writeUInt16BE(stringOffset, recordOffset + 10); // offset in storage + recordOffset += 12; + stringOffset += record.value.length; + } + strings.copy(nameTable, stringStorageOffset); + + // --- sfnt wrapper: offset table + one table record pointing at `name` --- + const offsetTableSize = 12; + const tableRecordSize = 16; + const nameTableFileOffset = offsetTableSize + tableRecordSize; + const font = Buffer.alloc(nameTableFileOffset + nameTable.length); + + font.writeUInt32BE(0x00010000, 0); // sfntVersion: TrueType + font.writeUInt16BE(1, 4); // numTables + // searchRange/entrySelector/rangeShift are not read by the parser; leave 0. + + font.write("name", offsetTableSize, "ascii"); // tag + font.writeUInt32BE(0, offsetTableSize + 4); // checksum (unused by the parser) + font.writeUInt32BE(nameTableFileOffset, offsetTableSize + 8); // offset + font.writeUInt32BE(nameTable.length, offsetTableSize + 12); // length + nameTable.copy(font, nameTableFileOffset); + + return font; +} diff --git a/tests/fonts.spec.ts b/tests/fonts.spec.ts new file mode 100644 index 0000000..91f72fd --- /dev/null +++ b/tests/fonts.spec.ts @@ -0,0 +1,59 @@ +import { expect } from "@playwright/test"; +import { test } from "./_support/fixtures"; +import { makeTestFont } from "./_support/font-fixture"; + +const FAMILY = "PPTypst Test"; + +test("adds a custom font from an uploaded file", async ({ powerPointPage }) => { + await powerPointPage.openFontsPanel(); + await powerPointPage.addFontFiles([ + { name: "PPTypstTest-Regular.ttf", buffer: makeTestFont(FAMILY, "Regular") }, + ]); + + await expect(powerPointPage.fontItems()).toHaveCount(1); + await powerPointPage.expectFontFamilyCount(FAMILY, 1); + await powerPointPage.expectFontStyleListed("Regular"); + await powerPointPage.expectStatus(`Added: ${FAMILY} Regular`); +}); + +test("keeps multiple weights of the same family as separate faces", async ({ powerPointPage }) => { + await powerPointPage.openFontsPanel(); + await powerPointPage.addFontFiles([ + { name: "PPTypstTest-Regular.ttf", buffer: makeTestFont(FAMILY, "Regular") }, + { name: "PPTypstTest-Bold.ttf", buffer: makeTestFont(FAMILY, "Bold") }, + ]); + + // Both faces coexist (keyed per family+style) instead of overwriting each other. + await expect(powerPointPage.fontItems()).toHaveCount(2); + await powerPointPage.expectFontFamilyCount(FAMILY, 2); + await powerPointPage.expectFontStyleListed("Regular"); + await powerPointPage.expectFontStyleListed("Bold"); +}); + +test("removes a custom font", async ({ powerPointPage }) => { + await powerPointPage.openFontsPanel(); + await powerPointPage.addFontFiles([ + { name: "PPTypstTest-Regular.ttf", buffer: makeTestFont(FAMILY, "Regular") }, + ]); + await expect(powerPointPage.fontItems()).toHaveCount(1); + + await powerPointPage.removeFirstFont(); + + await expect(powerPointPage.fontItems()).toHaveCount(0); + await powerPointPage.expectNoCustomFonts(); +}); + +test("persists custom fonts across a reload", async ({ powerPointPage }) => { + await powerPointPage.openFontsPanel(); + await powerPointPage.addFontFiles([ + { name: "PPTypstTest-Regular.ttf", buffer: makeTestFont(FAMILY, "Regular") }, + ]); + await expect(powerPointPage.fontItems()).toHaveCount(1); + + await powerPointPage.reload(); + await powerPointPage.openFontsPanel(); + + // The font was restored from IndexedDB, not re-uploaded. + await expect(powerPointPage.fontItems()).toHaveCount(1); + await powerPointPage.expectFontFamilyCount(FAMILY, 1); +}); diff --git a/tests/pages/powerpoint-page.ts b/tests/pages/powerpoint-page.ts index 6d154d2..32b9698 100644 --- a/tests/pages/powerpoint-page.ts +++ b/tests/pages/powerpoint-page.ts @@ -200,4 +200,56 @@ export class PowerPointPage { async expectPreviewVisible() { await expect(this.page.locator("#previewContent svg")).toBeVisible(); } + + /** Reloads the task pane and waits for the add-in to finish initializing. */ + async reload() { + await this.page.reload(); + await expect(this.page.locator("#insertBtn")).toContainText(/Insert|Update/); + } + + /** Opens the collapsible Custom fonts panel if it is currently closed. */ + async openFontsPanel() { + const isOpen = await this.page.locator("#fontsDetails").evaluate( + element => (element as HTMLDetailsElement).open, + ); + if (!isOpen) { + await this.page.locator("#fontsDetails summary").click(); + } + } + + /** Uploads one or more font files into the Custom fonts panel. */ + async addFontFiles(files: { name: string; buffer: Buffer }[]) { + await this.page.locator("#fontsInput").setInputFiles( + files.map(file => ({ name: file.name, mimeType: "font/ttf", buffer: file.buffer })), + ); + } + + /** Removes the first listed custom font via its remove button. */ + async removeFirstFont() { + await this.page.locator(".fonts-item-remove").first().click(); + } + + /** Locator for the listed custom-font rows. */ + fontItems() { + return this.page.locator(".fonts-item"); + } + + /** Asserts how many listed fonts report the given family name. */ + async expectFontFamilyCount(family: string, count: number) { + await expect( + this.page.locator(".fonts-item-family", { hasText: family }), + ).toHaveCount(count); + } + + /** Asserts that exactly one listed font shows the given style badge. */ + async expectFontStyleListed(style: string) { + await expect( + this.page.locator(".fonts-item-style", { hasText: style }), + ).toHaveCount(1); + } + + /** Asserts that no custom fonts are listed. */ + async expectNoCustomFonts() { + await expect(this.page.locator(".fonts-empty")).toBeVisible(); + } } From b53ac7f3099da4b606d76268e9d52d43f0c8a14f Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 11 Jun 2026 16:58:51 +0900 Subject: [PATCH 4/5] fix: address review feedback on custom fonts - font-ui: wrap the add/remove handlers fully in try/catch so failures from reloadCompilerFonts()/updatePreview() surface as an error status instead of an unhandled promise rejection. - tests: use exact (anchored) text matching for the font family/style assertions so they can't pass on substrings (e.g. "Bold" vs "ExtraBold"). Co-Authored-By: Claude Opus 4.8 --- tests/pages/powerpoint-page.ts | 10 ++++++++-- web/src/font-ui.ts | 29 ++++++++++++++++------------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/tests/pages/powerpoint-page.ts b/tests/pages/powerpoint-page.ts index 32b9698..ea18aaf 100644 --- a/tests/pages/powerpoint-page.ts +++ b/tests/pages/powerpoint-page.ts @@ -70,6 +70,12 @@ export const typstShapeMetadata = { altTextDescription: "Generated by PPTypst. Edit this shape with the PPTypst add-in.", } as const; +/** Builds a regex matching an element's whole (trimmed) text content exactly. */ +function exactText(value: string): RegExp { + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^\\s*${escaped}\\s*$`); +} + /** Page object for the PPTypst PowerPoint task pane. */ export class PowerPointPage { private readonly page: Page; @@ -237,14 +243,14 @@ export class PowerPointPage { /** Asserts how many listed fonts report the given family name. */ async expectFontFamilyCount(family: string, count: number) { await expect( - this.page.locator(".fonts-item-family", { hasText: family }), + this.page.locator(".fonts-item-family", { hasText: exactText(family) }), ).toHaveCount(count); } /** Asserts that exactly one listed font shows the given style badge. */ async expectFontStyleListed(style: string) { await expect( - this.page.locator(".fonts-item-style", { hasText: style }), + this.page.locator(".fonts-item-style", { hasText: exactText(style) }), ).toHaveCount(1); } diff --git a/web/src/font-ui.ts b/web/src/font-ui.ts index d0e7748..3d4c6d2 100644 --- a/web/src/font-ui.ts +++ b/web/src/font-ui.ts @@ -49,33 +49,36 @@ async function handleFilesSelected(input: HTMLInputElement) { input.value = ""; setStatus(`Loading ${files.length.toString()} font(s)…`); - const added: string[] = []; try { + const added: string[] = []; for (const file of files) { const font = await addFontFromFile(file); added.push(font.key); } + await reloadCompilerFonts(); + renderFontsList(); + await updatePreview(); + setStatus(`Added: ${added.join(", ")}`); } catch (error) { console.error("Failed to add custom font:", error); - setStatus("Could not read that font file.", true); - return; + setStatus("Could not add that font.", true); } - - await reloadCompilerFonts(); - renderFontsList(); - await updatePreview(); - setStatus(`Added: ${added.join(", ")}`); } /** * Removes a font, reloads the compiler and refreshes the preview. */ async function handleRemove(key: string) { - await removeFont(key); - await reloadCompilerFonts(); - renderFontsList(); - await updatePreview(); - setStatus(""); + try { + await removeFont(key); + await reloadCompilerFonts(); + renderFontsList(); + await updatePreview(); + setStatus(""); + } catch (error) { + console.error("Failed to remove custom font:", error); + setStatus("Could not remove that font.", true); + } } /** From a40365d550287756e72ea8520e9e3b3620f3b96f Mon Sep 17 00:00:00 2001 From: mioupa Date: Thu, 11 Jun 2026 17:02:08 +0900 Subject: [PATCH 5/5] fix: address remaining review feedback on custom fonts - font-ui: add each selected file independently so one bad file no longer aborts post-processing for the fonts that were added successfully; always apply (reload/render/preview) whatever succeeded and report failures. - font-name: don't treat Unicode/unknown-platform name records as English, so an explicit English record wins over an early platform-0 record of unknown language (still falling back to the first record when none is English). - user-fonts: close IndexedDB connections in a finally block on all paths. Co-Authored-By: Claude Opus 4.8 --- web/src/font-ui.ts | 37 +++++++++++++++++++++++++--------- web/src/registry/font-name.ts | 9 ++++++--- web/src/registry/user-fonts.ts | 18 +++++++++++------ 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/web/src/font-ui.ts b/web/src/font-ui.ts index 3d4c6d2..50ce915 100644 --- a/web/src/font-ui.ts +++ b/web/src/font-ui.ts @@ -49,19 +49,38 @@ async function handleFilesSelected(input: HTMLInputElement) { input.value = ""; setStatus(`Loading ${files.length.toString()} font(s)…`); - try { - const added: string[] = []; - for (const file of files) { + + // Add each file independently so one bad file doesn't discard the others. + const added: string[] = []; + const failed: string[] = []; + for (const file of files) { + try { const font = await addFontFromFile(file); added.push(font.key); + } catch (error) { + console.error(`Failed to add custom font "${file.name}":`, error); + failed.push(file.name); } - await reloadCompilerFonts(); - renderFontsList(); - await updatePreview(); + } + + // Always apply whatever was added successfully, keeping registry/compiler/UI + // in sync even if some files failed. + if (added.length > 0) { + try { + await reloadCompilerFonts(); + renderFontsList(); + await updatePreview(); + } catch (error) { + console.error("Failed to apply custom fonts:", error); + setStatus("Added fonts, but failed to apply them.", true); + return; + } + } + + if (failed.length > 0) { + setStatus(`Could not add: ${failed.join(", ")}`, true); + } else { setStatus(`Added: ${added.join(", ")}`); - } catch (error) { - console.error("Failed to add custom font:", error); - setStatus("Could not add that font.", true); } } diff --git a/web/src/registry/font-name.ts b/web/src/registry/font-name.ts index 940c9be..0d7cc8c 100644 --- a/web/src/registry/font-name.ts +++ b/web/src/registry/font-name.ts @@ -167,8 +167,11 @@ function preferEnglish(current: NameCandidate | null, next: NameCandidate): Name } /** - * Whether a name record is in English. Windows uses language ID 0x0409, - * Macintosh uses 0, and Unicode (platform 0) records have no language. + * Whether a name record is explicitly English. Windows uses language ID + * 0x0409 and Macintosh uses 0. Unicode/other platforms carry no reliable + * language marker, so they are not treated as English — that way an explicit + * English record is preferred over an early platform-0 record of unknown + * language (while still falling back to the first record when none is English). */ function isEnglish(platformId: number, languageId: number): boolean { if (platformId === 3) { @@ -177,7 +180,7 @@ function isEnglish(platformId: number, languageId: number): boolean { if (platformId === 1) { return languageId === 0; } - return true; + return false; } /** diff --git a/web/src/registry/user-fonts.ts b/web/src/registry/user-fonts.ts index 89ef080..558d463 100644 --- a/web/src/registry/user-fonts.ts +++ b/web/src/registry/user-fonts.ts @@ -105,13 +105,13 @@ interface StoredFont { * still works, just without persisted fonts. */ export async function loadStoredFonts(): Promise { + let db: IDBDatabase | undefined; try { - const db = await openDb(); + db = await openDb(); const request = db.transaction(STORE_NAME, "readonly") .objectStore(STORE_NAME) .getAll() as IDBRequest; const stored = await awaitRequest(request, "read"); - db.close(); fonts = stored.map(font => ({ key: font.key, @@ -124,6 +124,8 @@ export async function loadStoredFonts(): Promise { } catch (error) { console.warn("Could not load persisted custom fonts:", error); fonts = []; + } finally { + db?.close(); } } @@ -131,8 +133,9 @@ export async function loadStoredFonts(): Promise { * Persists a single font to IndexedDB. */ async function persistFont(font: UserFont): Promise { + let db: IDBDatabase | undefined; try { - const db = await openDb(); + db = await openDb(); const tx = db.transaction(STORE_NAME, "readwrite"); tx.objectStore(STORE_NAME).put({ key: font.key, @@ -143,9 +146,10 @@ async function persistFont(font: UserFont): Promise { data: font.data.slice().buffer, } satisfies StoredFont); await awaitTransaction(tx, "write"); - db.close(); } catch (error) { console.warn("Could not persist custom font:", error); + } finally { + db?.close(); } } @@ -153,14 +157,16 @@ async function persistFont(font: UserFont): Promise { * Removes a single font from IndexedDB. */ async function unpersistFont(key: string): Promise { + let db: IDBDatabase | undefined; try { - const db = await openDb(); + db = await openDb(); const tx = db.transaction(STORE_NAME, "readwrite"); tx.objectStore(STORE_NAME).delete(key); await awaitTransaction(tx, "delete"); - db.close(); } catch (error) { console.warn("Could not remove persisted custom font:", error); + } finally { + db?.close(); } }