Files
zeekay d72a13d4f9 feat: outlined wordmarks, one per product
The wordmark is per-product — "Hanzo AI" on hanzo.ai, "Hanzo ID" on hanzo.id —
so it is a table, not one asset. Sixteen names ship: the Hanzo products plus
Lux, Zoo and Pars.

They are PATHS. @hanzo/brand's wordmark.svg sets the name as an un-outlined
<text> in Zen, so it renders in whatever fallback is around wherever Zen has not
loaded — an OG card, a favicon, a PDF, a machine without the webfont. An outline
needs no font at all.

Cut from Zen at wght 606 (the house `medium` preset) and shaped through
HarfBuzz, so pair spacing is the font's own GPOS kerning rather than naive
advances — the first cut, with advances only, spaced visibly wrong.

getWordmarkSVG(name) returns null for a name with no outline rather than falling
back to <text>: a silent fallback is how a page ends up setting the brand in
Arial. Add the name to scripts/wordmarks.py and re-run it.

The table is ~143 KB, so build.ts also emits one file per name and
`./wordmark/*` exports them — a browser bundle takes the one it needs; the table
is for Node, where OG images and PDFs are made.

The generator is NOT wired into `build`. Outlines are static data, and making
every build depend on Python and fontTools to reproduce bytes that never change
is a cost with no payer.

This also fills a hook that had nothing behind it: build.ts called an optional
(marks as any).getWordmarkWhite?.() that logos.ts never defined.
2026-08-24 21:28:53 -07:00

62 lines
2.7 KiB
Python

"""Outline the wordmarks from Zen and regenerate src/wordmarks.ts.
uv venv .venv && uv pip install --python .venv/bin/python "fonttools[woff]" brotli uharfbuzz
.venv/bin/python scripts/wordmarks.py
Text becomes PATHS, so a rendered wordmark needs no font present. Cut at wght
606 (the house `medium` preset) and shaped through HarfBuzz, so pair spacing is
the font's own GPOS kerning rather than naive advances.
Deliberately NOT part of `pnpm build`: outlines are static data, and making
every build depend on Python and fontTools to reproduce bytes that never change
is a cost with no payer. Run it when NAMES changes or the face is recut.
"""
import sys, json
from fontTools.ttLib import TTFont
from fontTools.varLib import instancer
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.pens.boundsPen import BoundsPen
import uharfbuzz as hb
FONT = __import__("os").environ.get("ZEN_FONT", "../font/packages/zen/dist/fonts/zen-sans/Zen-Variable.woff2")
def outline(text, wght=606):
# a static cut at the wordmark weight — 606 is the house `medium` preset
var = TTFont(FONT)
inst = instancer.instantiateVariableFont(var, {'wght': wght}, inplace=False)
inst.flavor = None # harfbuzz cannot read woff2; TTFont keeps the flavour on save
buf_io = __import__('io').BytesIO(); inst.save(buf_io); raw = buf_io.getvalue()
face = hb.Face(raw); font = hb.Font(face)
buf = hb.Buffer(); buf.add_str(text); buf.guess_segment_properties()
hb.shape(font, buf) # GPOS kerning applied here
gs = inst.getGlyphSet()
order = inst.getGlyphOrder()
d, x = [], 0
for info, pos in zip(buf.glyph_infos, buf.glyph_positions):
name = order[info.codepoint]
pen = SVGPathPen(gs)
gs[name].draw(TransformPen(pen, (1, 0, 0, 1, x + pos.x_offset, pos.y_offset)))
seg = pen.getCommands()
if seg: d.append(seg)
x += pos.x_advance
path = ' '.join(d)
# tight ink box, so the caller sizes by what is drawn rather than by metrics
bp = BoundsPen(gs)
xx = 0
for info, pos in zip(buf.glyph_infos, buf.glyph_positions):
gs[order[info.codepoint]].draw(TransformPen(bp, (1, 0, 0, 1, xx + pos.x_offset, pos.y_offset)))
xx += pos.x_advance
x0, y0, x1, y1 = bp.bounds
return {'d': path, 'box': [x0, y0, x1 - x0, y1 - y0], 'advance': x, 'upem': inst['head'].unitsPerEm}
if __name__ == '__main__':
print(json.dumps(outline(sys.argv[1] if len(sys.argv) > 1 else 'Hanzo AI')))
NAMES = ['Hanzo', 'Hanzo AI', 'Hanzo ID', 'Hanzo Cloud', 'Hanzo Dev', 'Hanzo App',
'Hanzo Chat', 'Hanzo Base', 'Hanzo Industries', 'Hanzo Commerce',
'Hanzo Platform', 'Hanzo Docs', 'Hanzo Studio', 'Lux', 'Zoo', 'Pars']