| """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_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"], |
| } |
|
|
| |
| _FALLBACK_FONT_PATHS = [ |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", |
| "/System/Library/Fonts/Supplemental/Arial.ttf", |
| "/System/Library/Fonts/Helvetica.ttc", |
| "/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 |
| |
| 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 |
| |
| |
| try: |
| return ImageFont.load_default(px) |
| except TypeError: |
| 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) |
|
|
| |
| 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") |
|
|