|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +OG card generator — with round avatar + bigger GitHub mark + bottom bar. |
| 4 | +Saves: social_preview.png |
| 5 | +""" |
| 6 | + |
| 7 | +import os |
| 8 | +import argparse |
| 9 | +from PIL import Image, ImageDraw, ImageFont, ImageOps |
| 10 | + |
| 11 | +FONT_REGULAR = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" |
| 12 | +FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" |
| 13 | + |
| 14 | +def load_font(path, size): |
| 15 | + try: |
| 16 | + return ImageFont.truetype(path, size) |
| 17 | + except Exception: |
| 18 | + return ImageFont.load_default() |
| 19 | + |
| 20 | +def measure(draw, text, font): |
| 21 | + try: |
| 22 | + b = draw.textbbox((0,0), text, font=font) |
| 23 | + return b[2]-b[0], b[3]-b[1] |
| 24 | + except Exception: |
| 25 | + try: |
| 26 | + return font.getsize(text) |
| 27 | + except Exception: |
| 28 | + return (len(text)*6, 20) |
| 29 | + |
| 30 | +def wrap_text(text, font, max_w, draw): |
| 31 | + words = text.split() |
| 32 | + lines = [] |
| 33 | + cur = "" |
| 34 | + for w in words: |
| 35 | + test = cur + (" " if cur else "") + w |
| 36 | + w_px, _ = measure(draw, test, font) |
| 37 | + if w_px <= max_w: |
| 38 | + cur = test |
| 39 | + else: |
| 40 | + if cur: |
| 41 | + lines.append(cur) |
| 42 | + cur = w |
| 43 | + if cur: |
| 44 | + lines.append(cur) |
| 45 | + return lines |
| 46 | + |
| 47 | +def draw_stats(draw, x, y, font, color): |
| 48 | + items = [("Contributors", "1"), ("Issues", "0"), ("Stars", "0"), ("Forks", "0")] |
| 49 | + spacing = 64 |
| 50 | + for label, count in items: |
| 51 | + text = f"{count} {label}" |
| 52 | + draw.text((x, y), text, font=font, fill=color) |
| 53 | + w, _ = measure(draw, text, font) |
| 54 | + x += w + spacing |
| 55 | + |
| 56 | +def crop_circle(im): |
| 57 | + """Returns a perfectly circular cropped version of the image.""" |
| 58 | + w, h = im.size |
| 59 | + size = min(w, h) |
| 60 | + im = im.crop(((w - size) // 2, (h - size) // 2, (w + size) // 2, (h + size) // 2)) # square |
| 61 | + mask = Image.new("L", (size, size), 0) |
| 62 | + draw = ImageDraw.Draw(mask) |
| 63 | + draw.ellipse((0,0,size,size), fill=255) |
| 64 | + output = Image.new("RGBA", (size, size), (0,0,0,0)) |
| 65 | + output.paste(im, (0,0), mask) |
| 66 | + return output |
| 67 | + |
| 68 | +def draw_github_fallback(draw, gx, gy, size=48): |
| 69 | + """Draw fallback GH icon if github-mark.png missing.""" |
| 70 | + r = size // 6 |
| 71 | + rect = [gx, gy, gx + size, gy + size] |
| 72 | + draw.rounded_rectangle(rect, radius=r, fill=(36, 41, 46)) |
| 73 | + f = load_font(FONT_BOLD, size//2) |
| 74 | + tw, th = measure(draw, "GH", f) |
| 75 | + tx = gx + (size - tw)//2 |
| 76 | + ty = gy + (size - th)//2 |
| 77 | + draw.text((tx, ty), "GH", font=f, fill=(255,255,255)) |
| 78 | + |
| 79 | +def main(): |
| 80 | + ap = argparse.ArgumentParser() |
| 81 | + ap.add_argument("--output", default="social_preview.png") |
| 82 | + ap.add_argument("--title", default="username/repo") |
| 83 | + ap.add_argument("--subtitle", default="A project description.") |
| 84 | + ap.add_argument("--author", default="") |
| 85 | + ap.add_argument("--sha", default="") |
| 86 | + ap.add_argument("--logo", default="assets/brand-logo.png") |
| 87 | + ap.add_argument("--github-mark", default="assets/github-mark.png") |
| 88 | + args = ap.parse_args() |
| 89 | + |
| 90 | + W, H = 1280, 640 |
| 91 | + BG = (255,255,255) |
| 92 | + TEXT = (28,32,36) |
| 93 | + SUB = (98,108,118) |
| 94 | + STATS = (100,110,124) |
| 95 | + |
| 96 | + img = Image.new("RGB", (W,H), BG) |
| 97 | + draw = ImageDraw.Draw(img) |
| 98 | + |
| 99 | + left = 100 |
| 100 | + right = W - 260 |
| 101 | + maxw = right - left |
| 102 | + |
| 103 | + raw = args.title or "unknown/repo" |
| 104 | + if "/" in raw: |
| 105 | + owner, repo = raw.split("/", 1) |
| 106 | + else: |
| 107 | + owner, repo = "", raw |
| 108 | + |
| 109 | + f_owner = load_font(FONT_REGULAR, 28) |
| 110 | + f_repo = load_font(FONT_BOLD, 64) |
| 111 | + f_desc = load_font(FONT_REGULAR, 26) |
| 112 | + f_stats = load_font(FONT_REGULAR, 22) |
| 113 | + |
| 114 | + # Owner |
| 115 | + y = 120 |
| 116 | + if owner: |
| 117 | + draw.text((left, y), f"{owner}/", font=f_owner, fill=SUB) |
| 118 | + _, oh = measure(draw, f"{owner}/", f_owner) |
| 119 | + y += oh + 10 |
| 120 | + |
| 121 | + # Repo (shrink as needed) |
| 122 | + rw, rh = measure(draw, repo, f_repo) |
| 123 | + if rw > maxw: |
| 124 | + for s in range(64, 28, -2): |
| 125 | + f_repo = load_font(FONT_BOLD, s) |
| 126 | + rw, rh = measure(draw, repo, f_repo) |
| 127 | + if rw <= maxw: |
| 128 | + break |
| 129 | + draw.text((left, y), repo, font=f_repo, fill=TEXT) |
| 130 | + y += rh + 18 |
| 131 | + |
| 132 | + # Description |
| 133 | + lines = wrap_text(args.subtitle, f_desc, maxw, draw)[:3] |
| 134 | + for line in lines: |
| 135 | + draw.text((left, y), line, font=f_desc, fill=SUB) |
| 136 | + _, lh = measure(draw, line, f_desc) |
| 137 | + y += lh + 6 |
| 138 | + |
| 139 | + # Stats |
| 140 | + y += 18 |
| 141 | + draw_stats(draw, left, y, f_stats, STATS) |
| 142 | + |
| 143 | + # Meta bottom-left |
| 144 | + meta = "" |
| 145 | + if args.author: |
| 146 | + meta = f"by {args.author}" |
| 147 | + if args.sha: |
| 148 | + meta += f" • {args.sha[:7]}" |
| 149 | + draw.text((left, H-64), meta, font=f_stats, fill=SUB) |
| 150 | + |
| 151 | + # Round avatar (top-right) |
| 152 | + if os.path.exists(args.logo): |
| 153 | + try: |
| 154 | + avatar = Image.open(args.logo).convert("RGBA") |
| 155 | + avatar = crop_circle(avatar) |
| 156 | + avatar = avatar.resize((180,180), Image.LANCZOS) |
| 157 | + |
| 158 | + # Optional white border |
| 159 | + border = ImageOps.expand(avatar, border=6, fill="white") |
| 160 | + |
| 161 | + # Position |
| 162 | + ax = W - 260 + (260 - border.size[0])//2 |
| 163 | + ay = 100 |
| 164 | + img.paste(border, (ax, ay), border) |
| 165 | + |
| 166 | + except Exception as e: |
| 167 | + print("Avatar error:", e) |
| 168 | + |
| 169 | + # Bottom color bar |
| 170 | + bar_h = 18 |
| 171 | + draw.rectangle([0, H-bar_h, int(W*0.6), H], fill=(232,76,61)) # red |
| 172 | + draw.rectangle([int(W*0.6), H-bar_h, W, H], fill=(44,111,180)) # blue |
| 173 | + |
| 174 | + # Bigger GitHub icon |
| 175 | + gh_size = 48 |
| 176 | + gx = W - 48 - gh_size |
| 177 | + gy = H - bar_h - gh_size - 12 |
| 178 | + |
| 179 | + if os.path.exists(args.github_mark): |
| 180 | + try: |
| 181 | + gh = Image.open(args.github_mark).convert("RGBA") |
| 182 | + gh.thumbnail((gh_size, gh_size)) |
| 183 | + img.paste(gh, (gx, gy), gh) |
| 184 | + except: |
| 185 | + draw_github_fallback(draw, gx, gy, size=gh_size) |
| 186 | + else: |
| 187 | + draw_github_fallback(draw, gx, gy, size=gh_size) |
| 188 | + |
| 189 | + # Save final image |
| 190 | + img.save(args.output, quality=95) |
| 191 | + print("Generated", args.output) |
| 192 | + |
| 193 | +if __name__ == "__main__": |
| 194 | + main() |
0 commit comments