"""Постобработка изображений под требования площадок. Генератор отдаёт картинку в своём размере (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")