|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Generate GitHub social preview image for CHSZLabLib (1280x640).""" |
| 3 | + |
| 4 | +from PIL import Image, ImageDraw, ImageFont |
| 5 | +import math |
| 6 | + |
| 7 | +W, H = 1280, 640 |
| 8 | + |
| 9 | +# Colors |
| 10 | +BG_DARK = (15, 23, 42) # slate-900 |
| 11 | +BG_MID = (30, 41, 59) # slate-800 |
| 12 | +ACCENT = (59, 130, 246) # blue-500 |
| 13 | +ACCENT_LIGHT = (96, 165, 250) # blue-400 |
| 14 | +WHITE = (255, 255, 255) |
| 15 | +GRAY = (148, 163, 184) # slate-400 |
| 16 | +LIGHT_GRAY = (203, 213, 225) # slate-300 |
| 17 | +GREEN = (34, 197, 94) # green-500 |
| 18 | +PURPLE = (168, 85, 247) # purple-500 |
| 19 | +ORANGE = (249, 115, 22) # orange-500 |
| 20 | +CYAN = (6, 182, 212) # cyan-500 |
| 21 | + |
| 22 | +# Fonts |
| 23 | +font_bold_64 = ImageFont.truetype("/usr/share/fonts/opentype/inter/Inter-Bold.otf", 64) |
| 24 | +font_bold_48 = ImageFont.truetype("/usr/share/fonts/opentype/inter/Inter-Bold.otf", 48) |
| 25 | +font_semi_28 = ImageFont.truetype("/usr/share/fonts/opentype/inter/Inter-SemiBold.otf", 28) |
| 26 | +font_med_24 = ImageFont.truetype("/usr/share/fonts/opentype/inter/Inter-Medium.otf", 24) |
| 27 | +font_reg_22 = ImageFont.truetype("/usr/share/fonts/opentype/inter/Inter-Regular.otf", 22) |
| 28 | +font_mono_20 = ImageFont.truetype("/home/c_schulz/.local/share/fonts/FiraCodeNerdFont-Bold.ttf", 20) |
| 29 | +font_mono_18 = ImageFont.truetype("/home/c_schulz/.local/share/fonts/FiraCodeNerdFont-Bold.ttf", 18) |
| 30 | + |
| 31 | +img = Image.new("RGB", (W, H), BG_DARK) |
| 32 | +draw = ImageDraw.Draw(img) |
| 33 | + |
| 34 | +# Subtle gradient overlay (top to bottom) |
| 35 | +for y in range(H): |
| 36 | + alpha = y / H |
| 37 | + r = int(BG_DARK[0] * (1 - alpha * 0.3) + BG_MID[0] * (alpha * 0.3)) |
| 38 | + g = int(BG_DARK[1] * (1 - alpha * 0.3) + BG_MID[1] * (alpha * 0.3)) |
| 39 | + b = int(BG_DARK[2] * (1 - alpha * 0.3) + BG_MID[2] * (alpha * 0.3)) |
| 40 | + draw.line([(0, y), (W, y)], fill=(r, g, b)) |
| 41 | + |
| 42 | +# Draw decorative graph nodes and edges in background |
| 43 | +nodes = [ |
| 44 | + (95, 120, 8, ACCENT), |
| 45 | + (180, 80, 6, PURPLE), |
| 46 | + (140, 200, 7, CYAN), |
| 47 | + (250, 150, 5, GREEN), |
| 48 | + (60, 250, 6, ACCENT_LIGHT), |
| 49 | + (200, 280, 5, ORANGE), |
| 50 | + # Right side |
| 51 | + (1100, 100, 7, ACCENT), |
| 52 | + (1200, 160, 6, PURPLE), |
| 53 | + (1150, 250, 8, CYAN), |
| 54 | + (1050, 200, 5, GREEN), |
| 55 | + (1220, 80, 5, ACCENT_LIGHT), |
| 56 | + (1080, 310, 6, ORANGE), |
| 57 | + # Bottom corners |
| 58 | + (100, 500, 6, ACCENT), |
| 59 | + (200, 550, 5, PURPLE), |
| 60 | + (1150, 520, 7, CYAN), |
| 61 | + (1050, 570, 5, GREEN), |
| 62 | + (160, 580, 4, ACCENT_LIGHT), |
| 63 | + (1200, 560, 5, ORANGE), |
| 64 | +] |
| 65 | + |
| 66 | +edges = [ |
| 67 | + (0, 1), (0, 2), (1, 3), (2, 4), (2, 5), (3, 5), (4, 5), |
| 68 | + (6, 7), (6, 8), (7, 9), (8, 9), (6, 10), (8, 11), (9, 11), |
| 69 | + (12, 13), (13, 16), (12, 16), |
| 70 | + (14, 15), (14, 17), (15, 17), |
| 71 | +] |
| 72 | + |
| 73 | +for i, j in edges: |
| 74 | + x1, y1 = nodes[i][0], nodes[i][1] |
| 75 | + x2, y2 = nodes[j][0], nodes[j][1] |
| 76 | + c = tuple(int(v * 0.25) for v in nodes[i][3]) |
| 77 | + draw.line([(x1, y1), (x2, y2)], fill=c, width=2) |
| 78 | + |
| 79 | +for x, y, r, color in nodes: |
| 80 | + faded = tuple(int(v * 0.4) for v in color) |
| 81 | + draw.ellipse([x - r, y - r, x + r, y + r], fill=faded) |
| 82 | + # inner bright dot |
| 83 | + r2 = max(2, r - 3) |
| 84 | + bright = tuple(min(255, int(v * 0.6)) for v in color) |
| 85 | + draw.ellipse([x - r2, y - r2, x + r2, y + r2], fill=bright) |
| 86 | + |
| 87 | +# Accent line at top |
| 88 | +draw.rectangle([0, 0, W, 4], fill=ACCENT) |
| 89 | + |
| 90 | +# Title — "CHSZ" in red, "LabLib" in white |
| 91 | +RED = (220, 38, 38) |
| 92 | +part1 = "CHSZ" |
| 93 | +part2 = "LabLib" |
| 94 | +bbox1 = draw.textbbox((0, 0), part1, font=font_bold_64) |
| 95 | +bbox2 = draw.textbbox((0, 0), part2, font=font_bold_64) |
| 96 | +tw = (bbox1[2] - bbox1[0]) + (bbox2[2] - bbox2[0]) |
| 97 | +tx = (W - tw) // 2 |
| 98 | +draw.text((tx, 100), part1, fill=RED, font=font_bold_64) |
| 99 | +tx += bbox1[2] - bbox1[0] |
| 100 | +draw.text((tx, 100), part2, fill=WHITE, font=font_bold_64) |
| 101 | + |
| 102 | +# Subtitle |
| 103 | +sub = "State-of-the-art graph algorithms from C++ to Python" |
| 104 | +bbox = draw.textbbox((0, 0), sub, font=font_semi_28) |
| 105 | +sw = bbox[2] - bbox[0] |
| 106 | +draw.text(((W - sw) // 2, 185), sub, fill=LIGHT_GRAY, font=font_semi_28) |
| 107 | + |
| 108 | +# Divider |
| 109 | +div_y = 235 |
| 110 | +div_w = 200 |
| 111 | +draw.line([(W // 2 - div_w, div_y), (W // 2 + div_w, div_y)], fill=ACCENT, width=2) |
| 112 | + |
| 113 | +# Module boxes |
| 114 | +modules = [ |
| 115 | + ("Decomposition", "Partition, Cuts, Cluster", ACCENT), |
| 116 | + ("Independence", "MIS, MWIS, HyperMIS", GREEN), |
| 117 | + ("Orientation", "Edge Orientation", PURPLE), |
| 118 | + ("Dynamic", "Dynamic Problems", ORANGE), |
| 119 | +] |
| 120 | + |
| 121 | +box_w = 250 |
| 122 | +box_h = 90 |
| 123 | +gap = 30 |
| 124 | +total_w = len(modules) * box_w + (len(modules) - 1) * gap |
| 125 | +start_x = (W - total_w) // 2 |
| 126 | +box_y = 265 |
| 127 | + |
| 128 | +for i, (name, desc, color) in enumerate(modules): |
| 129 | + x = start_x + i * (box_w + gap) |
| 130 | + # Box background |
| 131 | + box_color = tuple(int(v * 0.15) + BG_DARK[j] for j, v in enumerate(color)) |
| 132 | + draw.rounded_rectangle([x, box_y, x + box_w, box_y + box_h], radius=10, fill=box_color) |
| 133 | + # Border |
| 134 | + border_color = tuple(int(v * 0.5) for v in color) |
| 135 | + draw.rounded_rectangle([x, box_y, x + box_w, box_y + box_h], radius=10, outline=border_color, width=2) |
| 136 | + # Top accent |
| 137 | + draw.line([(x + 10, box_y + 1), (x + box_w - 10, box_y + 1)], fill=color, width=2) |
| 138 | + # Name |
| 139 | + bbox = draw.textbbox((0, 0), name, font=font_med_24) |
| 140 | + nw = bbox[2] - bbox[0] |
| 141 | + draw.text((x + (box_w - nw) // 2, box_y + 15), name, fill=WHITE, font=font_med_24) |
| 142 | + # Description |
| 143 | + bbox = draw.textbbox((0, 0), desc, font=font_reg_22) |
| 144 | + dw = bbox[2] - bbox[0] |
| 145 | + draw.text((x + (box_w - dw) // 2, box_y + 50), desc, fill=GRAY, font=font_reg_22) |
| 146 | + |
| 147 | +# Code snippet |
| 148 | +code_y = 390 |
| 149 | +code_bg = (22, 30, 48) |
| 150 | +code_h = 130 |
| 151 | +code_w = 700 |
| 152 | +code_x = (W - code_w) // 2 |
| 153 | +draw.rounded_rectangle([code_x, code_y, code_x + code_w, code_y + code_h], radius=12, fill=code_bg) |
| 154 | +draw.rounded_rectangle([code_x, code_y, code_x + code_w, code_y + code_h], radius=12, outline=(51, 65, 85), width=1) |
| 155 | + |
| 156 | +# Terminal dots |
| 157 | +for ci, col in enumerate([(239, 68, 68), (250, 204, 21), (34, 197, 94)]): |
| 158 | + draw.ellipse([code_x + 16 + ci * 20, code_y + 12, code_x + 28 + ci * 20, code_y + 24], fill=col) |
| 159 | + |
| 160 | +lines = [ |
| 161 | + ("from", (198, 120, 221)), # keyword |
| 162 | + (" chszlablib ", LIGHT_GRAY), |
| 163 | + ("import", (198, 120, 221)), |
| 164 | + (" Graph, Decomposition", (230, 192, 123)), |
| 165 | +] |
| 166 | +lx = code_x + 20 |
| 167 | +ly = code_y + 40 |
| 168 | +for text, color in lines: |
| 169 | + draw.text((lx, ly), text, fill=color, font=font_mono_18) |
| 170 | + bbox = draw.textbbox((0, 0), text, font=font_mono_18) |
| 171 | + lx += bbox[2] - bbox[0] |
| 172 | + |
| 173 | +# Second line |
| 174 | +lx = code_x + 20 |
| 175 | +ly = code_y + 68 |
| 176 | +line2 = [ |
| 177 | + ("g = Graph(", LIGHT_GRAY), |
| 178 | + ("n=100", (230, 192, 123)), |
| 179 | + (").add_edges(", LIGHT_GRAY), |
| 180 | + ("edges", (152, 195, 121)), |
| 181 | + (").finalize()", LIGHT_GRAY), |
| 182 | +] |
| 183 | +for text, color in line2: |
| 184 | + draw.text((lx, ly), text, fill=color, font=font_mono_18) |
| 185 | + bbox = draw.textbbox((0, 0), text, font=font_mono_18) |
| 186 | + lx += bbox[2] - bbox[0] |
| 187 | + |
| 188 | +# Third line |
| 189 | +lx = code_x + 20 |
| 190 | +ly = code_y + 96 |
| 191 | +line3 = [ |
| 192 | + ("result = Decomposition.partition(g, k=", LIGHT_GRAY), |
| 193 | + ("4", (230, 192, 123)), |
| 194 | + (")", LIGHT_GRAY), |
| 195 | +] |
| 196 | +for text, color in line3: |
| 197 | + draw.text((lx, ly), text, fill=color, font=font_mono_18) |
| 198 | + bbox = draw.textbbox((0, 0), text, font=font_mono_18) |
| 199 | + lx += bbox[2] - bbox[0] |
| 200 | + |
| 201 | +# Footer |
| 202 | +footer = "Algorithm Engineering Group, Heidelberg University" |
| 203 | +bbox = draw.textbbox((0, 0), footer, font=font_reg_22) |
| 204 | +fw = bbox[2] - bbox[0] |
| 205 | + |
| 206 | +# Badges row — colors from README shields.io badges |
| 207 | +badges = ["Python 3.9+", "C++17", "pybind11"] |
| 208 | +badge_font = font_mono_20 |
| 209 | +badge_gap = 20 |
| 210 | +badge_colors = [ |
| 211 | + (0x37, 0x76, 0xab), # #3776ab — Python badge |
| 212 | + (0x00, 0x59, 0x9C), # #00599C — C++ badge |
| 213 | + (0x06, 0x4F, 0x8C), # #064F8C — build/cmake badge |
| 214 | +] |
| 215 | + |
| 216 | +# Measure total width |
| 217 | +total_badge_w = 0 |
| 218 | +badge_dims = [] |
| 219 | +for b in badges: |
| 220 | + bbox = draw.textbbox((0, 0), b, font=badge_font) |
| 221 | + bw = bbox[2] - bbox[0] + 24 |
| 222 | + bh = 30 |
| 223 | + badge_dims.append((bw, bh)) |
| 224 | + total_badge_w += bw |
| 225 | +total_badge_w += (len(badges) - 1) * badge_gap |
| 226 | + |
| 227 | +bx = (W - total_badge_w) // 2 |
| 228 | +by = 548 |
| 229 | + |
| 230 | +for i, (b, (bw, bh)) in enumerate(zip(badges, badge_dims)): |
| 231 | + col = badge_colors[i] |
| 232 | + bg = tuple(int(v * 0.3) for v in col) |
| 233 | + draw.rounded_rectangle([bx, by, bx + bw, by + bh], radius=5, fill=bg) |
| 234 | + draw.rounded_rectangle([bx, by, bx + bw, by + bh], radius=5, outline=col, width=1) |
| 235 | + bbox = draw.textbbox((0, 0), b, font=badge_font) |
| 236 | + tw = bbox[2] - bbox[0] |
| 237 | + th = bbox[3] - bbox[1] |
| 238 | + draw.text((bx + (bw - tw) // 2, by + (bh - th) // 2 - 2), b, fill=WHITE, font=badge_font) |
| 239 | + bx += bw + badge_gap |
| 240 | + |
| 241 | +# Footer text below badges |
| 242 | +draw.text(((W - fw) // 2, 588), footer, fill=GRAY, font=font_reg_22) |
| 243 | + |
| 244 | +out = "/home/c_schulz/projects/coding/CHSZLabLib/social-preview.png" |
| 245 | +img.save(out, "PNG") |
| 246 | +print(f"Saved: {out}") |
| 247 | +print(f"Size: {img.size}") |
0 commit comments