323 lines
12 KiB
Python
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()
|