designspace-lint
What a designspace will do when it is built — before you build it.
A designspace can be perfectly valid and still produce a variable font nobody
intended. A glyph missing from the master at the end of an axis quietly reverts
to the default's shape past that point. An avar2 mapping written at the default
location is thrown away, and every other mapping shifts by what it would have
moved. Two rules that swap one glyph over a shared region are settled by how
their substitutions sort, not by the order you wrote them. A features.fea
that differs from the default's pushes the whole build onto another path,
where varLib fails somewhere that has nothing to do with the cause. None of
this is an error at build time; the font just comes out wrong.
This library reports those cases, and each report names the consequence, not only the discrepancy.
$ designspace-lint Family.designspace
error 4.11 /eacute Weight 400 of 900: missing at Weight maximum (900); reverts to the default shape past 400
error 1.18 mapping 27: avar2 mapping from the default location is dropped: YTOS 11 -> 10
warn 4.2 /acute missing in 12 sources: anchor '_topacute' missing in 12 sources
3 problem(s), 2 structural
Exit code is 0 when clean, 1 when there are problems, 2 when the
designspace cannot be read — so a build script can gate on it directly. A
master that cannot be opened is reported and the others are still checked, and
a check that fails partway is reported as 0.1 rather than dropped, so a clean
result means every check ran.
Install
pip install designspace-lint # the checks and the CLI
pip install "designspace-lint[features]" # + the features.fea comparison (ufo2ft)
pip install "designspace-lint[interpolatable]" # + a solver for --interpolatable
Point by point: --interpolatable
The glyph checks compare structure. Two masters can agree on every count and
still interpolate badly: a contour that starts at another point or runs the
other way twists between them, contours in another order swap shapes, a curve
thins out or kinks halfway. varLib builds all of it without a word.
--interpolatable (or lint(..., interpolatable=True)) runs fontTools'
varLib.interpolatable over the masters already open and reports these as
4.14–4.17. It roughly doubles the run time, so it is off by default. Glyphs of
more than six contours need an assignment solver; without the extra they are
counted and reported once (4.18) instead of checked.
Use it from Python
from designspace_lint import lint_path
for problem in lint_path("Family.designspace"):
print(problem.category, problem.code, problem.description)
When the fonts are already open — inside an editor, or a build script that has
loaded them anyway — hand them over instead of paying to open them twice. Any
object with path, doc and sources will do, where each source has font,
path, name, location, layer_name and the layer accessors described by
designspace_lint.protocols.SourceLike:
from designspace_lint import lint
problems = lint(my_designspace) # nothing is re-opened
What it checks
| Category | What it looks at |
|---|---|
| 0 File | the document can be read at all, and every check could finish |
| 1 Geometry | axis minimum/default/maximum, mappings, duplicate names and tags, discrete axis values, avar2 mappings: from the default, duplicate inputs, unknown axes, clamped values, outputs past the masters, chains; the declared <variable-fonts> |
| 2 Sources | locations (as written in the file, before fontTools drops unknown axes), missing UFOs, duplicate locations as varLib compares them, the default, sources off a discrete axis' values |
| 3 Instances | instance locations and names, unknown location labels, one name at two locations |
| 4 Glyphs | master compatibility: contours, points, curve types, components, anchors, unicodes, contour direction, empty glyphs, how far along each axis a glyph actually reaches, and with --interpolatable start points, contour order and shapes that thin out or kink |
| 5 Kerning | kerning groups, masters with no kerning, a glyph in two groups of one side |
| 6 Font Info | units per em, required fields, values that differ |
| 7 Rules | rule conditions, rules that never apply, rule glyphs missing from the default master, overlapping rules |
| 8 Features | whether the masters' features.fea keep the build on the variable path |
| 9 Glyph Order | extra glyphs, and orders that break the per-master merge |
| 10 Labels | DS5 axis and location labels: the instance names the build derives from them, values no label covers, labels STAT drops, ranges and style links |
The (category, code) pairs are a public contract. A retired check keeps
its number, and new checks only ever get new ones.
The rules come from what the tools actually do
Every rule here was checked against fontTools and ufo2ft rather than
assumed, and several of them contradict what seems reasonable:
- A glyph may skip a master in the middle of an axis — varLib builds a per-glyph model from the masters that have it. Skipping the master at the end of an axis is the dangerous one: measured on a 0 / 0.5 / 1.0 axis carrying 100 / 150 / 300 with the last master missing, 0.75 gives 125 and 1.0 gives 100 instead of 225 and 300. The font builds without a word.
- Differing kerning pair sets are normal and are not reported: a pair a master does not list resolves to its group value or to 0, which is exactly what its absence means in a UFO. A master with no kerning is reported, and what it does depends on ufo2ft: up to 3.8 every pair resolves to 0 there and the kerning sags toward it; since 3.9 the master is skipped and the kerning interpolates across it.
- Groups without pairs do not help — verified by building a variable font and reading the values back.
ufo2ftcompiles one variable feature file from the default master only when every other master'sfeatures.feamatches it or all of them are empty. A mix of the two is not compatible either, and falling off that path is silent.- Overlapping rules are not settled by declaration order. varLib makes
one lookup per rule and numbers the lookups in sorted order of their
substitutions; in the overlap, the first to rewrite a glyph wins. Declared
a → a.zzzthena → a.aaa, the variable font showsa.aaa, while static instances, which apply rules in declaration order, showa.zzz. - A rule with no
<conditionset>never applies, and fontTools drops it. An empty<conditionset/>is the spec's always-on rule. - An avar2 mapping from the default location is discarded. varLib keeps it
only as the base of the variation store it then throws away, and measures
every other mapping from it: with Weight 100/400/900, adding
400 → 650to900 → 700moves normalized 1.0 from 0.6 to 0.1 and leaves the default where it was. - An avar2 mapping value past the axis end is clamped, and the mappings are evaluated at the coordinates the user set, before any of them applies: one mapping's output never feeds another's input. Two mappings whose inputs normalize to the same point stop the build, two default-input mappings on different axes included.
- Instance names come from the labels. When the document has labels, the
build names each instance from the labels at its location, and a
stylenamewithoutxml:lang="en"is replaced by that in the variable font. Where every label is elidable and there is noelidedfallbackname, the name is empty. - Duplicate masters are duplicates after the omitted axes are filled in:
{Weight: 900}and{Weight: 900, Width: 100}(the default) are one location to varLib, which refuses them. - An empty glyph is a missing glyph to the outline, not to the spacing. When the default draws a glyph, varLib reads an empty copy in another master as absent and interpolates the shape across it, while the advance width of that empty copy still interpolates. So it is not an incompatibility, and at the end of an axis it reverts like a missing glyph (4.11).
- Sparse masters need no naming convention. Which masters a glyph may skip follows from the axes; which sources carry kerning, features and a glyph order of their own follows from whether they are layers.
- A gap at the end of an axis only counts when the glyph varies on that axis. One that sits at a single coordinate carries no delta along it, so nothing fades out — that is a parametric family's normal arrangement, and a glyph drawn only in the default master is reported once as static (4.13) rather than once per axis end.
- A designspace 5 source may name only the axes it is not default on; that is not a missing value.
Each of those last two was learned the hard way, by running this over Amstelvar — 91–93 axes, up to 150 masters — where the earlier readings produced 1518 and 18609 findings that all meant "this is how a parametric family is built". See the changelog for 0.1.1.
What it does not see
It never compiles anything, so whatever exists only in the built font is out
of reach. The September 2026 coverage audit (docs/audit/) sorted these out;
the main ones:
- Features ufo2ft writes for you.
kern,markandmkmkare generated from kerning and anchors at build time. Category 8 compares the masters'features.featext and cannot see what the writers would add or where they would disagree across masters. - What only the compiled tables show: lookup overflows and table sizes, TrueType hinting, name-table ID reuse, and the order the shaper applies feature variations in beyond what 7.7 predicts.
- Renderers. avar2 is new; an engine that only reads avar1 ignores the whole table. Whether a given app or instancer handles a font correctly is not a property of the designspace.
- Muting.
mutedGlyphNames,muteKerningandmuteInfoon instances are not taken into account. - Point correspondence only with
--interpolatable. Even then, component transforms are compared by structure, not by what they do between masters. - Masters that could not be opened. They are reported (2.1) and left out; checks that measure extents, such as 4.11, then measure without them.
For those, build the font (fontmake, or fonttools varLib) and run
varLib.interpolatable and your shaping tests on the result.
Relation to designspaceProblems
This started as a reimplementation of designspaceProblems by Erik van Blokland (LettError), MIT-licensed, and it still owes that project its shape: the idea of numbering problems by category and code, and most of the category-4 glyph compatibility checks, come from there. The numbering is kept deliberately compatible — codes 4.0 to 4.10 mean what they mean in designspaceProblems — so anyone moving across reads the same numbers.
It was rewritten rather than wrapped because of what using it in a font editor asked for, and those needs turned out to be structural:
- Results as data, not as a report. Every problem is a dataclass carrying its location, the masters involved and the diagnosis, so a UI can group, filter and navigate them. The original returns a mapping of problem to glyph names, which is the right shape for a report and the wrong one for a list you click through.
- No re-opening fonts. An editor already holds them; the checks take whatever satisfies the protocol.
- Layer-based designspaces. Several masters in one UFO, told apart by layer, have to be compared on the layer they actually draw.
- Progress and cancellation, because a family with 84 masters takes long enough that a person will want to stop it.
- Consequences in the message. The checks grew to say what the build will do, which meant verifying each rule against varLib and ufo2ft — and finding a few where the reasonable-sounding rule is not the real one.
It is not a drop-in replacement: the entry points and the result type differ. For the glyph checks the vocabulary is the same.
License
Apache-2.0. See LICENSE, and NOTICE for the designspaceProblems attribution.
Metadata
Release files for designspace-lint 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| designspace_lint-0.5.0.tar.gz | 107.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| designspace_lint-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 204.2 kB
Release files / designspace_lint-0.5.0.tar.gz
| Download URL | designspace_lint-0.5.0.tar.gz |
|---|---|
| Size | 107.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4469d484396238f0d477c77a652f70ef95bb2c96640096ec6d1340a98ce5ecc3
|
|
BLAKE2b-256 checksum How to use checksums |
3d312361d670a213821f263cfd2a2a516afc293e2d1fae7411c806dbe5f55bb2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / designspace_lint-0.5.0-py3-none-any.whl
| Download URL | designspace_lint-0.5.0-py3-none-any.whl |
|---|---|
| Size | 96.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
89d5c9e0f395531e5623592873ab47161e1b062bcedff3ccd60f6c3b3eeda62b
|
|
BLAKE2b-256 checksum How to use checksums |
70be63568b38e56f7f8e4579753177c1dde156080097d4a41d1084aeaab7533c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency log