diff --git a/thumbnail.py b/thumbnail.py new file mode 100644 index 0000000..1d3245e --- /dev/null +++ b/thumbnail.py @@ -0,0 +1,254 @@ +#!/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)