Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 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
284f0a8
Merge branch 'invoke-ai:main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Jul 12, 2026
c6f6acb
Merge remote-tracking branch 'upstream/main' into Feat(Text-tool)/imp…
DustyShoe Jul 22, 2026
ba89b6f
fix(deps): add networkx runtime dependency
DustyShoe Jul 22, 2026
a320ff3
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Jul 24, 2026
13eb86d
Merge branch 'invoke-ai:main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Jul 27, 2026
9e7f0b3
Merge remote-tracking branch 'upstream/main' into Feat(Text-tool)/imp…
DustyShoe Jul 27, 2026
91f6db9
fix(text-tool): address remaining custom font review feedback
DustyShoe Jul 27, 2026
cfb0565
chore(api): regenerate schema for configurable fonts directory
DustyShoe Jul 27, 2026
32d1a54
docs: regenerate settings data for fonts directory
DustyShoe Jul 27, 2026
2fb64a0
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Jul 28, 2026
f7bc427
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Jul 29, 2026
c392232
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Jul 30, 2026
22ea467
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Jul 30, 2026
760ed6e
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Jul 31, 2026
cd80d4f
Improve custom font import reliability
DustyShoe Jul 31, 2026
e310217
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Jul 31, 2026
9a03bf4
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Jul 31, 2026
a960076
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Aug 1, 2026
f6cf726
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Aug 2, 2026
29d9809
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Aug 3, 2026
7f73f07
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Aug 3, 2026
81f73c3
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Aug 3, 2026
d9e6f86
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Aug 7, 2026
0e6b1df
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Aug 8, 2026
a0acc04
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Aug 8, 2026
321597c
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Aug 8, 2026
c181af7
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Aug 9, 2026
a4a7875
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Aug 10, 2026
89df841
Merge remote-tracking branch 'origin/main' into Feat(Text-tool)/impor…
DustyShoe Aug 14, 2026
2c0e85a
Merge branch 'main' into Feat(Text-tool)/import-custom-fonts
DustyShoe Aug 14, 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 configured `fonts_dir` directory, which defaults to `fonts` in the InvokeAI root:

- `invokeai/app/services/config/config_default.py` ensures the configured directory and its `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_dir` directory. It defaults to `fonts` in the InvokeAI root folder and can be changed in `invokeai.yaml`.

- On startup, InvokeAI creates `README.txt` in the configured directory if it does not already exist.
- Place `.ttf`, `.otf`, `.woff`, or `.woff2` files in the configured directory 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 configured 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
11 changes: 11 additions & 0 deletions docs/src/generated/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,17 @@
"type": "<class 'pathlib.Path'>",
"validation": {}
},
{
"category": "PATHS",
"default": "fonts",
"description": "Path to directory for custom fonts.",
"env_var": "INVOKEAI_FONTS_DIR",
"literal_values": [],
"name": "fonts_dir",
"required": false,
"type": "<class 'pathlib.Path'>",
"validation": {}
},
{
"category": "PATHS",
"default": "flat",
Expand Down
286 changes: 285 additions & 1 deletion invokeai/app/api/routers/utilities.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
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.responses import FileResponse
from fastapi.routing import APIRouter
from fontTools.ttLib import TTFont
from PIL import ImageFont
from pydantic import BaseModel, Field
from pyparsing import ParseException
from transformers import AutoProcessor, AutoTokenizer, LlavaOnevisionForConditionalGeneration, LlavaOnevisionProcessor
Expand All @@ -29,6 +34,14 @@
logger = logging.getLogger(__name__)

utilities_router = APIRouter(prefix="/v1/utilities", tags=["utilities"])
SUPPORTED_FONT_EXTENSIONS = {".ttf", ".otf", ".woff", ".woff2"}
FONT_MEDIA_TYPES = {
".ttf": "font/ttf",
".otf": "font/otf",
".woff": "font/woff",
".woff2": "font/woff2",
}
FONT_CACHE_CONTROL = "private, max-age=31536000, immutable"

# The underlying model loader is not thread-safe, so we serialize load_model calls.
_model_load_lock = threading.Lock()
Expand All @@ -39,6 +52,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 @@ -81,6 +114,257 @@ async def parse_dynamicprompts(
return DynamicPromptsResponse(prompts=prompts if prompts else [""], error=error)


def _get_fonts_dir() -> Path:
return ApiDependencies.invoker.services.configuration.fonts_path


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.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}},
)
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():
if fonts_dir.is_symlink():
logger.warning("Skipping custom fonts directory %s: symlinks are not supported", fonts_dir)
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):
logger.warning("Skipping font path %s: symlinks are not supported", font_file)
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",
)
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,
media_type=FONT_MEDIA_TYPES[requested.suffix.lower()],
filename=requested.name,
content_disposition_type="inline",
headers={"Cache-Control": FONT_CACHE_CONTROL},
)


# --- Expand Prompt ---


Expand Down Expand Up @@ -257,7 +541,7 @@ def _run_image_to_prompt(
with _model_load_lock:
loaded_model = model_manager.load.load_model(model_config, user_id=user_id)

# Load the image from InvokeAI's image store
# 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