"""Fixed product-card compositor and renderer-integrity checks.""" from __future__ import annotations import hashlib import io from dataclasses import dataclass from pathlib import Path from typing import Protocol from PIL import Image, ImageDraw, ImageFont, ImageOps, UnidentifiedImageError from ad_creative_env.config import FONT_PATH_BOLD from .models import AdCopy, CheckResult, Product CARD_SIZE = (1200, 628) _REGULAR_FONT = str(Path(FONT_PATH_BOLD).with_name("DejaVuSans.ttf")) class CompositionError(RuntimeError): def __init__(self, code: str, message: str) -> None: super().__init__(message) self.code = code @dataclass(frozen=True, slots=True) class CompositionResult: png_bytes: bytes source_image_sha256: str width: int height: int all_text_fit: bool class CardCompositor(Protocol): def compose(self, image_path: Path, product: Product, action: AdCopy) -> CompositionResult: ... def image_sha256(path: Path) -> str: try: return hashlib.sha256(Path(path).read_bytes()).hexdigest() except OSError as exc: raise CompositionError("image_missing", "supplied product image cannot be read") from exc def _font(path: str, size: int) -> ImageFont.FreeTypeFont: try: return ImageFont.truetype(path, size) except OSError as exc: # pragma: no cover - deployment image test exercises the installed font raise CompositionError("font_missing", "card font is unavailable") from exc def _wrap( draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont, max_width: int, max_lines: int, ) -> tuple[str, ...] | None: words = text.split() lines: list[str] = [] current = "" for word in words: if draw.textlength(word, font=font) > max_width: return None candidate = f"{current} {word}".strip() if draw.textlength(candidate, font=font) <= max_width: current = candidate else: lines.append(current) current = word if len(lines) >= max_lines: return None if current: lines.append(current) return tuple(lines) if len(lines) <= max_lines else None class PillowCardCompositor: """Deterministic fallback while the referenced internal OpenCV module is unavailable.""" def compose(self, image_path: Path, product: Product, action: AdCopy) -> CompositionResult: source_hash = image_sha256(image_path) try: with Image.open(image_path) as source: product_image = ImageOps.exif_transpose(source).convert("RGB") except (OSError, UnidentifiedImageError) as exc: raise CompositionError("image_invalid", "supplied product image is invalid") from exc canvas = Image.new("RGB", CARD_SIZE, "#f4f1eb") product_panel = Image.new("RGB", (620, CARD_SIZE[1]), "#ffffff") fitted = ImageOps.contain(product_image, (540, 548), method=Image.Resampling.LANCZOS) product_panel.paste(fitted, ((620 - fitted.width) // 2, (628 - fitted.height) // 2)) canvas.paste(product_panel, (0, 0)) draw = ImageDraw.Draw(canvas) draw.rectangle((620, 0, 1200, 628), fill="#161616") eyebrow_font = _font(FONT_PATH_BOLD, 18) headline_font = _font(FONT_PATH_BOLD, 40) body_font = _font(_REGULAR_FONT, 23) product_font = _font(FONT_PATH_BOLD, 20) cta_font = _font(FONT_PATH_BOLD, 19) max_width = 484 headline_lines = _wrap(draw, action.headline, headline_font, max_width, 3) body_lines = _wrap(draw, action.body, body_font, max_width, 5) cta_lines = _wrap(draw, action.cta, cta_font, 210, 1) if action.cta else () if headline_lines is None or body_lines is None or cta_lines is None: raise CompositionError("text_overflow", "supplied copy does not fit the fixed card") left = 674 draw.text((left, 58), "PERSONALIZED STYLE", font=eyebrow_font, fill="#c8b99d") draw.text((left, 92), product.name.upper(), font=product_font, fill="#ffffff") y = 148 for line in headline_lines: draw.text((left, y), line, font=headline_font, fill="#ffffff") y += 49 y += 20 for line in body_lines: draw.text((left, y), line, font=body_font, fill="#d8d8d8") y += 34 if action.cta: cta_y = 526 draw.rounded_rectangle((left, cta_y, left + 246, cta_y + 54), 7, fill="#f3e8d4") draw.text((left + 18, cta_y + 15), cta_lines[0], font=cta_font, fill="#161616") output = io.BytesIO() canvas.save(output, format="PNG", optimize=False, compress_level=9) return CompositionResult(output.getvalue(), source_hash, *CARD_SIZE, all_text_fit=True) def run_renderer_checks( expected_image_path: Path, composition: CompositionResult ) -> tuple[CheckResult, ...]: expected_hash = image_sha256(expected_image_path) return ( CheckResult( "renderer.image_identity", composition.source_image_sha256 == expected_hash, "The compositor used the supplied product image." if composition.source_image_sha256 == expected_hash else "The composed card does not match the supplied product image.", ), CheckResult( "renderer.text_fit", composition.all_text_fit, "All supplied copy fits the fixed card." if composition.all_text_fit else "Some supplied copy does not fit the fixed card.", ), )