初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unicodedata
|
||||
from functools import lru_cache
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
from PIL import Image, ImageDraw, ImageFont, features
|
||||
|
||||
from .colors import RGB
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextOptions:
|
||||
text: str
|
||||
font: str = "default"
|
||||
size: int = 12
|
||||
x: int = 0
|
||||
y: int = 0
|
||||
align: str = "left"
|
||||
color: RGB = (255, 255, 255)
|
||||
background: RGB = (0, 0, 0)
|
||||
|
||||
|
||||
DEFAULT_FONT_CANDIDATES = (
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansBengali-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansTamil-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansTelugu-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
||||
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
|
||||
"/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
|
||||
"C:/Windows/Fonts/arial.ttf",
|
||||
"C:/Windows/Fonts/segoeui.ttf",
|
||||
"C:/Windows/Fonts/msyh.ttc",
|
||||
"C:/Windows/Fonts/msjh.ttc",
|
||||
"C:/Windows/Fonts/YuGothR.ttc",
|
||||
"C:/Windows/Fonts/malgun.ttf",
|
||||
"C:/Windows/Fonts/LeelUIsl.ttf",
|
||||
)
|
||||
|
||||
FONTCONFIG_LANGUAGES = (
|
||||
"en", "ru", "el", "zh-cn", "ja", "ko", "ar", "he", "hi",
|
||||
"bn", "ta", "te", "th",
|
||||
)
|
||||
|
||||
PREFERRED_FONT_CANDIDATES: dict[str, tuple[tuple[str, int], ...]] = {
|
||||
"en": (("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
|
||||
"ru": (("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
|
||||
"el": (("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
|
||||
"zh-cn": (("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2), ("C:/Windows/Fonts/msyh.ttc", 0)),
|
||||
"ja": (("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 0), ("C:/Windows/Fonts/YuGothR.ttc", 0)),
|
||||
"ko": (("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 1), ("C:/Windows/Fonts/malgun.ttf", 0)),
|
||||
"ar": (("/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
|
||||
"he": (("/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
|
||||
"hi": (("/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf", 0),),
|
||||
"bn": (("/usr/share/fonts/truetype/noto/NotoSansBengali-Regular.ttf", 0),),
|
||||
"ta": (("/usr/share/fonts/truetype/noto/NotoSansTamil-Regular.ttf", 0),),
|
||||
"te": (("/usr/share/fonts/truetype/noto/NotoSansTelugu-Regular.ttf", 0),),
|
||||
"th": (("/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", 0), ("C:/Windows/Fonts/LeelUIsl.ttf", 0)),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FontFace:
|
||||
path: str
|
||||
index: int = 0
|
||||
|
||||
|
||||
FontResolver = Callable[[str], tuple[FontFace, ...]]
|
||||
|
||||
|
||||
def render_text_layer(
|
||||
options: TextOptions,
|
||||
size: tuple[int, int] = (64, 64),
|
||||
*,
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> Image.Image:
|
||||
if options.size < 1 or options.size > 64:
|
||||
raise ValueError("text size must be in 1..64")
|
||||
if options.align not in {"left", "center", "right"}:
|
||||
raise ValueError("align must be left, center, or right")
|
||||
|
||||
image = Image.new("RGBA", size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = load_font(options.font, options.size, options.text, font_resolver=font_resolver)
|
||||
|
||||
x = int(options.x)
|
||||
y = int(options.y)
|
||||
bbox = draw.textbbox((0, 0), options.text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
if options.align == "center":
|
||||
x = x - text_width // 2
|
||||
elif options.align == "right":
|
||||
x = x - text_width
|
||||
|
||||
draw.text((x, y), options.text, fill=(*options.color, 255), font=font)
|
||||
return image
|
||||
|
||||
|
||||
def render_text(
|
||||
options: TextOptions,
|
||||
size: tuple[int, int] = (64, 64),
|
||||
*,
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> Image.Image:
|
||||
background = Image.new("RGBA", size, (*options.background, 255))
|
||||
layer = render_text_layer(options, size=size, font_resolver=font_resolver)
|
||||
return Image.alpha_composite(background, layer).convert("RGB")
|
||||
|
||||
|
||||
def _required_codepoints(text: str) -> frozenset[int]:
|
||||
return frozenset(
|
||||
ord(character)
|
||||
for character in text
|
||||
if not character.isspace()
|
||||
and unicodedata.category(character) not in {"Cc", "Cf"}
|
||||
and not 0xFE00 <= ord(character) <= 0xFE0F
|
||||
and not 0xE0100 <= ord(character) <= 0xE01EF
|
||||
)
|
||||
|
||||
|
||||
def _contains_default_emoji(codepoints: frozenset[int]) -> bool:
|
||||
# Default automatic text intentionally stays deterministic across Debian
|
||||
# images. Some DejaVu releases contain a few monochrome emoji glyphs while
|
||||
# others do not, so accepting them based on the host cmap makes the API
|
||||
# change after an OS/font update. Explicitly imported fonts remain eligible.
|
||||
return any(0x1F000 <= codepoint <= 0x1FAFF for codepoint in codepoints)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _font_coverage(face: FontFace) -> frozenset[int]:
|
||||
try:
|
||||
# Fontconfig encodes variable-font named instances above the base
|
||||
# collection index. FontTools needs the base face while Pillow uses the
|
||||
# complete FreeType face index.
|
||||
with TTFont(face.path, fontNumber=face.index & 0xFFFF, lazy=True) as font:
|
||||
cmap = font.getBestCmap() or {}
|
||||
return frozenset(cmap)
|
||||
except Exception:
|
||||
return frozenset()
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _fontconfig_face(language: str) -> FontFace | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["fc-match", "--format=%{file}\n%{index}\n", f":lang={language}"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=2,
|
||||
)
|
||||
lines = result.stdout.splitlines()
|
||||
if not lines:
|
||||
return None
|
||||
path = lines[0].strip()
|
||||
index = int(lines[1].strip() or "0") if len(lines) > 1 else 0
|
||||
if path and Path(path).is_file():
|
||||
return FontFace(path, index)
|
||||
except (FileNotFoundError, OSError, subprocess.SubprocessError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fontconfig_faces() -> list[FontFace]:
|
||||
return [face for language in FONTCONFIG_LANGUAGES if (face := _fontconfig_face(language))]
|
||||
|
||||
|
||||
def _preferred_font_face(language: str) -> FontFace | None:
|
||||
for candidate, index in PREFERRED_FONT_CANDIDATES.get(language, ()):
|
||||
path = Path(candidate)
|
||||
if path.is_file():
|
||||
return FontFace(str(path.resolve()), index)
|
||||
face = _fontconfig_face(language)
|
||||
if face and Path(face.path).name == "NotoSansCJK-Regular.ttc":
|
||||
indexes = {"ja": 0, "ko": 1, "zh-cn": 2}
|
||||
return FontFace(face.path, indexes.get(language, face.index))
|
||||
return face
|
||||
|
||||
|
||||
def _preferred_font_language(text: str) -> str | None:
|
||||
codepoints = _required_codepoints(text)
|
||||
script_ranges = (
|
||||
("ko", ((0x1100, 0x11FF), (0x3130, 0x318F), (0xAC00, 0xD7AF))),
|
||||
("ja", ((0x3040, 0x30FF), (0x31F0, 0x31FF))),
|
||||
("ar", ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF))),
|
||||
("he", ((0x0590, 0x05FF),)),
|
||||
("hi", ((0x0900, 0x097F),)),
|
||||
("bn", ((0x0980, 0x09FF),)),
|
||||
("ta", ((0x0B80, 0x0BFF),)),
|
||||
("te", ((0x0C00, 0x0C7F),)),
|
||||
("th", ((0x0E00, 0x0E7F),)),
|
||||
("el", ((0x0370, 0x03FF),)),
|
||||
("ru", ((0x0400, 0x052F),)),
|
||||
("zh-cn", ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF))),
|
||||
)
|
||||
for language, ranges in script_ranges:
|
||||
if any(start <= codepoint <= end for codepoint in codepoints for start, end in ranges):
|
||||
return language
|
||||
return "en" if codepoints else None
|
||||
|
||||
|
||||
def _configured_faces() -> list[FontFace]:
|
||||
raw = os.environ.get("MATRIX_FONT_PATHS", "")
|
||||
return [
|
||||
FontFace(str(Path(value).expanduser()))
|
||||
for value in raw.split(os.pathsep)
|
||||
if value.strip() and Path(value.strip()).expanduser().is_file()
|
||||
]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def fallback_font_faces() -> tuple[FontFace, ...]:
|
||||
faces = _configured_faces() + _fontconfig_faces()
|
||||
faces.extend(
|
||||
FontFace(str(path))
|
||||
for candidate in DEFAULT_FONT_CANDIDATES
|
||||
if (path := Path(candidate)).is_file()
|
||||
)
|
||||
unique: list[FontFace] = []
|
||||
seen: set[tuple[str, int]] = set()
|
||||
for face in faces:
|
||||
key = (str(Path(face.path).resolve()), face.index)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(FontFace(key[0], face.index))
|
||||
return tuple(unique)
|
||||
|
||||
|
||||
def _custom_font_faces(font: str) -> tuple[FontFace, ...]:
|
||||
if not font or font == "default":
|
||||
return ()
|
||||
path = Path(font).expanduser()
|
||||
if not path.is_file():
|
||||
return ()
|
||||
# Existing requests do not carry a TTC index. Trying the indexes returned by
|
||||
# Fontconfig first retains useful custom TTC behavior without changing the API.
|
||||
indexes = [face.index for face in fallback_font_faces() if Path(face.path) == path.resolve()]
|
||||
indexes.append(0)
|
||||
return tuple(FontFace(str(path.resolve()), index) for index in dict.fromkeys(indexes))
|
||||
|
||||
|
||||
def resolve_font_face(
|
||||
font: str,
|
||||
text: str,
|
||||
*,
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> FontFace | None:
|
||||
required = _required_codepoints(text)
|
||||
if font == "default" and _contains_default_emoji(required):
|
||||
return None
|
||||
language = _preferred_font_language(text)
|
||||
preferred = _preferred_font_face(language) if language else None
|
||||
preferred_faces = (preferred,) if preferred is not None else ()
|
||||
resolved_faces = font_resolver(font) if font_resolver and font != "default" else ()
|
||||
primary_candidates = resolved_faces + _custom_font_faces(font) + preferred_faces
|
||||
if not required and primary_candidates:
|
||||
return primary_candidates[0]
|
||||
for face in primary_candidates:
|
||||
if required <= _font_coverage(face):
|
||||
return face
|
||||
candidates = primary_candidates + fallback_font_faces()
|
||||
if not candidates:
|
||||
return None
|
||||
if not required:
|
||||
return candidates[0]
|
||||
for face in candidates[len(primary_candidates):]:
|
||||
if required <= _font_coverage(face):
|
||||
return face
|
||||
missing = sorted(
|
||||
codepoint
|
||||
for codepoint in required
|
||||
if not any(codepoint in _font_coverage(face) for face in candidates)
|
||||
)
|
||||
if not missing:
|
||||
missing = sorted(required)
|
||||
codes = ", ".join(f"U+{codepoint:04X}" for codepoint in missing[:12])
|
||||
if len(missing) > 12:
|
||||
codes += ", ..."
|
||||
raise ValueError(f"no installed font covers Unicode code points: {codes}")
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _load_truetype(face: FontFace, size: int) -> ImageFont.FreeTypeFont:
|
||||
layout_engine = ImageFont.Layout.RAQM if features.check_feature("raqm") else ImageFont.Layout.BASIC
|
||||
return ImageFont.truetype(
|
||||
face.path,
|
||||
size=size,
|
||||
index=face.index,
|
||||
layout_engine=layout_engine,
|
||||
)
|
||||
|
||||
|
||||
def load_font(
|
||||
font: str,
|
||||
size: int,
|
||||
text: str = "",
|
||||
*,
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> ImageFont.ImageFont:
|
||||
face = resolve_font_face(font, text, font_resolver=font_resolver)
|
||||
if face is not None:
|
||||
return _load_truetype(face, size)
|
||||
if _required_codepoints(text):
|
||||
codes = ", ".join(f"U+{value:04X}" for value in sorted(_required_codepoints(text)))
|
||||
raise ValueError(f"no installed font covers Unicode code points: {codes}")
|
||||
try:
|
||||
return ImageFont.load_default(size=size)
|
||||
except TypeError:
|
||||
return ImageFont.load_default()
|
||||
Reference in New Issue
Block a user