| title | Vectorography |
|---|---|
| emoji | ✒️ |
| colorFrom | gray |
| colorTo | red |
| sdk | docker |
| app_port | 7860 |
| pinned | false |
| license | gpl-3.0 |
| short_description | Type design by traversal of a vector space of letterforms |
Part of the Vector Lab. Vector methods for vector theory.
Tier: design instrument. Object: a vector space of letterforms.
Sibling instruments: Vectorscope · Manifoldscope · Theoryscope · Manifold Atlas · LLMbench
Type design by traversal.
Author: David M. Berry
Institution: University of Sussex
Version: see VERSION
Licence: GPL-3.0
Vectorography is an experimental typographic instrument for designing type by travelling through a vector space of letterforms. You do not describe a typeface and receive one. You arrive somewhere, look at what is there, look at what is nearby, and decide where to go next. The space is the workspace. The rendering only shows what a location looks like. The design is the journey.
There is no generate button, and there is no prompt field. This is not an omission. Every control in the instrument is a movement: a direction, a distance, a heading, a return. The letterforms are already everywhere in the space; the work is getting to them, and knowing where you have been.
Traversal first. The core screen is a navigator, not a form. A compass rose shows eight neighbouring positions as eight rendered specimens, so the choice is between visible places rather than abstract parameters. Every move is recorded on a trail that can be revisited, branched, and compiled.
Anti-normalisation. A space fitted to Google Fonts has a dense neo-grotesque core, and every fitted space pulls toward the average of what it was trained on. This instrument makes that pull visible and resistible. The altitude meter shows, permanently, how far you are from the corpus centroid and how crowded your immediate neighbourhood is. REPEL steps directly against the local density gradient. The nearest-neighbour panel names the five real families whose neighbourhood you are standing in, so provenance is on screen rather than buried.
./run.shThen open http://localhost:5173.
The fitted space ships with the repository as
VectorModel 0.2 (backend/data/vectormodel-0.2.npz, 30 MB), so a
fresh clone can travel immediately: no corpus download, no fitting. The run
script only installs dependencies.
The space is 128 dimensions fitted from 441 families over 164 glyphs: ASCII, the punctuation typesetting needs, and enough of Latin-1 to set French, German and Spanish. Coefficients are float32 throughout.
Everything is local. No accounts, no telemetry, and after npm install and pip install there are no network calls at all.
To rebuild the space from scratch, for a different corpus size or a different glyph set:
.venv/bin/python backend/corpus/outlines.py # extract and align outlines
.venv/bin/python -c "import sys; sys.path.insert(0,'backend'); \
import json, numpy as np; from space.style_space import StyleSpace; \
d=np.load('backend/data/corpus.npz'); \
StyleSpace.fit(d['X'], d['names'].tolist(), \
[json.loads(m) for m in d['metas']]).save()"The raw corpus and the intermediate corpus.npz are not committed: they are
large and fully regenerable from fetch.py and the manifest.
| Control | Movement |
|---|---|
| Compass rose | click any of eight neighbouring positions to go there |
| Arrow keys | walk east, north, west, south |
| Walk radius | how far one step goes |
Drift (d) |
random step, scaled by temperature |
Repel (r) |
step against the local density gradient, away from the crowd |
| Ride | make the difference between two families a heading usable from anywhere |
| Orbit | circle a chosen family at fixed radius |
| Atlas | click a family to travel to it; click open ground to drop a waypoint, then step toward it or go |
| Drag the specimen | grab your own mark and pull it through the space; the letterforms change as it moves. Alt-drag anywhere does the same without leaving orbit mode, and shift while dragging moves along the third axis instead of across the ground |
| Steer | eight measured properties, each with a direction: thinner/fatter, tighter/looser, sans/serif, rounder/straighter, and so on. Each is also a chip: dragging the specimen moves along the lit chips and nothing else, so the chips aim the drag |
| Trail | click any stop to return; moving from an earlier stop opens a branch |
| Backspace | back to the previous stop |
The eight compass bearings are 45-degree steps in a heading plane spanned by two of the space's axes. The plane selector prints each axis's share of corpus variance, because which plane you turn in is a choice about which directions of variation you treat as the important ones. REPEL is not confined to the plane: it uses the density gradient in the dominant style subspace and will take you out of whatever plane you are turning in.
The corpus drawn as a place. Both ground axes are the plane the compass turns in, so it is the surface actually being steered on rather than another projection of the space, and height is crowding, which makes REPEL visibly downhill. Coordinates are absolute with the centroid at the origin, so the map holds still while you move across it.
Every family is drawn as the same letters set in that family's own typeface, so the map is a field of comparable specimens rather than a scatter of labels: a name tells you what a typeface is called, its letters tell you what it is. The mark can be switched to the family name, or turned off. Marks are decluttered by an occupancy grid, nearest first, and fade rather than blink as the selection changes.
Colour runs along whichever measured property you choose, with a legend naming it and saying which way it runs.
Your own position is the one mark drawn from the model rather than from a font file, in the accent colour: everything else on the map is a real typeface, and you are a location in the space.
A click on open ground names two coordinates, not a position: the other 126 are unspecified, and are left exactly as they are. Travelling to a spot on the map therefore moves within the plane you are steering in and changes nothing else.
The same holds when the specimen is dragged. The client is given the basis vectors spanning the view, so it works out where the specimen has landed itself and asks the server only for the outlines to draw it with: the mark follows the pointer rather than waiting on a round trip. Nothing is recorded on the trail until the drag ends, so a move is one stop rather than a hundred.
Set the atlas height to a corpus axis and the vertical becomes a direction as well, rather than a reading of where you already are, which is what makes dragging up and down mean anything.
The view axes can be measured properties rather than corpus eigendirections: set the plane to weight by serif and dragging left and right makes the letters heavier and lighter. Measured properties are not orthogonal to one another, so each axis after the first is drawn with the shared part removed and the interface says how much was taken off; otherwise dragging along one would silently drag along another.
The ball control draws the corpus as it sits in the three directions on screen: two wireframe shells at the median and ninetieth percentile radius, with the traveller inside and a line from the centroid to where they are standing.
Two things make it honest rather than decorative. It needs all three view directions to be real axes, so turning it on turns the height into one, and while it is on a unit up is worth the same on screen as a unit across, without which a ball would be drawn as an ellipsoid. And its radius is measured in those three coordinates rather than taken from the full space: the corpus median radius is 7.09 across all 128 dimensions but 1.10 in the three being shown, and drawing the larger number would put every font inside a sphere it does not fill.
Not quite, and the difference is worth knowing. After whitening the corpus is an isotropic cloud, so the space is a ball rather than a cone or a sphere. But in 128 dimensions almost all of a cloud's mass sits in a thin shell, and the corpus does exactly that: the median family is 8.6 units from the centroid and the furthest is 20.9, with not one of the 441 families inside three units.
So the centre of the space, the average of every font in the corpus, is a place where no font is at all. That is the anti-normalisation argument in one measurement: the pull is toward a point the distribution itself has largely vacated.
File → Share image sends the current location straight to the system share sheet as a PNG, which is what WhatsApp and Messages take; Share card shows it first. The card carries the specimen, the three readings, the nearest real families, and which model and corpus it came from. A specimen without its provenance is a picture of some letters.
Pasting the app's URL somewhere is a different mechanism: the scraper that
unfurls a link fetches the page and then one image, and runs no JavaScript. So
that card is a static file, frontend/public/og.png, named in the page's
metadata and regenerated with:
.venv/bin/python tools/make_og.py
It is drawn server-side by backend/export/card_png.py, which fills the
decoded outlines directly rather than adding a rasteriser: the points are
already there, and a filled polygon is a filled polygon. When deploying, make
the og:image URLs in index.html absolute; several scrapers will not resolve
a relative one.
A journey is the work, so it does not have to live in one tab.
New Project (⌘N) returns to the centroid with an empty trail. Save (⌘S)
and Save As (⇧⌘S) write a .vgy file: the whole trail including its
branches, where the cursor is standing, the axes the map is drawn in, the
travel settings and the specimen text. Open (⌘O) reads one back.
A .vgy is plain JSON, and it records the model it was made against. Opening a
journey recorded in a different number of dimensions is refused rather than
half-loaded, since a trail is only meaningful in the space it was walked in.
File → Export (⇧⌘E) opens one panel, grouped by what you do with the file rather than by what the file is: fonts to install, source to carry on working on, outlines to draw with. That is the distinction people get wrong, since an OTF and a UFO look like alternatives and are not.
The typeface's name is set in the title bar and travels with everything: the
name table of every font, the filenames, the UFO's fontinfo, and the share
card. Export says so while it is still Unnamed.
OTF / TTF. The current location as one installable static
font. The OTF carries cubic Bezier outlines converted from the same Catmull-Rom
construction the navigator draws with, so what you looked at is what you install:
no requantisation, no second approximation. Metrics, x-height, cap-height and
naming are filled in properly and fsType is 0, so it installs in Font Book and
sets text like any other font.
UFO source. This location as a UFO 3 package, which is what every type editor opens: Glyphs, RoboFont, FontLab, FontForge. An OTF is something a designer installs; a UFO is something they work on. Verified against fontTools' own reader, and every glyph's outline matches the compiled OTF to within half a font unit at 2048 upem.
Designspace + UFO masters. The journey as the standard source layout of a
variable font: one UFO per stop and a .designspace binding them to a single
Journey axis. This is the same shape the compiled variable font has, in the
form a designer can open and carry on with.
The outlines are resampled at uniform arc length, forty points per contour, in cyclic correspondence across every stop. That is what lets any two stops interpolate, and it also means the points are not where a designer would have put them: expect to run a curve fit before editing by hand. The README inside the export says so too.
SVG outlines. Every glyph as its own SVG, for Illustrator, Figma or a cutter. Shapes rather than type: no metrics, no kerning, and nothing downstream knows they are letters.
Licence for exports. The terms a compiled typeface goes out under: OFL 1.1,
MIT, CC BY 4.0, CC0, all rights reserved, or none. The choice is written into
the font's own name table (IDs 13 and 14, where a type designer looks for it),
into the copyright field along with whatever name you give, and into a
LICENSE.txt in the journey bundle. It is remembered between sessions.
Compile Journey to Variable Font. The recorded path becomes a variable
font. The trail is sampled at uniform arc length, each sample is decoded and
compiled as a master, and varLib builds a variable font whose single JRNY
(Journey) axis runs from the start of the journey to its end, with a named
instance and a STAT entry per stop so the stops appear as selectable styles.
The journey is the axis of the font.
The zip contains:
specimen.html open this first: tests everything in a browser, no install
<Family>-VF.ttf the variable font, one Journey axis, named instance per stop
instances/ each stop as a static OTF, ready to install
masters/ the TrueType masters the variable font interpolated from
journey.designspace the designspace varLib was given
journey.json the full path in space coordinates, and the model it belongs to
corpus-manifest.json every family the space was fitted from
README.txt what each of the above is
Test Journey. Compiles the journey and loads the resulting variable font into the app, so the artefact can be judged before it leaves: a journey-axis slider, a size slider, editable text, a waterfall, a paragraph, and faint cap-height, x-height and baseline guides drawn off a measured baseline. This is the same compilation the export runs, so what is tested is what ships.
Export Specimen Sheet (SVG). The current location as a specimen sheet with its map reading (distance from centroid, density percentile, isolation, and the five nearest real families) printed on it. The reading travels with the artefact.
(fontTools) (fixed length) (whitened PCA) (browser) (varLib)
Style vectors. Each font becomes one vector of 65,764 floats: 164 glyphs, five contours each, forty points per contour resampled at uniform arc length, plus one advance width per glyph. Contours that sit above the x-height are kept in slots of their own, so an accent is never matched against a counter. Contours are sorted by area, wound consistently, and put into cyclic correspondence across the whole corpus by a Procrustes fit, so point i of a contour lands in the same place on the letter in every font. That correspondence is what makes the space walkable. Without it the average of two fonts is noise rather than a letter, which is worth stating plainly because it was the single change that made this work at all.
The space. VectorModel 0.2: a whitened 128-dimensional principal subspace of the corpus, retaining 96% of variance. It was chosen for traversability rather than fidelity: encode and decode are exact linear maps, so every point in the space decodes to well-formed contours and every move is continuous. Whitening makes one unit of distance mean the same thing on every axis, so a compass radius is a real quantity.
The axes are corpus eigendirections. They were learned from the distribution rather than declared by a designer, and the instrument says so in the plane selector.
Density. A Gaussian KDE with an analytic gradient, estimated over the eight dominant style directions rather than all 128. In the full whitened space distances concentrate, the kernel goes flat, and the meter reads the same everywhere; the crowding this instrument exists to show is crowding in the directions along which typefaces actually vary.
Compilation. Every master is built with the same glyphs, the same contour
counts, and the same point counts, written directly into the glyf table rather
than through a pen. Any set of sampled locations is therefore interpolation
compatible and varLib needs no repair step.
DeepSVG was the first choice for the latent space and was rejected after
evaluation. It pins torch==1.4.0, numpy==1.16.1, Python 3.7 and the withdrawn
sklearn shim package, none of which install on a current interpreter, and its
font model depends on a paid dataset. The space here was built instead.
Edit → Settings (⌘,) keeps what should outlast a session: the theme, with a System option that follows the machine; the text the specimen opens with; whether the corpus shell is drawn; and the licence and copyright holder that exports carry. Everything on the stage toolbar is a thing you flip while working and is not kept.
It is all in this browser's local storage. There is no account, no server-side profile and no telemetry; Clear settings puts it back to the defaults without touching the journey.
The corpus is drawn only from the ofl/ tree of
google/fonts, which is the SIL Open Font
Licence tree. No other font source is permitted by the ingest code. This is a
design decision rather than a convenience: an instrument that shows you whose
neighbourhood you are standing in should be able to say where its ground came
from. backend/data/corpus-manifest.json records every family the space was
fitted from, and travels inside every journey export.
What comes out is yours to license. The outlines are produced by a linear
transformation of a fitted space rather than by copying any face, so the terms
are the author's to set: Export → Change offers OFL 1.1, MIT, CC BY 4.0,
CC0, all rights reserved, or none. The choice is written into name IDs 13 and
14, into the copyright field with whatever name you give, and into a
LICENSE.txt beside the fonts.
One container, built and served by one process: see DEPLOY.md.
The hosted instance runs on Google Cloud Run, with Firebase Hosting in front of it for the name. A container holds the fitted space in memory rather than reloading it, which is why a warm request answers in well under a tenth of a second and a cold one in about three quarters. Serverless hosts that would re-read the model per invocation are a poor fit for this shape of work.
This is a prototype, and the following are known and deliberate.
- Outline quality is not production quality. Forty points per contour, no hinting, no kerning, no overlap removal. Exports are for study and specimen, not for retail release.
- Interpolating across structural change produces blobs. A letter with one counter interpolated against a letter with two passes through intermediate states where the padded contour opens up. This is shown rather than hidden.
- Latin only. The representation assumes a fixed glyph set with stable contour counts. Cursive-connected and contextual scripts need a different representation, and that is the interesting next problem rather than a detail.
- Density saturates far out. Past the corpus hull the density percentile pins at zero, which is why distance from the centroid is reported alongside it.
backend/
corpus/outlines.py outline extraction, resampling, corpus alignment
space/style_space.py the vector space, density, and travel primitives
data/vectormodel-*.npz the fitted space, see MODEL.md
export/fontfile.py master and variable font compilation
export/ufo.py UFO 3 and designspace source
render.py contours to SVG
main.py FastAPI: location, compass, travel, export
frontend/src/
App.tsx the navigator
components/ compass rose, altitude meter, atlas, specimen stage,
export panel, settings, help, project files
DESIGN.md design of record, including the pre-build review
@software{berry_vectorography_2026,
author = {Berry, David M.},
title = {Vectorography: Type Design by Traversal},
year = {2026},
url = {https://github.com/vector-lab-tools/vectorography},
license = {GPL-3.0}
}VERSION at the repository root is the single source for the application
version. The fitted space carries its own version (see MODEL.md),
because refitting invalidates saved journey coordinates even when no code has
changed.
VERSION at the repository root is the single source. The backend reads it at
import, the frontend inherits it at build time through vite.config.ts, and
tools/sync_version.py stamps it into CITATION.cff, which needs a literal
because citation metadata has to stand alone. frontend/package.json carries no
version of its own.
Versions move in steps of 0.01, and only when agreed.