|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Generate Open Graph SVG+PNG cards (1200×630) for each pattern. |
| 4 | +Light theme, side-by-side Old/Modern code, slug title at top. |
| 5 | +Python equivalent of generateog.java — produces identical output. |
| 6 | +
|
| 7 | +Usage: python html-generators/generateog.py [category/slug] |
| 8 | + No arguments → generate all patterns. |
| 9 | +
|
| 10 | +Requires: cairosvg (pip install cairosvg) |
| 11 | +""" |
| 12 | + |
| 13 | +import json |
| 14 | +import os |
| 15 | +import re |
| 16 | +import sys |
| 17 | +import glob as glob_mod |
| 18 | +from collections import OrderedDict |
| 19 | + |
| 20 | +try: |
| 21 | + import yaml |
| 22 | +except ImportError: |
| 23 | + yaml = None |
| 24 | + |
| 25 | +try: |
| 26 | + import cairosvg |
| 27 | +except ImportError: |
| 28 | + cairosvg = None |
| 29 | + |
| 30 | +CONTENT_DIR = "content" |
| 31 | +OUTPUT_DIR = "site/og" |
| 32 | +CATEGORIES_FILE = "html-generators/categories.properties" |
| 33 | + |
| 34 | +# ── Light-theme palette ───────────────────────────────────────────────── |
| 35 | +BG = "#ffffff" |
| 36 | +BORDER = "#d8d8e0" |
| 37 | +TEXT = "#1a1a2e" |
| 38 | +TEXT_MUTED = "#6b7280" |
| 39 | +OLD_BG = "#fef2f2" |
| 40 | +MODERN_BG = "#eff6ff" |
| 41 | +OLD_ACCENT = "#dc2626" |
| 42 | +GREEN = "#059669" |
| 43 | +ACCENT = "#6366f1" |
| 44 | +BADGE_BG = "#f3f4f6" |
| 45 | + |
| 46 | +# ── Syntax highlight colors (VS Code light-inspired) ──────────────────── |
| 47 | +SYN_KEYWORD = "#7c3aed" |
| 48 | +SYN_TYPE = "#0e7490" |
| 49 | +SYN_STRING = "#059669" |
| 50 | +SYN_COMMENT = "#6b7280" |
| 51 | +SYN_ANNOTATION = "#b45309" |
| 52 | +SYN_NUMBER = "#c2410c" |
| 53 | +SYN_DEFAULT = "#1a1a2e" |
| 54 | + |
| 55 | +JAVA_KEYWORDS = { |
| 56 | + "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", |
| 57 | + "class", "const", "continue", "default", "do", "double", "else", "enum", |
| 58 | + "extends", "final", "finally", "float", "for", "goto", "if", "implements", |
| 59 | + "import", "instanceof", "int", "interface", "long", "native", "new", "null", |
| 60 | + "package", "private", "protected", "public", "record", "return", "sealed", |
| 61 | + "short", "static", "strictfp", "super", "switch", "synchronized", "this", |
| 62 | + "throw", "throws", "transient", "try", "var", "void", "volatile", "when", |
| 63 | + "while", "with", "yield", "permits", "non-sealed", "module", "open", "opens", |
| 64 | + "requires", "exports", "provides", "to", "uses", "transitive", |
| 65 | + "true", "false", |
| 66 | +} |
| 67 | + |
| 68 | +SYN_PATTERN = re.compile( |
| 69 | + r"(?P<comment>//.*)|" |
| 70 | + r"(?P<blockcomment>/\*.*?\*/)|" |
| 71 | + r"(?P<annotation>@\w+)|" |
| 72 | + r'(?P<string>"""[\s\S]*?"""|"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\')|' |
| 73 | + r"(?P<number>\b\d[\d_.]*[dDfFlL]?\b)|" |
| 74 | + r"(?P<word>\b[A-Za-z_]\w*\b)|" |
| 75 | + r"(?P<other>[^\s])" |
| 76 | +) |
| 77 | + |
| 78 | +# ── Dimensions ────────────────────────────────────────────────────────── |
| 79 | +W = 1200 |
| 80 | +H = 630 |
| 81 | +PAD = 40 |
| 82 | +HEADER_H = 100 |
| 83 | +FOOTER_H = 56 |
| 84 | +CODE_TOP = HEADER_H |
| 85 | +CODE_H = H - HEADER_H - FOOTER_H |
| 86 | +COL_W = (W - PAD * 2 - 20) // 2 |
| 87 | +CODE_PAD = 14 |
| 88 | +LABEL_H = 32 |
| 89 | +USABLE_W = COL_W - CODE_PAD * 2 |
| 90 | +USABLE_H = CODE_H - LABEL_H - CODE_PAD |
| 91 | +CHAR_WIDTH_RATIO = 0.6 |
| 92 | +LINE_HEIGHT_RATIO = 1.55 |
| 93 | +MIN_CODE_FONT = 9 |
| 94 | +MAX_CODE_FONT = 16 |
| 95 | + |
| 96 | + |
| 97 | +# ── Helpers ───────────────────────────────────────────────────────────── |
| 98 | + |
| 99 | +def load_properties(path): |
| 100 | + props = OrderedDict() |
| 101 | + with open(path) as f: |
| 102 | + for line in f: |
| 103 | + line = line.strip() |
| 104 | + if not line or line.startswith("#"): |
| 105 | + continue |
| 106 | + idx = line.find("=") |
| 107 | + if idx > 0: |
| 108 | + props[line[:idx].strip()] = line[idx + 1:].strip() |
| 109 | + return props |
| 110 | + |
| 111 | + |
| 112 | +CATEGORY_DISPLAY = load_properties(CATEGORIES_FILE) |
| 113 | + |
| 114 | + |
| 115 | +def read_auto(path): |
| 116 | + with open(path) as f: |
| 117 | + if path.endswith((".yaml", ".yml")): |
| 118 | + if yaml is None: |
| 119 | + raise ImportError("PyYAML is required for YAML files: pip install pyyaml") |
| 120 | + return yaml.safe_load(f) |
| 121 | + return json.load(f) |
| 122 | + |
| 123 | + |
| 124 | +def xml_escape(s): |
| 125 | + if s is None: |
| 126 | + return "" |
| 127 | + return (s.replace("&", "&").replace("<", "<").replace(">", ">") |
| 128 | + .replace('"', """).replace("'", "'")) |
| 129 | + |
| 130 | + |
| 131 | +def load_all_snippets(): |
| 132 | + snippets = OrderedDict() |
| 133 | + for cat in CATEGORY_DISPLAY: |
| 134 | + cat_dir = os.path.join(CONTENT_DIR, cat) |
| 135 | + if not os.path.isdir(cat_dir): |
| 136 | + continue |
| 137 | + files = [] |
| 138 | + for ext in ("json", "yaml", "yml"): |
| 139 | + files.extend(glob_mod.glob(os.path.join(cat_dir, f"*.{ext}"))) |
| 140 | + files.sort() |
| 141 | + for path in files: |
| 142 | + data = read_auto(path) |
| 143 | + key = f"{data['category']}/{data['slug']}" |
| 144 | + snippets[key] = data |
| 145 | + return snippets |
| 146 | + |
| 147 | + |
| 148 | +# ── Syntax highlighting ───────────────────────────────────────────────── |
| 149 | + |
| 150 | +def highlight_line(line): |
| 151 | + if line == "...": |
| 152 | + return xml_escape(line) |
| 153 | + result = [] |
| 154 | + last = 0 |
| 155 | + for m in SYN_PATTERN.finditer(line): |
| 156 | + if m.start() > last: |
| 157 | + result.append(xml_escape(line[last:m.start()])) |
| 158 | + last = m.end() |
| 159 | + token = m.group() |
| 160 | + color = None |
| 161 | + if m.group("comment") or m.group("blockcomment"): |
| 162 | + color = SYN_COMMENT |
| 163 | + elif m.group("annotation"): |
| 164 | + color = SYN_ANNOTATION |
| 165 | + elif m.group("string"): |
| 166 | + color = SYN_STRING |
| 167 | + elif m.group("number"): |
| 168 | + color = SYN_NUMBER |
| 169 | + elif m.group("word"): |
| 170 | + if token in JAVA_KEYWORDS: |
| 171 | + color = SYN_KEYWORD |
| 172 | + elif token[0].isupper(): |
| 173 | + color = SYN_TYPE |
| 174 | + if color: |
| 175 | + result.append(f'<tspan fill="{color}">{xml_escape(token)}</tspan>') |
| 176 | + else: |
| 177 | + result.append(xml_escape(token)) |
| 178 | + if last < len(line): |
| 179 | + result.append(xml_escape(line[last:])) |
| 180 | + return "".join(result) |
| 181 | + |
| 182 | + |
| 183 | +# ── SVG rendering ─────────────────────────────────────────────────────── |
| 184 | + |
| 185 | +def best_font_size(old_lines, modern_lines): |
| 186 | + max_chars = max( |
| 187 | + max((len(l) for l in old_lines), default=1), |
| 188 | + max((len(l) for l in modern_lines), default=1), |
| 189 | + ) |
| 190 | + max_lines = max(len(old_lines), len(modern_lines)) |
| 191 | + by_width = int(USABLE_W / (max_chars * CHAR_WIDTH_RATIO)) |
| 192 | + by_height = int(USABLE_H / (max_lines * LINE_HEIGHT_RATIO)) |
| 193 | + return max(MIN_CODE_FONT, min(MAX_CODE_FONT, min(by_width, by_height))) |
| 194 | + |
| 195 | + |
| 196 | +def fit_lines(lines, font_size): |
| 197 | + line_h = int(font_size * LINE_HEIGHT_RATIO) |
| 198 | + max_lines = USABLE_H // line_h |
| 199 | + if len(lines) <= max_lines: |
| 200 | + return lines |
| 201 | + truncated = list(lines[:max_lines - 1]) |
| 202 | + truncated.append("...") |
| 203 | + return truncated |
| 204 | + |
| 205 | + |
| 206 | +def render_code_block(lines, x, y, line_h): |
| 207 | + parts = [] |
| 208 | + for i, line in enumerate(lines): |
| 209 | + parts.append( |
| 210 | + f' <text x="{x}" y="{y + i * line_h}" class="code" xml:space="preserve">' |
| 211 | + f'{highlight_line(line)}</text>\n' |
| 212 | + ) |
| 213 | + return "".join(parts) |
| 214 | + |
| 215 | + |
| 216 | +def generate_svg(data): |
| 217 | + left_x = PAD |
| 218 | + right_x = PAD + COL_W + 20 |
| 219 | + label_y = CODE_TOP + 26 |
| 220 | + code_y = CODE_TOP + 52 |
| 221 | + |
| 222 | + old_lines = data["oldCode"].split("\n") |
| 223 | + modern_lines = data["modernCode"].split("\n") |
| 224 | + |
| 225 | + font_size = best_font_size(old_lines, modern_lines) |
| 226 | + line_h = int(font_size * LINE_HEIGHT_RATIO) |
| 227 | + |
| 228 | + old_lines = fit_lines(old_lines, font_size) |
| 229 | + modern_lines = fit_lines(modern_lines, font_size) |
| 230 | + |
| 231 | + cat_display = CATEGORY_DISPLAY.get(data["category"], data["category"]) |
| 232 | + badge_width = len(cat_display) * 8 + 16 |
| 233 | + |
| 234 | + old_code_svg = render_code_block(old_lines, left_x + 14, code_y, line_h) |
| 235 | + modern_code_svg = render_code_block(modern_lines, right_x + 14, code_y, line_h) |
| 236 | + |
| 237 | + return f"""<?xml version="1.0" encoding="UTF-8"?> |
| 238 | +<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}"> |
| 239 | + <defs> |
| 240 | + <style> |
| 241 | + .title {{ font: 700 24px/1 'Inter', sans-serif; fill: {TEXT}; }} |
| 242 | + .category {{ font: 600 13px/1 'Inter', sans-serif; fill: {TEXT_MUTED}; }} |
| 243 | + .label {{ font: 600 11px/1 'Inter', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }} |
| 244 | + .code {{ font: 400 {font_size}px/1 'JetBrains Mono', monospace; fill: {TEXT}; }} |
| 245 | + .footer {{ font: 500 13px/1 'Inter', sans-serif; fill: {TEXT_MUTED}; }} |
| 246 | + .brand {{ font: 700 14px/1 'Inter', sans-serif; fill: {ACCENT}; }} |
| 247 | + </style> |
| 248 | + <clipPath id="clip-left"> |
| 249 | + <rect x="{left_x}" y="{CODE_TOP}" width="{COL_W}" height="{CODE_H}" rx="8"/> |
| 250 | + </clipPath> |
| 251 | + <clipPath id="clip-right"> |
| 252 | + <rect x="{right_x}" y="{CODE_TOP}" width="{COL_W}" height="{CODE_H}" rx="8"/> |
| 253 | + </clipPath> |
| 254 | + </defs> |
| 255 | +
|
| 256 | + <!-- Background --> |
| 257 | + <rect width="{W}" height="{H}" rx="16" fill="{BG}"/> |
| 258 | + <rect x="0.5" y="0.5" width="{W - 1}" height="{H - 1}" rx="16" fill="none" stroke="{BORDER}" stroke-width="1"/> |
| 259 | +
|
| 260 | + <!-- Header: category badge + title --> |
| 261 | + <rect x="{PAD}" y="28" width="{badge_width}" height="22" rx="6" fill="{BADGE_BG}"/> |
| 262 | + <text x="{PAD + 8}" y="43" class="category">{xml_escape(cat_display)}</text> |
| 263 | + <text x="{PAD}" y="76" class="title">{xml_escape(data['title'])}</text> |
| 264 | +
|
| 265 | + <!-- Left panel: Old code --> |
| 266 | + <rect x="{left_x}" y="{CODE_TOP}" width="{COL_W}" height="{CODE_H}" rx="8" fill="{OLD_BG}"/> |
| 267 | + <rect x="{left_x}" y="{CODE_TOP}" width="{COL_W}" height="{CODE_H}" rx="8" fill="none" stroke="{BORDER}" stroke-width="0.5"/> |
| 268 | + <text x="{left_x + 14}" y="{label_y}" class="label" fill="{OLD_ACCENT}">\u2717 {xml_escape(data['oldLabel'])}</text> |
| 269 | + <g clip-path="url(#clip-left)"> |
| 270 | +{old_code_svg} </g> |
| 271 | +
|
| 272 | + <!-- Right panel: Modern code --> |
| 273 | + <rect x="{right_x}" y="{CODE_TOP}" width="{COL_W}" height="{CODE_H}" rx="8" fill="{MODERN_BG}"/> |
| 274 | + <rect x="{right_x}" y="{CODE_TOP}" width="{COL_W}" height="{CODE_H}" rx="8" fill="none" stroke="{BORDER}" stroke-width="0.5"/> |
| 275 | + <text x="{right_x + 14}" y="{label_y}" class="label" fill="{GREEN}">\u2713 {xml_escape(data['modernLabel'])}</text> |
| 276 | + <g clip-path="url(#clip-right)"> |
| 277 | +{modern_code_svg} </g> |
| 278 | +
|
| 279 | + <!-- Footer --> |
| 280 | + <text x="{PAD}" y="{H - 22}" class="footer">JDK {data['jdkVersion']}+</text> |
| 281 | + <text x="{W - PAD}" y="{H - 22}" class="brand" text-anchor="end">javaevolved.github.io</text> |
| 282 | +</svg> |
| 283 | +""" |
| 284 | + |
| 285 | + |
| 286 | +def svg_to_png(svg_content, png_path): |
| 287 | + if cairosvg is None: |
| 288 | + raise ImportError("cairosvg is required for PNG generation: pip install cairosvg") |
| 289 | + cairosvg.svg2png( |
| 290 | + bytestring=svg_content.encode("utf-8"), |
| 291 | + write_to=png_path, |
| 292 | + output_width=W * 2, |
| 293 | + output_height=H * 2, |
| 294 | + ) |
| 295 | + |
| 296 | + |
| 297 | +# ── Main ──────────────────────────────────────────────────────────────── |
| 298 | + |
| 299 | +def main(): |
| 300 | + all_snippets = load_all_snippets() |
| 301 | + print(f"Loaded {len(all_snippets)} snippets") |
| 302 | + |
| 303 | + # Filter to a single slug if provided |
| 304 | + if len(sys.argv) > 1: |
| 305 | + key = sys.argv[1] |
| 306 | + if key not in all_snippets: |
| 307 | + print(f"Unknown pattern: {key}") |
| 308 | + print(f"Available: {', '.join(all_snippets.keys())}") |
| 309 | + sys.exit(1) |
| 310 | + targets = {key: all_snippets[key]} |
| 311 | + else: |
| 312 | + targets = all_snippets |
| 313 | + |
| 314 | + count = 0 |
| 315 | + for key, data in targets.items(): |
| 316 | + cat = data["category"] |
| 317 | + slug = data["slug"] |
| 318 | + out_dir = os.path.join(OUTPUT_DIR, cat) |
| 319 | + os.makedirs(out_dir, exist_ok=True) |
| 320 | + |
| 321 | + svg = generate_svg(data) |
| 322 | + svg_path = os.path.join(out_dir, f"{slug}.svg") |
| 323 | + with open(svg_path, "w") as f: |
| 324 | + f.write(svg) |
| 325 | + |
| 326 | + png_path = os.path.join(out_dir, f"{slug}.png") |
| 327 | + svg_to_png(svg, png_path) |
| 328 | + count += 1 |
| 329 | + |
| 330 | + print(f"Generated {count} SVG+PNG card(s) in {OUTPUT_DIR}/") |
| 331 | + |
| 332 | + |
| 333 | +if __name__ == "__main__": |
| 334 | + main() |
0 commit comments