Files

118 lines
4.2 KiB
Python
Raw Permalink Normal View History

2025-12-13 10:19:52 +00:00
import asyncio
import logging
import os
from io import BytesIO
from typing import Optional
import httpx
from PIL import Image, ImageDraw, ImageFont, ImageOps
from .base import PluginBase, PluginOutput
logger = logging.getLogger(__name__)
class XKCDPlugin(PluginBase):
"""Fetch the current XKCD comic and render a summary panel."""
BASENAME = "xkcd"
OUTPUT_SUBDIR = "xkcd"
REGISTRY_ORDER = 30
REFRESH_INTERVAL = 3600
DISPLAY_NAME = "XKCD"
INFO_URL = "https://xkcd.com/info.0.json"
async def run(self, **kwargs) -> Optional[PluginOutput]:
"""Download the comic of the day, render metadata, and store it as BMP."""
target_size = kwargs.get("target_size", (800, 480))
output_dir = kwargs.get("output_dir", "web")
os.makedirs(output_dir, exist_ok=True)
try:
async with httpx.AsyncClient(timeout=15) as client:
metadata = await self._fetch_metadata(client)
if not metadata:
logger.warning("No XKCD metadata available")
return None
image_bytes = await self._download_image(client, metadata.get("img"))
comic_image = await asyncio.to_thread(self._load_image, image_bytes)
rendered = await asyncio.to_thread(self._render_panel, comic_image, metadata, target_size)
output = await asyncio.to_thread(self.save_assets, rendered, output_dir, 'xkcd')
logger.info(
"Saved XKCD assets to %s and %s",
output.monochrome_path,
output.grayscale_path
)
return output
except Exception as exc:
logger.error("Failed to fetch XKCD comic: %s", exc)
raise
def get_content_ttl(self) -> int:
return 43200 # 12 hours; comics update daily
async def _fetch_metadata(self, client: httpx.AsyncClient) -> dict:
response = await client.get(self.INFO_URL)
response.raise_for_status()
return response.json()
async def _download_image(self, client: httpx.AsyncClient, url: Optional[str]) -> bytes:
if not url:
raise ValueError("XKCD metadata missing image URL")
response = await client.get(url)
response.raise_for_status()
return response.content
def _load_image(self, data: bytes) -> Image.Image:
return Image.open(BytesIO(data)).convert("RGB")
def _render_panel(self, comic: Image.Image, metadata: dict, target_size) -> Image.Image:
base_canvas = Image.new("L", target_size, color=255)
base_draw = ImageDraw.Draw(base_canvas)
title = metadata.get("safe_title") or metadata.get("title", "XKCD")
alt_text = metadata.get("alt", "")
transcript = metadata.get("transcript", "")
title_font = self.load_font(32)
body_font = self.load_font(18)
max_comic_height = 280
max_comic_width = target_size[0] - 60
fitted = ImageOps.contain(comic, (max_comic_width, max_comic_height))
fitted = fitted.convert("L")
top_margin = 30
comic_x = max(0, (target_size[0] - fitted.width) // 2)
base_canvas.paste(fitted, (comic_x, top_margin))
draw = ImageDraw.Draw(base_canvas)
text_y = top_margin + fitted.height + 20
draw.text((30, text_y), title, fill=0, font=title_font)
text_y += title_font.size + 10
wrapped_alt = self._wrap_text(alt_text or transcript, body_font, target_size[0] - 60)
for line in wrapped_alt:
draw.text((30, text_y), line, fill=0, font=body_font)
text_y += body_font.size + 4
return base_canvas
def _wrap_text(self, text: str, font: ImageFont.ImageFont, max_width: int):
words = text.split()
lines = []
current = []
for word in words:
test_line = " ".join(current + [word]).strip()
width = font.getlength(test_line)
if width <= max_width:
current.append(word)
else:
if current:
lines.append(" ".join(current))
current = [word]
if current:
lines.append(" ".join(current))
return lines