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