veil-pgd / src /veil_pgd /render /overlay.py
Klaus Clawd
Initial public release: VEIL-PGD v0.1
c793f45
Raw
History Blame Contribute Delete
3.49 kB
"""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")