File size: 5,626 Bytes
526cf2e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | """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.",
),
)
|