Use full-bleed photos with a single-line bottom watermark
This commit is contained in:
@@ -1,46 +1,68 @@
|
||||
"""Native frame image with a separate, high-contrast caption area."""
|
||||
"""Full-bleed photo with a compact, single-line camera-style watermark."""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pigway_frame_flow.renderers.images import convert
|
||||
from pigway_frame_flow.renderers.photo_layout import WatermarkLayout
|
||||
|
||||
|
||||
def render_captioned_photo(
|
||||
data, caption, font_loader, fit="contain", profile="color", *, size=(1200, 1600)
|
||||
data,
|
||||
caption,
|
||||
font_loader,
|
||||
fit="cover",
|
||||
profile="color",
|
||||
*,
|
||||
size=(1200, 1600),
|
||||
layout_provider=None,
|
||||
):
|
||||
width, height = size
|
||||
landscape = width > height
|
||||
caption_height = 240 if landscape else 320
|
||||
photo_height = height - caption_height
|
||||
photo_data = convert(data, fit, profile, size=(width, photo_height))
|
||||
layout = (layout_provider or WatermarkLayout()).layout(size, caption)
|
||||
if not (
|
||||
0 < layout.footer_height < height
|
||||
and 0 < layout.margin < width / 2
|
||||
and layout.font_size > 0
|
||||
):
|
||||
raise ValueError("Invalid photo layout")
|
||||
photo_height = height - layout.footer_height
|
||||
# Legacy tasks used contain: watermark photos now fill the photo area,
|
||||
# keeping aspect ratio and cropping rather than stretching or letterboxing.
|
||||
photo_data = convert(
|
||||
data,
|
||||
"smart" if fit == "smart" else "cover",
|
||||
profile,
|
||||
size=(width, photo_height),
|
||||
)
|
||||
canvas = Image.new("RGB", size, "white")
|
||||
with Image.open(BytesIO(photo_data)) as photo:
|
||||
canvas.paste(photo, (0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
rows = (
|
||||
[(24, 44), (88, 36), (140, 34)]
|
||||
if landscape
|
||||
else [(40, 52), (130, 40), (200, 38)]
|
||||
)
|
||||
values = [caption["title"], caption["date"], caption.get("place", "")]
|
||||
for text, (offset, font_size) in zip(values, rows, strict=True):
|
||||
if not text:
|
||||
continue
|
||||
values = [
|
||||
" ".join(str(caption.get(key, "")).split())
|
||||
for key in ("title", "date", "place")
|
||||
]
|
||||
text = " · ".join(value for value in values if value)
|
||||
font_size = layout.font_size
|
||||
font = font_loader(font_size)
|
||||
available = width - 2 * layout.margin
|
||||
while font_size > 24 and draw.textlength(text, font=font) > available:
|
||||
font_size -= 2
|
||||
font = font_loader(font_size)
|
||||
while font_size > 24 and draw.textlength(text, font=font) > width - 120:
|
||||
font_size -= 2
|
||||
font = font_loader(font_size)
|
||||
while draw.textlength(text, font=font) > width - 120 and text:
|
||||
text = text[:-2] + "…"
|
||||
draw.text(
|
||||
(width / 2, photo_height + offset),
|
||||
text,
|
||||
font=font,
|
||||
fill="black",
|
||||
anchor="mt",
|
||||
)
|
||||
# Shorten the title before touching the date/location. Never wrap the band.
|
||||
while draw.textlength(text, font=font) > available and len(values[0]) > 2:
|
||||
values[0] = values[0].rstrip("…")[:-1] + "…"
|
||||
text = " · ".join(value for value in values if value)
|
||||
while draw.textlength(text, font=font) > available and len(text) > 2:
|
||||
text = text.rstrip("…")[:-1] + "…"
|
||||
draw.text(
|
||||
(layout.margin, photo_height + layout.footer_height / 2),
|
||||
text,
|
||||
font=font,
|
||||
fill="black",
|
||||
anchor="lm",
|
||||
)
|
||||
out = BytesIO()
|
||||
canvas.save(out, "JPEG", quality=93, optimize=True)
|
||||
return out.getvalue()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Layout-only extension point for a future AI layout provider."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhotoLayout:
|
||||
footer_height: int
|
||||
font_size: int
|
||||
margin: int
|
||||
|
||||
|
||||
class PhotoLayoutProvider(Protocol):
|
||||
def layout(self, size: tuple[int, int], caption: dict[str, str]) -> PhotoLayout: ...
|
||||
|
||||
|
||||
class WatermarkLayout:
|
||||
def layout(self, size: tuple[int, int], caption: dict[str, str]) -> PhotoLayout:
|
||||
width, height = size
|
||||
return PhotoLayout(
|
||||
footer_height=max(60, round(min(width, height) * 0.06)),
|
||||
font_size=max(24, round(min(width, height) * 0.03)),
|
||||
margin=max(24, round(width * 0.025)),
|
||||
)
|
||||
Reference in New Issue
Block a user