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
|
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(
|
def _draw_text_overlays(
|
||||||
draw: ImageDraw.ImageDraw,
|
draw: ImageDraw.ImageDraw,
|
||||||
width: int,
|
width: int,
|
||||||
@@ -80,7 +97,7 @@ def _draw_text_overlays(
|
|||||||
) -> None:
|
) -> None:
|
||||||
title_font = _get_font(38)
|
title_font = _get_font(38)
|
||||||
date_font = _get_font(20)
|
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.rectangle([(0, 0), (width, 90)], fill=(0, 0, 0, 204))
|
||||||
draw.text(
|
draw.text(
|
||||||
@@ -100,18 +117,18 @@ def _draw_text_overlays(
|
|||||||
|
|
||||||
if headlines:
|
if headlines:
|
||||||
max_headlines = min(len(headlines), 5)
|
max_headlines = min(len(headlines), 5)
|
||||||
strip_height = 28 + max_headlines * 22
|
strip_height = 28 + max_headlines * 26
|
||||||
strip_top = height - strip_height
|
strip_top = height - strip_height
|
||||||
draw.rectangle(
|
draw.rectangle(
|
||||||
[(0, strip_top), (width, height)], fill=(0, 0, 0, 170)
|
[(0, strip_top), (width, height)], fill=(0, 0, 0, 170)
|
||||||
)
|
)
|
||||||
|
max_width = width - 32
|
||||||
y = strip_top + 8
|
y = strip_top + 8
|
||||||
for headline in headlines[:max_headlines]:
|
for headline in headlines[:max_headlines]:
|
||||||
text = f"\u2022 {headline}"
|
text = f"\u2022 {headline}"
|
||||||
if len(text) > 45:
|
text = _truncate_to_width(text, headline_font, max_width)
|
||||||
text = text[:42] + "..."
|
|
||||||
draw.text((16, y), text, fill="white", font=headline_font)
|
draw.text((16, y), text, fill="white", font=headline_font)
|
||||||
y += 22
|
y += 26
|
||||||
|
|
||||||
|
|
||||||
def generate_programmatic_cover(
|
def generate_programmatic_cover(
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ from collections import Counter
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage, ImageDraw, ImageFont
|
||||||
|
|
||||||
from src.cover import (
|
from src.cover import (
|
||||||
generate_programmatic_cover,
|
generate_programmatic_cover,
|
||||||
generate_ai_cover,
|
generate_ai_cover,
|
||||||
generate_cover,
|
generate_cover,
|
||||||
_get_dominant_category,
|
_get_dominant_category,
|
||||||
|
_truncate_to_width,
|
||||||
|
_get_font,
|
||||||
CATEGORY_PROMPTS,
|
CATEGORY_PROMPTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,3 +111,28 @@ def test_get_dominant_category():
|
|||||||
def test_category_prompts_include_massachusetts():
|
def test_category_prompts_include_massachusetts():
|
||||||
for category, prompt in CATEGORY_PROMPTS.items():
|
for category, prompt in CATEGORY_PROMPTS.items():
|
||||||
assert "Massachusetts" in prompt, f"Prompt for {category} missing 'Massachusetts'"
|
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