Skip to content

Feat(text tool): Add import custom fonts - #9091

Open
DustyShoe wants to merge 61 commits into
invoke-ai:mainfrom
DustyShoe:Feat(Text-tool)/import-custom-fonts
Open

Feat(text tool): Add import custom fonts#9091
DustyShoe wants to merge 61 commits into
invoke-ai:mainfrom
DustyShoe:Feat(Text-tool)/import-custom-fonts

Conversation

@DustyShoe

@DustyShoe DustyShoe commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

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 and README.txt automatically, 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.

image

Related Issues / Discussions

N/A

QA Instructions

  • Add one or more .ttf, .otf, .woff, or .woff2 files to invokeai/Fonts
  • Start Invoke and open the Canvas text tool
  • Confirm custom fonts appear in the font dropdown above built-in fonts
  • Confirm built-in fonts still appear when invokeai/Fonts is empty
  • Confirm font names use family names instead of raw filenames
  • Confirm long font names do not wrap to a second line
  • Confirm bold/italic toggles still work for custom fonts
  • Confirm invokeai/frontend/web/src/services/api/schema.ts was regenerated after adding the new utilities endpoints

Merge Plan

Standard merge.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

@github-actions github-actions Bot added api python PRs that change python files Root services PRs that change app services frontend PRs that change frontend files python-deps PRs that change python dependencies labels Apr 28, 2026
@github-actions github-actions Bot added the python-tests PRs that change python tests label Apr 28, 2026
@Pfannkuchensack

Copy link
Copy Markdown
Collaborator
image Works good.

Adversarial Review

Findings

High

  • invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts:62-72 — User fonts are stored in a module-level mutable variable (customTextFontStacks) that is not subscribed to by React or Redux. getFontStackById is consumed by Konva renderers in invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx:76 and invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts:160,356,372. On page load, a saved canvas layer whose fontId is user:<family> renders before useListUserFontsQuery resolves and before setCustomTextFontStacks runs, so getFontStackById falls through to TEXT_FONT_STACKS[0] and the layer paints in sans-serif. Because the mutation happens outside React state, no Konva node receives a prop change to trigger a redraw, so the wrong font persists until the user manually edits the layer. This regresses fidelity of any persisted text layer that targets a user font.

    • To expose this issue, add a vitest that calls getFontStackById('user:foo') before setCustomTextFontStacks runs, then calls setCustomTextFontStacks([...]), and asserts a downstream consumer detects the change without re-reading the function. Also add manual verification: hard-reload a canvas containing a saved user-font layer and confirm the rendered glyphs match.
  • invokeai/app/api/routers/utilities.py:219 and :281list_user_fonts and get_user_font_file serve files off disk under <root>/Fonts with no auth dependency. _get_fonts_dir returns a single global directory shared across users with no per-user partitioning. While the rest of utilities.py is also dependency-free (matching existing convention), this is the first endpoint in the router that streams arbitrary file bytes under a path:path parameter. In multiuser deployments, any session can enumerate and download every file under <root>/Fonts. Either gate on the same dependency used by other file-serving routers (see invokeai/app/api/routers/images.py), or document explicitly that the Fonts directory is global and world-readable.

    • To expose this issue, add a test that calls GET /v1/utilities/fonts and GET /v1/utilities/fonts/<file> against a multiuser-mode test client and asserts the response status matches the project's auth contract for file-serving endpoints.

Medium

  • invokeai/app/api/routers/utilities.py:289 — Path-traversal protection relies on Path.resolve() followed by relative_to(fonts_dir). fonts_dir itself is (root / "Fonts").resolve(). If <root>/Fonts (or any directory inside it) is a symlink whose target lies outside <root>, both sides of relative_to resolve through the symlink and the check passes. Combined with rglob("*") enumeration in list_user_fonts, an operator (or a local user with write access to that directory) can leak file contents through a planted symlink. Reject symlinked candidates explicitly via Path.is_symlink(), or compare os.path.realpath of the candidate against the realpath of fonts_dir before serving.

    • To expose this issue, add a test that creates a symlink inside Fonts pointing at a file outside the InvokeAI root, calls GET /v1/utilities/fonts/<symlink>, and asserts the route returns 400/404 rather than the linked file's bytes.
  • invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts:3zTextFontId was relaxed from z.enum([...]) to z.string().min(1). Persisted canvas state validated against this schema now accepts arbitrary strings, including stale user:<family> ids whose backing file has been removed. invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts:145 then silently substitutes TEXT_FONT_STACKS[0]?.stack ?? 'sans-serif' for unknown ids, with no telemetry, toast, or visual indicator. Users who delete a font from <root>/Fonts will see existing layers silently switch to the default sans on next load.

    • To expose this issue, add a vitest that calls getFontStackById('user:does-not-exist') and asserts a sentinel return value or an explicit fallback contract that the UI can branch on; today the call is indistinguishable from getFontStackById('sans').
  • invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx:96-126 — Two module-level singletons (loadedUserFontFaces: Set<string> and the global document.fonts registry) are never pruned. After a font is removed from disk, the cache key in loadedUserFontFaces still pins the registered FontFace in document.fonts, so layers using that family continue to claim the font is loaded even though GET /v1/utilities/fonts/<url> now 404s. There is no document.fonts.delete() path on font removal.

    • To expose this issue, extract the loader logic and add a vitest that simulates a font removed between two useListUserFontsQuery results, then asserts that no stale FontFace remains tracked.
  • invokeai/app/api/routers/utilities.py:219list_user_fonts keys families by family.strip().lower() and emits ids of the form user:<lowercased family>. Two distinct font files declaring the same name table family (common with rebrands or forks) collapse into a single id; selected_relative is whichever candidate scored lowest, so the wrong file may be served for a layer that originally selected a different one. Persisted canvas state has no way to disambiguate. Consider keying on file path or content hash.

    • To expose this issue, add a backend test that places two distinct font files declaring the same name family in a temp Fonts dir and asserts list_user_fonts either deduplicates with a documented rule or surfaces both with distinct ids.
  • invokeai/app/services/config/config_default.py:643get_config() unconditionally creates <root>/Fonts and writes Fonts/README.txt on every call, with no try/except. On a read-only mount or misconfigured root path, startup will crash with a bare OSError instead of degrading gracefully.

    • To expose this issue, add a test that runs get_config against a non-writable root and asserts a clear error or graceful skip rather than an unhandled exception.

Low

  • invokeai/app/services/config/config_default.py:646 — README is written using locale.getpreferredencoding(). Content is ASCII so this is harmless today, but on Windows installs this can be cp1252. Pin to encoding="utf-8" for determinism.

  • invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx:86-87t('controlLayers.text.customFonts', { defaultValue: 'User Fonts' }) and the equivalent for builtInFonts pass inline English defaultValues even though both keys exist in invokeai/frontend/web/public/locales/en.json. Inline defaults silently mask future translation gaps.

  • invokeai/app/api/routers/utilities.py:159_infer_font_weight correctness depends on the ordering of weight_keywords (specifically that ("bold",) is matched last so "semibold"/"extrabold" win first). A reviewer reordering this list to "tidy it up" will silently break weight detection. Either sort by descending specificity at lookup time or add a comment pinning the ordering invariant.

  • tests/app/util/test_custom_openapi.py:64 — The new test for normalize_path_defaults only exercises top-level properties. The implementation recurses into nested properties, items, additionalProperties, and oneOf/anyOf, none of which are covered.

    • To expose this issue, extend the test with a nested case (e.g., a path default inside oneOf or items) and assert it is normalized.

Open Questions

  • Is this router mounted behind a parent FastAPI Depends in multiuser deployments? If yes, the auth finding downgrades from High to Low.
  • Is getFontStackById ever called inside a React-subscribed selector that would naturally re-run when useListUserFontsQuery refetches? I did not find one; the Konva module reads it imperatively. Confirmation would tighten the High finding.
  • No backend tests added for either new route; no frontend tests added for the new module-level font registry. Runtime test coverage on this PR is effectively zero where it matters most.

@DustyShoe

Copy link
Copy Markdown
Collaborator Author

@Pfannkuchensack
Thanks. I addressed the findings in this PR.

What changed:

  • The custom font registry is now reactive instead of a module-local mutable variable. CanvasTextOverlay and CanvasTextToolModule now react to custom font availability changes, so saved custom-font layers re-measure and redraw after fonts finish loading.
  • The font loader now prunes stale FontFace entries and uses authenticated fetches for font files in multiuser mode.
  • /api/v1/utilities/fonts and /api/v1/utilities/fonts/{font_path} now require CurrentUserOrDefault.
  • The font file route now rejects symlinked paths/components and keeps path resolution constrained to Fonts.
  • Custom font IDs are now path-based instead of family-name-based to avoid collisions between different files with the same internal family name.
  • Missing custom fonts are handled explicitly in the UI instead of being silently treated as a normal existing option.
  • Fonts/README.txt creation was moved behind a safe helper that logs OSError instead of crashing startup, and the README is now written as UTF-8.
  • The path-default OpenAPI regression test was extended to cover nested schema normalization.

On the auth question: this router is not mounted behind a parent FastAPI dependency. utilities_router is included directly, so the original finding was valid. The new per-route CurrentUserOrDefault dependency is what closes it.

Confirmed: getFontStackById() was not being re-run through any React-subscribed selector path. The review was correct that Konva was reading it imperatively.

I addressed that by making custom font availability reactive:

  • custom fonts now live in a store instead of a module-local mutable variable
  • CanvasTextOverlay subscribes to that store
  • CanvasTextToolModule subscribes to it and forces a redraw / cursor metric reset

So this is no longer relying on an implicit re-render path.

@Pfannkuchensack Pfannkuchensack left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works great.

@DustyShoe
DustyShoe requested a review from joshistoast July 22, 2026 01:59

@joshistoast joshistoast left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

  1. Duplicate FontFace objects accumulate in document.fonts on every text-tool toggle

    TextToolOptions.tsxloadedUserFontFacesRef = useRef<Map<string, FontFace>>(new Map())

    FontSelect lives inside TextToolOptions, which CanvasToolbar.tsx:86 mounts only while the text tool is selected. Press T, press B, press T again → the ref is a fresh empty Map, so:

    • the prune loop in syncUserFontFaces iterates an empty map and deletes nothing,
    • every font is re-fetched from the network,
    • new FontFace objects are document.fonts.add()-ed alongside the stale ones.

    document.fonts grows without bound across a session. The readiness registry ($userFontReadyStates, userFontReadyPromises in textUserFonts.ts) is already module-level — move the loaded-face map there too.

  2. list_user_fonts does blocking filesystem + font parsing on the event loop

    invokeai/app/api/routers/utilities.pyasync def list_user_fonts

    sorted(fonts_dir.rglob("*")) plus a TTFont open (with a Pillow ImageFont.truetype fallback) for every font file, all synchronous, inside async def, stalls the entire server for the duration, on every call. Drop async so FastAPI runs it in the threadpool, and/or memoize on (mtime, size).

  3. .woff2 is advertised but will be silently skipped

    SUPPORTED_FONT_EXTENSIONS and both .mdx docs pages list .woff2, but fontTools cannot decompress WOFF2 without brotli/brotlicffi — not in pyproject.toml, and:

    grep -c 'name = "brotli"' uv.lock → 0
    

    TTFont() raises; the Pillow fallback depends on how FreeType was built, so it is not a reliable backstop. Result: .woff2 files get a "Skipping font file" warning and never appear.

    Either add fonttools[woff] or remove .woff/.woff2 from the supported set and the docs.

    Note no backend test exercises a real font file of any format — every test either monkeypatches _get_font_metadata or asserts the skip path.

  4. Font URLs are hardcoded /api/... and bypass the deployment base URL

    Server returns url=f"/api/v1/utilities/fonts/{quote(relative)}"; syncUserFontFaces fetches it verbatim. Everything else routes through getBaseUrl() / 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

  1. fonts/ is hardcoded; every other root subdir is a config field

    _get_fonts_dir() hardcodes root / "fonts". Compare custom_nodes_dir, style_presets_dir, workflow_thumbnails_dir (config_default.py:183-185), each with a matching *_path property that calls self._resolve(...). Add fonts_dir: Path = Field(default=Path("fonts"), ...) + fonts_path, and have the router use it.

  2. FileResponse(path=requested) skips the conventions used by every other file route

    style_presets.py:279, images.py:709, workflows.py:353 all pass explicit media_type, filename, content_disposition_type and set Cache-Control. Fonts are effectively immutable and (per #1) re-fetched often — a cache header matters here. Also import FileResponse from fastapi.responses; this is the only starlette.responses import among the routers.

  3. Unrelated changes bundled into the PR

    • pyproject.toml adds networkx — a genuine fix (invokeai/app/services/shared/graph.py:9 imports it undeclared), but unrelated to fonts.
    • tests/app/util/test_custom_openapi.py adds test_path_defaults_are_normalized_to_forward_slashes for the pre-existing normalize_path_defaults — nothing to do with fonts.
    • tests/app/routers/test_utilities.py rewrites unrelated tests: rresponse, 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 store comment in utilities.py; en.json key reordering; text-tool.mdx frontmatter quote reflow.

    Splitting these out would cut the review surface substantially.


Nits

  1. _get_font_metadata returns 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. Likewise UserFont.label == UserFont.family, and UserFont.path/url duplicate the selected face's.

  2. setCustomTextFontStacks([...customFontStacks]) copies an array solely to force a nanostores notification after loading completes. Subscribing CanvasTextToolModule to $userFontReadyStates would express the actual dependency. Relatedly, setCustomTextFontStacks / subscribeToCustomTextFontStacks are thin wrappers over the already-exported $customTextFontStacks atom — two APIs for one thing.

  3. 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.

  4. awaitUserFontReady races a 2 s setTimeout that is never cleared; harmless, but leaves a pending timer per call.

  5. truncateLabel(value, 36) hard-truncates with .... CSS ellipsis adapts to container width and keeps the full string searchable in the combobox.

  6. zTextFontId widened from z.enum([...]) to z.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.

  7. Path traversal handling in _resolve_font_request_path is correct — .. survives .absolute() but is caught by the resolve() + relative_to() check, absolute paths are caught by the first relative_to, and symlinked components are rejected. One cosmetic gap: _path_has_symlink_component guards current.is_symlink() behind current.exists(), which is False for a broken symlink — not exploitable (the route's own exists() check catches it), but the guard reads as covering a case it doesn't.

@DustyShoe

Copy link
Copy Markdown
Collaborator Author

Thanks for the detailed review. I went through every point and made the following changes.

Blocking issues

  1. Fixed the FontFace accumulation.

    The loaded-face registry is now module-level alongside the readiness state and promises. Remounting TextToolOptions no longer loses track of previously registered faces, so stale faces can be removed and existing faces are not fetched and registered again.

  2. Fixed the blocking work in list_user_fonts.

    Both font-listing and font-serving handlers are now synchronous functions, allowing FastAPI to run them in its thread pool instead of doing filesystem access and font parsing on the event loop.

  3. Added reliable WOFF/WOFF2 support.

    The dependency is now fonttools[woff], and there is a backend test which creates and reads a real WOFF2 font. I also added coverage using a real TTF file rather than mocking _get_font_metadata.

  4. Fixed font URLs for deployments with a base URL.

    The API now returns a relative font path, and the frontend composes it with the existing getBaseUrl() helper before fetching it.

Should-fix issues

  1. Added configurable font-directory support.

    fonts_dir is now an application configuration field with a corresponding fonts_path property. It defaults to fonts, can be configured through invokeai.yaml, and is used both during initialization and by the API routes. The documentation and configuration tests were updated accordingly.

  2. Updated the font-file response.

    The route now uses fastapi.responses.FileResponse and explicitly sets the media type, filename, inline content disposition, and an immutable private cache header.

  3. About the apparently unrelated changes:

    The networkx change is unrelated to custom fonts, but it was added deliberately after this PR started failing in GitHub Actions.

    InvokeAI already imports networkx in invokeai/app/services/shared/graph.py, but it was not declared as a direct runtime dependency. This had previously gone unnoticed because torch installed it transitively.

    After merging the latest main into this long-lived branch, the resulting uv.lock changed the torch -> networkx environment markers. With the CI installation command, uv sync --locked --extra test, networkx was no longer installed for the default Linux/Windows environments. The tests then failed during application imports. macOS remained covered by the lock marker.

    That is why the missing dependency appeared to affect only this PR: this branch was the first one to exercise that particular post-merge lock state. Adding networkx as a direct dependency removes the accidental reliance on torch dependency markers. The fix is isolated in its own fix(deps) commit and can be moved to a separate PR if preferred.

    The OpenAPI path-normalization test is similar in that it covers a cross-platform CI issue exposed while regenerating the schema on Windows. It is not part of the font-loading behavior and is kept isolated from the feature implementation.

    I restored the utilities test module documentation and the comments explaining the security intent of the image ownership tests. I also restored the removed image-store comment and reverted unrelated locale/documentation formatting noise.

Nits

  1. Agreed that the internal metadata tuple contains redundant values. The API-level label, path, and url fields are retained because they provide the selected/default face directly to clients, but the internal metadata representation can be simplified separately.

  2. The loaded FontFace registry has been moved out of the component, which fixes the lifecycle bug. The current notification call remains because the text rendering module consumes the resolved font stacks rather than the readiness store directly. Moving the entire loading and notification flow into application-level state would be a larger refactor and is better handled separately.

  3. Likewise, the retry and online handling remain in the current loading owner for now. With the persistent registry, remounting the combobox no longer duplicates browser font entries. Moving all loading orchestration out of FontSelect would be a useful follow-up architectural cleanup.

  4. Fixed the readiness timeout leak. The timeout is now cleared after the race settles.

  5. Agreed that CSS ellipsis would adapt better to the available width. This is a presentation improvement rather than a font-loading correctness issue and can be handled separately.

  6. Added a comment explaining why zTextFontId must accept arbitrary non-empty strings: custom font IDs are server-generated paths and therefore cannot be represented by the built-in enum.

  7. Simplified the symlink check so broken symlink components are detected directly rather than being guarded by exists().

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 joshistoast left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must fix:

  1. FontFace leak on every text-tool activationloadedUserFontFacesRef is a useRef in FontSelect, which only mounts while the text tool is selected (CanvasToolbar.tsx:86). On unmount the loaded faces stay in document.fonts but 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.fonts grows by N per activation. Fix: hoist the map to module scope in textUserFonts.ts, which already owns the module-scoped readiness registry.
  2. list_user_fonts blocks the event loop — it's async def but does synchronous rglob + 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 the async keyword hands it to FastAPI's threadpool.
  3. Unrelated networkx dependency in pyproject.toml drives most of the 225-line uv.lock churn. 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 cosmetic rresponse rename 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.

@DustyShoe

DustyShoe commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the actionable parts.

Changed:

  • @pyproject.toml: removed direct networkx dependency.
  • @uv.lock: regenerated; only direct InvokeAI networkx entries dropped.
  • @tests/app/routers/test_utilities.py: restored existing test docstring/comments/r style from main, kept only custom-font test additions.

Already fixed in current branch:

  • loadedUserFontFaces is module-scoped in @invokeai/frontend/web/src/features/controlLayers/text/textUserFonts.ts.
  • list_user_fonts is already sync def, not async, in @invokeai/app/api/routers/utilities.py.

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 user:<path> font IDs, so it should be handled separately from these safety/readiness fixes.

  • Symlinked font paths are no longer silently ignored. The backend now logs a warning when the Fonts directory or a font path is skipped because it contains a symlink.
  • Added backend test coverage for the symlink skip warning.
  • Custom fonts now have explicit readiness states. The UI disables custom font options until their FontFace entries are loaded.
  • Failed custom font loads now show a toast and mark the option label as failed to load.
  • Text commit now waits for font readiness result. If the selected custom font is still loading or failed, commit is cancelled, the text session returns to editing, and a toast explains the issue.
  • Added Vitest coverage for ready, error, and timeout readiness results.

Note:
The CI failure is expected after removing networkx as a direct dependency. scripts/generate_openapi_schema.py imports the FastAPI app, which imports invokeai.app.services.shared.graph, and that module currently imports networkx at module load time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14 Nice-to-Have 6.14.0 api docs PRs that change docs frontend PRs that change frontend files python PRs that change python files python-deps PRs that change python dependencies python-tests PRs that change python tests Root services PRs that change app services

Projects

Status: 6.14.x Theme: USER EXPERIENCE

Development

Successfully merging this pull request may close these issues.

5 participants