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