feat(cover): pixel-width headline truncation and larger font
Replace character-count truncation (45 chars) with Pillow textbbox pixel-width measurement. Bump headline font 14→18, line spacing 22→26. Made-with: Cursor
This commit is contained in:
27
src/cover.py
27
src/cover.py
@@ -70,6 +70,23 @@ def _get_dominant_category(categories: list[str]) -> str:
|
||||
return most_common
|
||||
|
||||
|
||||
def _truncate_to_width(
|
||||
text: str, font: ImageFont.FreeTypeFont, max_width: int
|
||||
) -> str:
|
||||
dummy = PILImage.new("RGBA", (1, 1))
|
||||
draw = ImageDraw.Draw(dummy)
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
if (bbox[2] - bbox[0]) <= max_width:
|
||||
return text
|
||||
ellipsis = "\u2026"
|
||||
for i in range(len(text) - 1, 0, -1):
|
||||
candidate = text[:i] + ellipsis
|
||||
bbox = draw.textbbox((0, 0), candidate, font=font)
|
||||
if (bbox[2] - bbox[0]) <= max_width:
|
||||
return candidate
|
||||
return ellipsis
|
||||
|
||||
|
||||
def _draw_text_overlays(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
width: int,
|
||||
@@ -80,7 +97,7 @@ def _draw_text_overlays(
|
||||
) -> None:
|
||||
title_font = _get_font(38)
|
||||
date_font = _get_font(20)
|
||||
headline_font = _get_font(14)
|
||||
headline_font = _get_font(18)
|
||||
|
||||
draw.rectangle([(0, 0), (width, 90)], fill=(0, 0, 0, 204))
|
||||
draw.text(
|
||||
@@ -100,18 +117,18 @@ def _draw_text_overlays(
|
||||
|
||||
if headlines:
|
||||
max_headlines = min(len(headlines), 5)
|
||||
strip_height = 28 + max_headlines * 22
|
||||
strip_height = 28 + max_headlines * 26
|
||||
strip_top = height - strip_height
|
||||
draw.rectangle(
|
||||
[(0, strip_top), (width, height)], fill=(0, 0, 0, 170)
|
||||
)
|
||||
max_width = width - 32
|
||||
y = strip_top + 8
|
||||
for headline in headlines[:max_headlines]:
|
||||
text = f"\u2022 {headline}"
|
||||
if len(text) > 45:
|
||||
text = text[:42] + "..."
|
||||
text = _truncate_to_width(text, headline_font, max_width)
|
||||
draw.text((16, y), text, fill="white", font=headline_font)
|
||||
y += 22
|
||||
y += 26
|
||||
|
||||
|
||||
def generate_programmatic_cover(
|
||||
|
||||
@@ -3,13 +3,15 @@ from collections import Counter
|
||||
from datetime import date
|
||||
from unittest.mock import patch, MagicMock
|
||||
from io import BytesIO
|
||||
from PIL import Image as PILImage
|
||||
from PIL import Image as PILImage, ImageDraw, ImageFont
|
||||
|
||||
from src.cover import (
|
||||
generate_programmatic_cover,
|
||||
generate_ai_cover,
|
||||
generate_cover,
|
||||
_get_dominant_category,
|
||||
_truncate_to_width,
|
||||
_get_font,
|
||||
CATEGORY_PROMPTS,
|
||||
)
|
||||
|
||||
@@ -109,3 +111,28 @@ def test_get_dominant_category():
|
||||
def test_category_prompts_include_massachusetts():
|
||||
for category, prompt in CATEGORY_PROMPTS.items():
|
||||
assert "Massachusetts" in prompt, f"Prompt for {category} missing 'Massachusetts'"
|
||||
|
||||
|
||||
def test_truncate_to_width_respects_pixel_budget():
|
||||
font = _get_font(18)
|
||||
short = "Short text"
|
||||
result = _truncate_to_width(short, font, 400)
|
||||
assert result == short, "Short text should not be truncated"
|
||||
|
||||
long = "A" * 200
|
||||
result = _truncate_to_width(long, font, 400)
|
||||
assert result.endswith("\u2026"), "Long text should end with ellipsis"
|
||||
assert len(result) < len(long), "Truncated text should be shorter"
|
||||
|
||||
|
||||
def test_truncate_to_width_fits_within_budget():
|
||||
font = _get_font(18)
|
||||
long = "This is a really long headline that should definitely be truncated to fit"
|
||||
max_width = 448
|
||||
result = _truncate_to_width(long, font, max_width)
|
||||
|
||||
dummy = PILImage.new("RGBA", (1, 1))
|
||||
draw = ImageDraw.Draw(dummy)
|
||||
bbox = draw.textbbox((0, 0), result, font=font)
|
||||
rendered_width = bbox[2] - bbox[0]
|
||||
assert rendered_width <= max_width, f"Rendered width {rendered_width} exceeds {max_width}"
|
||||
|
||||
Reference in New Issue
Block a user