diff --git a/src/cover.py b/src/cover.py new file mode 100644 index 0000000..b17ca65 --- /dev/null +++ b/src/cover.py @@ -0,0 +1,123 @@ +import logging +import os +from datetime import date +from io import BytesIO +from urllib.parse import quote + +import requests +from PIL import Image as PILImage, ImageDraw, ImageFont + +import config +from src.images import _resize_to_fit + +logger = logging.getLogger(__name__) + + +def _get_font(size: int) -> ImageFont.FreeTypeFont: + try: + return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size) + except OSError: + pass + try: + return ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size) + except OSError: + pass + return ImageFont.load_default() + + +def generate_text_cover( + output_dir: str, + week_start: date, + week_end: date, + headlines: list[str], +) -> str: + os.makedirs(output_dir, exist_ok=True) + img = PILImage.new("RGB", (800, 480), color="white") + draw = ImageDraw.Draw(img) + + title_font = _get_font(36) + date_font = _get_font(20) + headline_font = _get_font(16) + + draw.text((400, 30), "Plymouth Independent", fill="black", + font=title_font, anchor="mt") + + date_str = f"Week of {week_start.strftime('%b %d')} \u2013 {week_end.strftime('%b %d, %Y')}" + draw.text((400, 80), date_str, fill="gray", font=date_font, anchor="mt") + + draw.line([(50, 110), (750, 110)], fill="black", width=2) + + y = 130 + for i, headline in enumerate(headlines[:8]): + if y > 440: + break + prefix = f"\u2022 {headline}" + if len(prefix) > 70: + prefix = prefix[:67] + "..." + draw.text((60, y), prefix, fill="black", font=headline_font) + y += 35 + + filename = f"cover-{week_start.isoformat()}-text.jpg" + path = os.path.join(output_dir, filename) + img.save(path, format="JPEG", progressive=False, quality=90) + return path + + +def generate_ai_cover( + output_dir: str, + week_start: date, + week_end: date, + headlines: list[str], +) -> str: + os.makedirs(output_dir, exist_ok=True) + + top_headlines = ", ".join(headlines[:3]) + prompt = ( + f"Newspaper front page illustration for Plymouth Massachusetts local news. " + f"Headlines: {top_headlines}. " + f"Classic broadsheet newspaper style, black and white ink drawing, editorial illustration." + ) + url = config.POLLINATIONS_URL.format(prompt=quote(prompt)) + + try: + response = requests.get(url, timeout=60) + response.raise_for_status() + + img = PILImage.open(BytesIO(response.content)) + if img.mode != "RGB": + img = img.convert("RGB") + img = _resize_to_fit(img) + + draw = ImageDraw.Draw(img) + title_font = _get_font(28) + date_font = _get_font(16) + + draw.text((img.width // 2, 15), "Plymouth Independent", + fill="white", font=title_font, anchor="mt", + stroke_width=2, stroke_fill="black") + + date_str = f"Week of {week_start.strftime('%b %d')} \u2013 {week_end.strftime('%b %d, %Y')}" + draw.text((img.width // 2, 50), date_str, + fill="white", font=date_font, anchor="mt", + stroke_width=1, stroke_fill="black") + + filename = f"cover-{week_start.isoformat()}-ai.jpg" + path = os.path.join(output_dir, filename) + img.save(path, format="JPEG", progressive=False, quality=90) + return path + + except Exception as e: + logger.error("AI cover generation failed, falling back to text: %s", e) + return generate_text_cover(output_dir, week_start, week_end, headlines) + + +def generate_cover( + method: str, + output_dir: str, + week_start: date, + week_end: date, + headlines: list[str], +) -> str: + if method == "ai": + return generate_ai_cover(output_dir, week_start, week_end, headlines) + return generate_text_cover(output_dir, week_start, week_end, headlines) diff --git a/tests/test_cover.py b/tests/test_cover.py new file mode 100644 index 0000000..c7e9743 --- /dev/null +++ b/tests/test_cover.py @@ -0,0 +1,77 @@ +import os +from datetime import date +from unittest.mock import patch, MagicMock +from io import BytesIO +from PIL import Image as PILImage + +from src.cover import generate_text_cover, generate_ai_cover, generate_cover + + +def test_text_cover_creates_jpeg(tmp_path): + path = generate_text_cover( + output_dir=str(tmp_path), + week_start=date(2026, 4, 6), + week_end=date(2026, 4, 12), + headlines=["Article One", "Article Two", "Article Three"], + ) + + assert os.path.exists(path) + img = PILImage.open(path) + assert img.format == "JPEG" + assert img.width <= 800 + assert img.height <= 480 + + +def test_ai_cover_creates_jpeg(tmp_path): + fake_img = PILImage.new("RGB", (800, 480), color="blue") + buf = BytesIO() + fake_img.save(buf, format="JPEG") + fake_bytes = buf.getvalue() + + mock_response = MagicMock() + mock_response.content = fake_bytes + mock_response.raise_for_status = MagicMock() + + with patch("src.cover.requests.get", return_value=mock_response): + path = generate_ai_cover( + output_dir=str(tmp_path), + week_start=date(2026, 4, 6), + week_end=date(2026, 4, 12), + headlines=["Test Headline"], + ) + + assert os.path.exists(path) + img = PILImage.open(path) + assert img.format == "JPEG" + assert img.width <= 800 + assert img.height <= 480 + + +def test_ai_cover_falls_back_on_failure(tmp_path): + with patch("src.cover.requests.get", side_effect=Exception("API down")): + path = generate_ai_cover( + output_dir=str(tmp_path), + week_start=date(2026, 4, 6), + week_end=date(2026, 4, 12), + headlines=["Test"], + ) + + assert os.path.exists(path) + img = PILImage.open(path) + assert img.format == "JPEG" + + +def test_generate_cover_dispatches(tmp_path): + with patch("src.cover.generate_ai_cover") as mock_ai: + mock_ai.return_value = "/fake/ai.jpg" + result = generate_cover("ai", str(tmp_path), date(2026, 4, 6), + date(2026, 4, 12), ["A"]) + assert result == "/fake/ai.jpg" + mock_ai.assert_called_once() + + with patch("src.cover.generate_text_cover") as mock_text: + mock_text.return_value = "/fake/text.jpg" + result = generate_cover("text", str(tmp_path), date(2026, 4, 6), + date(2026, 4, 12), ["A"]) + assert result == "/fake/text.jpg" + mock_text.assert_called_once()