| """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: |
| 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.", |
| ), |
| ) |
|
|