ParthKulshreshtha's picture
Deploy personalized ad environment
526cf2e verified
Raw
History Blame Contribute Delete
5.63 kB
"""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.",
),
)