Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
23 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
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
271 changes: 270 additions & 1 deletion invokeai/app/api/routers/utilities.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
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
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.image_files.image_files_common import ImageFileNotFoundException
from invokeai.app.services.model_records.model_records_base import UnknownModelException
Expand All @@ -23,6 +29,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 @@ -33,6 +40,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 @@ -63,6 +90,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]:
Comment thread
DustyShoe marked this conversation as resolved.
Outdated
fallback_family = font_file.stem
fallback_label = font_file.stem.replace("_", " ").replace("-", " ").strip() or font_file.stem

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:
pass

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 not family_name:
return (
fallback_family,
fallback_label,
_infer_font_weight(style_name, font_file.stem, None),
_infer_font_style(style_name, font_file.stem, False),
)
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:
return (
fallback_family,
fallback_label,
_infer_font_weight("", font_file.stem, None),
_infer_font_style("", font_file.stem, False),
)


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()
family, _label, weight, style = _get_font_metadata(font_file)
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 @@ -167,7 +437,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
22 changes: 22 additions & 0 deletions invokeai/app/services/config/config_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import copy
import filecmp
import locale
import logging
import os
import re
import shutil
Expand Down Expand Up @@ -43,6 +44,8 @@
"external_seedream_base_url",
)

logger = logging.getLogger(__name__)


class URLRegexTokenPair(BaseModel):
url_regex: str = Field(description="Regular expression to match against the URL")
Expand Down Expand Up @@ -600,6 +603,23 @@ def load_external_api_keys(api_keys_file_path: Path) -> dict[str, str]:
return parsed_api_keys


def ensure_fonts_dir(root_path: Path) -> None:
fonts_path = root_path / "Fonts"
fonts_readme_path = fonts_path / "README.txt"

try:
fonts_path.mkdir(parents=True, exist_ok=True)
if not fonts_readme_path.exists():
with open(fonts_readme_path, "wt", encoding="utf-8") as f:
f.write(
"Custom fonts folder for InvokeAI text tools.\n\n"
"Place your font files in this folder (or subfolders).\n"
"Supported formats: .ttf, .otf, .woff, .woff2\n"
)
except OSError:
logger.warning("Unable to initialize Fonts directory at %s", fonts_path, exc_info=True)


@lru_cache(maxsize=1)
def get_config() -> InvokeAIAppConfig:
"""Get the global singleton app config.
Expand Down Expand Up @@ -678,6 +698,8 @@ def get_config() -> InvokeAIAppConfig:
default_config = DefaultInvokeAIAppConfig()
default_config.write_file(config.config_file_path, as_example=False)

ensure_fonts_dir(config.root_path)

api_keys_from_file = load_external_api_keys(config.api_keys_file_path)
if api_keys_from_file:
# API keys file should take precedence over invokeai.yaml, but not over environment variables.
Expand Down
Loading
Loading