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

* Add custom font import feat.

* Tweak dropdown to separate cusom and built-in fonts

* fix(text-tool): add locale strings and fonttools dependency

* chore(text-tool): remove unused export, Prettier formatting

* chore: ruff formatting

* chore(api): regenerate OpenAPI schema

* fix(typegen): normalize path defaults in OpenAPI schema

* test(typegen): make path default normalization test platform-independent

* fix(text-tool): address custom font review findings

* fix(text-tool): address custom font loading review feedback

* docs(text-tool): document custom fonts directory support

* refactor(text-tool): rename custom font directory to lowercase fonts

* chore(api): refresh openapi schema for user fonts

* fix: resolve test_utilities conflicts with main

* fix: resolve test_utilities conflicts with main

* test(typegen): align path default test with in-place normalization

* Fix custom font test FontFace mock for Vitest 4

* fix(deps): add networkx runtime dependency

* fix(text-tool): address remaining custom font review feedback

* chore(api): regenerate schema for configurable fonts directory

* docs: regenerate settings data for fonts directory

* Improve custom font import reliability

* Stabilize custom font loading and IDs

* Preserve cached fonts while font list loads

---------

Co-authored-by: Alexander Eichhorn <[email protected]>
Co-authored-by: Josh Corbett <[email protected]>
Co-authored-by: Lincoln Stein <[email protected]>
This commit is contained in:
Valeri Che
2026-09-06 18:55:54 +00:00
committed by GitHub
co-authored by Alexander Eichhorn Josh Corbett Lincoln Stein
parent 61b9ebae9b
commit ef832d1aa5
21 changed files with 1882 additions and 42 deletions
@@ -1,5 +1,5 @@
---
title: "Canvas Text Tool"
title: 'Canvas Text Tool'
---
## Overview
@@ -29,9 +29,16 @@ 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.
2. If you add a new stack, extend the `TEXT_FONT_IDS` tuple and update the `canvasTextSlice` schema default (`TEXT_DEFAULT_FONT_ID`).
2. If the new stack should be the default, update `TEXT_DEFAULT_FONT_ID`.
3. Provide translation strings for any new labels in `public/locales/*`.
4. The editor and renderer will automatically pick up the new stack via `getFontStackById()`.
@@ -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.
+11
View File
@@ -213,6 +213,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",
+285 -1
View File
@@ -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
@@ -29,6 +34,14 @@ from invokeai.backend.util.devices import TorchDevice
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()
@@ -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",
@@ -81,6 +114,257 @@ 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 family_key, 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:{family_key}",
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 ---
@@ -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")
@@ -6,6 +6,7 @@ from __future__ import annotations
import copy
import filecmp
import locale
import logging
import os
import re
import shutil
@@ -47,6 +48,8 @@ EXTERNAL_PROVIDER_CONFIG_FIELDS = (
"external_seedream_base_url",
)
logger = logging.getLogger(__name__)
class URLRegexTokenPair(BaseModel):
url_regex: str = Field(description="Regular expression to match against the URL")
@@ -182,6 +185,7 @@ class InvokeAIAppConfig(BaseSettings):
legacy_conf_dir: Path = Field(default=Path("configs"), description="Path to directory of legacy checkpoint config files.")
db_dir: Path = Field(default=Path("databases"), description="Path to InvokeAI databases directory.")
outputs_dir: Path = Field(default=Path("outputs"), description="Path to directory for outputs.")
fonts_dir: Path = Field(default=Path("fonts"), description="Path to directory for custom fonts.")
image_subfolder_strategy: IMAGE_SUBFOLDER_STRATEGY = Field(default="flat", description="Strategy for organizing images into subfolders. 'flat' stores all images in a single folder. 'date' organizes by YYYY/MM/DD. 'type' organizes by image category. 'hash' uses first 2 characters of UUID for filesystem performance.")
custom_nodes_dir: Path = Field(default=Path("nodes"), description="Path to directory for custom nodes.")
style_presets_dir: Path = Field(default=Path("style_presets"), description="Path to directory for style presets.")
@@ -412,6 +416,11 @@ class InvokeAIAppConfig(BaseSettings):
"""Path to the outputs directory, resolved to an absolute path.."""
return self._resolve(self.outputs_dir)
@property
def fonts_path(self) -> Path:
"""Path to the custom fonts directory, resolved to an absolute path."""
return self._resolve(self.fonts_dir)
@property
def db_path(self) -> Path:
"""Path to the invokeai.db file, resolved to an absolute path.."""
@@ -666,6 +675,22 @@ def load_external_api_keys(api_keys_file_path: Path) -> dict[str, str]:
return parsed_api_keys
def ensure_fonts_dir(fonts_path: Path) -> None:
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.
@@ -744,6 +769,8 @@ def get_config() -> InvokeAIAppConfig:
default_config = DefaultInvokeAIAppConfig()
default_config.write_file(config.config_file_path, as_example=False)
ensure_fonts_dir(config.fonts_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.
+145
View File
@@ -514,6 +514,73 @@
]
}
},
"/api/v1/utilities/fonts": {
"get": {
"tags": ["utilities"],
"summary": "List User Fonts",
"operationId": "list_user_fonts",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserFontsResponse"
}
}
}
}
},
"security": [
{
"HTTPBearer": []
}
]
}
},
"/api/v1/utilities/fonts/{font_path}": {
"get": {
"tags": ["utilities"],
"summary": "Get User Font File",
"operationId": "get_user_font_file",
"security": [
{
"HTTPBearer": []
}
],
"parameters": [
{
"name": "font_path",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Font Path"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/utilities/expand-prompt": {
"post": {
"tags": ["utilities"],
@@ -49342,6 +49409,13 @@
"description": "Path to directory for outputs.",
"default": "outputs"
},
"fonts_dir": {
"type": "string",
"format": "path",
"title": "Fonts Dir",
"description": "Path to directory for custom fonts.",
"default": "fonts"
},
"image_subfolder_strategy": {
"type": "string",
"enum": ["flat", "date", "type", "hash"],
@@ -87121,6 +87195,77 @@
"title": "UserDTO",
"description": "User data transfer object."
},
"UserFont": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"family": {
"type": "string",
"title": "Family"
},
"label": {
"type": "string",
"title": "Label"
},
"path": {
"type": "string",
"title": "Path"
},
"url": {
"type": "string",
"title": "Url"
},
"faces": {
"items": {
"$ref": "#/components/schemas/UserFontFace"
},
"type": "array",
"title": "Faces"
}
},
"type": "object",
"required": ["id", "family", "label", "path", "url", "faces"],
"title": "UserFont"
},
"UserFontFace": {
"properties": {
"path": {
"type": "string",
"title": "Path"
},
"url": {
"type": "string",
"title": "Url"
},
"weight": {
"type": "integer",
"title": "Weight"
},
"style": {
"type": "string",
"title": "Style"
}
},
"type": "object",
"required": ["path", "url", "weight", "style"],
"title": "UserFontFace"
},
"UserFontsResponse": {
"properties": {
"fonts": {
"items": {
"$ref": "#/components/schemas/UserFont"
},
"type": "array",
"title": "Fonts"
}
},
"type": "object",
"required": ["fonts"],
"title": "UserFontsResponse"
},
"UserProfileUpdateRequest": {
"properties": {
"display_name": {
+14 -5
View File
@@ -2056,6 +2056,11 @@
"imageSavingFailed": "Image Saving Failed",
"imageUploaded": "Image Uploaded",
"imageUploadFailed": "Image Upload Failed",
"customFontLoadFailed": "Custom Font Failed to Load",
"customFontLoadFailedDesc": "Could not load custom font: {{fontName}}.",
"customFontStillLoading": "Custom Font Still Loading",
"customFontStillLoadingDesc": "Text was not committed because the selected custom font is still loading. Try again in a moment.",
"customFontUnavailableDesc": "Text was not committed because the selected custom font could not be loaded. Choose another font or check the font file.",
"downloadsInterrupted": "Your session ended while downloads were being prepared. Run the download again after signing in.",
"imagesFailedToDownload": "{{count}} image could not be prepared for download.",
"imagesFailedToDownload_other": "{{count}} images could not be prepared for download.",
@@ -3031,6 +3036,10 @@
"text": {
"font": "Font",
"size": "Size",
"lineHeight": "Spacing",
"lineHeightDense": "Dense",
"lineHeightNormal": "Normal",
"lineHeightSpacious": "Spacious",
"bold": "Bold",
"italic": "Italic",
"underline": "Underline",
@@ -3038,11 +3047,11 @@
"alignLeft": "Align Left",
"alignCenter": "Align Center",
"alignRight": "Align Right",
"px": "px",
"lineHeight": "Spacing",
"lineHeightDense": "Dense",
"lineHeightNormal": "Normal",
"lineHeightSpacious": "Spacious"
"customFonts": "User Fonts",
"builtInFonts": "Built-in Fonts",
"missingFont": "Missing Font",
"fontLoadFailed": "failed to load",
"px": "px"
},
"newCanvasFromImage": "New Canvas from Image",
"newImg2ImgCanvasFromImage": "New Img2Img from Image",
@@ -7,7 +7,11 @@ import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSe
import type { CanvasTextSettingsState } from 'features/controlLayers/store/canvasTextSlice';
import { selectCanvasTextSlice } from 'features/controlLayers/store/canvasTextSlice';
import type { Coordinate } from 'features/controlLayers/store/types';
import { getFontStackById, TEXT_RASTER_PADDING } from 'features/controlLayers/text/textConstants';
import {
$customTextFontStacks,
getFontStackById,
TEXT_RASTER_PADDING,
} from 'features/controlLayers/text/textConstants';
import { isAllowedTextShortcut } from 'features/controlLayers/text/textHotkeys';
import { measureTextContent, type TextMeasureConfig } from 'features/controlLayers/text/textRenderer';
import {
@@ -28,6 +32,7 @@ export const CanvasTextOverlay = memo(() => {
const canvasManager = useCanvasManager();
const session = useStore(canvasManager.tool.tools.text.$session);
const stageAttrs = useStore(canvasManager.stage.$stageAttrs);
useStore($customTextFontStacks);
if (!session) {
return null;
@@ -71,12 +76,16 @@ const ROTATE_ANCHOR_LINE_LENGTH = ROTATE_ANCHOR_GAP;
const ROTATE_ANCHOR_FILL = 'invokeBlue.50';
const ROTATE_ANCHOR_STROKE = 'invokeBlue.500';
const buildMeasureConfig = (text: string, settings: CanvasTextSettingsState): TextMeasureConfig => {
const buildMeasureConfig = (
text: string,
settings: CanvasTextSettingsState,
fontFamily: TextMeasureConfig['fontFamily']
): TextMeasureConfig => {
const fontStyle: TextMeasureConfig['fontStyle'] = settings.italic ? 'italic' : 'normal';
return {
text,
fontSize: settings.fontSize,
fontFamily: getFontStackById(settings.fontId),
fontFamily,
fontWeight: settings.bold ? 700 : 400,
fontStyle,
lineHeight: settings.lineHeight,
@@ -99,6 +108,7 @@ const TextEditor = ({
const canvasManager = useCanvasManager();
const textSettings = useAppSelector(selectCanvasTextSlice);
const canvasSettings = useAppSelector(selectCanvasSettingsSlice);
useStore($customTextFontStacks);
const containerRef = useRef<HTMLDivElement | null>(null);
const editorRef = useRef<HTMLDivElement | null>(null);
const lastSessionIdRef = useRef<string | null>(null);
@@ -111,9 +121,10 @@ const TextEditor = ({
const [isDragging, setIsDragging] = useState(false);
const [isRotating, setIsRotating] = useState(false);
const [textValue, setTextValue] = useState(initialText);
const fontFamily = getFontStackById(textSettings.fontId);
const contentMetrics = useMemo(
() => measureTextContent(buildMeasureConfig(textValue, textSettings)),
[textValue, textSettings]
() => measureTextContent(buildMeasureConfig(textValue, textSettings, fontFamily)),
[fontFamily, textSettings, textValue]
);
const textContainerData = useMemo(() => {
const padding = TEXT_RASTER_PADDING;
@@ -570,7 +581,7 @@ const TextEditor = ({
decorations.push('line-through');
}
return {
fontFamily: getFontStackById(textSettings.fontId),
fontFamily,
fontWeight: textSettings.bold ? 700 : 400,
fontStyle: textSettings.italic ? 'italic' : 'normal',
textDecorationLine: decorations.length ? decorations.join(' ') : 'none',
@@ -579,7 +590,7 @@ const TextEditor = ({
color,
textAlign: textSettings.alignment,
} as const;
}, [canvasSettings, contentMetrics.lineHeightPx, textSettings]);
}, [canvasSettings, contentMetrics.lineHeightPx, fontFamily, textSettings]);
const stageScale = stageAttrs.scale || 1;
const outlineScale = stageScale ? 1 / stageScale : 1;
@@ -2,6 +2,7 @@ import {
Box,
ButtonGroup,
Combobox,
type ComboboxOption,
CompositeSlider,
Flex,
IconButton,
@@ -17,7 +18,10 @@ import {
Text,
Tooltip,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import type { GroupBase } from 'chakra-react-select';
import { selectAuthToken } from 'features/auth/store/authSlice';
import {
selectTextAlignment,
selectTextFontId,
@@ -33,14 +37,27 @@ import {
textUnderlineToggled,
} from 'features/controlLayers/store/canvasTextSlice';
import {
getTextFontStack,
isCustomTextFontId,
resolveAvailableFont,
setCustomTextFontStacks,
TEXT_FONT_STACKS,
TEXT_MAX_FONT_SIZE,
TEXT_MIN_FONT_SIZE,
type TextFontId,
} from 'features/controlLayers/text/textConstants';
import {
$userFontReadyStates,
buildCustomTextFontStacks,
getUserFontAutoRetryDelayMs,
loadedUserFontFaces,
primeUserFontReadiness,
reconcileUserFontRetryAttempts,
syncUserFontFaces,
} from 'features/controlLayers/text/textUserFonts';
import { toast } from 'features/toast/toast';
import type { FocusEvent, KeyboardEvent, MouseEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
PiCaretDownBold,
@@ -52,8 +69,18 @@ import {
PiTextStrikethroughBold,
PiTextUnderlineBold,
} from 'react-icons/pi';
import { getBaseUrl } from 'services/api';
import { useListUserFontsQuery } from 'services/api/endpoints/utilities';
const formatSliderValue = (value: number) => String(value);
const toastedUserFontLoadErrorIds = new Set<TextFontId>();
const truncateLabel = (value: string, maxLength: number = 36): string => {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 3)}...`;
};
export const TextToolOptions = () => {
return (
@@ -71,16 +98,188 @@ const FontSelect = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const fontId = useAppSelector(selectTextFontId);
const authToken = useAppSelector(selectAuthToken);
const { data: userFonts } = useListUserFontsQuery();
const autoRetryAttemptsRef = useRef(new Map<TextFontId, number>());
const userFontReadyStates = useStore($userFontReadyStates);
const [fontSyncVersion, setFontSyncVersion] = useState(0);
const userFontsLabel = t('controlLayers.text.customFonts');
const builtInFontsLabel = t('controlLayers.text.builtInFonts');
const missingFontLabel = t('controlLayers.text.missingFont');
const fontLoadFailedLabel = t('controlLayers.text.fontLoadFailed');
const customFontStacks = useMemo(() => buildCustomTextFontStacks(userFonts ?? []), [userFonts]);
const failedUserFontIds = useMemo(() => {
return (userFonts ?? []).filter((font) => userFontReadyStates[font.id] === 'error').map((font) => font.id);
}, [userFontReadyStates, userFonts]);
const hasUserFontErrors = failedUserFontIds.length > 0;
useEffect(() => {
if (userFonts === undefined) {
return;
}
setCustomTextFontStacks(customFontStacks);
}, [customFontStacks, userFonts]);
useEffect(() => {
if (!isCustomTextFontId(fontId) || !userFonts || userFonts.length === 0) {
return;
}
const hasExactUserFont = userFonts.some((font) => font.id === fontId);
if (hasExactUserFont) {
return;
}
const resolvedFont = getTextFontStack(fontId);
if (resolvedFont && resolvedFont.id !== fontId) {
dispatch(textFontChanged(resolvedFont.id));
}
}, [dispatch, fontId, userFonts]);
useEffect(() => {
if (userFonts === undefined || typeof document === 'undefined' || typeof FontFace === 'undefined') {
return;
}
primeUserFontReadiness(userFonts, loadedUserFontFaces);
let isCancelled = false;
void (async () => {
await syncUserFontFaces({
fonts: userFonts,
token: authToken,
baseUrl: getBaseUrl(),
loadedFontFaces: loadedUserFontFaces,
fontFaceSet: document.fonts,
fontFaceCtor: FontFace,
fetchFn: fetch,
});
if (!isCancelled) {
// Trigger downstream re-measurement once font availability has changed.
setCustomTextFontStacks([...customFontStacks]);
}
})();
return () => {
isCancelled = true;
};
}, [authToken, customFontStacks, fontSyncVersion, userFonts]);
useEffect(() => {
if (userFonts === undefined) {
return;
}
reconcileUserFontRetryAttempts(autoRetryAttemptsRef.current, userFonts, userFontReadyStates);
}, [userFontReadyStates, userFonts]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const timeouts = failedUserFontIds.flatMap((fontId) => {
const attempts = autoRetryAttemptsRef.current.get(fontId) ?? 0;
const delayMs = getUserFontAutoRetryDelayMs(attempts);
if (delayMs === undefined) {
return [];
}
return [
window.setTimeout(() => {
autoRetryAttemptsRef.current.set(fontId, attempts + 1);
setFontSyncVersion((version) => version + 1);
}, delayMs),
];
});
return () => {
timeouts.forEach((timeout) => window.clearTimeout(timeout));
};
}, [failedUserFontIds, fontSyncVersion]);
useEffect(() => {
if (!hasUserFontErrors || typeof window === 'undefined') {
return;
}
const retry = () => {
failedUserFontIds.forEach((fontId) => autoRetryAttemptsRef.current.delete(fontId));
setFontSyncVersion((version) => version + 1);
};
window.addEventListener('focus', retry);
window.addEventListener('online', retry);
return () => {
window.removeEventListener('focus', retry);
window.removeEventListener('online', retry);
};
}, [failedUserFontIds, hasUserFontErrors]);
useEffect(() => {
for (const font of userFonts ?? []) {
if (userFontReadyStates[font.id] !== 'error') {
toastedUserFontLoadErrorIds.delete(font.id);
continue;
}
if (toastedUserFontLoadErrorIds.has(font.id)) {
continue;
}
toastedUserFontLoadErrorIds.add(font.id);
toast({
id: `custom-font-load-failed:${font.id}`,
status: 'error',
title: t('toast.customFontLoadFailed'),
description: t('toast.customFontLoadFailedDesc', { fontName: font.label }),
withCount: false,
});
}
}, [t, userFontReadyStates, userFonts]);
const options = useMemo(() => {
return TEXT_FONT_STACKS.map(({ id, label, stack }) => {
const resolved = resolveAvailableFont(stack);
const customOptions: ComboboxOption[] = (userFonts ?? []).map((font) => {
return {
value: id,
label: `${label} (${resolved})`,
value: font.id,
label: truncateLabel(
`${font.label}${userFontReadyStates[font.id] === 'error' ? ` (${fontLoadFailedLabel})` : ''}`
),
isDisabled: userFontReadyStates[font.id] !== 'ready',
};
});
}, []);
const selectedOption = options.find((option) => option.value === fontId) ?? null;
const builtInOptions: ComboboxOption[] = TEXT_FONT_STACKS.map(({ id, label, stack }) => {
const resolved = resolveAvailableFont(stack);
const display = truncateLabel(`${label} (${resolved})`);
return {
value: id,
label: display,
};
});
if (customOptions.length === 0) {
return builtInOptions;
}
return [
{
label: userFontsLabel,
options: customOptions,
},
{ label: builtInFontsLabel, options: builtInOptions },
] as GroupBase<ComboboxOption>[];
}, [builtInFontsLabel, fontLoadFailedLabel, userFontReadyStates, userFonts, userFontsLabel]);
const selectedOption = useMemo(() => {
const firstOption = options[0];
const flattened =
firstOption && 'options' in firstOption
? (options as GroupBase<ComboboxOption>[]).flatMap((group) => group.options)
: (options as ComboboxOption[]);
const existingOption = flattened.find((option) => option.value === fontId) ?? null;
if (existingOption) {
return existingOption;
}
if (isCustomTextFontId(fontId) && !getTextFontStack(fontId)) {
return {
value: fontId,
label: `${missingFontLabel} (${truncateLabel(fontId.slice(5))})`,
};
}
return null;
}, [fontId, missingFontLabel, options]);
const handleFontChange = useCallback(
(option: { value: string } | null) => {
if (!option) {
@@ -90,9 +289,23 @@ const FontSelect = () => {
},
[dispatch]
);
const formatFontGroupLabel = useCallback(
(group: GroupBase<ComboboxOption>) => {
const isBuiltInGroup = group.label === builtInFontsLabel;
return (
<Flex w="full" flexDir="column" gap={1} py={1}>
{isBuiltInGroup && <Box borderTopWidth="1px" borderTopColor="base.500" opacity={0.85} />}
<Text fontSize="xs" fontWeight="semibold" color="base.400" textTransform="uppercase" letterSpacing="0.04em">
{group.label}
</Text>
</Flex>
);
},
[builtInFontsLabel]
);
return (
<Flex w="200px" minW="200px" alignItems="center" gap={2}>
<Flex w="280px" minW="280px" alignItems="center" gap={2}>
<Text fontSize="sm" lineHeight="1" whiteSpace="nowrap">
{t('controlLayers.text.font')}
</Text>
@@ -103,6 +316,7 @@ const FontSelect = () => {
options={options}
value={selectedOption}
onChange={handleFontChange}
formatGroupLabel={formatFontGroupLabel}
/>
</Flex>
);
@@ -5,7 +5,11 @@ import { canvasToBlob, getPrefixedId } from 'features/controlLayers/konva/util';
import { type CanvasTextSettingsState, selectCanvasTextSlice } from 'features/controlLayers/store/canvasTextSlice';
import type { Coordinate, RgbaColor } from 'features/controlLayers/store/types';
import { buildCommittedTextImageState, getCommittedTextImageDimensions } from 'features/controlLayers/text/textCommit';
import { getFontStackById, TEXT_RASTER_PADDING } from 'features/controlLayers/text/textConstants';
import {
getFontStackById,
subscribeToCustomTextFontStacks,
TEXT_RASTER_PADDING,
} from 'features/controlLayers/text/textConstants';
import {
buildFontDescriptor,
calculateLayerPosition,
@@ -15,7 +19,10 @@ import {
type TextMeasureConfig,
} from 'features/controlLayers/text/textRenderer';
import { type TextSessionStatus, transitionTextSessionStatus } from 'features/controlLayers/text/textSessionMachine';
import { awaitUserFontReady } from 'features/controlLayers/text/textUserFonts';
import { toast } from 'features/toast/toast';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { t } from 'i18next';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { atom } from 'nanostores';
@@ -117,6 +124,12 @@ export class CanvasTextToolModule extends CanvasModuleBase {
this.render();
})
);
this.subscriptions.add(
subscribeToCustomTextFontStacks(() => {
this.cursorMetricsKey = null;
this.render();
})
);
}
destroy = () => {
@@ -351,6 +364,25 @@ export class CanvasTextToolModule extends CanvasModuleBase {
textSettings: CanvasTextSettingsState,
color: RgbaColor
) => {
const fontReadiness = await awaitUserFontReady(textSettings.fontId);
if (fontReadiness !== 'ready') {
const currentSession = this.$session.get();
if (currentSession?.id === session.id) {
this.$session.set({ ...currentSession, status: 'editing' });
}
const isTimeout = fontReadiness === 'timeout';
toast({
id: isTimeout
? `custom-font-still-loading:${textSettings.fontId}`
: `custom-font-load-failed:${textSettings.fontId}`,
status: 'error',
title: t(isTimeout ? 'toast.customFontStillLoading' : 'toast.customFontLoadFailed'),
description: t(isTimeout ? 'toast.customFontStillLoadingDesc' : 'toast.customFontUnavailableDesc'),
withCount: false,
});
return;
}
if (typeof document !== 'undefined' && document.fonts?.load) {
const fontSpec = buildFontDescriptor({
fontFamily: getFontStackById(textSettings.fontId),
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
getFontStackById,
getTextFontStack,
setCustomTextFontStacks,
subscribeToCustomTextFontStacks,
TEXT_FONT_STACKS,
} from './textConstants';
describe('textConstants custom font registry', () => {
afterEach(() => {
setCustomTextFontStacks([]);
});
it('notifies subscribers when custom fonts are updated', () => {
const listener = vi.fn();
const unsubscribe = subscribeToCustomTextFontStacks(listener);
setCustomTextFontStacks([
{
id: 'user:my font',
label: 'My Font',
stack: '"My Font",sans-serif',
aliases: ['user:fonts/MyFont-Regular.ttf'],
},
]);
expect(listener).toHaveBeenCalledTimes(1);
expect(getFontStackById('user:fonts/MyFont-Regular.ttf')).toBe('"My Font",sans-serif');
expect(getTextFontStack('user:fonts/MyFont-Regular.ttf')?.id).toBe('user:my font');
unsubscribe();
});
it('distinguishes missing custom font ids from known font stacks', () => {
expect(getTextFontStack('user:missing-font')).toBeUndefined();
expect(getFontStackById('user:missing-font')).toBe(TEXT_FONT_STACKS[0]?.stack ?? 'sans-serif');
});
});
@@ -1,21 +1,13 @@
import { atom } from 'nanostores';
import { z } from 'zod';
const TEXT_FONT_IDS = [
'sans',
'serif',
'mono',
'rounded',
'script',
'humanist',
'slab',
'display',
'narrow',
'uiSerif',
] as const;
export const zTextFontId = z.enum(TEXT_FONT_IDS);
export type TextFontId = z.infer<typeof zTextFontId>;
// Custom font IDs are server-generated paths, so this cannot be restricted to the built-in font ID enum.
export const zTextFontId = z.string().min(1);
export type TextFontId = string;
export const TEXT_FONT_STACKS: Array<{ id: TextFontId; label: string; stack: string }> = [
type TextFontStack = { id: TextFontId; label: string; stack: string; aliases?: TextFontId[] };
export const TEXT_FONT_STACKS: Array<TextFontStack> = [
{
id: 'sans',
label: 'Sans',
@@ -69,6 +61,24 @@ export const TEXT_FONT_STACKS: Array<{ id: TextFontId; label: string; stack: str
},
];
export const $customTextFontStacks = atom<Array<TextFontStack>>([]);
export const setCustomTextFontStacks = (fonts: Array<TextFontStack>) => {
$customTextFontStacks.set(fonts);
};
export const subscribeToCustomTextFontStacks = (listener: (fonts: readonly TextFontStack[]) => void) => {
return $customTextFontStacks.listen((fonts) => listener(fonts));
};
const getAllTextFontStacks = (): Array<TextFontStack> => {
const customTextFontStacks = $customTextFontStacks.get();
if (customTextFontStacks.length === 0) {
return TEXT_FONT_STACKS;
}
return [...customTextFontStacks, ...TEXT_FONT_STACKS];
};
export const TEXT_DEFAULT_FONT_ID: TextFontId = 'sans';
export const TEXT_DEFAULT_FONT_SIZE = 48;
export const TEXT_MIN_FONT_SIZE = 1;
@@ -139,6 +149,25 @@ export const resolveAvailableFont = (stack: string): string => {
return fontCandidates[0] ?? 'sans-serif';
};
export const isCustomTextFontId = (fontId: TextFontId): boolean => fontId.startsWith('user:');
export const getTextFontStack = (fontId: TextFontId): TextFontStack | undefined => {
const fonts = getAllTextFontStacks();
const exactMatch = fonts.find((font) => font.id === fontId);
if (exactMatch) {
return exactMatch;
}
const aliasMatch = fonts.find((font) => font.aliases?.includes(fontId));
if (aliasMatch) {
return aliasMatch;
}
if (!isCustomTextFontId(fontId)) {
return undefined;
}
const legacyFamilyId = fontId.slice(5).trim().toLowerCase();
return fonts.find((font) => font.label.trim().toLowerCase() === legacyFamilyId);
};
export const getFontStackById = (fontId: TextFontId): string => {
return TEXT_FONT_STACKS.find((font) => font.id === fontId)?.stack ?? TEXT_FONT_STACKS[0]?.stack ?? 'sans-serif';
return getTextFontStack(fontId)?.stack ?? TEXT_FONT_STACKS[0]?.stack ?? 'sans-serif';
};
@@ -0,0 +1,288 @@
import type { UserFont } from 'services/api/endpoints/utilities';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
awaitUserFontReady,
buildCustomTextFontStacks,
clearUserFontRegistryForTests,
getUserFontAutoRetryDelayMs,
getUserFontFaceKey,
isUserFontReady,
loadedUserFontFaces,
MAX_USER_FONT_AUTO_RETRY_ATTEMPTS,
reconcileUserFontRetryAttempts,
syncUserFontFaces,
} from './textUserFonts';
describe('textUserFonts', () => {
const face = {
path: 'fonts/MyFont-Regular.ttf',
url: 'api/v1/utilities/fonts/fonts/MyFont-Regular.ttf',
weight: 400,
style: 'normal' as const,
};
const font: UserFont = {
id: 'user:my font',
family: 'My Font',
label: 'My Font',
path: 'fonts/MyFont-Regular.ttf',
url: 'api/v1/utilities/fonts/fonts/MyFont-Regular.ttf',
faces: [face],
};
afterEach(() => {
clearUserFontRegistryForTests();
});
it('builds custom font stacks from user fonts', () => {
expect(buildCustomTextFontStacks([font])).toEqual([
{
id: font.id,
label: font.label,
stack: '"My Font",sans-serif',
aliases: ['user:fonts/MyFont-Regular.ttf'],
},
]);
});
it('preserves retry attempts while a failed font is pending', () => {
const attempts = new Map([[font.id, MAX_USER_FONT_AUTO_RETRY_ATTEMPTS]]);
reconcileUserFontRetryAttempts(attempts, [font], { [font.id]: 'pending' });
expect(attempts.get(font.id)).toBe(MAX_USER_FONT_AUTO_RETRY_ATTEMPTS);
reconcileUserFontRetryAttempts(attempts, [font], { [font.id]: 'error' });
expect(attempts.get(font.id)).toBe(MAX_USER_FONT_AUTO_RETRY_ATTEMPTS);
expect(getUserFontAutoRetryDelayMs(attempts.get(font.id) ?? 0)).toBeUndefined();
reconcileUserFontRetryAttempts(attempts, [font], { [font.id]: 'ready' });
expect(attempts.has(font.id)).toBe(false);
});
it('loads authenticated font faces and prunes stale entries', async () => {
const staleFace = { family: 'Stale Font' };
const loadedFontFaces = new Map<string, object>([['stale|400|normal|/stale.ttf', staleFace]]);
const addedFaces: object[] = [];
const deletedFaces: object[] = [];
const loadedFace = { family: 'My Font' };
const fetchFn = vi.fn(() =>
Promise.resolve({
ok: true,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
})
);
const fontFaceCtor = vi.fn(function () {
return {
load: () => Promise.resolve(loadedFace),
};
});
await syncUserFontFaces({
fonts: [font],
token: 'test-token',
baseUrl: 'https://invoke.example.com/subpath',
loadedFontFaces,
fontFaceSet: {
add: (face) => addedFaces.push(face),
delete: (face) => {
deletedFaces.push(face);
return true;
},
},
fontFaceCtor,
fetchFn,
});
const faceKey = getUserFontFaceKey(font, face);
expect(fetchFn).toHaveBeenCalledWith(`https://invoke.example.com/subpath/${face.url}`, {
headers: { Authorization: 'Bearer test-token' },
});
expect(fontFaceCtor).toHaveBeenCalledWith('My Font', expect.any(ArrayBuffer), {
weight: '400',
style: 'normal',
});
expect(addedFaces).toEqual([loadedFace]);
expect(deletedFaces).toEqual([staleFace]);
expect(loadedFontFaces.get(faceKey)).toBe(loadedFace);
expect(loadedFontFaces.has('stale|400|normal|/stale.ttf')).toBe(false);
});
it('reuses the module-level font face registry across sync cycles', async () => {
const loadedFace = { family: 'My Font' } as FontFace;
const addedFaces: FontFace[] = [];
const fetchFn = vi.fn(() =>
Promise.resolve({
ok: true,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
})
);
const fontFaceCtor = vi.fn(function () {
return {
load: () => Promise.resolve(loadedFace),
};
});
const args = {
fonts: [font],
token: null,
baseUrl: 'https://invoke.example.com',
loadedFontFaces: loadedUserFontFaces,
fontFaceSet: {
add: (loadedFace: FontFace) => addedFaces.push(loadedFace),
delete: () => true,
},
fontFaceCtor,
fetchFn,
};
await syncUserFontFaces(args);
await syncUserFontFaces(args);
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(addedFaces).toEqual([loadedFace]);
});
it('tracks custom font readiness until all faces load', async () => {
const loadedFontFaces = new Map<string, object>();
const loadedFace = { family: 'My Font' };
let resolveFetch: ((response: { ok: boolean; arrayBuffer: () => Promise<ArrayBuffer> }) => void) | undefined;
const fetchFn = vi.fn(
() =>
new Promise<{ ok: boolean; arrayBuffer: () => Promise<ArrayBuffer> }>((resolve) => {
resolveFetch = resolve;
})
);
const fontFaceCtor = vi.fn(function () {
return {
load: () => Promise.resolve(loadedFace),
};
});
const syncPromise = syncUserFontFaces({
fonts: [font],
token: null,
baseUrl: 'https://invoke.example.com',
loadedFontFaces,
fontFaceSet: {
add: () => undefined,
delete: () => true,
},
fontFaceCtor,
fetchFn,
});
expect(isUserFontReady(font.id)).toBe(false);
const readyPromise = awaitUserFontReady(font.id);
let isReadyResolved = false;
void readyPromise.then(() => {
isReadyResolved = true;
});
await Promise.resolve();
expect(isReadyResolved).toBe(false);
resolveFetch?.({
ok: true,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
});
await syncPromise;
await expect(readyPromise).resolves.toBe('ready');
expect(isUserFontReady(font.id)).toBe(true);
});
it('reports timeout while a custom font is still pending', async () => {
vi.useFakeTimers();
try {
const loadedFontFaces = new Map<string, object>();
const fetchFn = vi.fn(
() =>
new Promise<{ ok: boolean; arrayBuffer: () => Promise<ArrayBuffer> }>(() => {
// Keep the font request pending so readiness must time out.
})
);
const fontFaceCtor = vi.fn(function () {
return {
load: () => Promise.resolve({ family: 'My Font' }),
};
});
void syncUserFontFaces({
fonts: [font],
token: null,
baseUrl: 'https://invoke.example.com',
loadedFontFaces,
fontFaceSet: {
add: () => undefined,
delete: () => true,
},
fontFaceCtor,
fetchFn,
});
await Promise.resolve();
const readinessPromise = awaitUserFontReady(font.id);
await vi.advanceTimersByTimeAsync(2000);
await expect(readinessPromise).resolves.toBe('timeout');
expect(isUserFontReady(font.id)).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('allows a later sync to recover from an initial load failure', async () => {
const loadedFontFaces = new Map<string, object>();
const loadedFace = { family: 'My Font' };
const fetchFn = vi
.fn<() => Promise<{ ok: boolean; arrayBuffer: () => Promise<ArrayBuffer> }>>()
.mockResolvedValueOnce({
ok: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
})
.mockResolvedValueOnce({
ok: true,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
});
const fontFaceCtor = vi.fn(function () {
return {
load: () => Promise.resolve(loadedFace),
};
});
await syncUserFontFaces({
fonts: [font],
token: null,
baseUrl: 'https://invoke.example.com',
loadedFontFaces,
fontFaceSet: {
add: () => undefined,
delete: () => true,
},
fontFaceCtor,
fetchFn,
});
expect(isUserFontReady(font.id)).toBe(false);
await expect(awaitUserFontReady(font.id)).resolves.toBe('error');
await syncUserFontFaces({
fonts: [font],
token: null,
baseUrl: 'https://invoke.example.com',
loadedFontFaces,
fontFaceSet: {
add: () => undefined,
delete: () => true,
},
fontFaceCtor,
fetchFn,
});
expect(fetchFn).toHaveBeenCalledTimes(2);
expect(isUserFontReady(font.id)).toBe(true);
});
});
@@ -0,0 +1,266 @@
import { isCustomTextFontId, type TextFontId } from 'features/controlLayers/text/textConstants';
import { atom } from 'nanostores';
import type { UserFont, UserFontFace } from 'services/api/endpoints/utilities';
type CustomTextFontStack = { id: TextFontId; label: string; stack: string; aliases: TextFontId[] };
type UserFontReadyState = 'pending' | 'ready' | 'error';
type UserFontReadinessResult = Exclude<UserFontReadyState, 'pending'> | 'timeout';
type FetchResponseLike = {
ok: boolean;
arrayBuffer: () => Promise<ArrayBuffer>;
};
type FetchLike = (input: string, init?: RequestInit) => Promise<FetchResponseLike>;
type FontFaceLike<TLoadedFontFace> = {
load: () => Promise<TLoadedFontFace>;
};
type FontFaceConstructorLike<TLoadedFontFace> = new (
family: string,
source: string | ArrayBuffer,
descriptors?: { weight?: string; style?: string }
) => FontFaceLike<TLoadedFontFace>;
type FontFaceSetLike<TLoadedFontFace> = {
add: (fontFace: TLoadedFontFace) => unknown;
delete: (fontFace: TLoadedFontFace) => boolean;
};
export const $userFontReadyStates = atom<Record<TextFontId, UserFontReadyState>>({});
export const loadedUserFontFaces = new Map<string, FontFace>();
const userFontReadyPromises = new Map<TextFontId, Promise<void>>();
const userFontReadyResolvers = new Map<TextFontId, () => void>();
const USER_FONT_READY_TIMEOUT_MS = 2000;
export const MAX_USER_FONT_AUTO_RETRY_ATTEMPTS = 2;
export const getUserFontAutoRetryDelayMs = (attempts: number): number | undefined => {
if (attempts >= MAX_USER_FONT_AUTO_RETRY_ATTEMPTS) {
return undefined;
}
return attempts === 0 ? 1000 : 3000;
};
export const reconcileUserFontRetryAttempts = (
attempts: Map<TextFontId, number>,
fonts: Array<UserFont>,
readyStates: Record<TextFontId, UserFontReadyState>
): void => {
const activeFontIds = new Set(fonts.map((font) => font.id));
for (const fontId of attempts.keys()) {
if (!activeFontIds.has(fontId) || readyStates[fontId] === 'ready') {
attempts.delete(fontId);
}
}
};
export const buildCustomTextFontStacks = (fonts: Array<UserFont>): Array<CustomTextFontStack> => {
return fonts.map((font) => ({
id: font.id,
label: font.label,
stack: `"${font.family}",sans-serif`,
aliases: Array.from(new Set([font.path, ...font.faces.map((face) => face.path)]))
.map((path) => `user:${path}`)
.filter((id) => id !== font.id),
}));
};
export const getUserFontFaceKey = (
font: Pick<UserFont, 'family'>,
face: Pick<UserFontFace, 'weight' | 'style' | 'url'>
) => `${font.family}|${face.weight}|${face.style}|${face.url}`;
const areAllFontFacesLoaded = <TLoadedFontFace>(
font: UserFont,
loadedFontFaces: Map<string, TLoadedFontFace>
): boolean => {
return font.faces.every((face) => loadedFontFaces.has(getUserFontFaceKey(font, face)));
};
const setUserFontReadyState = (fontId: TextFontId, state: UserFontReadyState): void => {
const currentStates = $userFontReadyStates.get();
if (currentStates[fontId] === state) {
return;
}
$userFontReadyStates.set({
...currentStates,
[fontId]: state,
});
};
const deleteUserFontReadyState = (fontId: TextFontId): void => {
const currentStates = $userFontReadyStates.get();
if (!(fontId in currentStates)) {
return;
}
const nextStates = { ...currentStates };
delete nextStates[fontId];
$userFontReadyStates.set(nextStates);
};
const ensureUserFontReadyPromise = (fontId: TextFontId): Promise<void> => {
const existingPromise = userFontReadyPromises.get(fontId);
if (existingPromise) {
return existingPromise;
}
let resolvePromise: (() => void) | undefined;
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
userFontReadyPromises.set(fontId, promise);
userFontReadyResolvers.set(fontId, resolvePromise ?? (() => undefined));
return promise;
};
const settleUserFontReadyPromise = (fontId: TextFontId): void => {
userFontReadyResolvers.get(fontId)?.();
userFontReadyResolvers.delete(fontId);
userFontReadyPromises.delete(fontId);
};
export const primeUserFontReadiness = <TLoadedFontFace>(
fonts: Array<UserFont>,
loadedFontFaces: Map<string, TLoadedFontFace>
): void => {
const activeFontIds = new Set(fonts.map((font) => font.id));
for (const fontId of Object.keys($userFontReadyStates.get()) as TextFontId[]) {
if (isCustomTextFontId(fontId) && !activeFontIds.has(fontId)) {
deleteUserFontReadyState(fontId);
settleUserFontReadyPromise(fontId);
}
}
for (const font of fonts) {
if (areAllFontFacesLoaded(font, loadedFontFaces)) {
setUserFontReadyState(font.id, 'ready');
settleUserFontReadyPromise(font.id);
continue;
}
setUserFontReadyState(font.id, 'pending');
ensureUserFontReadyPromise(font.id);
}
};
export const isUserFontReady = (fontId: TextFontId): boolean => {
return !isCustomTextFontId(fontId) || $userFontReadyStates.get()[fontId] === 'ready';
};
export const awaitUserFontReady = async (fontId: TextFontId): Promise<UserFontReadinessResult> => {
if (!isCustomTextFontId(fontId)) {
return 'ready';
}
const state = $userFontReadyStates.get()[fontId];
if (state === 'ready' || state === 'error') {
return state;
}
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race<UserFontReadinessResult>([
ensureUserFontReadyPromise(fontId).then(() => {
const settledState = $userFontReadyStates.get()[fontId];
return settledState === 'ready' || settledState === 'error' ? settledState : 'timeout';
}),
new Promise<'timeout'>((resolve) => {
timeoutId = setTimeout(() => resolve('timeout'), USER_FONT_READY_TIMEOUT_MS);
}),
]);
} finally {
clearTimeout(timeoutId);
}
};
export const clearUserFontRegistryForTests = (): void => {
$userFontReadyStates.set({});
userFontReadyPromises.clear();
loadedUserFontFaces.clear();
userFontReadyResolvers.clear();
};
type SyncUserFontFacesArgs<TLoadedFontFace> = {
fonts: Array<UserFont>;
token: string | null;
loadedFontFaces: Map<string, TLoadedFontFace>;
baseUrl: string;
fontFaceSet: FontFaceSetLike<TLoadedFontFace>;
fontFaceCtor: FontFaceConstructorLike<TLoadedFontFace>;
fetchFn: FetchLike;
};
export async function syncUserFontFaces<TLoadedFontFace>({
fonts,
token,
loadedFontFaces,
baseUrl,
fontFaceSet,
fontFaceCtor,
fetchFn,
}: SyncUserFontFacesArgs<TLoadedFontFace>): Promise<void> {
const activeFaceKeys = new Set(fonts.flatMap((font) => font.faces.map((face) => getUserFontFaceKey(font, face))));
for (const [faceKey, fontFace] of loadedFontFaces.entries()) {
if (activeFaceKeys.has(faceKey)) {
continue;
}
fontFaceSet.delete(fontFace);
loadedFontFaces.delete(faceKey);
}
await Promise.all(
fonts.map(async (font) => {
if (areAllFontFacesLoaded(font, loadedFontFaces)) {
setUserFontReadyState(font.id, 'ready');
settleUserFontReadyPromise(font.id);
return;
}
setUserFontReadyState(font.id, 'pending');
ensureUserFontReadyPromise(font.id);
let hadFailure = false;
await Promise.all(
font.faces.map(async (face) => {
const faceKey = getUserFontFaceKey(font, face);
if (loadedFontFaces.has(faceKey)) {
return;
}
try {
const fontUrl = `${baseUrl.replace(/\/$/, '')}/${face.url.replace(/^\//, '')}`;
const response = await fetchFn(fontUrl, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!response.ok) {
hadFailure = true;
return;
}
const fontBuffer = await response.arrayBuffer();
const fontFace = new fontFaceCtor(font.family, fontBuffer, {
weight: String(face.weight),
style: face.style,
});
const loadedFontFace = await fontFace.load();
fontFaceSet.add(loadedFontFace);
loadedFontFaces.set(faceKey, loadedFontFace);
} catch {
hadFailure = true;
}
})
);
if (areAllFontFacesLoaded(font, loadedFontFaces)) {
setUserFontReadyState(font.id, 'ready');
} else if (hadFailure) {
setUserFontReadyState(font.id, 'error');
}
if ($userFontReadyStates.get()[font.id] !== 'pending') {
settleUserFontReadyPromise(font.id);
}
})
);
}
@@ -2,6 +2,26 @@ import type { paths } from 'services/api/schema';
import { api, buildV1Url } from '..';
export type UserFontFace = {
path: string;
url: string;
weight: number;
style: 'normal' | 'italic';
};
export type UserFont = {
id: string;
family: string;
label: string;
path: string;
url: string;
faces: UserFontFace[];
};
type UserFontsResponse = {
fonts: UserFont[];
};
/**
* Builds an endpoint URL for the utilities router
* @example
@@ -54,6 +74,13 @@ export const utilitiesApi = api.injectEndpoints({
// disconnected.
providesTags: ['FetchOnReconnect'],
}),
listUserFonts: build.query<UserFont[], void>({
query: () => ({
url: buildUtilitiesUrl('fonts'),
}),
transformResponse: (response: UserFontsResponse) => response.fonts,
providesTags: ['FetchOnReconnect'],
}),
expandPrompt: build.mutation<ExpandPromptResponse, ExpandPromptRequest>({
query: (arg) => ({
url: buildUtilitiesUrl('expand-prompt'),
@@ -71,4 +98,4 @@ export const utilitiesApi = api.injectEndpoints({
}),
});
export const { useExpandPromptMutation, useImageToPromptMutation } = utilitiesApi;
export const { useListUserFontsQuery, useExpandPromptMutation, useImageToPromptMutation } = utilitiesApi;
@@ -356,6 +356,40 @@ export type paths = {
patch?: never;
trace?: never;
};
"/api/v1/utilities/fonts": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List User Fonts */
get: operations["list_user_fonts"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/utilities/fonts/{font_path}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get User Font File */
get: operations["get_user_font_file"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/utilities/expand-prompt": {
parameters: {
query?: never;
@@ -19222,6 +19256,13 @@ export type components = {
* @default outputs
*/
outputs_dir?: string;
/**
* Fonts Dir
* Format: path
* @description Path to directory for custom fonts.
* @default fonts
*/
fonts_dir?: string;
/**
* Image Subfolder Strategy
* @description Strategy for organizing images into subfolders. 'flat' stores all images in a single folder. 'date' organizes by YYYY/MM/DD. 'type' organizes by image category. 'hash' uses first 2 characters of UUID for filesystem performance.
@@ -38241,6 +38282,37 @@ export type components = {
*/
token_epoch?: number;
};
/** UserFont */
UserFont: {
/** Id */
id: string;
/** Family */
family: string;
/** Label */
label: string;
/** Path */
path: string;
/** Url */
url: string;
/** Faces */
faces: components["schemas"]["UserFontFace"][];
};
/** UserFontFace */
UserFontFace: {
/** Path */
path: string;
/** Url */
url: string;
/** Weight */
weight: number;
/** Style */
style: string;
};
/** UserFontsResponse */
UserFontsResponse: {
/** Fonts */
fonts: components["schemas"]["UserFont"][];
};
/**
* UserProfileUpdateRequest
* @description Request body for a user to update their own profile.
@@ -43028,6 +43100,57 @@ export interface operations {
};
};
};
list_user_fonts: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserFontsResponse"];
};
};
};
};
get_user_font_file: {
parameters: {
query?: never;
header?: never;
path: {
font_path: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
expand_prompt: {
parameters: {
query?: never;
+1
View File
@@ -93,6 +93,7 @@ dependencies = [
"dynamicprompts",
"einops",
"email-validator>=2.0.0",
"fonttools[woff]",
"passlib[bcrypt]>=1.7.4",
"picklescan",
"pillow",
+191
View File
@@ -7,12 +7,15 @@ Covers:
- image-to-prompt: a missing image surfaces as 404, not 500.
"""
import shutil
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from fontTools.ttLib import TTFont
from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
from invokeai.app.services.invoker import Invoker
@@ -39,6 +42,12 @@ def _create_extra_user(mock_invoker: Invoker, email: str) -> str:
return user.user_id
@pytest.fixture
def font_root(mock_invoker: Invoker, invokeai_root_dir: Path) -> Path:
mock_invoker.services.configuration._root = invokeai_root_dir
return invokeai_root_dir
# ----------------------------- Auth gating -----------------------------
@@ -171,3 +180,185 @@ def test_image_to_prompt_admin_can_access_any_image(
)
# Admin passes the read-access check; model loading then fails with 404.
assert r.status_code == status.HTTP_404_NOT_FOUND
def test_list_user_fonts_requires_auth(enable_multiuser: Any, font_root: Path, client: TestClient) -> None:
fonts_dir = font_root / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
(fonts_dir / "MyFont.ttf").write_bytes(b"not-a-real-font")
r = client.get("/api/v1/utilities/fonts")
assert r.status_code == status.HTTP_401_UNAUTHORIZED
def test_get_user_font_file_requires_auth(enable_multiuser: Any, font_root: Path, client: TestClient) -> None:
fonts_dir = font_root / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
(fonts_dir / "MyFont.ttf").write_bytes(b"not-a-real-font")
r = client.get("/api/v1/utilities/fonts/MyFont.ttf")
assert r.status_code == status.HTTP_401_UNAUTHORIZED
def test_user_fonts_support_real_font_files_and_configured_directory(
admin_token: str, client: TestClient, font_root: Path, mock_invoker: Invoker
) -> None:
assert mock_invoker.services.configuration.root_path == font_root
mock_invoker.services.configuration.fonts_dir = Path("custom-fonts")
fonts_dir = mock_invoker.services.configuration.fonts_path
fonts_dir.mkdir(parents=True, exist_ok=True)
source_font = Path(__file__).parents[3] / "invokeai" / "assets" / "fonts" / "inter" / "Inter-Regular.ttf"
shutil.copyfile(source_font, fonts_dir / "Inter-Regular.ttf")
r = client.get("/api/v1/utilities/fonts", headers={"Authorization": f"Bearer {admin_token}"})
assert r.status_code == status.HTTP_200_OK
body = r.json()
assert len(body["fonts"]) == 1
assert body["fonts"][0]["family"] == "Inter"
assert body["fonts"][0]["url"] == "api/v1/utilities/fonts/Inter-Regular.ttf"
font_response = client.get(
"/api/v1/utilities/fonts/Inter-Regular.ttf",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert font_response.status_code == status.HTTP_200_OK
assert font_response.headers["content-type"] == "font/ttf"
assert font_response.headers["cache-control"] == "private, max-age=31536000, immutable"
assert font_response.headers["content-disposition"].startswith('inline; filename="Inter-Regular.ttf"')
assert font_response.content == source_font.read_bytes()
def test_list_user_fonts_reads_real_woff2_file(
admin_token: str, client: TestClient, mock_invoker: Invoker, tmp_path: Path
) -> None:
mock_invoker.services.configuration.fonts_dir = tmp_path / "fonts"
fonts_dir = mock_invoker.services.configuration.fonts_path
fonts_dir.mkdir(parents=True, exist_ok=True)
source_font = Path(__file__).parents[3] / "invokeai" / "assets" / "fonts" / "inter" / "Inter-Regular.ttf"
font = TTFont(source_font)
try:
font.flavor = "woff2"
font.save(fonts_dir / "Inter-Regular.woff2")
finally:
font.close()
r = client.get("/api/v1/utilities/fonts", headers={"Authorization": f"Bearer {admin_token}"})
assert r.status_code == status.HTTP_200_OK
body = r.json()
assert len(body["fonts"]) == 1
assert body["fonts"][0]["family"] == "Inter"
assert body["fonts"][0]["faces"][0]["path"] == "Inter-Regular.woff2"
def test_list_user_fonts_allows_authenticated_access(
admin_token: str, client: TestClient, font_root: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
fonts_dir = font_root / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
(fonts_dir / "MyFont.ttf").write_bytes(b"not-a-real-font")
monkeypatch.setattr(
"invokeai.app.api.routers.utilities._get_font_metadata",
lambda _font_file: ("My Font", "My Font", 400, "normal"),
)
r = client.get("/api/v1/utilities/fonts", headers={"Authorization": f"Bearer {admin_token}"})
assert r.status_code == status.HTTP_200_OK
body = r.json()
assert len(body["fonts"]) == 1
assert body["fonts"][0]["id"] == "user:my font"
def test_list_user_fonts_id_is_stable_when_preferred_face_changes(
admin_token: str,
client: TestClient,
mock_invoker: Invoker,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
mock_invoker.services.configuration.fonts_dir = tmp_path / "fonts"
fonts_dir = mock_invoker.services.configuration.fonts_path
fonts_dir.mkdir(parents=True, exist_ok=True)
(fonts_dir / "MyFont-Bold.ttf").write_bytes(b"not-a-real-font")
def get_metadata(font_file: Path) -> tuple[str, str, int, str]:
weight = 400 if "Regular" in font_file.stem else 700
return ("My Font", "My Font", weight, "normal")
monkeypatch.setattr("invokeai.app.api.routers.utilities._get_font_metadata", get_metadata)
first_response = client.get("/api/v1/utilities/fonts", headers={"Authorization": f"Bearer {admin_token}"})
first_font = first_response.json()["fonts"][0]
assert first_font["id"] == "user:my font"
assert first_font["path"] == "MyFont-Bold.ttf"
(fonts_dir / "MyFont-Regular.ttf").write_bytes(b"not-a-real-font")
second_response = client.get("/api/v1/utilities/fonts", headers={"Authorization": f"Bearer {admin_token}"})
second_font = second_response.json()["fonts"][0]
assert second_font["id"] == first_font["id"]
assert second_font["path"] == "MyFont-Regular.ttf"
def test_list_user_fonts_skips_malformed_fonts_and_logs_warning(
admin_token: str,
client: TestClient,
font_root: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
fonts_dir = font_root / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
(fonts_dir / "BrokenFont.ttf").write_bytes(b"not-a-real-font")
with caplog.at_level("WARNING"):
r = client.get("/api/v1/utilities/fonts", headers={"Authorization": f"Bearer {admin_token}"})
assert r.status_code == status.HTTP_200_OK
assert r.json()["fonts"] == []
assert "Skipping font file" in caplog.text
def test_get_user_font_file_rejects_symlink(
admin_token: str, client: TestClient, font_root: Path, tmp_path: Path
) -> None:
fonts_dir = font_root / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
outside_file = tmp_path / "outside.ttf"
outside_file.write_bytes(b"outside-font")
symlink_path = fonts_dir / "linked.ttf"
try:
symlink_path.symlink_to(outside_file)
except (NotImplementedError, OSError):
pytest.skip("Symlinks are not available in this test environment")
r = client.get("/api/v1/utilities/fonts/linked.ttf", headers={"Authorization": f"Bearer {admin_token}"})
assert r.status_code == status.HTTP_400_BAD_REQUEST
def test_list_user_fonts_skips_symlinked_files(
admin_token: str, client: TestClient, font_root: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
fonts_dir = font_root / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
outside_dir = tmp_path / "outside-fonts"
outside_dir.mkdir()
(outside_dir / "outside.ttf").write_bytes(b"outside-font")
symlink_path = fonts_dir / "linked-dir"
try:
symlink_path.symlink_to(outside_dir, target_is_directory=True)
except (NotImplementedError, OSError):
pytest.skip("Symlinks are not available in this test environment")
with caplog.at_level("WARNING"):
r = client.get("/api/v1/utilities/fonts", headers={"Authorization": f"Bearer {admin_token}"})
assert r.status_code == status.HTTP_200_OK
assert r.json()["fonts"] == []
assert "Skipping font path" in caplog.text
+30 -1
View File
@@ -2,7 +2,8 @@ from fastapi import FastAPI
from pydantic import create_model
from invokeai.app.invocations.baseinvocation import InvocationRegistry
from invokeai.app.util.custom_openapi import get_openapi_func
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.util.custom_openapi import get_openapi_func, normalize_path_defaults
class _FakeOutput:
@@ -59,3 +60,31 @@ def test_invocation_output_map_required_is_sorted(monkeypatch: object) -> None:
required = schema["components"]["schemas"]["InvocationOutputMap"]["required"]
assert required == ["a_type", "b_type"], f"Expected sorted required list, got: {required}"
def test_path_defaults_are_normalized_to_forward_slashes() -> None:
schema = InvokeAIAppConfig.model_json_schema()
schema["properties"]["convert_cache_dir"]["default"] = "models\\.convert_cache"
schema["properties"]["download_cache_dir"]["default"] = "models\\.download_cache"
schema["properties"]["nested_path"] = {
"oneOf": [
{
"type": "object",
"properties": {
"cache_dir": {
"type": "string",
"format": "path",
"default": "models\\.nested_cache",
}
},
}
]
}
normalize_path_defaults(schema)
assert schema["properties"]["convert_cache_dir"]["default"] == "models/.convert_cache"
assert schema["properties"]["download_cache_dir"]["default"] == "models/.download_cache"
assert (
schema["properties"]["nested_path"]["oneOf"][0]["properties"]["cache_dir"]["default"] == "models/.nested_cache"
)
+22
View File
@@ -9,6 +9,7 @@ from invokeai.app.invocations.baseinvocation import InvocationRegistry
from invokeai.app.services.config.config_default import (
DefaultInvokeAIAppConfig,
InvokeAIAppConfig,
ensure_fonts_dir,
get_config,
load_and_migrate_config,
)
@@ -287,6 +288,8 @@ def test_get_config_writing(patch_rootdir: None, monkeypatch: pytest.MonkeyPatch
assert config.config_file_path == config_file_path
assert config_file_path.exists()
assert example_file_path.exists()
assert (tmp_path / "fonts").exists()
assert (tmp_path / "fonts" / "README.txt").exists()
# The example file should have the default values
example_file_content = example_file_path.read_text()
@@ -304,6 +307,25 @@ def test_get_config_writing(patch_rootdir: None, monkeypatch: pytest.MonkeyPatch
InvokeAIArgs.did_parse = False
def test_ensure_fonts_dir_logs_warning_on_oserror(
patch_rootdir: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
):
original_mkdir = Path.mkdir
fonts_path = tmp_path / "fonts"
def mock_mkdir(self: Path, *args: Any, **kwargs: Any) -> None:
if self == fonts_path:
raise OSError("read-only")
original_mkdir(self, *args, **kwargs)
monkeypatch.setattr(Path, "mkdir", mock_mkdir)
with caplog.at_level("WARNING"):
ensure_fonts_dir(fonts_path)
assert "Unable to initialize fonts directory" in caplog.text
def test_get_config_reads_external_api_keys_file(patch_rootdir: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
"""Test that API keys are loaded from the dedicated api_keys.yaml file."""
InvokeAIArgs.did_parse = True
Generated
+73
View File
@@ -245,6 +245,50 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/c3/e8effb323af40a5d8e85ab557da2f0475f12a31f3266f28d91d0bf406963/blessed-1.44.0-py3-none-any.whl", hash = "sha256:e1d2ed93d3d90d0a1494a8b134d188c83fb89ae25590af638b921e8c1c8fa223", size = 130232, upload-time = "2026-05-24T02:06:26.458Z" },
]
[[package]]
name = "brotli"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" },
{ url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" },
{ url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" },
{ url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" },
{ url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" },
{ url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" },
{ url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" },
{ url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" },
{ url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" },
{ url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" },
{ url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" },
{ url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" },
{ url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" },
{ url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" },
]
[[package]]
name = "brotlicffi"
version = "1.2.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/97/7845739a36828ffe751a1c6b240692f552fd7ecf65026c51326c0a4aa369/brotlicffi-1.2.0.2.tar.gz", hash = "sha256:5e0fbd13644cf1f6015e75fa5e0ad8fdce1048d9c9ff90b0ce826174b249ee35", size = 478755, upload-time = "2026-08-21T17:29:18.415Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/71/c27f24b8334f65f2492601c7764338f156cb904d2ffe0061e6004a76d9cc/brotlicffi-1.2.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d5a8ffa154f16660ab818d78045b55fa6f9970f1ca4c38998766e99c672071cb", size = 438885, upload-time = "2026-08-21T17:29:04.113Z" },
{ url = "https://files.pythonhosted.org/packages/ef/22/d8fd1a4d09b7ab563b89380395e09151d2ef1344be31594df6a6987d4028/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec6b1af7b7a8ce788354f2c603651ada0fba166ec31ab879e2eec462a3e6dbf4", size = 1534365, upload-time = "2026-08-21T17:29:05.878Z" },
{ url = "https://files.pythonhosted.org/packages/06/78/076419ed6c2c6aa3eaac6fd6b076502b4be89d50625fcdc513cd4aeca718/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22916101de0e7ff535f2edf54b52a85591853b8ae9a98737643defdd3c063a3a", size = 1536851, upload-time = "2026-08-21T17:29:07.599Z" },
{ url = "https://files.pythonhosted.org/packages/35/dd/31ae9945cbd605339fb51c9a609f7dbb182cd361adeabc1d470142357206/brotlicffi-1.2.0.2-cp39-abi3-win32.whl", hash = "sha256:df1d34c4ad9adbf7f63a6b42f7d0e4dfd259c88141b85145b57abecc1abc3b24", size = 342379, upload-time = "2026-08-21T17:29:09.05Z" },
{ url = "https://files.pythonhosted.org/packages/95/ae/afd54e744df93b51cc29f6a19beccf9998b25743d7177697390de10479d1/brotlicffi-1.2.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:489ca4da3ee65926d72bf01584b61088a9da6bdd1bb01b2040901e1beaffa8f0", size = 379761, upload-time = "2026-08-21T17:29:10.687Z" },
{ url = "https://files.pythonhosted.org/packages/37/da/a5b65a86725d772504a348193cf1fab5ad6410794b422bf81faa17a96a66/brotlicffi-1.2.0.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:cf500bb9e02e1474ced1ecf22f74c568de2816b3627af6352ec51ac5e09e60ee", size = 407459, upload-time = "2026-08-21T17:29:12.385Z" },
{ url = "https://files.pythonhosted.org/packages/e1/c7/a253288e66ee340f2f6320eda7022daa723f2918438d586a59e9c998aa27/brotlicffi-1.2.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbb81489562dd5363bf86d9a8edb0ec8c97049b0819ba4936fc023e8847248bc", size = 406825, upload-time = "2026-08-21T17:29:13.992Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6c/ea8e3d34e1d64c5e5a920bb0c89bf9e92badf973937a60922820395e622d/brotlicffi-1.2.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc7647657e4f3d73eab591910dbecb57d1ecaea7aa3dd04e6d704a2756fe0c59", size = 402903, upload-time = "2026-08-21T17:29:15.524Z" },
{ url = "https://files.pythonhosted.org/packages/4e/17/17c22d48819001ca08cadab63b09b00e0c56a7579478aa7c2623f4280de6/brotlicffi-1.2.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5eb5563173afb92c9111b180349ff17d7c83c79febabadca5de983b552565c3c", size = 378395, upload-time = "2026-08-21T17:29:16.857Z" },
]
[[package]]
name = "build"
version = "1.5.0"
@@ -725,6 +769,13 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" },
]
[package.optional-dependencies]
woff = [
{ name = "brotli", marker = "platform_python_implementation == 'CPython' or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-xpu') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-xpu') or (extra == 'extra-8-invokeai-rocm' and extra == 'extra-8-invokeai-xpu')" },
{ name = "brotlicffi", marker = "platform_python_implementation != 'CPython' or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-xpu') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-xpu') or (extra == 'extra-8-invokeai-rocm' and extra == 'extra-8-invokeai-xpu')" },
{ name = "zopfli" },
]
[[package]]
name = "fsspec"
version = "2026.6.0"
@@ -1062,6 +1113,7 @@ dependencies = [
{ name = "email-validator" },
{ name = "fastapi" },
{ name = "fastapi-events" },
{ name = "fonttools", extra = ["woff"] },
{ name = "gguf" },
{ name = "huggingface-hub" },
{ name = "imageio", extra = ["ffmpeg"] },
@@ -1185,6 +1237,7 @@ requires-dist = [
{ name = "email-validator", specifier = ">=2.0.0" },
{ name = "fastapi", specifier = ">=0.141.1,<0.142" },
{ name = "fastapi-events" },
{ name = "fonttools", extras = ["woff"] },
{ name = "gguf" },
{ name = "gprof2dot", marker = "extra == 'dev'" },
{ name = "httpx", marker = "extra == 'test'" },
@@ -4317,3 +4370,23 @@ sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" },
]
[[package]]
name = "zopfli"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156, upload-time = "2026-06-10T09:10:19.96Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492, upload-time = "2026-06-10T09:10:06.621Z" },
{ url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354, upload-time = "2026-06-10T09:10:07.909Z" },
{ url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436, upload-time = "2026-06-10T09:10:09.063Z" },
{ url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931, upload-time = "2026-06-10T09:10:10.282Z" },
{ url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132, upload-time = "2026-06-10T09:10:11.595Z" },
{ url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715, upload-time = "2026-06-10T09:10:12.7Z" },
{ url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" },
{ url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" },
{ url = "https://files.pythonhosted.org/packages/6f/bf/403da5a753731d9a4e4d65a494c4a9ae5a0fe62e7afffe5ab49915adc9a3/zopfli-0.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d", size = 147045, upload-time = "2026-06-10T09:10:15.883Z" },
{ url = "https://files.pythonhosted.org/packages/bd/5f/afaa18db62ab44da01a3fc39b6cb110478d26cd2287baa83461c6454ed45/zopfli-0.4.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e", size = 127265, upload-time = "2026-06-10T09:10:16.911Z" },
{ url = "https://files.pythonhosted.org/packages/04/3a/76bdfd8b35300391666b090357d059ce4c555b9d9ce9878dd551a9ad63a0/zopfli-0.4.3-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94", size = 124288, upload-time = "2026-06-10T09:10:17.891Z" },
{ url = "https://files.pythonhosted.org/packages/c5/95/5781bfb29782c39918686070dbf2ad1425c21384c3223f5c6bd911a806f8/zopfli-0.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3", size = 304624, upload-time = "2026-06-10T09:10:18.944Z" },
]