generate-language-tiles.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. #!/usr/bin/env python3
  2. """Generate assets/languages/*.svg — the README "Language Support" icon grid.
  3. One tile per supported language: a paper card with the language's vector mark and
  4. its name centered underneath. Labels are converted to vector outlines (Archivo,
  5. same pipeline as generate-waitlist.py) so the SVG renders pixel-identical
  6. everywhere: GitHub loads README SVGs in "secure static mode", which blocks
  7. @font-face / web-font loading, so outlines are the only reliable way to ship the
  8. brand typeface. Every tile sits on the brand paper (#f7f6f2), so brand-colored
  9. marks — including near-black ones like Rust — stay legible on GitHub light *and*
  10. dark themes with no media-query tricks.
  11. Glyph sources (fetched at generation time, results checked in):
  12. - simple-icons via https://cdn.simpleicons.org/<slug> (CC0-1.0)
  13. - devicon via jsDelivr, pinned @v2.16.0 (MIT) — Java, C#,
  14. Objective-C, which simple-icons doesn't carry
  15. - hand-drawn in this file: CFML, COBOL, VB.NET (no usable upstream mark)
  16. All trademarks/logos belong to their respective owners; they're used here only
  17. to indicate language support.
  18. Adding a language: append to LANGS, re-run, reference the new SVG in README.md
  19. with a fresh `?v=1`. If you change EXISTING tile bytes, bump that tile's `?v=N`
  20. in README.md in the same commit (GitHub caches README images aggressively).
  21. Requires: fonttools, brotli (pip install fonttools brotli), network access.
  22. Font: @fontsource-variable/archivo (latin, wght axis) from the landing-page package.
  23. Usage: python3 generate-language-tiles.py [--preview /path/to/preview.svg]
  24. """
  25. import os
  26. import re
  27. import sys
  28. import urllib.request
  29. from fontTools.ttLib import TTFont
  30. from fontTools.varLib.instancer import instantiateVariableFont
  31. from fontTools.pens.svgPathPen import SVGPathPen
  32. from fontTools.pens.transformPen import TransformPen
  33. from fontTools.misc.transform import Transform
  34. # --- brand palette (mirrors generate-waitlist.py) ---------------------------
  35. PAPER = "#f7f6f2"
  36. HAIRLINE = "#d6d3c8"
  37. INK = "#16150f"
  38. FONT = os.path.join(
  39. os.path.dirname(__file__),
  40. "../../landing-page/node_modules/@fontsource-variable/archivo/files/archivo-latin-wght-normal.woff2",
  41. )
  42. # --- tile geometry (px) ------------------------------------------------------
  43. TILE = 104 # square tile, rx=8 card like the waitlist button
  44. GLYPH_BOX = 44 # logo box, horizontally centered
  45. GLYPH_TOP = 18
  46. LABEL_SIZE = 12.5
  47. LABEL_WT = 640
  48. LABEL_GAP = 12 # glyph box bottom -> label cap top
  49. TRACK = 0.15
  50. DEVICON = "https://cdn.jsdelivr.net/gh/devicons/devicon@v2.16.0/icons/{0}/{0}-{1}.svg"
  51. # (file slug, label, source) — README "Supported Languages" table order.
  52. # source: ("si", slug) simple-icons | ("devicon", name, variant) | ("custom", key)
  53. LANGS = [
  54. ("typescript", "TypeScript", ("si", "typescript")),
  55. ("javascript", "JavaScript", ("si", "javascript")),
  56. ("arkts", "ArkTS", ("si", "harmonyos")),
  57. ("python", "Python", ("si", "python")),
  58. ("go", "Go", ("si", "go")),
  59. ("rust", "Rust", ("si", "rust")),
  60. ("java", "Java", ("devicon", "java", "original")),
  61. ("csharp", "C#", ("devicon", "csharp", "original")),
  62. ("php", "PHP", ("si", "php")),
  63. ("ruby", "Ruby", ("si", "ruby")),
  64. ("c", "C", ("si", "c")),
  65. ("cpp", "C++", ("si", "cplusplus")),
  66. ("objective-c", "Objective-C", ("devicon", "objectivec", "plain")),
  67. ("metal", "Metal", ("si", "apple")),
  68. ("cuda", "CUDA", ("si", "nvidia")),
  69. ("swift", "Swift", ("si", "swift")),
  70. ("kotlin", "Kotlin", ("si", "kotlin")),
  71. ("scala", "Scala", ("si", "scala")),
  72. ("dart", "Dart", ("si", "dart")),
  73. ("svelte", "Svelte", ("si", "svelte")),
  74. ("vue", "Vue", ("si", "vuedotjs")),
  75. ("astro", "Astro", ("si", "astro")),
  76. ("liquid", "Liquid", ("si", "shopify")),
  77. ("delphi", "Delphi", ("si", "delphi")),
  78. ("lua", "Lua", ("si", "lua")),
  79. ("r", "R", ("si", "r")),
  80. ("luau", "Luau", ("si", "luau")),
  81. ("cfml", "CFML", ("custom", "cfml")),
  82. ("cobol", "COBOL", ("custom", "cobol")),
  83. ("vbnet", "VB.NET", ("custom", "vbnet")),
  84. ("erlang", "Erlang", ("si", "erlang")),
  85. ("solidity", "Solidity", ("si", "solidity")),
  86. ("terraform", "Terraform", ("si", "terraform")),
  87. ("nix", "Nix", ("si", "nixos")),
  88. ]
  89. def fetch(url):
  90. req = urllib.request.Request(url, headers={"User-Agent": "codegraph-assets"})
  91. with urllib.request.urlopen(req, timeout=20) as r:
  92. return r.read().decode("utf-8")
  93. class Outliner:
  94. """Archivo text -> SVG path outlines, one font instance per weight."""
  95. def __init__(self):
  96. self._by_weight = {}
  97. def _font(self, weight):
  98. if weight not in self._by_weight:
  99. ft = TTFont(FONT)
  100. instantiateVariableFont(ft, {"wght": weight}, inplace=True)
  101. self._by_weight[weight] = (ft, ft.getBestCmap(), ft.getGlyphSet(), ft["hmtx"])
  102. return self._by_weight[weight]
  103. def measure(self, text, size, weight, track=TRACK):
  104. ft, cmap, _, hmtx = self._font(weight)
  105. scale = size / ft["head"].unitsPerEm
  106. w = sum(hmtx[cmap[ord(ch)]][0] * scale + track for ch in text)
  107. return w - track
  108. def cap_height(self, size, weight):
  109. ft, _, _, _ = self._font(weight)
  110. return getattr(ft["OS/2"], "sCapHeight", 700) * size / ft["head"].unitsPerEm
  111. def outline(self, text, size, weight, start_x, baseline_y, track=TRACK):
  112. ft, cmap, glyphs, hmtx = self._font(weight)
  113. scale = size / ft["head"].unitsPerEm
  114. sink = SVGPathPen(glyphs, ntos=lambda v: f"{round(v, 2):g}")
  115. pen_x = start_x
  116. for ch in text:
  117. gname = cmap[ord(ch)]
  118. if ch != " ":
  119. t = Transform(scale, 0, 0, -scale, pen_x, baseline_y)
  120. glyphs[gname].draw(TransformPen(sink, t))
  121. pen_x += hmtx[gname][0] * scale + track
  122. return sink.getCommands()
  123. def centered(self, text, size, weight, center_x, baseline_y, track=TRACK):
  124. w = self.measure(text, size, weight, track)
  125. return self.outline(text, size, weight, center_x - w / 2, baseline_y, track)
  126. def si_glyph(slug):
  127. """simple-icons: single brand-colored 24x24 path, scaled into the glyph box."""
  128. svg = fetch(f"https://cdn.simpleicons.org/{slug}")
  129. fill = re.search(r'fill="(#[0-9A-Fa-f]{3,8})"', svg).group(1)
  130. d = re.search(r'<path d="([^"]+)"', svg).group(1)
  131. s = round(GLYPH_BOX / 24, 5)
  132. x = (TILE - GLYPH_BOX) / 2
  133. return f'<g transform="translate({x},{GLYPH_TOP}) scale({s})"><path fill="{fill}" d="{d}"/></g>'
  134. def devicon_glyph(name, variant):
  135. """devicon: multi-path 128x128 markup, embedded verbatim and scaled."""
  136. svg = fetch(DEVICON.format(name, variant))
  137. inner = re.search(r"<svg[^>]*>(.*)</svg>", svg, re.S).group(1).strip()
  138. s = round(GLYPH_BOX / 128, 5)
  139. x = (TILE - GLYPH_BOX) / 2
  140. return f'<g transform="translate({x},{GLYPH_TOP}) scale({s})">{inner}</g>'
  141. def custom_glyph(key, out):
  142. x = (TILE - GLYPH_BOX) / 2 # glyph box left edge (30)
  143. cx = TILE / 2 # 52
  144. cy = GLYPH_TOP + GLYPH_BOX / 2 # glyph box vertical center (40)
  145. if key == "cfml":
  146. # The community CFML mark: a <cf> tag, set in Archivo.
  147. size, wt = 20.0, 700
  148. baseline = round(cy + out.cap_height(size, wt) / 2, 2)
  149. d = out.centered("<cf>", size, wt, cx, baseline, track=0.4)
  150. return f'<path fill="#1b5ea6" d="{d}"/>'
  151. if key == "cobol":
  152. # A punched card: manila stock, clipped corner, punched rows.
  153. w, h = GLYPH_BOX, 28
  154. top, cut = round(cy - h / 2, 2), 7
  155. holes = []
  156. for row in range(3):
  157. hy = top + 7.4 + row * 6.4
  158. for col in range(7):
  159. if (row * 3 + col * 5) % 4 != 0: # deterministic "data" pattern
  160. holes.append(
  161. f'<rect x="{round(x + 4.4 + col * 5.4, 2)}" y="{round(hy, 2)}" width="2.4" height="4" fill="#6b5d33"/>'
  162. )
  163. card = (
  164. f'M{x + cut},{top} H{x + w} V{top + h} H{x} V{top + cut} Z'
  165. )
  166. return (
  167. f'<path d="{card}" fill="#ecdfb1" stroke="#b3a06a" stroke-width="1" stroke-linejoin="miter"/>'
  168. + "".join(holes)
  169. )
  170. if key == "vbnet":
  171. # The .NET-purple badge with a VB monogram.
  172. size, wt = 16.5, 720
  173. baseline = round(cy + out.cap_height(size, wt) / 2, 2)
  174. d = out.centered("VB", size, wt, cx, baseline, track=0.6)
  175. return (
  176. f'<rect x="{x}" y="{GLYPH_TOP}" width="{GLYPH_BOX}" height="{GLYPH_BOX}" rx="6" fill="#512bd4"/>'
  177. f'<path fill="{PAPER}" d="{d}"/>'
  178. )
  179. raise KeyError(key)
  180. def main():
  181. preview_path = None
  182. if "--preview" in sys.argv:
  183. preview_path = sys.argv[sys.argv.index("--preview") + 1]
  184. out = Outliner()
  185. label_baseline = round(GLYPH_TOP + GLYPH_BOX + LABEL_GAP + out.cap_height(LABEL_SIZE, LABEL_WT), 2)
  186. out_dir = os.path.join(os.path.dirname(__file__), "languages")
  187. os.makedirs(out_dir, exist_ok=True)
  188. bodies = {}
  189. for slug, label, source in LANGS:
  190. if source[0] == "si":
  191. glyph = si_glyph(source[1])
  192. elif source[0] == "devicon":
  193. glyph = devicon_glyph(source[1], source[2])
  194. else:
  195. glyph = custom_glyph(source[1], out)
  196. label_d = out.centered(label, LABEL_SIZE, LABEL_WT, TILE / 2, label_baseline)
  197. body = (
  198. f'<rect x="0.5" y="0.5" width="{TILE - 1}" height="{TILE - 1}" rx="8" fill="{PAPER}" stroke="{HAIRLINE}"/>\n'
  199. f" {glyph}\n"
  200. f' <path fill="{INK}" d="{label_d}"/>'
  201. )
  202. bodies[slug] = body
  203. svg = (
  204. f'<svg xmlns="http://www.w3.org/2000/svg" width="{TILE}" height="{TILE}" '
  205. f'viewBox="0 0 {TILE} {TILE}" role="img" aria-label="{label}">\n'
  206. f" <title>{label}</title>\n"
  207. f" {body}\n"
  208. f"</svg>\n"
  209. )
  210. path = os.path.join(out_dir, f"{slug}.svg")
  211. with open(path, "w") as fh:
  212. fh.write(svg)
  213. print(f"wrote {os.path.relpath(path, os.path.dirname(__file__))} ({len(svg)}b) {label}")
  214. if preview_path:
  215. cols, pad = 7, 8
  216. rows = -(-len(LANGS) // cols)
  217. w = cols * (TILE + pad) + pad
  218. h = rows * (TILE + pad) + pad
  219. cells = []
  220. for i, (slug, _, _) in enumerate(LANGS):
  221. tx = pad + (i % cols) * (TILE + pad)
  222. ty = pad + (i // cols) * (TILE + pad)
  223. cells.append(f'<g transform="translate({tx},{ty})">{bodies[slug]}</g>')
  224. light = "".join(cells)
  225. with open(preview_path, "w") as fh:
  226. fh.write(
  227. f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h * 2}" viewBox="0 0 {w} {h * 2}">'
  228. f'<rect width="{w}" height="{h}" fill="#ffffff"/>{light}'
  229. f'<g transform="translate(0,{h})"><rect width="{w}" height="{h}" fill="#0d1117"/>{light}</g>'
  230. f"</svg>\n"
  231. )
  232. print(f"wrote preview {preview_path} (top: light bg, bottom: dark bg)")
  233. if __name__ == "__main__":
  234. main()