Feat(text tool): Add import custom fonts - #9091
Conversation
…t-custom-fonts # Conflicts: # invokeai/app/api/routers/utilities.py # invokeai/app/services/config/config_default.py # invokeai/frontend/web/public/locales/en.json # invokeai/frontend/web/src/services/api/endpoints/utilities.ts
|
@Pfannkuchensack What changed:
On the auth question: this router is not mounted behind a parent FastAPI dependency. Confirmed: I addressed that by making custom font availability reactive:
So this is no longer relying on an implicit re-render path. |
…ort-custom-fonts # Conflicts: # invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts # uv.lock
joshistoast
left a comment
There was a problem hiding this comment.
Blocking
-
Duplicate
FontFaceobjects accumulate indocument.fontson every text-tool toggleTextToolOptions.tsx—loadedUserFontFacesRef = useRef<Map<string, FontFace>>(new Map())FontSelectlives insideTextToolOptions, whichCanvasToolbar.tsx:86mounts only while the text tool is selected. Press T, press B, press T again → the ref is a fresh emptyMap, so:- the prune loop in
syncUserFontFacesiterates an empty map and deletes nothing, - every font is re-fetched from the network,
- new
FontFaceobjects aredocument.fonts.add()-ed alongside the stale ones.
document.fontsgrows without bound across a session. The readiness registry ($userFontReadyStates,userFontReadyPromisesintextUserFonts.ts) is already module-level — move the loaded-face map there too. - the prune loop in
-
list_user_fontsdoes blocking filesystem + font parsing on the event loopinvokeai/app/api/routers/utilities.py—async def list_user_fontssorted(fonts_dir.rglob("*"))plus aTTFontopen (with a PillowImageFont.truetypefallback) for every font file, all synchronous, insideasync def, stalls the entire server for the duration, on every call. Dropasyncso FastAPI runs it in the threadpool, and/or memoize on(mtime, size). -
.woff2is advertised but will be silently skippedSUPPORTED_FONT_EXTENSIONSand both.mdxdocs pages list.woff2, but fontTools cannot decompress WOFF2 withoutbrotli/brotlicffi— not inpyproject.toml, and:grep -c 'name = "brotli"' uv.lock → 0TTFont()raises; the Pillow fallback depends on how FreeType was built, so it is not a reliable backstop. Result:.woff2files get a "Skipping font file" warning and never appear.Either add
fonttools[woff]or remove.woff/.woff2from the supported set and the docs.Note no backend test exercises a real font file of any format — every test either monkeypatches
_get_font_metadataor asserts the skip path. -
Font URLs are hardcoded
/api/...and bypass the deployment base URLServer returns
url=f"/api/v1/utilities/fonts/{quote(relative)}";syncUserFontFacesfetches it verbatim. Everything else routes throughgetBaseUrl()/getDeploymentBaseUrl()(services/api/index.ts:94,services/events/useSocketIO.ts:3), which exists so the UI can be served from a different origin or sub-path. Under such a deployment every font fetch 404s.Return the relative path only and compose the URL client-side with the existing helper.
Should fix
-
fonts/is hardcoded; every other root subdir is a config field_get_fonts_dir()hardcodesroot / "fonts". Comparecustom_nodes_dir,style_presets_dir,workflow_thumbnails_dir(config_default.py:183-185), each with a matching*_pathproperty that callsself._resolve(...). Addfonts_dir: Path = Field(default=Path("fonts"), ...)+fonts_path, and have the router use it. -
FileResponse(path=requested)skips the conventions used by every other file routestyle_presets.py:279,images.py:709,workflows.py:353all pass explicitmedia_type,filename,content_disposition_typeand setCache-Control. Fonts are effectively immutable and (per #1) re-fetched often — a cache header matters here. Also importFileResponsefromfastapi.responses; this is the onlystarlette.responsesimport among the routers. -
Unrelated changes bundled into the PR
pyproject.tomladdsnetworkx— a genuine fix (invokeai/app/services/shared/graph.py:9imports it undeclared), but unrelated to fonts.tests/app/util/test_custom_openapi.pyaddstest_path_defaults_are_normalized_to_forward_slashesfor the pre-existingnormalize_path_defaults— nothing to do with fonts.tests/app/routers/test_utilities.pyrewrites unrelated tests:r→response, and deletes the module docstring and inline comments that recorded the security intent ("ownership must fire BEFORE inference", "non-owners can't probe stored images"). That rationale is the most valuable part of those tests — losing it is a regression.- Also dropped: the
# Load the image from InvokeAI's image storecomment inutilities.py;en.jsonkey reordering;text-tool.mdxfrontmatter quote reflow.
Splitting these out would cut the review surface substantially.
Nits
-
_get_font_metadatareturns a 4-tuple whose first two elements are always the same value (family_name, family_name, ...) and the call site discards the second as_label. LikewiseUserFont.label == UserFont.family, andUserFont.path/urlduplicate the selected face's. -
setCustomTextFontStacks([...customFontStacks])copies an array solely to force a nanostores notification after loading completes. SubscribingCanvasTextToolModuleto$userFontReadyStateswould express the actual dependency. Relatedly,setCustomTextFontStacks/subscribeToCustomTextFontStacksare thin wrappers over the already-exported$customTextFontStacksatom — two APIs for one thing. -
Five
useEffects now live in a combobox component, including a focus/online retry listener and a two-attempt backoff. Font loading is app state, not combobox state — a listener keyed on the RTK Query result would also fix #1 for free. -
awaitUserFontReadyraces a 2 ssetTimeoutthat is never cleared; harmless, but leaves a pending timer per call. -
truncateLabel(value, 36)hard-truncates with.... CSS ellipsis adapts to container width and keeps the full string searchable in the combobox. -
zTextFontIdwidened fromz.enum([...])toz.string().min(1), so persisted canvas text state no longer validates the font id. Necessary for dynamic ids and backward-compatible (so the unchecked "redux slice migration" checklist item does look genuinely N/A), but worth a comment. -
Path traversal handling in
_resolve_font_request_pathis correct —..survives.absolute()but is caught by theresolve()+relative_to()check, absolute paths are caught by the firstrelative_to, and symlinked components are rejected. One cosmetic gap:_path_has_symlink_componentguardscurrent.is_symlink()behindcurrent.exists(), which isFalsefor a broken symlink — not exploitable (the route's ownexists()check catches it), but the guard reads as covering a case it doesn't.
|
Thanks for the detailed review. I went through every point and made the following changes. Blocking issues
Should-fix issues
Nits
I also added or updated tests covering the persistent font registry, base-URL composition, real TTF and WOFF2 parsing, configured font directories, response headers, and configuration initialization. |
joshistoast
left a comment
There was a problem hiding this comment.
Must fix:
FontFaceleak on every text-tool activation —loadedUserFontFacesRefis auseRefinFontSelect, which only mounts while the text tool is selected (CanvasToolbar.tsx:86). On unmount the loaded faces stay indocument.fontsbut the tracking map is discarded, so the next activation re-fetches and re-adds every font and the stale-prune loop can't see the old generation.document.fontsgrows by N per activation. Fix: hoist the map to module scope intextUserFonts.ts, which already owns the module-scoped readiness registry.list_user_fontsblocks the event loop — it'sasync defbut does synchronousrglob+ font parsing. I measured 1.3 ms/font, so ~900 fonts stalls all HTTP and socket.io traffic for ~1.2 s per request, and RTK Query drops its cache 60 s after deselecting the tool so it refires regularly. Dropping theasynckeyword hands it to FastAPI's threadpool.- Unrelated
networkxdependency inpyproject.tomldrives most of the 225-lineuv.lockchurn. Also worth pushing back on: the test file rewrite strips the module docstring and the comments documenting the security intent of the auth/ownership tests, plus a cosmeticr→responserename across untouched tests.
Notable non-blockers: font IDs are heuristically-chosen file paths, so adding a file to an existing family can flip the representative and orphan a user's saved selection; symlinked font directories are silently ignored with no log or UI signal; load failures leave an option permanently disabled with no explanation, and the 2 s readiness timeout lets a raster commit with the wrong font.
|
Addressed the actionable parts. Changed:
Already fixed in current branch:
Notable non-blockers: Implemented fixes for items 2, 3, and 4. Leaving item 1 as a follow-up. Leaving item 1 as a follow-up because changing custom font IDs from heuristic representative paths to stable IDs/content hashes would be a persistence/schema decision. It affects saved canvas state compatibility and may require migration or fallback handling for existing
Note: |

Summary
Add support for custom text-tool fonts loaded from
invokeai/Fonts.This feature lets users add their own font files without replacing the built-in font set. The backend now discovers and serves fonts from
invokeai/Fonts, creates the directory andREADME.txtautomatically, and parses font metadata to show clean family names. The frontend loads these fonts into the text tool, shows them separately from built-in fonts, and keeps built-in fonts as fallback when no custom fonts are available.Related Issues / Discussions
N/A
QA Instructions
.ttf,.otf,.woff, or.woff2files toinvokeai/Fontsinvokeai/Fontsis emptyinvokeai/frontend/web/src/services/api/schema.tswas regenerated after adding the new utilities endpointsMerge Plan
Standard merge.
Checklist
What's Newcopy (if doing a release after this PR)