Files
matrix-screen-controller/核桃派软件源代码/app/display/text_renderer.py
T

323 lines
12 KiB
Python

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()