97 lines
3.4 KiB
Python
97 lines
3.4 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
from pathlib import Path
|
|
import random
|
|
import re
|
|
|
|
from PIL import Image, ImageDraw, ImageFilter
|
|
|
|
from publication_meta import CATEGORIES
|
|
|
|
WIDTH = 1200
|
|
HEIGHT = 630
|
|
MAX_BYTES = 5 * 1024 * 1024
|
|
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
|
|
_PALETTES = {
|
|
"وكلاء": ((17, 25, 48), (82, 214, 186)),
|
|
"نماذج": ((20, 22, 48), (129, 140, 248)),
|
|
"استرجاع": ((13, 31, 43), (56, 189, 248)),
|
|
"سلامة": ((37, 21, 35), (244, 114, 182)),
|
|
"أدوات": ((16, 30, 35), (245, 174, 65)),
|
|
"أعمال": ((31, 24, 20), (249, 115, 80)),
|
|
}
|
|
|
|
|
|
def _art(slug: str, category: str) -> Image.Image:
|
|
seed = int.from_bytes(hashlib.sha256(f"{slug}|{category}".encode()).digest()[:8], "big")
|
|
rng = random.Random(seed)
|
|
background, accent = _PALETTES[category]
|
|
image = Image.new("RGB", (WIDTH, HEIGHT), background)
|
|
|
|
glow = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
|
glow_draw = ImageDraw.Draw(glow, "RGBA")
|
|
for _ in range(5):
|
|
x = rng.randint(80, WIDTH - 80)
|
|
y = rng.randint(60, HEIGHT - 60)
|
|
radius = rng.randint(130, 320)
|
|
glow_draw.ellipse(
|
|
(x - radius, y - radius, x + radius, y + radius),
|
|
fill=(*accent, rng.randint(18, 42)),
|
|
)
|
|
glow = glow.filter(ImageFilter.GaussianBlur(70))
|
|
image = Image.alpha_composite(image.convert("RGBA"), glow)
|
|
|
|
overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(overlay, "RGBA")
|
|
points = [(rng.randint(110, WIDTH - 110), rng.randint(90, HEIGHT - 90)) for _ in range(12)]
|
|
for index, start in enumerate(points):
|
|
end = points[(index * 5 + 3) % len(points)]
|
|
draw.line((start, end), fill=(*accent, 95), width=3)
|
|
for index, (x, y) in enumerate(points):
|
|
radius = 8 + (index % 4) * 4
|
|
draw.ellipse(
|
|
(x - radius, y - radius, x + radius, y + radius),
|
|
fill=(*accent, 220),
|
|
outline=(235, 241, 247, 180),
|
|
width=2,
|
|
)
|
|
|
|
left = 68
|
|
top = 54
|
|
right = WIDTH - 68
|
|
bottom = HEIGHT - 54
|
|
draw.rounded_rectangle((left, top, right, bottom), radius=32, outline=(*accent, 120), width=3)
|
|
draw.line((left + 35, bottom - 55, right - 35, bottom - 55), fill=(*accent, 155), width=4)
|
|
return Image.alpha_composite(image, overlay).convert("RGB")
|
|
|
|
|
|
def generate_cover(slug: str, category: str, output_dir: Path) -> dict:
|
|
if not SLUG_RE.fullmatch(slug):
|
|
raise ValueError("slug must be lowercase ASCII kebab-case")
|
|
if category not in CATEGORIES:
|
|
raise ValueError("category is invalid")
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
output = output_dir / f"{slug}.jpg"
|
|
temporary = output.with_suffix(".jpg.tmp")
|
|
image = _art(slug, category)
|
|
image.save(temporary, format="JPEG", quality=91, optimize=True, progressive=True)
|
|
|
|
if temporary.stat().st_size > MAX_BYTES:
|
|
temporary.unlink(missing_ok=True)
|
|
raise ValueError("generated cover exceeds 5MB")
|
|
with Image.open(temporary) as check:
|
|
if check.size != (WIDTH, HEIGHT) or check.format != "JPEG" or check.mode != "RGB":
|
|
temporary.unlink(missing_ok=True)
|
|
raise ValueError("generated cover failed validation")
|
|
temporary.replace(output)
|
|
|
|
encoded = base64.b64encode(output.read_bytes()).decode("ascii")
|
|
return {
|
|
"path": output,
|
|
"coverImage": {"filename": output.name, "dataBase64": encoded},
|
|
}
|