初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
from .store import (
|
||||
ACCEPTED_FONT_EXTENSIONS,
|
||||
MAX_FONT_UPLOAD_BYTES,
|
||||
FontCatalog,
|
||||
FontCatalogError,
|
||||
FontStorageFullError,
|
||||
FontTooLargeError,
|
||||
FontTypeError,
|
||||
FontValidationError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ACCEPTED_FONT_EXTENSIONS",
|
||||
"MAX_FONT_UPLOAD_BYTES",
|
||||
"FontCatalog",
|
||||
"FontCatalogError",
|
||||
"FontStorageFullError",
|
||||
"FontTooLargeError",
|
||||
"FontTypeError",
|
||||
"FontValidationError",
|
||||
]
|
||||
@@ -0,0 +1,341 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterable, Iterable
|
||||
from uuid import uuid4
|
||||
|
||||
from fontTools.ttLib import TTCollection, TTFont, TTLibError
|
||||
|
||||
from app.display.text_renderer import DEFAULT_FONT_CANDIDATES, FontFace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACCEPTED_FONT_EXTENSIONS = (".ttf", ".otf", ".ttc", ".otc")
|
||||
MAX_FONT_UPLOAD_BYTES = 32 * 1024 * 1024
|
||||
_FONTCONFIG_TIMEOUT_SECONDS = 8
|
||||
_USER_FONT_NAME = "{digest}.font"
|
||||
|
||||
|
||||
class FontCatalogError(RuntimeError):
|
||||
"""Base error for font catalog operations."""
|
||||
|
||||
|
||||
class FontTooLargeError(FontCatalogError):
|
||||
pass
|
||||
|
||||
|
||||
class FontTypeError(FontCatalogError):
|
||||
pass
|
||||
|
||||
|
||||
class FontValidationError(FontCatalogError):
|
||||
pass
|
||||
|
||||
|
||||
class FontStorageFullError(FontCatalogError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DiscoveredFace:
|
||||
face: FontFace
|
||||
family: str
|
||||
style: str
|
||||
|
||||
|
||||
def _friendly_name(font: TTFont, fallback: str) -> tuple[str, str]:
|
||||
names = font.get("name")
|
||||
if names is None:
|
||||
return fallback, "Regular"
|
||||
family = names.getBestFamilyName() or names.getDebugName(1) or fallback
|
||||
style = names.getBestSubFamilyName() or names.getDebugName(2) or "Regular"
|
||||
return str(family).strip() or fallback, str(style).strip() or "Regular"
|
||||
|
||||
|
||||
def _has_unicode_cmap(font: TTFont) -> bool:
|
||||
cmap = font.get("cmap")
|
||||
return bool(cmap and any(table.isUnicode() and table.cmap for table in cmap.tables))
|
||||
|
||||
|
||||
def _inspect_font_file(path: Path, *, require_unicode: bool) -> tuple[_DiscoveredFace, ...]:
|
||||
path = Path(path)
|
||||
fallback = path.stem or "Unnamed font"
|
||||
collection: TTCollection | None = None
|
||||
fonts: list[TTFont] = []
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
try:
|
||||
collection = TTCollection(BytesIO(content), lazy=False)
|
||||
fonts = list(collection.fonts)
|
||||
except TTLibError:
|
||||
fonts = [TTFont(BytesIO(content), lazy=False)]
|
||||
discovered: list[_DiscoveredFace] = []
|
||||
for index, font in enumerate(fonts):
|
||||
if require_unicode and not _has_unicode_cmap(font):
|
||||
continue
|
||||
family, style = _friendly_name(font, fallback)
|
||||
discovered.append(
|
||||
_DiscoveredFace(FontFace(str(path.resolve()), index), family, style)
|
||||
)
|
||||
if not discovered:
|
||||
raise FontValidationError("font file does not contain a usable Unicode font face")
|
||||
return tuple(discovered)
|
||||
except FontValidationError:
|
||||
raise
|
||||
except (OSError, TTLibError, KeyError, ValueError) as exc:
|
||||
raise FontValidationError("font file is invalid or unsupported") from exc
|
||||
finally:
|
||||
if collection is not None:
|
||||
collection.close()
|
||||
else:
|
||||
for font in fonts:
|
||||
font.close()
|
||||
|
||||
|
||||
def _system_font_id(face: FontFace) -> str:
|
||||
raw = f"{Path(face.path).resolve()}\0{face.index}".encode("utf-8")
|
||||
return f"system:{hashlib.sha256(raw).hexdigest()}"
|
||||
|
||||
|
||||
def _user_font_id(digest: str, index: int) -> str:
|
||||
return f"user:{digest}:{index}"
|
||||
|
||||
|
||||
def _entry(identifier: str, family: str, style: str, source: str) -> dict[str, str]:
|
||||
family = family.strip() or "未命名字体"
|
||||
style = style.strip() or "Regular"
|
||||
label = family if style.casefold() in {"regular", "normal", "book"} else f"{family} — {style}"
|
||||
return {
|
||||
"id": identifier,
|
||||
"label": label,
|
||||
"family": family,
|
||||
"style": style,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
def _fontconfig_faces() -> tuple[_DiscoveredFace, ...]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["fc-list", "--format=%{file}\t%{index}\t%{family[0]}\t%{style[0]}\n"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=_FONTCONFIG_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (FileNotFoundError, OSError, subprocess.SubprocessError):
|
||||
return ()
|
||||
|
||||
discovered: list[_DiscoveredFace] = []
|
||||
for line in result.stdout.splitlines():
|
||||
fields = line.split("\t", 3)
|
||||
if len(fields) != 4:
|
||||
continue
|
||||
raw_path, raw_index, family, style = fields
|
||||
path = Path(raw_path.strip())
|
||||
if path.suffix.casefold() not in ACCEPTED_FONT_EXTENSIONS or not path.is_file():
|
||||
continue
|
||||
try:
|
||||
index = int(raw_index.strip() or "0")
|
||||
except ValueError:
|
||||
continue
|
||||
discovered.append(
|
||||
_DiscoveredFace(
|
||||
FontFace(str(path.resolve()), index),
|
||||
family.strip() or path.stem,
|
||||
style.strip() or "Regular",
|
||||
)
|
||||
)
|
||||
return tuple(discovered)
|
||||
|
||||
|
||||
def _configured_paths() -> Iterable[Path]:
|
||||
raw = os.environ.get("MATRIX_FONT_PATHS", "")
|
||||
for value in raw.split(os.pathsep):
|
||||
if value.strip():
|
||||
yield Path(value.strip()).expanduser()
|
||||
|
||||
|
||||
def _sync_directory(path: Path) -> None:
|
||||
if os.name == "nt":
|
||||
return
|
||||
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
class FontCatalog:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.root = Path(data_dir) / "fonts"
|
||||
self._lock = threading.RLock()
|
||||
self._items: tuple[dict[str, str], ...] = ()
|
||||
self._faces: dict[str, tuple[FontFace, ...]] = {}
|
||||
self._warnings: tuple[str, ...] = ()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
if os.name != "nt":
|
||||
self.root.chmod(0o750)
|
||||
self._cleanup_upload_temporaries()
|
||||
self._system_catalog = self._system_faces()
|
||||
self.refresh()
|
||||
|
||||
def _cleanup_upload_temporaries(self) -> None:
|
||||
for path in self.root.glob(".upload-*.tmp"):
|
||||
if path.is_file() and path.parent == self.root:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def _system_faces(self) -> tuple[_DiscoveredFace, ...]:
|
||||
discovered = list(_fontconfig_faces())
|
||||
known = {(str(Path(item.face.path).resolve()), item.face.index) for item in discovered}
|
||||
known_paths = {path for path, _index in known}
|
||||
for candidate in (*_configured_paths(), *(Path(value) for value in DEFAULT_FONT_CANDIDATES)):
|
||||
if candidate.suffix.casefold() not in ACCEPTED_FONT_EXTENSIONS or not candidate.is_file():
|
||||
continue
|
||||
resolved_candidate = str(candidate.resolve())
|
||||
if resolved_candidate in known_paths:
|
||||
continue
|
||||
try:
|
||||
inspected = _inspect_font_file(candidate, require_unicode=True)
|
||||
except FontValidationError:
|
||||
continue
|
||||
for item in inspected:
|
||||
key = (str(Path(item.face.path).resolve()), item.face.index)
|
||||
if key not in known:
|
||||
known.add(key)
|
||||
known_paths.add(key[0])
|
||||
discovered.append(item)
|
||||
return tuple(discovered)
|
||||
|
||||
def refresh(self) -> None:
|
||||
faces: dict[str, tuple[FontFace, ...]] = {}
|
||||
system_items: list[dict[str, str]] = []
|
||||
imported_items: list[dict[str, str]] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
seen_system: set[tuple[str, int]] = set()
|
||||
for item in self._system_catalog:
|
||||
key = (str(Path(item.face.path).resolve()), item.face.index)
|
||||
if key in seen_system:
|
||||
continue
|
||||
seen_system.add(key)
|
||||
identifier = _system_font_id(item.face)
|
||||
faces[identifier] = (item.face,)
|
||||
system_items.append(_entry(identifier, item.family, item.style, "system"))
|
||||
|
||||
for path in sorted(self.root.glob("*.font"), key=lambda item: item.name):
|
||||
digest = path.stem.casefold()
|
||||
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
|
||||
warnings.append(f"已忽略未登记字体文件:{path.name}")
|
||||
continue
|
||||
try:
|
||||
inspected = _inspect_font_file(path, require_unicode=True)
|
||||
except FontValidationError:
|
||||
warnings.append(f"已保留但忽略损坏字体:{path.name}")
|
||||
logger.warning("Ignoring invalid persisted font %s", path)
|
||||
continue
|
||||
for item in inspected:
|
||||
identifier = _user_font_id(digest, item.face.index)
|
||||
faces[identifier] = (item.face,)
|
||||
imported_items.append(_entry(identifier, item.family, item.style, "imported"))
|
||||
|
||||
sort_key = lambda value: (value["family"].casefold(), value["style"].casefold(), value["id"])
|
||||
automatic = {
|
||||
"id": "default",
|
||||
"label": "自动多语言字体",
|
||||
"family": "自动多语言字体",
|
||||
"style": "自动回退",
|
||||
"source": "automatic",
|
||||
}
|
||||
with self._lock:
|
||||
self._items = (
|
||||
automatic,
|
||||
*sorted(imported_items, key=sort_key),
|
||||
*sorted(system_items, key=sort_key),
|
||||
)
|
||||
self._faces = faces
|
||||
self._warnings = tuple(warnings)
|
||||
|
||||
def document(self) -> dict:
|
||||
with self._lock:
|
||||
return {
|
||||
"items": [dict(item) for item in self._items],
|
||||
"accepted_extensions": list(ACCEPTED_FONT_EXTENSIONS),
|
||||
"max_upload_bytes": MAX_FONT_UPLOAD_BYTES,
|
||||
"warnings": list(self._warnings),
|
||||
}
|
||||
|
||||
def resolve(self, identifier: str) -> tuple[FontFace, ...]:
|
||||
with self._lock:
|
||||
return self._faces.get(identifier, ())
|
||||
|
||||
async def import_font(
|
||||
self,
|
||||
filename: str,
|
||||
chunks: AsyncIterable[bytes],
|
||||
) -> tuple[bool, list[dict[str, str]]]:
|
||||
if (
|
||||
not filename
|
||||
or len(filename) > 255
|
||||
or "\0" in filename
|
||||
or "/" in filename
|
||||
or "\\" in filename
|
||||
):
|
||||
raise FontTypeError("font filename is missing or invalid")
|
||||
if Path(filename).suffix.casefold() not in ACCEPTED_FONT_EXTENSIONS:
|
||||
raise FontTypeError("font type must be TTF, OTF, TTC, or OTC")
|
||||
|
||||
temporary = self.root / f".upload-{uuid4().hex}.tmp"
|
||||
digest = hashlib.sha256()
|
||||
total = 0
|
||||
try:
|
||||
with temporary.open("xb") as handle:
|
||||
if os.name != "nt":
|
||||
os.chmod(temporary, 0o640)
|
||||
async for chunk in chunks:
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > MAX_FONT_UPLOAD_BYTES:
|
||||
raise FontTooLargeError("font file exceeds the 32 MiB limit")
|
||||
digest.update(chunk)
|
||||
handle.write(chunk)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if total == 0:
|
||||
raise FontValidationError("font file is empty")
|
||||
inspected = _inspect_font_file(temporary, require_unicode=True)
|
||||
file_digest = digest.hexdigest()
|
||||
target = self.root / _USER_FONT_NAME.format(digest=file_digest)
|
||||
with self._lock:
|
||||
created = not target.exists()
|
||||
if created:
|
||||
os.replace(temporary, target)
|
||||
_sync_directory(self.root)
|
||||
else:
|
||||
temporary.unlink(missing_ok=True)
|
||||
self.refresh()
|
||||
identifiers = {_user_font_id(file_digest, item.face.index) for item in inspected}
|
||||
imported = [dict(item) for item in self._items if item["id"] in identifiers]
|
||||
return created, imported
|
||||
except FontCatalogError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
except OSError as exc:
|
||||
temporary.unlink(missing_ok=True)
|
||||
if exc.errno in {errno.ENOSPC, errno.EDQUOT}:
|
||||
raise FontStorageFullError("not enough disk space to import font") from exc
|
||||
raise FontCatalogError(str(exc)) from exc
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
Reference in New Issue
Block a user