#!/usr/bin/env python3 """ thumbnail.py — generate a link-preview card thumbnail from a screenshot. Usage: python3 thumbnail.py [-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)