|
17 | 17 | from datetime import datetime |
18 | 18 | from pathlib import Path |
19 | 19 |
|
| 20 | +from PIL import Image, ImageChops |
| 21 | + |
20 | 22 | import yaml |
21 | | -from PIL import Image, ImageDraw, ImageFilter, ImageFont |
| 23 | +from PIL import ImageDraw, ImageFilter, ImageFont |
22 | 24 |
|
23 | 25 | # --------------------------------------------------------------------------- |
24 | 26 | # Constants |
|
44 | 46 | CONFIG_PATH = ROOT / "_config.yml" |
45 | 47 |
|
46 | 48 |
|
| 49 | +def images_equal(path: Path, image: Image.Image) -> bool: |
| 50 | + """Return True if path already contains the same rendered image.""" |
| 51 | + if not path.exists(): |
| 52 | + return False |
| 53 | + try: |
| 54 | + with Image.open(path) as existing: |
| 55 | + diff = ImageChops.difference(existing.convert("RGB"), image.convert("RGB")) |
| 56 | + return diff.getbbox() is None |
| 57 | + except OSError: |
| 58 | + return False |
| 59 | + |
| 60 | + |
| 61 | +def save_if_changed( |
| 62 | + img: Image.Image, output_path: Path, *, force: bool = False |
| 63 | +) -> bool: |
| 64 | + """Save an OG image only when pixels changed; return True when written.""" |
| 65 | + output_path.parent.mkdir(parents=True, exist_ok=True) |
| 66 | + rgb = img.convert("RGB") |
| 67 | + if not force and images_equal(output_path, rgb): |
| 68 | + return False |
| 69 | + rgb.save(output_path, "PNG", optimize=True) |
| 70 | + return True |
| 71 | + |
| 72 | + |
47 | 73 | def load_font(path: str, size: int) -> ImageFont.FreeTypeFont: |
48 | 74 | return ImageFont.truetype(path, size) |
49 | 75 |
|
@@ -131,8 +157,12 @@ def auto_title_size(title: str, draw: ImageDraw.ImageDraw, |
131 | 157 |
|
132 | 158 | def generate_post_og(title: str, date_str: str, tags: list[str], |
133 | 159 | excerpt: str, hero_path: Path | None, |
134 | | - output_path: Path, site_domain: str = "") -> None: |
135 | | - """Generate OG image for a blog post (Variant 1 or 2).""" |
| 160 | + output_path: Path, site_domain: str = "", |
| 161 | + *, force: bool = False) -> bool: |
| 162 | + """Generate OG image for a blog post (Variant 1 or 2). |
| 163 | +
|
| 164 | + Returns True when the output file changed. |
| 165 | + """ |
136 | 166 | img = Image.new("RGBA", (WIDTH, HEIGHT)) |
137 | 167 |
|
138 | 168 | if hero_path and hero_path.exists(): |
@@ -263,12 +293,16 @@ def generate_post_og(title: str, date_str: str, tags: list[str], |
263 | 293 | ey += (bbox_e[3] - bbox_e[1]) + 6 |
264 | 294 |
|
265 | 295 | # Save |
266 | | - output_path.parent.mkdir(parents=True, exist_ok=True) |
267 | | - img.convert("RGB").save(output_path, "PNG", optimize=True) |
| 296 | + return save_if_changed(img, output_path, force=force) |
| 297 | + |
268 | 298 |
|
| 299 | +def generate_site_default( |
| 300 | + output_path: Path, tagline: str, *, force: bool = False |
| 301 | +) -> bool: |
| 302 | + """Generate the site-default OG image (Variant 3). |
269 | 303 |
|
270 | | -def generate_site_default(output_path: Path, tagline: str) -> None: |
271 | | - """Generate the site-default OG image (Variant 3).""" |
| 304 | + Returns True when the output file changed. |
| 305 | + """ |
272 | 306 | img = Image.new("RGBA", (WIDTH, HEIGHT)) |
273 | 307 | draw_diagonal_gradient(img, PRIMARY, DARK_BG) |
274 | 308 |
|
@@ -313,8 +347,7 @@ def generate_site_default(output_path: Path, tagline: str) -> None: |
313 | 347 | draw.text((cx - tw2 // 2, accent_y + 26), |
314 | 348 | tagline, fill=(255, 255, 255, 200), font=tagline_font) |
315 | 349 |
|
316 | | - output_path.parent.mkdir(parents=True, exist_ok=True) |
317 | | - img.convert("RGB").save(output_path, "PNG", optimize=True) |
| 350 | + return save_if_changed(img, output_path, force=force) |
318 | 351 |
|
319 | 352 |
|
320 | 353 | # --------------------------------------------------------------------------- |
@@ -384,8 +417,8 @@ def main() -> None: |
384 | 417 |
|
385 | 418 | # --- Site default --- |
386 | 419 | default_path = IMAGES_DIR / "og-default.png" |
387 | | - print(f"Generating site default → {default_path.relative_to(ROOT)}") |
388 | | - generate_site_default(default_path, tagline) |
| 420 | + print(f"Checking site default → {default_path.relative_to(ROOT)}") |
| 421 | + generate_site_default(default_path, tagline, force=args.force) |
389 | 422 |
|
390 | 423 | # --- Blog posts --- |
391 | 424 | posts = sorted(POSTS_DIR.glob("*.md")) |
@@ -415,10 +448,12 @@ def main() -> None: |
415 | 448 | if hero_image: |
416 | 449 | hero_path = ROOT / hero_image.lstrip("/") |
417 | 450 |
|
418 | | - print(f" [{generated + 1}] {slug}") |
419 | | - generate_post_og(title, date_str, tags, excerpt, hero_path, output_path, |
420 | | - site_domain) |
421 | | - generated += 1 |
| 451 | + if generate_post_og(title, date_str, tags, excerpt, hero_path, output_path, |
| 452 | + site_domain, force=args.force): |
| 453 | + generated += 1 |
| 454 | + print(f" [{generated}] {slug}") |
| 455 | + else: |
| 456 | + skipped += 1 |
422 | 457 |
|
423 | 458 | print(f"\nDone: {generated} generated, {skipped} skipped (up-to-date)") |
424 | 459 |
|
|
0 commit comments