feat: custom fonts via in-browser upload - #85
Conversation
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 Splines#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 <noreply@anthropic.com>
Per maintainer feedback on Splines#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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This comment was marked as off-topic.
This comment was marked as off-topic.
This comment was marked as off-topic.
This comment was marked as off-topic.
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
web/src/typst.ts (1)
110-114: ⚡ Quick winClarify comment to include "text" assets for precision.
The comment states user fonts are inserted before "CJK/emoji assets," but line 106 fetches
["text", "cjk", "emoji"]assets. User fonts are ordered before all three asset categories (text, CJK, emoji), not just CJK and emoji.📝 Suggested comment update
/** * 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). + * it on the compiler. User fonts are inserted before the default text/CJK/emoji + * assets so they win font fallback (matching the previous `loadFonts()` ordering). */🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/src/typst.ts` around lines 110 - 114, Update the comment above the font resolver logic to explicitly state that user fonts are inserted before the "text" assets as well as the "cjk" and "emoji" assets: reference the asset order ["text", "cjk", "emoji"] fetched earlier and say user fonts win font fallback ahead of text/CJK/emoji, matching the previous loadFonts() ordering; mention this applies to the font resolver set on the compiler so readers know which component is affected.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/pages/powerpoint-page.ts`:
- Around line 238-248: The assertions in expectFontFamilyCount and
expectFontStyleListed use substring matching via hasText and can match
unintended values; update the locator calls for ".fonts-item-family" and
".fonts-item-style" to use exact text matching (e.g., change hasText: family to
hasText: { exact: family } and hasText: style to hasText: { exact: style }) so
the tests only pass on exact matches.
In `@web/src/font-ui.ts`:
- Around line 32-34: The event handlers and fire-and-forget calls currently use
"void" (e.g., input.addEventListener -> void handleFilesSelected(...),
handleRemove(...)) so rejections from async helpers like reloadCompilerFonts()
and updatePreview() may become unhandled; update those call sites (the listener
invoking handleFilesSelected, places calling reloadCompilerFonts and
updatePreview, and the handler invoking handleRemove) to await the promise or
attach a .catch() handler that logs the error and surfaces user feedback/state
rollback (e.g., update status UI and clear any stale loading flags).
Specifically, wrap calls to handleFilesSelected and handleRemove with try/catch
or add .catch(...) and ensure reloadCompilerFonts() and updatePreview() failures
are caught and handled so the UI state is consistent.
- Around line 53-67: The current try/catch around the whole loop causes one bad
file to abort post-processing and skip reloadCompilerFonts, renderFontsList,
updatePreview, and final setStatus; move error handling inside the loop so each
call to addFontFromFile(file) is wrapped in its own try/catch, push successful
font.key into the added array and record failed filenames/errors into a separate
failures list, then after the loop always call reloadCompilerFonts(),
renderFontsList(), await updatePreview(), and call setStatus with a message that
reports both added.join(", ") and any failures so the registry/compiler/UI stay
in sync even if some files fail.
In `@web/src/registry/font-name.ts`:
- Around line 173-181: The isEnglish function incorrectly treats
unknown/non-Windows/non-Mac platform IDs as English by returning true; change
its logic so that only explicit English indicators are considered English (i.e.,
return false for unrecognized platformId values). Update the
isEnglish(platformId, languageId) function to return true only for the known
English cases (platformId === 3 with languageId 0x0409, and platformId === 1
with languageId === 0) and return false for all other platformId values so
preferEnglish won't prematurely choose an unknown/Unicode record.
In `@web/src/registry/user-fonts.ts`:
- Around line 107-127: The loadStoredFonts function opens an IndexedDB
connection with openDb() but only calls db.close() on the success path; move DB
closing into a finally block so the connection is always closed on success or
error. Refactor loadStoredFonts to declare a let db variable before try, assign
it from await openDb(), remove the inline db.close() inside the try, and in
finally do if (db) db.close(); apply the same pattern to other DB helpers in
this file that use openDb()/awaitRequest()/STORE_NAME (e.g., any
save/delete/read functions) so every code path closes the IDBDatabase.
---
Nitpick comments:
In `@web/src/typst.ts`:
- Around line 110-114: Update the comment above the font resolver logic to
explicitly state that user fonts are inserted before the "text" assets as well
as the "cjk" and "emoji" assets: reference the asset order ["text", "cjk",
"emoji"] fetched earlier and say user fonts win font fallback ahead of
text/CJK/emoji, matching the previous loadFonts() ordering; mention this applies
to the font resolver set on the compiler so readers know which component is
affected.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: d915e94b-6166-4692-b581-2affc96038ec
📒 Files selected for processing (14)
tests/_support/browser-mocks/typst-options.tstests/_support/browser-mocks/typst.tstests/_support/font-fixture.tstests/fonts.spec.tstests/pages/powerpoint-page.tsweb/powerpoint.htmlweb/src/constants.tsweb/src/font-ui.tsweb/src/main.tsweb/src/registry/font-cache.tsweb/src/registry/font-name.tsweb/src/registry/user-fonts.tsweb/src/typst.tsweb/styles/main.css
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Splines
left a comment
There was a problem hiding this comment.
Thanks for your PR and sorry for my late review. In principle, it works really nice, I've tried it out with some font files.
Here are some comments, with my biggest concern being the font parsing.
There was a problem hiding this comment.
I'm not feeling too well having custom parsing of fonts inside this project as I'm unaware of their internals and files like these seem hard to maintain without that domain knowledge. Isn't there a nice JS API for that already? Or a small and well-maintained npm package? This seems like too low-level logic for a plugin to me.
| } No newline at end of file | ||
| } | ||
| /* Custom fonts panel */ | ||
| .fonts-panel { |
There was a problem hiding this comment.
Please outsource fonts-panel styles to a new CSS file.
| ></textarea> | ||
| </details> | ||
|
|
||
| <details id="fontsDetails" class="fonts-panel"> |
There was a problem hiding this comment.
The fonts panel should have a small vertical spacing to the Global preamble panel above.
| const family = document.createElement("code"); | ||
| family.className = "fonts-item-family"; | ||
| family.textContent = font.family; | ||
| family.title = `Use in Typst: #set text(font: "${font.family}")`; |
There was a problem hiding this comment.
This is only shown when accidentally hovering of the title of the font. Instead, what about showing this as text such that the user can actually copy it as well?
| const remove = document.createElement("button"); | ||
| remove.type = "button"; | ||
| remove.className = "fonts-item-remove"; | ||
| remove.textContent = "✕"; |
There was a problem hiding this comment.
- There should be a confirmation dialog such that users don't acidentally delete their added fonts.
- Furthermore, the X button is not vertically centered right now making it look weird.
- And the contrast of the white X to the red hover color in dark mode is not high enough I think.
| Custom fonts | ||
| </summary> | ||
| <label class="fonts-upload"> | ||
| <span class="fonts-upload-text">+ Add font file</span> |
There was a problem hiding this comment.
We should indicate which font formats are actually supported and where it is limited. Maybe also with a link to the Typst documentation.
|
|
||
| <details id="fontsDetails" class="fonts-panel"> | ||
| <summary class="fonts-summary" title="Upload font files to use them in your Typst code via #set text(font: ...)"> | ||
| Custom fonts |
There was a problem hiding this comment.
I think we should add a small section explaining briefly (via a hint icon tooltip) how this works, e.g. that we store the actual font in a database in the local browser (that the plugin runs in).
Addresses #64.
Why
Today, using any font outside typst.ts's default set means forking this repo, bundling the font, and self-hosting the add-in. This lets users add their own fonts directly in the task pane — no fork, no custom server.
How (overview)
.ttf/.otf/.ttc). The bytes are fed straight to the Typst compiler.compiler.setFonts(...)rather than re-initializing the compiler: the base fonts (bundled math font + the cachedtext/cjk/emojiassets) are fetched once, and on each change a font resolver is rebuilt from those in-memory bytes plus the user fonts. No WASM re-init and no re-download. User fonts are ordered before the cjk/emoji assets so they keep fallback priority.nametable is parsed to detect the family/subfamily, so the UI shows what to type in#set text(font: "...")and multiple weights of one family (e.g. Regular + Bold) coexist instead of overwriting each other.Tests
Playwright coverage (compiler mocked): adding a font, keeping multiple weights as separate faces, removing a font, and persistence across a reload. Uses a tiny synthetic test font generated in-memory (a minimal sfnt with just a
nametable), so no large binary fixture is committed.Notes / decisions
preloadSystemFonts/queryLocalFonts) are intentionally left out: the API is Chromium-only (won't work in WKWebView on macOS PowerPoint) and prompts for permission.#600) are excluded from this PR as discussed; prototyped separately onfeat/custom-font-url.PowerPoint.Presentation.customXmlPartslooks feasible as a future opt-in if desired.🤖 Generated with Claude Code