The Cal Sans build pipeline: a single automated build for a 6-axis variable font (opsz,
GEOM, wght, YTAS, SHRP, ital). The scripts package takes the
hand-drawn Glyphs source and produces the variable font, 384 static instances (192 roman-only), and a set of
curated release packages, all without ever modifying the source file.
You'll need Python 3.9+ and a virtual environment.
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txtpython -m venv venv
.\venv\Scripts\Activate.ps1
pip install -r requirements.txtrequirements.txt installs:
- glyphsLib — reads the
.glyphspackagesource - fontTools — low-level font table manipulation (instancing, avar2, etc.)
- fontmake — compiles UFOs/designspace into variable & static binaries
- tqdm — progress bars for the long-running steps
- uharfbuzz — gives fontTools access to HarfBuzz's
hb.repacktable packer, which is needed to correctly resolve GPOS table overflow during compilation (without it, fontTools falls back to a legacy packer that crashes on this font's large@All-class GPOS table) - brotli — required by fontTools to write
.woff2files (step 10/11 compresses the variable font and all static instances to WOFF2) - skia-pathops — unions overlapping contours so the animated README sheet
can draw glyphs as clean outlines (only
docsheets metricsneeds it)
python3 -m scriptsThis runs the entire pipeline end-to-end — source → packaged release folders —
printing a numbered, timed [n/11] header for each step. Expect it to take a
while: glyphsLib.load alone takes ~45s, and the instancing/compiling steps
are CPU-heavy (the full run can take well over ten minutes).
Useful flags:
--varonly(alias--variable-only) — compile just the variable font and stop, skipping instancing, compression, and packaging. The variable build still runs its post-compile passes (GEOM merge, YTAS accent-rise, axis-default shift, STAT + instance names), so it's a fast way to test variable-font output.--roman— build roman styles only (192), skipping the italic statics. Italics are built by default (384 styles).--no-flex— skip the morphing variable-font builds (step 8), including Cal Sans Flex, the true-HOI font. They are built by default on every full run; this opts out for a faster build when you only need the base/static families.--no-flex-hoiskips only Cal Sans Flex.--no-docs— skip regenerating the documentation: the character-alternatives doc (step 7) and the staticfonts/README.mdsheets (after step 11). Both are rebuilt on every run by default. Use this when you only want fonts: it leavesdocumentation/exactly as committed and shaves ~1,800 file writes off the run.fonts/README.mditself is still re-emitted and stamped. See Documentation.--verbose— show full glyph/instance name lists in the pre-processing stage (step 4); by default only counts and the first few names are printed.
- Extract metrics — dumps master metrics/stems to
sources/metrics.json. - Load source — reads
sources/CalSans.glyphspackagevia glyphsLib. - Validate — checks axes, master count, and opsz values match expectations.
- Pre-process for fontmake — translates the source from the Glyphs-app feature dialect to the fontmake/feaLib dialect entirely in memory.
- Save
_READYpackages — writes disposableCalSans_READY*.glyphspackageintermediates that fontmake compiles from. - Compile the variable font — runs
fontmake, then post-processes the result (merges overlapping GEOM feature variations, shifts axis defaults). - Document character alternatives — regenerates
documentation/character-alternatives.mdfrom the freshly compiled variable font: everyssXX/cvXXfeature, one row each, with every glyph the feature produces drawn as its own SVG cell indocumentation/images/character-alternatives/. Runs in a separate Python process that nothing waits on, so ~1,760 SVG writes never delay the font build's success report and a failure in the docs can never fail the fonts. The build prints the child's pid and a log path (scripts/temp/character-alternatives.log) and moves straight on. Skip with--no-docs. - Compile the morphing fonts — Cal Sans Flex is true HOI: it
re-preps a fresh copy of the source, keeps the stock
rcltGEOM swaps, adds three hidden helper axes (GE1M,GE2M,GE3M) and writes the morph windows as sparse masters on them, then compiles a second variable font from that disposable_FLEXHOIpackage. A post-process applies avar2 (the helpers followGEOM, the hiddenYTASfollowsopsz), hides those axes and renames it to Cal Sans Flex. The base build is untouched. Skip with--no-flex. - Instance statics — generates all static styles (384 with italics by
default, or 192 roman-only with
--roman) intoscripts/temp/static/, baking the correct GEOM substitutions into each. - Compress — generates
.woff2siblings for the variable font and statics. - Package releases — sorts the finished exports into the
fonts/release folders (e.g.calsans-var-full,calsans-var-flex,calsans-static-essentials,calsans-gf-workspace, etc.) It also re-emitsfonts/README.mdfrom its template, stamped with the built version, then starts the README sheets redrawing in a background process (log:scripts/temp/docsheets.log). Skip the sheets with--no-docs.
A few constants in scripts/config.py control the run:
BUILD_ITALIC—True(default) builds the full 384 styles (roman + italic);Falsebuilds 192 roman-only. The CLI--romanflag forces roman-only for a single run.SOURCE_PATH/OUTPUT_PATH/BUILD_DIR/RELEASE_DIR— paths for the source, intermediate_READYfiles, build artifacts, and final release folders, respectively.STATIC_*_TOKENS/STATIC_AXIS_VALUES— the per-axis style-name tokens and instancer coordinates thatscripts/lib/manifest.pycombines into the full static-style catalog.BUILD_CHARALTS—True(default) regenerates the character-alternatives doc on every run;Falseis the permanent form of--no-docs.CHARALTS_MD/CHARALTS_SVG_DIRset where the markdown and its cells land.
There are two output locations, serving different purposes:
-
scripts/temp/— raw/intermediate compiled output, the working area used before final packaging:scripts/temp/variable/— compiled variable font(s)scripts/temp/static/— all instanced static styles (TTF + WOFF2)
-
fonts/— the final, curated, ready-to-ship release packages, sorted fromscripts/temp/'s output by step 11/11. This directory is wiped and regenerated from scratch on every run. The packages are:Package Contents calsans-var-fullThe full variable font, all axes exposed calsans-var-flexCal Sans Flex — the true-HOI morphing font: GEOM letterforms blend along curved paths on hidden helper axes ( GE1M–GE3M) that avar2 drives fromGEOM, withYTAShidden and followingopsz. Built by default (step 8); skip with--no-flex. The folder's full contents are listed infonts/README.md.calsans-cossuiVariable font with ss*/cv*/aaltfeatures and their alternate glyphs subset outcalsans-gf-apiSame subsetting as cossui, packaged for the Google Fonts APIcalsans-gf-api-textuiCal Sans Text UI — second GF family (google/fonts#9970): wght-only (400–700) VF pair,opsz10 /GEOM25 /YTAS760 baked, curved l (l.rcltA11y) as defaultcalsans-static-fullAll static instances (384 with italics by default, or 192 roman-only with --roman)calsans-static-{a11y,ui,base,geo}Per-GEOM-family static subsets (base YTAS/SHRP only) calsans-static-essentialsCurated minimal set: Text+UI (roman) and Display+Base (incl. italics), TTF-only calsans-gf-workspaceSame two families as static-essentials, withoutopsz-axis awareness; the Text UI half is instanced fresh at thegf-api-textuiposition (YTAS760, curved l)
The build writes two sets of documentation, both drawn from the fonts it just
made, so they can never describe a build that no longer exists. Both run in
their own Python process that nothing waits on (a failure there never fails the
fonts), both are skipped by --no-docs, and both are committed with the fonts.
| What | Made by | When | Lands in |
|---|---|---|---|
Character alternatives: every ssXX/cvXX, every glyph it produces |
scripts/lib/charalts.py |
step 7, from the variable font | documentation/character-alternatives.md + documentation/images/character-alternatives/ |
fonts/README.md, version stamped |
scripts/lib/release.py |
step 11, every run, even with --no-docs |
fonts/README.md |
The specimen sheets fonts/README.md shows |
scripts/lib/docsheets.py |
after step 11, from the packaged statics | documentation/images/fonts-readme/ |
fonts/ is wiped every run, so its README is re-emitted from
scripts/lib/fonts_README.md. Edit that, never fonts/README.md. Write the
version as vX.XXX; the build stamps every one with the version read from the
built variable font's name ID 5, so the README can never disagree with the
binary beside it. Two packages are left out of it on purpose: adobe-vf and
gf-api-static still build but are not described. Each run ends with a
reminder to edit the template if a package changed.
Outlined text, no webfonts: they render the same in every browser. Every sheet
is written twice, name.svg and name-dark.svg, with the palette baked in,
because Safari ignores prefers-color-scheme inside an SVG loaded through
<img>; the README picks one with <picture>. They sit on the page with no
background or margin, like the character-alternative cells. Each is a table:
the label is README text on the left, the SVG holds only the specimen, and every
row of one table shares a scale (the waterfall's rows are drawn at real pixels).
| Sheet | Shows | Built by the build |
|---|---|---|
tiers-{display,text,micro} |
Rows of a table: "Cal v2" in UI Display, Text and Micro at one size, each letter's advance marked. The text label says the opsz and how much wider each sets than Display | yes |
waterfall-{08…192} |
One row per size, 8px to 192px, each at real pixels (640 wide, drawn 1:1, <img> given explicit width and height), in its tier: Micro below 10px, Text 10–20px, Display from 24px. The longest of "Scheduling Infrastructure", "Sched Infra" and "Infra" that fits one line. The label (8px, tier) is text |
yes |
families-{a11y,ui,base,geo} |
Rows of a table: "2160 just Groovy, I’ll Magic" in A11y, UI, Base and Geo, the family and its GEOM as a text label |
yes |
textui-l-{a11y,ui} |
Two rows: Cal Sans A11y Text against Cal Sans Text UI (the static cut), how I, l and 1 are told apart; the text label spells out which I and l each draws | yes |
cuts-{default,tall,sharp,tall-sharp} |
A one-row table per static table: the four weights, roman over italic; the text label gives the name and its YTAS, SHRP and opsz |
yes |
header |
Animated. The README's banner: this page's specimens at one scale, drifting right to left in three lanes, looping seamlessly, feathered at both edges | no — by hand |
metrics |
Animated. "Hax" in every tier, Regular and Bold: only x-height moves, the small tiers set wider. Keeps a card, full width | no — by hand |
Animated sheets are judged by eye before they ship, so a build never redraws
them (ANIMATED in docsheets.py). Redraw any sheet by hand, from the repo
root, after a full build has filled fonts/:
python3 -m scripts.lib.docsheets # every static sheet
python3 -m scripts.lib.docsheets metrics # the animated one
python3 -m scripts.lib.docsheets tiers cuts # just theseThe README points at them with relative paths
(../documentation/images/fonts-readme/…), so the same file works in this repo
and in calcom/sans. When populating sans, copy fonts/README.md and
documentation/images/fonts-readme/ together, or the pictures 404.
fontmake: Error: ... Generating fonts from Designspace failedduring step 6/11, with aGPOSOTLOffsetOverflowErrorin the log — make sureuharfbuzzis installed (pip install uharfbuzzor re-runpip install -r requirements.txt). Without it, fontTools uses a legacy table packer that can crash on this font's large GPOS table.ImportError: No module named brotliduring step 10/11 (WOFF2 compression) — installbrotli(pip install brotlior re-runpip install -r requirements.txt).command not found: fontmake— thefontmakeconsole script may have installed outside yourPATH(commonly under~/Library/Python/<version>/binon macOS if not using a venv). Activating the venv from Setup avoids this; otherwise add that directory toPATH.- README sheets did not change after a build — they redraw in the
background; check
scripts/temp/docsheets.log.No module named 'pathops'there meansskia-pathopsis missing (pip install -r requirements.txt); only the animatedmetricssheet needs it, and builds do not draw that one.