thumbnail generator for recipes in presentation
This commit is contained in:
254
thumbnail.py
Normal file
254
thumbnail.py
Normal file
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
thumbnail.py — generate a link-preview card thumbnail from a screenshot.
|
||||
|
||||
Usage:
|
||||
python3 thumbnail.py <screenshot> <url> <title> [-o output.png] [--width 800]
|
||||
|
||||
Example:
|
||||
python3 thumbnail.py page.png "souschef.wahwa.com" "Chana Masala" -o thumb.png
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||||
except ImportError:
|
||||
sys.exit("Install Pillow: pip install Pillow")
|
||||
|
||||
# Render at 2× then downsample — gives crisp text and sharp edges.
|
||||
RENDER_SCALE = 2
|
||||
|
||||
# ── visual constants (all at 1× display size) ─────────────────────────────────
|
||||
BG_COLOR = (255, 251, 235) # warm cream
|
||||
CARD_COLOR = (255, 255, 255)
|
||||
URL_COLOR = (37, 99, 235) # blue-600
|
||||
TITLE_COLOR = (17, 24, 39) # gray-900
|
||||
DIVIDER_COLOR = (229, 231, 235) # gray-200
|
||||
ICON_BG_COLOR = (240, 241, 243) # gray-100
|
||||
ICON_FG_COLOR = (100, 108, 120) # gray-500
|
||||
|
||||
CORNER_RADIUS = 16
|
||||
CARD_PADDING = 24
|
||||
HEADER_V_PAD = 20
|
||||
ICON_SIZE = 44 # circle diameter
|
||||
TEXT_GAP = 14
|
||||
CANVAS_PAD = 44
|
||||
MAX_CONTENT_H = 640
|
||||
|
||||
|
||||
# ── fonts ─────────────────────────────────────────────────────────────────────
|
||||
def _load_font(paths, size):
|
||||
for p in paths:
|
||||
try:
|
||||
return ImageFont.truetype(p, size)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def get_font(size, bold=False):
|
||||
if bold:
|
||||
paths = [
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
||||
"/Library/Fonts/Arial Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
||||
]
|
||||
else:
|
||||
paths = [
|
||||
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"/Library/Fonts/Arial.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||
]
|
||||
return _load_font(paths, size)
|
||||
|
||||
|
||||
# ── text measurement ──────────────────────────────────────────────────────────
|
||||
def tw(f, s):
|
||||
bb = f.getbbox(s)
|
||||
return bb[2] - bb[0]
|
||||
|
||||
|
||||
def th(f, s="Ag"):
|
||||
bb = f.getbbox(s)
|
||||
return bb[3] - bb[1]
|
||||
|
||||
|
||||
# ── paperclip icon ────────────────────────────────────────────────────────────
|
||||
def draw_paperclip_icon(draw, cx, cy, r, color, lw):
|
||||
"""
|
||||
Two concentric rounded-rectangle outlines sharing the same top arc —
|
||||
the classic paperclip silhouette.
|
||||
"""
|
||||
# Outer pill
|
||||
ow = int(r * 0.50) # half-width
|
||||
oh = int(r * 0.82) # half-height
|
||||
draw.rounded_rectangle(
|
||||
[cx - ow, cy - oh, cx + ow, cy + oh],
|
||||
radius=ow, # = half-width → perfect semicircular ends
|
||||
outline=color,
|
||||
width=lw,
|
||||
)
|
||||
# Inner clip: same top, ends ~55% down
|
||||
iw = int(r * 0.21)
|
||||
i_top = cy - oh + lw + max(2, lw // 2) # just clear of outer top arc
|
||||
i_bot = cy + int(r * 0.46)
|
||||
draw.rounded_rectangle(
|
||||
[cx - iw, i_top, cx + iw, i_bot],
|
||||
radius=iw,
|
||||
outline=color,
|
||||
width=lw,
|
||||
)
|
||||
|
||||
|
||||
def draw_icon(draw, cx, cy, r):
|
||||
"""Paperclip inside a light circle."""
|
||||
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=ICON_BG_COLOR)
|
||||
lw = max(2, r // 9)
|
||||
draw_paperclip_icon(draw, cx, cy, r, ICON_FG_COLOR, lw)
|
||||
|
||||
|
||||
# ── external-link glyph ───────────────────────────────────────────────────────
|
||||
def draw_ext_link(draw, x, y, s, color, lw):
|
||||
"""
|
||||
Classic external-link indicator: small box (lower-left) with an arrow
|
||||
exiting through the upper-right corner.
|
||||
"""
|
||||
m = lw
|
||||
# Box occupies the lower-left ~60% of the bounding square
|
||||
bx2 = x + int(s * 0.62)
|
||||
by1 = y + int(s * 0.38)
|
||||
draw.rounded_rectangle(
|
||||
[x + m, by1, bx2, y + s - m],
|
||||
radius=max(1, s // 7),
|
||||
outline=color,
|
||||
width=lw,
|
||||
)
|
||||
# Diagonal shaft from inside the box toward upper-right
|
||||
ax1, ay1 = x + int(s * 0.34), y + int(s * 0.66)
|
||||
ax2, ay2 = x + s - m, y + m
|
||||
draw.line([(ax1, ay1), (ax2, ay2)], fill=color, width=lw)
|
||||
# Arrowhead (two orthogonal tails at the tip)
|
||||
aw = int(s * 0.30)
|
||||
draw.line([(ax2 - aw, ay2), (ax2, ay2)], fill=color, width=lw)
|
||||
draw.line([(ax2, ay2), (ax2, ay2 + aw)], fill=color, width=lw)
|
||||
|
||||
|
||||
# ── main ──────────────────────────────────────────────────────────────────────
|
||||
def create_thumbnail(screenshot_path, url, title,
|
||||
output_path="thumbnail.png", card_width=800):
|
||||
S = RENDER_SCALE
|
||||
|
||||
screenshot = Image.open(screenshot_path).convert("RGB")
|
||||
|
||||
url_f = get_font(17 * S)
|
||||
title_f = get_font(22 * S, bold=True)
|
||||
|
||||
uh = th(url_f, url)
|
||||
th_ = th(title_f, title)
|
||||
text_block_h = uh + 8 * S + th_
|
||||
|
||||
# Scaled geometry
|
||||
CW = card_width * S
|
||||
CP = CARD_PADDING * S
|
||||
HVP = HEADER_V_PAD * S
|
||||
IS = ICON_SIZE * S
|
||||
TG = TEXT_GAP * S
|
||||
CR = CORNER_RADIUS * S
|
||||
CNVP = CANVAS_PAD * S
|
||||
MCH = MAX_CONTENT_H * S
|
||||
|
||||
header_h = HVP + max(IS, text_block_h) + HVP
|
||||
|
||||
# Scale screenshot to fill card width; crop height
|
||||
raw_h = int(screenshot.height * CW / screenshot.width)
|
||||
content_h = min(raw_h, MCH)
|
||||
ss = screenshot.resize((CW, raw_h), Image.LANCZOS).crop((0, 0, CW, content_h))
|
||||
|
||||
card_h = header_h + S + content_h
|
||||
canvas_w = CW + CNVP * 2
|
||||
canvas_h = card_h + CNVP * 2
|
||||
|
||||
canvas = Image.new("RGBA", (canvas_w, canvas_h), BG_COLOR + (255,))
|
||||
|
||||
# Drop shadow
|
||||
shadow = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
|
||||
sd = ImageDraw.Draw(shadow)
|
||||
off = 6 * S
|
||||
sd.rounded_rectangle(
|
||||
[CNVP + off, CNVP + off, CNVP + CW + off, CNVP + card_h + off],
|
||||
radius=CR, fill=(0, 0, 0, 38),
|
||||
)
|
||||
shadow = shadow.filter(ImageFilter.GaussianBlur(11 * S))
|
||||
canvas.alpha_composite(shadow)
|
||||
|
||||
# White card
|
||||
card_l = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(card_l).rounded_rectangle(
|
||||
[CNVP, CNVP, CNVP + CW, CNVP + card_h],
|
||||
radius=CR, fill=CARD_COLOR + (255,),
|
||||
)
|
||||
canvas.alpha_composite(card_l)
|
||||
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
# Icon (vertically centred in header)
|
||||
icx = CNVP + CP + IS // 2
|
||||
icy = CNVP + header_h // 2
|
||||
draw_icon(draw, icx, icy, IS // 2)
|
||||
|
||||
# URL text
|
||||
tx = CNVP + CP + IS + TG
|
||||
ty_url = CNVP + (header_h - text_block_h) // 2
|
||||
draw.text((tx, ty_url), url, font=url_f, fill=URL_COLOR)
|
||||
|
||||
# External-link glyph (same height as URL text, a little larger)
|
||||
uw = tw(url_f, url)
|
||||
glyph_s = int(uh * 1.05)
|
||||
glyph_lw = max(2, S)
|
||||
draw_ext_link(
|
||||
draw,
|
||||
tx + uw + 6 * S,
|
||||
ty_url + (uh - glyph_s) // 2,
|
||||
glyph_s, URL_COLOR, glyph_lw,
|
||||
)
|
||||
|
||||
# Title
|
||||
draw.text((tx, ty_url + uh + 8 * S), title, font=title_f, fill=TITLE_COLOR)
|
||||
|
||||
# Divider
|
||||
div_y = CNVP + header_h
|
||||
draw.line([(CNVP, div_y), (CNVP + CW, div_y)], fill=DIVIDER_COLOR, width=S)
|
||||
|
||||
# Screenshot (edge-to-edge inside card)
|
||||
rgb = canvas.convert("RGB")
|
||||
rgb.paste(ss, (CNVP, div_y + S))
|
||||
|
||||
# Clip to rounded card shape
|
||||
mask = Image.new("L", (canvas_w, canvas_h), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle(
|
||||
[CNVP, CNVP, CNVP + CW, CNVP + card_h], radius=CR, fill=255,
|
||||
)
|
||||
bg = Image.new("RGB", (canvas_w, canvas_h), BG_COLOR)
|
||||
bg.paste(rgb, mask=mask)
|
||||
|
||||
# Downsample to 1× for crisp output
|
||||
out_w, out_h = canvas_w // S, canvas_h // S
|
||||
bg.resize((out_w, out_h), Image.LANCZOS).save(output_path)
|
||||
print(f"Saved: {output_path} ({out_w} × {out_h})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description="Generate a link-preview thumbnail card")
|
||||
ap.add_argument("screenshot", help="Path to the input screenshot")
|
||||
ap.add_argument("url", help='URL label, e.g. "souschef.wahwa.com"')
|
||||
ap.add_argument("title", help="Page title displayed in bold")
|
||||
ap.add_argument("-o", "--output", default="thumbnail.png",
|
||||
help="Output image path (default: thumbnail.png)")
|
||||
ap.add_argument("--width", type=int, default=800,
|
||||
help="Card width in pixels (default: 800)")
|
||||
args = ap.parse_args()
|
||||
create_thumbnail(args.screenshot, args.url, args.title, args.output, args.width)
|
||||
Reference in New Issue
Block a user