mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-10-12 02:16:43 +00:00
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:
co-authored by
Alexander Eichhorn
Josh Corbett
Lincoln Stein
parent
61b9ebae9b
commit
ef832d1aa5
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+18
-7
@@ -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;
|
||||
|
||||
+222
-8
@@ -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>
|
||||
);
|
||||
|
||||
+33
-1
@@ -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;
|
||||
|
||||
@@ -93,6 +93,7 @@ dependencies = [
|
||||
"dynamicprompts",
|
||||
"einops",
|
||||
"email-validator>=2.0.0",
|
||||
"fonttools[woff]",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"picklescan",
|
||||
"pillow",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user