199 lines
5.7 KiB
Python
199 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BLOG_DIR = ROOT / "src" / "content" / "blog"
|
|
OUTPUT_DIR = ROOT / "public" / "images" / "blog" / "title-cards"
|
|
WIDTH = 1200
|
|
HEIGHT = 630
|
|
FONT_BOLD = Path("/System/Library/Fonts/Supplemental/Arial Bold.ttf")
|
|
|
|
PALETTES = {
|
|
"light": {
|
|
"start": (248, 252, 255),
|
|
"mid": (255, 247, 243),
|
|
"end": (243, 252, 248),
|
|
"text": (19, 32, 51),
|
|
"icon_bg": (255, 255, 255),
|
|
"icon_stroke": (71, 99, 135),
|
|
"icon_border": (215, 226, 238),
|
|
},
|
|
"dark": {
|
|
"start": (225, 233, 245),
|
|
"mid": (237, 228, 246),
|
|
"end": (221, 239, 241),
|
|
"text": (17, 24, 39),
|
|
"icon_bg": (246, 249, 253),
|
|
"icon_stroke": (54, 76, 112),
|
|
"icon_border": (198, 210, 226),
|
|
},
|
|
}
|
|
|
|
|
|
def extract_title(path: Path) -> str:
|
|
text = path.read_text(encoding="utf-8")
|
|
match = re.search(r"^title:\s*['\"](.+?)['\"]\s*$", text, flags=re.MULTILINE)
|
|
if not match:
|
|
raise ValueError(f"Could not find title in {path}")
|
|
return match.group(1)
|
|
|
|
|
|
def interpolate(a: int, b: int, t: float) -> int:
|
|
return round(a + (b - a) * t)
|
|
|
|
|
|
def gradient(palette: dict[str, tuple[int, int, int]]) -> Image.Image:
|
|
image = Image.new("RGB", (WIDTH, HEIGHT))
|
|
pixels = image.load()
|
|
for y in range(HEIGHT):
|
|
vertical = y / (HEIGHT - 1)
|
|
for x in range(WIDTH):
|
|
horizontal = x / (WIDTH - 1)
|
|
t = (horizontal * 0.72) + (vertical * 0.28)
|
|
if t < 0.5:
|
|
local = t / 0.5
|
|
a = palette["start"]
|
|
b = palette["mid"]
|
|
else:
|
|
local = (t - 0.5) / 0.5
|
|
a = palette["mid"]
|
|
b = palette["end"]
|
|
pixels[x, y] = tuple(interpolate(a[i], b[i], local) for i in range(3))
|
|
return image
|
|
|
|
|
|
def wrap_title(
|
|
draw: ImageDraw.ImageDraw,
|
|
title: str,
|
|
font: ImageFont.FreeTypeFont,
|
|
max_width: int,
|
|
) -> list[str]:
|
|
words = title.split()
|
|
lines: list[str] = []
|
|
current: list[str] = []
|
|
|
|
for word in words:
|
|
candidate = " ".join([*current, word])
|
|
bbox = draw.textbbox((0, 0), candidate, font=font)
|
|
if bbox[2] - bbox[0] <= max_width:
|
|
current.append(word)
|
|
else:
|
|
if current:
|
|
lines.append(" ".join(current))
|
|
current = [word]
|
|
|
|
if current:
|
|
lines.append(" ".join(current))
|
|
|
|
return lines
|
|
|
|
|
|
def fit_title(
|
|
draw: ImageDraw.ImageDraw,
|
|
title: str,
|
|
) -> tuple[ImageFont.FreeTypeFont, list[str], int]:
|
|
max_width = 820
|
|
max_height = 330
|
|
|
|
for size in range(68, 37, -2):
|
|
font = ImageFont.truetype(str(FONT_BOLD), size=size)
|
|
lines = wrap_title(draw, title, font, max_width)
|
|
line_height = round(size * 1.16)
|
|
total_height = line_height * len(lines)
|
|
widest = max(
|
|
(draw.textbbox((0, 0), line, font=font)[2] for line in lines),
|
|
default=0,
|
|
)
|
|
if total_height <= max_height and widest <= max_width and len(lines) <= 4:
|
|
return font, lines, line_height
|
|
|
|
font = ImageFont.truetype(str(FONT_BOLD), size=40)
|
|
lines = textwrap.wrap(title, width=28)
|
|
return font, lines, 48
|
|
|
|
|
|
def draw_article_icon(
|
|
draw: ImageDraw.ImageDraw,
|
|
palette: dict[str, tuple[int, int, int]],
|
|
) -> None:
|
|
x = 86
|
|
y = 76
|
|
size = 92
|
|
radius = 24
|
|
stroke = palette["icon_stroke"]
|
|
|
|
draw.rounded_rectangle(
|
|
(x, y, x + size, y + size),
|
|
radius=radius,
|
|
fill=palette["icon_bg"],
|
|
outline=palette["icon_border"],
|
|
width=2,
|
|
)
|
|
|
|
page_left = x + 30
|
|
page_top = y + 23
|
|
page_right = x + 62
|
|
page_bottom = y + 69
|
|
fold = 10
|
|
|
|
draw.line((page_left, page_top, page_right - fold, page_top), fill=stroke, width=3)
|
|
draw.line((page_right, page_top + fold, page_right, page_bottom), fill=stroke, width=3)
|
|
draw.line((page_left, page_top, page_left, page_bottom), fill=stroke, width=3)
|
|
draw.line((page_left, page_bottom, page_right, page_bottom), fill=stroke, width=3)
|
|
draw.line((page_right - fold, page_top, page_right, page_top + fold), fill=stroke, width=3)
|
|
draw.line((page_right - fold, page_top, page_right - fold, page_top + fold), fill=stroke, width=2)
|
|
draw.line((page_right - fold, page_top + fold, page_right, page_top + fold), fill=stroke, width=2)
|
|
|
|
for offset in (24, 34, 44):
|
|
draw.line(
|
|
(page_left + 7, page_top + offset, page_right - 7, page_top + offset),
|
|
fill=stroke,
|
|
width=3,
|
|
)
|
|
|
|
|
|
def render_card(title: str, palette_name: str, output_path: Path) -> None:
|
|
palette = PALETTES[palette_name]
|
|
image = gradient(palette)
|
|
draw = ImageDraw.Draw(image)
|
|
draw_article_icon(draw, palette)
|
|
font, lines, line_height = fit_title(draw, title)
|
|
|
|
total_height = line_height * len(lines)
|
|
right_margin = 88
|
|
bottom_margin = 86
|
|
y = HEIGHT - bottom_margin - total_height
|
|
|
|
for line in lines:
|
|
bbox = draw.textbbox((0, 0), line, font=font)
|
|
line_width = bbox[2] - bbox[0]
|
|
x = WIDTH - right_margin - line_width
|
|
draw.text((x, y), line, fill=palette["text"], font=font)
|
|
y += line_height
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
image.save(output_path, "PNG", optimize=True)
|
|
|
|
|
|
def main() -> None:
|
|
for path in sorted(BLOG_DIR.glob("*.mdx")):
|
|
title = extract_title(path)
|
|
slug = path.stem
|
|
for palette_name in PALETTES:
|
|
render_card(
|
|
title,
|
|
palette_name,
|
|
OUTPUT_DIR / f"{slug}-{palette_name}.png",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|