Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
f6463a6
Add custom font import feat.
DustyShoe Feb 20, 2026
3dfe63b
Tweak dropdown to separate cusom and built-in fonts
DustyShoe Feb 20, 2026
c04a6eb
Merge branch 'invoke-ai:main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Apr 8, 2026
7c31b09
fix(text-tool): add locale strings and fonttools dependency
DustyShoe Apr 9, 2026
8f4fb20
Merge remote-tracking branch 'origin/main' into Feat(Text-tool)/impor…
DustyShoe Apr 28, 2026
99ceb85
chore(text-tool): remove unused export, Prettier formatting
DustyShoe Apr 28, 2026
b4b9e76
chore: ruff formatting
DustyShoe Apr 28, 2026
77d0266
chore(api): regenerate OpenAPI schema
DustyShoe Apr 28, 2026
7dd16e6
fix(typegen): normalize path defaults in OpenAPI schema
DustyShoe Apr 28, 2026
835c129
test(typegen): make path default normalization test platform-independent
DustyShoe Apr 28, 2026
ab84403
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Apr 30, 2026
945fafe
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
Pfannkuchensack May 1, 2026
c8dcab1
fix(text-tool): address custom font review findings
DustyShoe May 2, 2026
9b1d92c
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe May 5, 2026
e034135
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe May 6, 2026
be8deb4
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe May 7, 2026
d958596
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe May 8, 2026
c1b5a78
fix(text-tool): address custom font loading review feedback
DustyShoe May 10, 2026
30f0ad3
Merge branch 'Feat(Text-tool)/import-custom-fonts' of https://github.…
DustyShoe May 10, 2026
89bd29e
docs(text-tool): document custom fonts directory support
DustyShoe May 10, 2026
39a48d5
refactor(text-tool): rename custom font directory to lowercase fonts
DustyShoe May 10, 2026
634c3c8
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe May 11, 2026
11f61df
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe May 13, 2026
b7918b5
Merge remote-tracking branch 'upstream/main' into pr-9091-resolve
DustyShoe May 19, 2026
3a01a39
chore(api): refresh openapi schema for user fonts
DustyShoe May 19, 2026
7ff8c9d
fix: resolve test_utilities conflicts with main
DustyShoe May 25, 2026
50d925c
fix: resolve test_utilities conflicts with main
DustyShoe May 25, 2026
eafdde8
Merge origin/main into Feat(Text-tool)/import-custom-fonts
DustyShoe May 25, 2026
f76c267
Merge branch 'Feat(Text-tool)/import-custom-fonts' of https://github.…
DustyShoe May 25, 2026
e18f5a2
Merge branch 'invoke-ai:main' into Feat(Text-tool)/import-custom-fonts
DustyShoe May 27, 2026
8a7aa4f
Merge branch 'invoke-ai:main' into Feat(Text-tool)/import-custom-fonts
DustyShoe May 27, 2026
9c36d76
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe May 28, 2026
63909f1
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
Pfannkuchensack May 30, 2026
9665c4f
Merge remote-tracking branch 'origin/main' into Feat(Text-tool)/impor…
DustyShoe Jun 13, 2026
1c01dd9
test(typegen): align path default test with in-place normalization
DustyShoe Jun 13, 2026
017390d
Merge remote-tracking branch 'upstream/main' into Feat(Text-tool)/imp…
DustyShoe Jun 24, 2026
1167679
Fix custom font test FontFace mock for Vitest 4
DustyShoe Jun 24, 2026
c5ef7c2
Merge remote-tracking branch 'upstream/main' into Feat(Text-tool)/imp…
DustyShoe Jul 5, 2026
12af8ee
Merge branch 'invoke-ai:main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Jul 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/src/content/docs/development/Front End/text-tool.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: "Canvas Text Tool"
title: 'Canvas Text Tool'
---

## Overview
Expand Down Expand Up @@ -29,6 +29,13 @@ Layer placement preserves the original click location:

Font definitions live in `invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts` as ten deterministic stacks (sans, serif, mono, rounded, script, humanist, slab serif, display, narrow, UI serif). Each stack lists system-safe fallbacks so the editor can choose the first available font per platform.

The Text tool also supports server-managed custom fonts from the InvokeAI root `fonts` directory:

- `invokeai/app/services/config/config_default.py` ensures the `fonts` directory and `README.txt` exist.
- `invokeai/app/api/routers/utilities.py` lists supported font files (`.ttf`, `.otf`, `.woff`, `.woff2`) and serves them to the frontend.
- `invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx` groups custom fonts separately from built-in stacks and keeps them disabled until their `FontFace` entries are ready.
- `invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts` waits for custom font readiness before rasterizing text, avoiding fallback renders when a font has been listed but not fully loaded yet.

To add or adjust fonts:

1. Update `TEXT_FONT_STACKS` with the new `id`, `label`, and CSS `font-family` stack.
Expand Down
11 changes: 11 additions & 0 deletions docs/src/content/docs/features/Canvas/text-tool.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ import { LinkCard } from '@astrojs/starlight/components';

The Text tool uses a set of predefined font stacks. When you choose a font, the app resolves the first available font on your system from that stack and uses it for both the editor overlay and the rasterized result. This provides consistent styling across platforms while still falling back to safe system fonts if a preferred font is missing.

## Custom fonts

InvokeAI can also load custom fonts from the host-managed `fonts` directory in the InvokeAI root folder.

- On startup, InvokeAI creates `fonts/README.txt` if the directory does not already exist.
- Place `.ttf`, `.otf`, `.woff`, or `.woff2` files in `fonts` or any subfolder under it.
- Refresh the app after adding or removing fonts so the Text tool can reload the available font list.
- If the `fonts` directory is empty, the Text tool continues to use the built-in font list.

In multiuser deployments, this directory is shared by the entire InvokeAI instance. Fonts added there are available to all authenticated users of that server.

## Size and spacing

- **Size** controls the font size in pixels.
Expand Down
270 changes: 269 additions & 1 deletion invokeai/app/api/routers/utilities.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import asyncio
import logging
import re
import threading
from pathlib import Path
from typing import Optional, Union
from urllib.parse import quote

import torch
from dynamicprompts.generators import CombinatorialPromptGenerator, RandomPromptGenerator
from fastapi import Body, HTTPException
from fastapi.routing import APIRouter
from fontTools.ttLib import TTFont
from PIL import ImageFont
from pydantic import BaseModel, Field
from pyparsing import ParseException
from starlette.responses import FileResponse
from transformers import AutoProcessor, AutoTokenizer, LlavaOnevisionForConditionalGeneration, LlavaOnevisionProcessor

from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
Expand All @@ -27,6 +32,7 @@
logger = logging.getLogger(__name__)

utilities_router = APIRouter(prefix="/v1/utilities", tags=["utilities"])
SUPPORTED_FONT_EXTENSIONS = {".ttf", ".otf", ".woff", ".woff2"}

# The underlying model loader is not thread-safe, so we serialize load_model calls.
_model_load_lock = threading.Lock()
Expand All @@ -37,6 +43,26 @@ class DynamicPromptsResponse(BaseModel):
error: Optional[str] = None


class UserFontFace(BaseModel):
path: str
url: str
weight: int
style: str


class UserFont(BaseModel):
id: str
family: str
label: str
path: str
url: str
faces: list[UserFontFace]


class UserFontsResponse(BaseModel):
fonts: list[UserFont]


@utilities_router.post(
"/dynamicprompts",
operation_id="parse_dynamicprompts",
Expand Down Expand Up @@ -79,6 +105,249 @@ async def parse_dynamicprompts(
return DynamicPromptsResponse(prompts=prompts if prompts else [""], error=error)


def _get_fonts_dir() -> Path:
root = ApiDependencies.invoker.services.configuration.root_path
return root / "fonts"


def _path_has_symlink_component(path: Path, boundary: Path) -> bool:
current = path
boundary = boundary.absolute()
try:
current.absolute().relative_to(boundary)
except ValueError:
return True

while True:
if current.exists() and current.is_symlink():
return True
if current == boundary:
return False
current = current.parent


def _get_name_table_value(font: TTFont, name_ids: tuple[int, ...]) -> str | None:
if "name" not in font:
return None

def _sort_key(record: object) -> tuple[int, int]:
platform_id = getattr(record, "platformID", -1)
lang_id = getattr(record, "langID", -1)
if platform_id == 3 and lang_id in (0x0409, 0):
return (0, 0)
if platform_id == 3:
return (1, 0)
if platform_id == 0:
return (2, 0)
return (3, lang_id)

records = font["name"].names
for name_id in name_ids:
for record in sorted((record for record in records if record.nameID == name_id), key=_sort_key):
try:
value = record.toUnicode().strip()
except Exception:
continue
if value:
return value
return None


def _normalize_variant_text(value: str) -> str:
value = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", value)
value = value.replace("_", " ").replace("-", " ")
value = re.sub(r"\s+", " ", value)
return value.strip().lower()


def _infer_font_weight(style_name: str, file_stem: str, weight_class: int | None) -> int:
if isinstance(weight_class, int) and 1 <= weight_class <= 1000:
return weight_class

combined = _normalize_variant_text(f"{style_name} {file_stem}")
# Ordering matters: more specific keywords must be matched before "bold".
weight_keywords = [
(("thin", "hairline"), 100),
(("extra light", "ultra light", "extralight", "ultralight"), 200),
(("light",), 300),
(("normal", "regular", "roman", "book"), 400),
(("medium",), 500),
(("semi bold", "semibold", "demi bold", "demibold"), 600),
(("extra bold", "ultra bold", "extrabold", "ultrabold"), 800),
(("black", "heavy"), 900),
(("bold",), 700),
]
for keywords, weight in weight_keywords:
if any(keyword in combined for keyword in keywords):
return weight

return 400


def _infer_font_style(style_name: str, file_stem: str, italic_flag: bool) -> str:
if italic_flag:
return "italic"

combined = _normalize_variant_text(f"{style_name} {file_stem}")
if "italic" in combined or "oblique" in combined:
return "italic"

return "normal"


def _get_font_metadata(font_file: Path) -> tuple[str, str, int, str] | None:
ttfont_error: Exception | None = None
try:
with TTFont(font_file.as_posix(), lazy=True) as font:
family_name = (_get_name_table_value(font, (16, 1)) or "").strip()
style_name = (_get_name_table_value(font, (17, 2)) or "").strip()
os2_table = font["OS/2"] if "OS/2" in font else None
head_table = font["head"] if "head" in font else None
post_table = font["post"] if "post" in font else None
weight_class = getattr(os2_table, "usWeightClass", None)
italic_flag = bool(getattr(os2_table, "fsSelection", 0) & 0x01) or bool(
getattr(head_table, "macStyle", 0) & 0x02
)
if post_table is not None:
italic_flag = italic_flag or bool(getattr(post_table, "italicAngle", 0))
if family_name:
return (
family_name,
family_name,
_infer_font_weight(style_name, font_file.stem, weight_class),
_infer_font_style(style_name, font_file.stem, italic_flag),
)
except Exception as e:
ttfont_error = e

try:
font = ImageFont.truetype(font_file.as_posix(), size=16)
family_name, style_name = font.getname()
family_name = family_name.strip()
style_name = style_name.strip()
if family_name:
return (
family_name,
family_name,
_infer_font_weight(style_name, font_file.stem, None),
_infer_font_style(style_name, font_file.stem, False),
)
except Exception as e:
logger.warning(
"Skipping font file %s: unable to read font metadata with fontTools or Pillow (%s, %s)",
font_file,
type(ttfont_error).__name__ if ttfont_error else "no-fontTools-error",
type(e).__name__,
exc_info=e,
)
return None

logger.warning("Skipping font file %s: missing font family metadata", font_file)
return None


def _resolve_font_request_path(font_path: str) -> Path:
fonts_dir = _get_fonts_dir()
if not fonts_dir.exists() or not fonts_dir.is_dir():
raise HTTPException(status_code=404, detail="Font file not found")

requested = (fonts_dir / font_path).absolute()
if _path_has_symlink_component(requested, fonts_dir):
raise HTTPException(status_code=400, detail="Invalid font path")

resolved_fonts_dir = fonts_dir.resolve()
resolved_requested = requested.resolve()

try:
resolved_requested.relative_to(resolved_fonts_dir)
except ValueError as e:
raise HTTPException(status_code=400, detail="Invalid font path") from e

return resolved_requested


@utilities_router.get(
"/fonts",
operation_id="list_user_fonts",
responses={200: {"model": UserFontsResponse}},
)
async def list_user_fonts(_current_user: CurrentUserOrDefault) -> UserFontsResponse:
fonts_dir = _get_fonts_dir()
if not fonts_dir.exists() or not fonts_dir.is_dir() or fonts_dir.is_symlink():
return UserFontsResponse(fonts=[])

family_candidates: dict[str, list[tuple[Path, str, str, int, str]]] = {}
# key -> [(font_file, relative, family, weight, style)]
for font_file in sorted(fonts_dir.rglob("*")):
if _path_has_symlink_component(font_file.absolute(), fonts_dir):
continue
if not font_file.is_file() or font_file.suffix.lower() not in SUPPORTED_FONT_EXTENSIONS:
continue
relative = font_file.relative_to(fonts_dir).as_posix()
metadata = _get_font_metadata(font_file)
if metadata is None:
continue
family, _label, weight, style = metadata
family_key = family.strip().lower()
family_candidates.setdefault(family_key, []).append((font_file, relative, family, weight, style))

def _candidate_score(weight: int, style: str, path: Path) -> tuple[int, int, int]:
"""Lower score is better. Prefer regular/normal faces, then shorter names."""
return (0 if style == "normal" else 1, abs(weight - 400), len(path.stem))

fonts: list[UserFont] = []
for _, candidates in sorted(family_candidates.items(), key=lambda kv: kv[0]):
_, selected_relative, selected_family, _, _ = min(candidates, key=lambda c: _candidate_score(c[3], c[4], c[0]))
faces_by_variant: dict[tuple[int, str], tuple[Path, str, str, int, str]] = {}
for candidate in candidates:
variant_key = (candidate[3], candidate[4])
current = faces_by_variant.get(variant_key)
if current is None or _candidate_score(candidate[3], candidate[4], candidate[0]) < _candidate_score(
current[3], current[4], current[0]
):
faces_by_variant[variant_key] = candidate

faces = [
UserFontFace(
path=relative,
url=f"/api/v1/utilities/fonts/{quote(relative)}",
weight=weight,
style=style,
)
for (weight, style), (_, relative, _, _, _) in sorted(
faces_by_variant.items(), key=lambda item: (item[0][1] != "normal", abs(item[0][0] - 400), item[0][0])
)
]

fonts.append(
UserFont(
id=f"user:{selected_relative}",
family=selected_family,
label=selected_family,
path=selected_relative,
url=f"/api/v1/utilities/fonts/{quote(selected_relative)}",
faces=faces,
)
)

return UserFontsResponse(fonts=fonts)


@utilities_router.get(
"/fonts/{font_path:path}",
operation_id="get_user_font_file",
)
async def get_user_font_file(font_path: str, _current_user: CurrentUserOrDefault) -> FileResponse:
requested = _resolve_font_request_path(font_path)
if not requested.exists() or not requested.is_file():
raise HTTPException(status_code=404, detail="Font file not found")

if requested.suffix.lower() not in SUPPORTED_FONT_EXTENSIONS:
raise HTTPException(status_code=400, detail="Unsupported font format")

return FileResponse(path=requested)


# --- Expand Prompt ---


Expand Down Expand Up @@ -183,7 +452,6 @@ def _run_image_to_prompt(image_name: str, model_key: str, instruction: str) -> s
with _model_load_lock:
loaded_model = model_manager.load.load_model(model_config)

# Load the image from InvokeAI's image store
image = ApiDependencies.invoker.services.images.get_pil_image(image_name)
image = image.convert("RGB")

Expand Down
Loading
Loading