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/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..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; @@ -200,4 +206,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: 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: exactText(style) }), + ).toHaveCount(1); + } + + /** Asserts that no custom fonts are listed. */ + async expectNoCustomFonts() { + await expect(this.page.locator(".fonts-empty")).toBeVisible(); + } } 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..50ce915 --- /dev/null +++ b/web/src/font-ui.ts @@ -0,0 +1,166 @@ +/** + * 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)…`); + + // 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); + } + } + + // 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(", ")}`); + } +} + +/** + * Removes a font, reloads the compiler and refreshes the preview. + */ +async function handleRemove(key: string) { + 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); + } +} + +/** + * 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-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/registry/font-name.ts b/web/src/registry/font-name.ts new file mode 100644 index 0000000..0d7cc8c --- /dev/null +++ b/web/src/registry/font-name.ts @@ -0,0 +1,207 @@ +/** + * 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 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) { + return languageId === 0x0409; + } + if (platformId === 1) { + return languageId === 0; + } + return false; +} + +/** + * 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..558d463 --- /dev/null +++ b/web/src/registry/user-fonts.ts @@ -0,0 +1,215 @@ +/** + * 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 { + let db: IDBDatabase | undefined; + try { + db = await openDb(); + const request = db.transaction(STORE_NAME, "readonly") + .objectStore(STORE_NAME) + .getAll() as IDBRequest; + const stored = await awaitRequest(request, "read"); + + 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 = []; + } finally { + db?.close(); + } +} + +/** + * Persists a single font to IndexedDB. + */ +async function persistFont(font: UserFont): Promise { + let db: IDBDatabase | undefined; + try { + 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"); + } catch (error) { + console.warn("Could not persist custom font:", error); + } finally { + db?.close(); + } +} + +/** + * Removes a single font from IndexedDB. + */ +async function unpersistFont(key: string): Promise { + let db: IDBDatabase | undefined; + try { + db = await openDb(); + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).delete(key); + await awaitTransaction(tx, "delete"); + } catch (error) { + console.warn("Could not remove persisted custom font:", error); + } finally { + db?.close(); + } +} + +/** + * 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..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"; @@ -25,21 +29,45 @@ 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; 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(); } /** - * Initializes the Typst compiler. + * 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 applyFonts(); +} + +/** + * 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 @@ -52,9 +80,6 @@ async function initCompiler() { getModule: () => typstCompilerWasm, beforeBuild: [ disableDefaultFontAssets(), - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - loadFonts([mathFontUrl]), - ...cachedFontInitOptions().beforeBuild, withAccessModel(accessModel), withPackageRegistry( new NodeFetchPackageRegistry(accessModel, registryRequest), @@ -64,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. * 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; +}