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