"""Core renderer: composite a faint text overlay onto an image. Uses a transparent RGBA layer + Image.alpha_composite (NOT a direct RGBA draw onto an RGB image, which does not honor transparency correctly). """ from __future__ import annotations from functools import lru_cache from PIL import Image, ImageDraw, ImageFont from veil_pgd.render.colors import resolve_color from veil_pgd.render.region import resolve_box, scale_px from veil_pgd.types import RenderSpec # Font candidates by family. Includes Linux (klaus-3) + macOS (Mac orchestrator) # paths so a scalable TrueType is always found; only if none resolve do we fall # back to Pillow's bundled default (which, on Pillow >=10, still honors `px`). _FONT_PATHS = { "DejaVuSans": [ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/opt/homebrew/share/fonts/DejaVuSans.ttf", "/Library/Fonts/DejaVuSans.ttf", ], "Arial": ["/Library/Fonts/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf"], "Helvetica": ["/System/Library/Fonts/Helvetica.ttc"], } # Universal fallbacks tried (in order) for ANY requested family before giving up. _FALLBACK_FONT_PATHS = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # klaus-3 / most Linux "/System/Library/Fonts/Supplemental/Arial.ttf", # macOS "/System/Library/Fonts/Helvetica.ttc", # macOS "/Library/Fonts/Arial.ttf", ] @lru_cache(maxsize=128) def _load_font(family: str, px: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: for path in _FONT_PATHS.get(family, []) + _FALLBACK_FONT_PATHS: try: return ImageFont.truetype(path, px) except OSError: continue # matplotlib bundles DejaVuSans.ttf; use it if installed. try: import os import matplotlib p = os.path.join(os.path.dirname(matplotlib.__file__), "mpl-data/fonts/ttf/DejaVuSans.ttf") return ImageFont.truetype(p, px) except Exception: pass # Last resort: Pillow's bundled default. Pillow >=10 honors the size arg, so # requested px is respected instead of silently collapsing to ~10px. try: return ImageFont.load_default(px) except TypeError: # very old Pillow return ImageFont.load_default() def _measure(font, text: str) -> tuple[int, int]: draw = ImageDraw.Draw(Image.new("RGB", (1, 1))) l, t, r, b = draw.multiline_textbbox((0, 0), text, font=font, spacing=2) return r - l, b - t def render(image: Image.Image, spec: RenderSpec) -> Image.Image: """Return a new RGB image with the overlay composited on.""" base = image.convert("RGBA") w, h = base.size px = scale_px(spec.font_px, image) font = _load_font(spec.font_family, px) # Repetition: stack the text so it tiles down the region. text = "\n".join([spec.text] * max(1, spec.repetition)) box_w, box_h = _measure(font, text) box_w = min(box_w, w) box_h = min(box_h, h) x0, y0, x1, y1 = resolve_box(image, spec.position, box_w, box_h) fill = resolve_color(spec, image, (x0, y0, x1, y1)) overlay = Image.new("RGBA", base.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) draw.multiline_text((x0, y0), text, font=font, fill=fill, spacing=2, align="center") if spec.rotation_deg: overlay = overlay.rotate(spec.rotation_deg, resample=Image.BICUBIC, expand=False) out = Image.alpha_composite(base, overlay) return out.convert("RGB")