File size: 5,908 Bytes
4448e70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
153
"""Постобработка изображений под требования площадок.

Генератор отдаёт картинку в своём размере (1024×1536 и т.п.), а площадкам нужны
свои пропорции и разрешения. Здесь: подгонка холста, плашки с текстом,
водяной знак. Всё на Pillow, без внешних сервисов.
"""

from __future__ import annotations

import logging
from functools import lru_cache
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont

log = logging.getLogger(__name__)

# Размеры под площадки. Значения — требования/рекомендации на момент сборки,
# площадки их периодически меняют: проверяйте в справке площадки.
SIZE_PRESETS: dict[str, tuple[int, int] | None] = {
    "Wildberries — 900×1200": (900, 1200),
    "Ozon — 1200×1600": (1200, 1600),
    "Яндекс Маркет — 1000×1000": (1000, 1000),
    "Квадрат — 1080×1080": (1080, 1080),
    "Сторис — 1080×1920": (1080, 1920),
    "Баннер — 1920×1080": (1920, 1080),
    "Как сгенерировано": None,
}

FONT_CANDIDATES = (
    "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
    "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
    "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
    "C:/Windows/Fonts/arialbd.ttf",
)

BADGE_POSITIONS = ("Слева сверху", "Справа сверху", "Слева снизу", "Справа снизу")


@lru_cache(maxsize=16)
def _font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
    for path in FONT_CANDIDATES:
        if Path(path).exists():
            try:
                return ImageFont.truetype(path, size)
            except OSError:  # pragma: no cover
                continue
    log.warning("Системный шрифт не найден, использую встроенный Pillow")
    try:
        return ImageFont.load_default(size=size)
    except TypeError:  # Pillow < 10.1
        return ImageFont.load_default()


def fit_canvas(
    image: Image.Image,
    target: tuple[int, int] | None,
    mode: str = "Обрезать по краям",
    background: str = "#FFFFFF",
) -> Image.Image:
    """Приводит картинку к нужному размеру.

    «Обрезать по краям» (cover) — кадр заполнен целиком, края срезаются.
    «Вписать целиком» (contain) — кадр виден полностью, поля заливаются цветом.
    """
    if not target:
        return image.convert("RGB")

    tw, th = target
    img = image.convert("RGB")
    scale = max(tw / img.width, th / img.height)
    if mode.startswith("Вписать"):
        scale = min(tw / img.width, th / img.height)

    new_size = (max(1, round(img.width * scale)), max(1, round(img.height * scale)))
    img = img.resize(new_size, Image.LANCZOS)

    canvas = Image.new("RGB", (tw, th), background)
    canvas.paste(img, ((tw - img.width) // 2, (th - img.height) // 2))
    return canvas


def _wrap(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]:
    lines: list[str] = []
    for paragraph in text.splitlines():
        words, current = paragraph.split(), ""
        for word in words:
            probe = f"{current} {word}".strip()
            if draw.textlength(probe, font=font) <= max_width or not current:
                current = probe
            else:
                lines.append(current)
                current = word
        lines.append(current)
    return [ln for ln in lines if ln]


def add_badge(
    image: Image.Image,
    text: str,
    *,
    position: str = "Слева сверху",
    fill: str = "#E4287C",
    text_color: str = "#FFFFFF",
    scale: float = 0.032,
) -> Image.Image:
    """Плашка с текстом поверх изображения — то, чем живут карточки на площадках."""
    if not text or not text.strip():
        return image

    img = image.convert("RGB")
    draw = ImageDraw.Draw(img)
    font_size = max(14, int(img.height * scale))
    font = _font(font_size)
    pad = int(font_size * 0.55)
    margin = int(img.width * 0.04)

    lines = _wrap(draw, text.strip(), font, int(img.width * 0.5))
    line_h = int(font_size * 1.25)
    box_w = int(max(draw.textlength(ln, font=font) for ln in lines)) + pad * 2
    box_h = line_h * len(lines) + pad * 2

    x = margin if position.startswith("Слева") else img.width - margin - box_w
    y = margin if "сверху" in position else img.height - margin - box_h

    radius = int(min(box_w, box_h) * 0.22)
    draw.rounded_rectangle([x, y, x + box_w, y + box_h], radius=radius, fill=fill)
    for i, line in enumerate(lines):
        draw.text((x + pad, y + pad + i * line_h), line, font=font, fill=text_color)
    return img


def add_watermark(image: Image.Image, text: str, opacity: int = 110) -> Image.Image:
    """Полупрозрачная подпись в углу — для черновиков и согласований."""
    if not text or not text.strip():
        return image

    base = image.convert("RGBA")
    layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)
    font_size = max(12, int(base.height * 0.022))
    font = _font(font_size)
    margin = int(base.width * 0.025)
    tw = draw.textlength(text, font=font)
    draw.text(
        (base.width - tw - margin, base.height - font_size - margin),
        text,
        font=font,
        fill=(255, 255, 255, opacity),
    )
    return Image.alpha_composite(base, layer).convert("RGB")