Skip to content

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

Open
DustyShoe wants to merge 17 commits intoinvoke-ai:mainfrom
DustyShoe:Feat(Text-tool)/import-custom-fonts
Open

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

Conversation

@DustyShoe
Copy link
Copy Markdown
Collaborator

@DustyShoe DustyShoe commented Apr 28, 2026

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.

Copy link
Copy Markdown
Collaborator

@Pfannkuchensack Pfannkuchensack left a comment

Choose a reason for hiding this comment

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

Works great.

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

Labels

api 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

None yet

Development

Successfully merging this pull request may close these issues.

2 participants