|
| 1 | +"""Generate the OG cover image for superuserlabs.org. |
| 2 | +
|
| 3 | +Renders at 2x and downscales for sharp antialiasing. |
| 4 | +Requires: Pillow (pip install Pillow) |
| 5 | +Usage: python scripts/generate_og_cover.py |
| 6 | +""" |
| 7 | + |
| 8 | +from PIL import Image, ImageDraw, ImageFont |
| 9 | +from pathlib import Path |
| 10 | + |
| 11 | +BASE = Path(__file__).resolve().parent.parent / "media" |
| 12 | + |
| 13 | +SCALE = 2 |
| 14 | +W, H = 1200 * SCALE, 630 * SCALE |
| 15 | +BG_COLOR = (2, 6, 23) # slate-950 |
| 16 | + |
| 17 | + |
| 18 | +def main(): |
| 19 | + img = Image.new("RGBA", (W, H), BG_COLOR) |
| 20 | + draw = ImageDraw.Draw(img) |
| 21 | + |
| 22 | + s = SCALE |
| 23 | + font_bold = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 54 * s) |
| 24 | + font_tagline = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 22 * s) |
| 25 | + font_label = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 15 * s) |
| 26 | + font_url = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 15 * s) |
| 27 | + |
| 28 | + # --- Load and fix logo background to match page bg --- |
| 29 | + logo_full = Image.open(BASE / "superuserlabs-logo.png").convert("RGBA") |
| 30 | + lw, lh = logo_full.size |
| 31 | + pixels = logo_full.load() |
| 32 | + |
| 33 | + for y in range(lh): |
| 34 | + for x in range(lw): |
| 35 | + r, g, b, a = pixels[x, y] |
| 36 | + if r < 15 and g < 15 and b < 15: |
| 37 | + pixels[x, y] = (BG_COLOR[0], BG_COLOR[1], BG_COLOR[2], a) |
| 38 | + |
| 39 | + # Find SU text bounds (white pixels, not the green cursor) |
| 40 | + min_y_su, max_y_su = lh, 0 |
| 41 | + for y in range(lh): |
| 42 | + for x in range(lw): |
| 43 | + r, g, b, a = pixels[x, y] |
| 44 | + if r > 120 and b > 120: |
| 45 | + min_y_su = min(min_y_su, y) |
| 46 | + max_y_su = max(max_y_su, y) |
| 47 | + |
| 48 | + su_content_h = max_y_su - min_y_su |
| 49 | + su_top_frac = min_y_su / lh |
| 50 | + |
| 51 | + # --- Measure text --- |
| 52 | + bbox = draw.textbbox((0, 0), "Superuser Labs", font=font_bold) |
| 53 | + text_w = bbox[2] - bbox[0] |
| 54 | + bbox_cap = draw.textbbox((0, 0), "S", font=font_bold) |
| 55 | + cap_h = bbox_cap[3] - bbox_cap[1] |
| 56 | + |
| 57 | + # Size logo so SU text matches cap height |
| 58 | + logo_size = int(cap_h * 1.15 * lh / su_content_h) |
| 59 | + logo = logo_full.resize((logo_size, logo_size), Image.Resampling.LANCZOS) |
| 60 | + |
| 61 | + gap = 18 * s |
| 62 | + total_w = logo_size + gap + text_w |
| 63 | + |
| 64 | + # --- Vertical layout --- |
| 65 | + tagline = "Free and open-source software that empowers you" |
| 66 | + bbox_t = draw.textbbox((0, 0), tagline, font=font_tagline) |
| 67 | + tagline_h = bbox_t[3] - bbox_t[1] |
| 68 | + tw_t = bbox_t[2] - bbox_t[0] |
| 69 | + |
| 70 | + proj_size = 52 * s |
| 71 | + proj_label_h = 30 * s |
| 72 | + |
| 73 | + gap_to_tagline = 18 * s |
| 74 | + gap_to_divider = 44 * s |
| 75 | + gap_to_projects = 28 * s |
| 76 | + total_content = ( |
| 77 | + logo_size |
| 78 | + + gap_to_tagline |
| 79 | + + tagline_h |
| 80 | + + gap_to_divider |
| 81 | + + gap_to_projects |
| 82 | + + proj_size |
| 83 | + + proj_label_h |
| 84 | + ) |
| 85 | + |
| 86 | + content_top = (H - total_content) // 2 - 15 * s |
| 87 | + |
| 88 | + # --- Heading (logo + text) --- |
| 89 | + start_x = (W - total_w) // 2 |
| 90 | + name_y = content_top + int(su_top_frac * logo_size) - int(bbox[1]) |
| 91 | + logo_y = content_top |
| 92 | + |
| 93 | + img.paste(logo, (start_x, logo_y), logo) |
| 94 | + |
| 95 | + text_x = start_x + logo_size + gap |
| 96 | + bbox_su = draw.textbbox((0, 0), "Superuser ", font=font_bold) |
| 97 | + su_w = bbox_su[2] - bbox_su[0] |
| 98 | + draw.text((text_x, name_y), "Superuser ", fill=(255, 255, 255), font=font_bold) |
| 99 | + draw.text((text_x + su_w, name_y), "Labs", fill=(100, 116, 139), font=font_bold) |
| 100 | + |
| 101 | + # --- Tagline --- |
| 102 | + tagline_y = logo_y + logo_size + gap_to_tagline |
| 103 | + lockup_center = start_x + total_w // 2 |
| 104 | + page_center = W // 2 |
| 105 | + tagline_center = (lockup_center + page_center) // 2 |
| 106 | + draw.text( |
| 107 | + (tagline_center - tw_t // 2, tagline_y), |
| 108 | + tagline, |
| 109 | + fill=(148, 163, 184), |
| 110 | + font=font_tagline, |
| 111 | + ) |
| 112 | + |
| 113 | + # --- Divider --- |
| 114 | + line_y = tagline_y + tagline_h + gap_to_divider |
| 115 | + draw.line( |
| 116 | + [(page_center - 100 * s, line_y), (page_center + 100 * s, line_y)], |
| 117 | + fill=(51, 65, 85), |
| 118 | + width=s, |
| 119 | + ) |
| 120 | + |
| 121 | + # --- Projects --- |
| 122 | + projects = [ |
| 123 | + ("activitywatch-logo.png", "ActivityWatch"), |
| 124 | + ("gptme-logo.png", "gptme"), |
| 125 | + ] |
| 126 | + proj_gap = 95 * s |
| 127 | + proj_y = line_y + gap_to_projects |
| 128 | + total_pw = len(projects) * proj_size + (len(projects) - 1) * proj_gap |
| 129 | + pstart_x = (W - total_pw) // 2 |
| 130 | + |
| 131 | + for i, (fname, label) in enumerate(projects): |
| 132 | + p = ( |
| 133 | + Image.open(BASE / fname) |
| 134 | + .convert("RGBA") |
| 135 | + .resize((proj_size, proj_size), Image.Resampling.LANCZOS) |
| 136 | + ) |
| 137 | + px = pstart_x + i * (proj_size + proj_gap) |
| 138 | + img.paste(p, (px, proj_y), p) |
| 139 | + bbox_l = draw.textbbox((0, 0), label, font=font_label) |
| 140 | + lbw = bbox_l[2] - bbox_l[0] |
| 141 | + draw.text( |
| 142 | + (px + (proj_size - lbw) // 2, proj_y + proj_size + 8 * s), |
| 143 | + label, |
| 144 | + fill=(148, 163, 184), |
| 145 | + font=font_label, |
| 146 | + ) |
| 147 | + |
| 148 | + # --- URL --- |
| 149 | + url = "superuserlabs.org" |
| 150 | + bbox_u = draw.textbbox((0, 0), url, font=font_url) |
| 151 | + uw = bbox_u[2] - bbox_u[0] |
| 152 | + draw.text(((W - uw) // 2, H - 36 * s), url, fill=(148, 163, 184), font=font_url) |
| 153 | + |
| 154 | + # --- Downscale and save --- |
| 155 | + final = img.resize((1200, 630), Image.Resampling.LANCZOS) |
| 156 | + out = BASE / "og-cover.png" |
| 157 | + final.save(out, format="PNG") |
| 158 | + print(f"Saved {out}") |
| 159 | + |
| 160 | + |
| 161 | +if __name__ == "__main__": |
| 162 | + main() |
0 commit comments