|
| 1 | +"""Generate a transparent, vector SVG wordmark for Modern Python. |
| 2 | +
|
| 3 | +Extracts the "MODERN PYTHON" glyph outlines from Futura (present on macOS) as |
| 4 | +real vector paths — no font dependency at render time — and frames them with the |
| 5 | +brand's corner brackets. Fill is `currentColor` so the same file can be tinted |
| 6 | +per theme (green on light surfaces, white on the green header) via CSS/attr. |
| 7 | +
|
| 8 | +Run locally: |
| 9 | +
|
| 10 | + uv run --with fonttools python scripts/gen_logo_svg.py |
| 11 | +
|
| 12 | +Outputs: |
| 13 | + docs/assets/modern-python.svg (two-line wordmark + brackets) |
| 14 | + docs/assets/modern-python-mark.svg (compact bracket mark, for the favicon) |
| 15 | +""" |
| 16 | + |
| 17 | +import pathlib |
| 18 | + |
| 19 | +from fontTools.pens.svgPathPen import SVGPathPen |
| 20 | +from fontTools.ttLib import TTFont |
| 21 | + |
| 22 | +FONT_PATH = "/System/Library/Fonts/Supplemental/Futura.ttc" |
| 23 | +FONT_NUMBER = 0 # Futura Medium |
| 24 | +ASSETS = pathlib.Path(__file__).resolve().parent.parent / "docs" / "assets" |
| 25 | + |
| 26 | +LINES = ["MODERN", "PYTHON"] |
| 27 | +TRACKING = 0.10 # fraction of em added between glyphs |
| 28 | +LINE_GAP = 0.18 # fraction of em between the two lines |
| 29 | +PAD = 0.34 # padding (em) inside the bracket frame |
| 30 | +BRACKET = 0.55 # bracket arm length (em) |
| 31 | +STROKE = 0.07 # bracket stroke width (em) |
| 32 | + |
| 33 | + |
| 34 | +def layout_line(font, glyph_set, cmap, text, upm): |
| 35 | + """Return (svg_paths, width_em) for one tracked line, baseline at y=0, y-up.""" |
| 36 | + paths, x = [], 0.0 |
| 37 | + track = TRACKING * upm |
| 38 | + for ch in text: |
| 39 | + gname = cmap[ord(ch)] |
| 40 | + pen = SVGPathPen(glyph_set) |
| 41 | + glyph_set[gname].draw(pen) |
| 42 | + d = pen.getCommands() |
| 43 | + if d: |
| 44 | + paths.append(f'<path transform="translate({x:.1f},0)" d="{d}"/>') |
| 45 | + x += glyph_set[gname].width + track |
| 46 | + return paths, (x - track) / upm # drop trailing track |
| 47 | + |
| 48 | + |
| 49 | +def main() -> None: |
| 50 | + font = TTFont(FONT_PATH, fontNumber=FONT_NUMBER) |
| 51 | + glyph_set = font.getGlyphSet() |
| 52 | + cmap = font.getBestCmap() |
| 53 | + upm = font["head"].unitsPerEm |
| 54 | + cap = font["OS/2"].sCapHeight if hasattr(font["OS/2"], "sCapHeight") else int(0.7 * upm) |
| 55 | + cap_em = cap / upm |
| 56 | + |
| 57 | + # Lay out each line; track widest for centering. |
| 58 | + laid = [layout_line(font, glyph_set, cmap, line, upm) for line in LINES] |
| 59 | + text_w = max(w for _, w in laid) |
| 60 | + |
| 61 | + line_h = cap_em + LINE_GAP |
| 62 | + text_h = line_h * len(LINES) - LINE_GAP |
| 63 | + |
| 64 | + # Frame geometry (em units, y-down in final SVG). |
| 65 | + frame_w = text_w + 2 * PAD |
| 66 | + frame_h = text_h + 2 * PAD |
| 67 | + vb_w = frame_w |
| 68 | + vb_h = frame_h |
| 69 | + |
| 70 | + groups = [] |
| 71 | + # Each line: flip y-up glyphs into y-down SVG space and position. |
| 72 | + for i, (paths, w) in enumerate(laid): |
| 73 | + if not paths: |
| 74 | + continue |
| 75 | + x0 = (frame_w - w) / 2 * upm |
| 76 | + baseline = (PAD + cap_em + i * line_h) * upm # y of baseline in y-down em*upm |
| 77 | + # translate to (x0, baseline) then scale(1,-1) maps glyph y-up to y-down. |
| 78 | + groups.append( |
| 79 | + f'<g transform="scale({1/upm:.6f}) translate({x0:.1f},{baseline:.1f}) ' |
| 80 | + f'scale(1,-1)">{"".join(paths)}</g>' |
| 81 | + ) |
| 82 | + |
| 83 | + # Corner brackets: top-left and bottom-right, stroked (no fill). |
| 84 | + sw, arm = STROKE, BRACKET |
| 85 | + |
| 86 | + def wordmark(color: str) -> str: |
| 87 | + tl = ( |
| 88 | + f'<path fill="none" stroke="{color}" stroke-width="{sw}" ' |
| 89 | + f'd="M {sw/2:.3f} {arm:.3f} L {sw/2:.3f} {sw/2:.3f} L {arm:.3f} {sw/2:.3f}"/>' |
| 90 | + ) |
| 91 | + br = ( |
| 92 | + f'<path fill="none" stroke="{color}" stroke-width="{sw}" ' |
| 93 | + f'd="M {frame_w-sw/2:.3f} {frame_h-arm:.3f} L {frame_w-sw/2:.3f} {frame_h-sw/2:.3f} ' |
| 94 | + f'L {frame_w-arm:.3f} {frame_h-sw/2:.3f}"/>' |
| 95 | + ) |
| 96 | + return ( |
| 97 | + f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {vb_w:.3f} {vb_h:.3f}" ' |
| 98 | + f'fill="{color}" role="img" aria-label="Modern Python">' |
| 99 | + f'{"".join(groups)}{tl}{br}</svg>\n' |
| 100 | + ) |
| 101 | + |
| 102 | + # currentColor variant — inlined in the homepage hero, themed via CSS. |
| 103 | + (ASSETS / "modern-python.svg").write_text(wordmark("currentColor")) |
| 104 | + # White variant — used as the header logo (the header sits on brand green). |
| 105 | + (ASSETS / "modern-python-white.svg").write_text(wordmark("#ffffff")) |
| 106 | + print(f"wrote modern-python.svg + modern-python-white.svg (viewBox 0 0 {vb_w:.2f} {vb_h:.2f})") |
| 107 | + |
| 108 | + # Compact favicon mark: the two corner brackets only, in a tight square. |
| 109 | + # Adapts to the browser/OS theme: brand green on light tabs, white on dark. |
| 110 | + m_sw, m_arm = 0.12, 0.62 |
| 111 | + paths = ( |
| 112 | + f'<path d="M {m_sw/2:.3f} {m_arm:.3f} L {m_sw/2:.3f} {m_sw/2:.3f} L {m_arm:.3f} {m_sw/2:.3f}"/>' |
| 113 | + f'<path d="M {1-m_sw/2:.3f} {1-m_arm:.3f} L {1-m_sw/2:.3f} {1-m_sw/2:.3f} ' |
| 114 | + f'L {1-m_arm:.3f} {1-m_sw/2:.3f}"/>' |
| 115 | + ) |
| 116 | + style = ( |
| 117 | + "<style>:root{--mp:#356852}" |
| 118 | + "@media(prefers-color-scheme:dark){:root{--mp:#ffffff}}</style>" |
| 119 | + ) |
| 120 | + mark = ( |
| 121 | + f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1" ' |
| 122 | + f'fill="none" stroke="var(--mp)" stroke-width="{m_sw}" ' |
| 123 | + f'stroke-linecap="square" role="img" aria-label="Modern Python">' |
| 124 | + f'{style}{paths}</svg>\n' |
| 125 | + ) |
| 126 | + (ASSETS / "modern-python-mark.svg").write_text(mark) |
| 127 | + print("wrote modern-python-mark.svg") |
| 128 | + |
| 129 | + |
| 130 | +if __name__ == "__main__": |
| 131 | + main() |
0 commit comments