Unzip the release. On macOS, open the TTF files and select Install. On Windows, right-click the files and select Install. On Linux, copy them to ~/.local/share/fonts and run fc-cache.
+
On the web
+
Copy the WOFF2 files to your site, then add this CSS. Change the path to where you put the files.
Square brackets in a file name are valid in a URL path. If your server does not accept them, rename the files and change the CSS to match.
+
+
+
+
+
Packages
+
npm package coming
+
+
An npm package for each family is coming. Until then, use the files from the GitHub releases.
+
+
+
+
+
Licence
+
Free to use, change and share
+
+
+
FontsSIL Open Font License 1.1. Use them in any project, also commercial ones. You can embed them in apps, documents and websites. If you change a font, give it a new name: "machine-made" is a Reserved Font Name. You cannot sell the fonts alone.
+
CodeApache License 2.0. This covers the generator, the tools, the tests, the CI and this site.
+
Sans basemachine-made Sans is based on Plus Jakarta Sans by Gumpita Rahayu, Tokotype (SIL OFL 1.1).
A monospace set on a 12-unit grid by a generator called the loom. One axis sets the weight. A second axis, FRAG, takes it from clean strokes to broken pieces.
Run the loom and it writes every outline from a table of strokes. Fontmake compiles the result, and the checks run on every pull request. A person reviews the proof by eye before a release.
Sans · upright and italic · wght {{SANS_WGHT_RANGE}} · {{SANS_VERSION}}
+
machine-made Sans
+
A reading sans for text and interfaces. It keeps the geometry, tall x-height and open counters of Plus Jakarta Sans, makes the straight alternates the default forms, and puts the sharp forms in stylistic set ss02.
+
+
+
+
+
Specimen
+
Set for reading
+
+
+
Rg Ka 0123
+
+
Every result a machine hands you is checked by another machine.
+
machine-made Sans sits next to Weft. Its x-height is tall and its counters are open, so it stays clear at small sizes on screens. The straight forms give it a plain, flat look, and the sharp set in ss02 gives it the cut look of the monospace.
One variable font per style. The weight axis runs from {{SANS_WGHT_RANGE}}.
+
+
+
+
Upright
+
+{{SANS_WATERFALL}}
+
+
+
+
Italic
+
+{{SANS_WATERFALL_ITALIC}}
+
+
+
+
+
+
+
+
Default forms
+
Straight by default, sharp in ss02
+
Plus Jakarta Sans ships three sets of alternates: sharp, straight and swirl. machine-made Sans makes the straight a j p q r y the default. The sharp forms are in ss02, the original round forms in ss01, and swirl in {{SANS_SWIRL_TAG}}.
+
+
+
+
machine-made Sans 0.1 · default
+
a j p q r y f g h i k l m n t u G I K U
+
{{SANS_FORMS_NOTE}}
+
+
+
Sharp · font-feature-settings: "ss02" 1
+
a j p q r y f g h i k l m n t u G I K U
+
Turn the sharp forms on with font-feature-settings: "ss02" 1;
Turn swirl on with font-feature-settings: "{{SANS_SWIRL_TAG}}" 1;
+
+
+
+
+
+
+
Credit
+
Based on Plus Jakarta Sans
+
+
Based on Plus Jakarta Sans by Gumpita Rahayu, Tokotype (SIL OFL 1.1).
+
The Open Font License lets anyone change and share the font, under a new name. machine-made Sans is that new name. The original licence and copyright notice ship with every file.
+
+
+
+
+
Next
+
What comes in 0.2
+
+
0.2 brings Weft's chamfered terminals and Text/Display cuts.
A monospace built from strokes on a 12-unit grid. Every glyph is 750 units wide. The weight axis sets the stroke. The FRAG axis takes the strokes from clean to broken.
+
+
+
+
+
Playground
+
Set your own text
+
+
+
+
+
+
+
+
+
+
+
+
The weight axis arrives with the variable font. This build has Regular only.
+
+
+
+
+ 01550100
+
+
Clean · safe for body text
+
+
FRAG arrives with the variable font.
+
+
+
+
+
+
+
Made by machines
+Proven for machines
+pc spend card 0042
+
+
+
+
+
+
+
The FRAG axis
+
From clean to broken on one axis
+
FRAG runs from 0 to 100. Up to 15 it only cuts small notches into the joints, so body text stays safe. From 16 it cuts across the strokes. Past 50 the pieces move and drop out.
+
+
+
0CleanBody text
+
1–15Ink-trap notchesBody text
+
16–50Cuts across strokesHeadings
+
51–100Broken piecesTitles and marks
+
+
+
FRAG arrives with the variable font. Then this section shows Weft at FRAG 0, 8, 15, 30, 50, 70 and 100.
+
+
+
+
+
Specimen
+
From 12 to 200 pixels
+
+
+
12 pxThe loom writes every outline from a table of strokes on a twelve unit grid and fontmake compiles it
+
14 pxThe loom writes every outline from a table of strokes on a twelve unit grid and fontmake compiles it
+
16 pxThe loom writes every outline from a table of strokes on a twelve unit grid
+
20 pxEvery glyph is seven hundred fifty units wide
+
24 pxMade by machines Proven for machines
+
32 pxProven for machines 0123456789
+
48 pxStrokes on a grid
+
72 pxWeft 0042
+
120 pxRg Ka
+
200 pxWeft
+
+
+
+
+
+
Character set
+
{{WEFT_CHAR_COUNT}} characters
+
Read from the font's character map when the site is built. Select a character to see it large.
+
+
+
+
a
+
+
Character
Latin Small Letter A
+
Unicode
U+0061
+
Glyph
a
+
+
+
Loading the character map.
+
+
+
+
+
+
Look-alikes
+
Characters that must not be confused
+
Code, serial numbers and card numbers need these pairs to stay distinct. Each group is set at 48 px.
+
+
+
I l 1 |
+
O 0 o
+
rn m
+
cl d
+
5 S
+
2 Z
+
B 8
+
g 9 q
+
u v
+
+
+
+
+
+
+
Proven readable
+
Read back by OCR at every FRAG level
+
CI sets a test text in Weft at each FRAG level, renders it and reads it back with OCR. The character error rate (CER) is the share of characters the OCR gets wrong. The release gate: up to FRAG 15, CER must match FRAG 0.
Agents write the generator, the tests and the CI. People set the goals and review the proofs.
+
+
+
+
The loom
+
A Python generator. It reads a table of centre-line strokes on a 12-unit grid, cuts each stroke into pieces with the same points in every master, and writes 12 UFO masters (wght × FRAG) at 1000 units per em. No outline comes from another font.
Every pull request runs the tests, builds the fonts with fontmake, checks them with Fontspector and renders a proof sheet. GitHub Actions are pinned to commit SHAs.
machine-made/type · Weft · sketch 0.1 · 26 October 2026
+
+
+
A first look at Weft, mm's own monospace. Every letter on this page is drawn live from strokes on a 12-unit grid, by the rules the generator will follow.
+
+
+
+
+
+
+
Playground
+
Set your own text
+
+
+
+
+
+
+
+
+
+
+ Notches · safe for body text
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Style
+
+
+
+
+ Thread ships after the first release.
+
+
+
+
+
+
+
+
+
+
+
+
The FRAG axis
+
From clean to broken on one slider
+
Up to 15, FRAG only notches the joints, which keeps body text safe. From 16 it cuts across strokes, narrower than the stroke itself. Past 50 the pieces drift, shrink and drop out.
+
+
+
+
+
+
+
Readability
+
Body text stays at 15 or below
+
Set at 20 px. Canvas strokes have no hinting, so small sizes look rougher here than the compiled font will.
+
+
+
Weft · FRAG 0
+
Weft · FRAG 12
+
Plus Jakarta Sans · the Sans base
+
+
+ Look-alikes · FRAG 0, then FRAG 15
+
+
+
+
+
+
+
+
Character set
+
89 characters on the loom grid
+
Each cell shows the unit grid, the metric lines and the stroke skeleton the generator works from. The set follows the playground's settings.
+
+
+
+
skeleton and nodesbaseline, x-height, cap height, descender
+
+
+
+
+
+
+
machine-made Sans
+
The second font starts from Plus Jakarta Sans
+
+
+
Rg Ka 0123Plus Jakarta Sans 500, unmodified
+
+ Phase 4 · not started
+
Shown as it ships today. The fork keeps its geometry, tall x-height and open counters, and takes these details from Weft:
+
+
The sharp and straight alternates become the default forms.
+
Terminals are cut at Weft's chamfer angles.
+
The x-height matches Weft, so both sit on one line.
+
Two cuts: Text with open apertures, Display with ink traps.
+
+
+
+
+
+
+
+
About this sketch
+
What it shows, and what it does not yet
+
+
+
Letters are drawn from centre-line strokes. The real font will be outlines written by the generator, the loom, and compiled by fontmake.
+
No spacing or kerning pass yet. Every character sits in a 12-unit cell.
+
A letter breaks the same way each time it appears, as it will in a variable font. Reweave picks another pattern.
+
Thread is here so you can judge it. It ships after the first release.
+
The OCR gate and Fontspector checks have not run on this. They start in phase 2.
+
+
+
+
+
+
+
diff --git a/weft/scripts/build.sh b/weft/scripts/build.sh
new file mode 100755
index 0000000..1d9730e
--- /dev/null
+++ b/weft/scripts/build.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# Build Weft 0.1: UFO masters and statics, then the variable TTF, static
+# TTF and OTF, and WOFF2.
+# Usage: weft/scripts/build.sh (from any directory)
+set -euo pipefail
+WEFT="$(cd "$(dirname "$0")/.." && pwd)"
+ROOT="$(cd "$WEFT/.." && pwd)"
+OUT="$ROOT/fonts/weft"
+# Fixed timestamps (head.created, head.modified) make the build reproducible.
+export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-1792972800}" # 2026-10-26 00:00 UTC
+cd "$WEFT"
+
+python -m loom.build --out build
+
+rm -rf "$OUT"
+mkdir -p "$OUT/variable" "$OUT/ttf" "$OUT/otf" "$OUT/webfonts"
+
+# Variable font. The pieces overlap on purpose: keep the overlaps.
+fontmake -m build/Weft.designspace -o variable --keep-overlaps \
+ --output-path "$OUT/variable/MachineMadeWeft[FRAG,wght].ttf"
+python tools/finish.py --variable "$OUT/variable/MachineMadeWeft[FRAG,wght].ttf"
+
+# Static fonts at FRAG 0. The generator removed the overlaps already.
+fontmake -u build/static/*.ufo -o ttf --keep-overlaps --output-dir "$OUT/ttf"
+fontmake -u build/static/*.ufo -o otf --keep-overlaps --output-dir "$OUT/otf"
+python tools/finish.py --static "$OUT"/ttf/*.ttf "$OUT"/otf/*.otf
+
+python tools/finish.py --woff2-dir "$OUT/webfonts" --woff2 \
+ "$OUT/variable/MachineMadeWeft[FRAG,wght].ttf" \
+ "$OUT/ttf/MachineMadeWeft-Regular.ttf" \
+ "$OUT/ttf/MachineMadeWeft-Bold.ttf"
diff --git a/weft/scripts/check.sh b/weft/scripts/check.sh
new file mode 100755
index 0000000..451bdcd
--- /dev/null
+++ b/weft/scripts/check.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Check the compiled fonts with Fontspector (universal profile).
+# Usage: weft/scripts/check.sh [path-to-fontspector]
+# Runs once for the variable font, once for the static TTF family and
+# once for the static OTF family. TTF and OTF of one style are not one
+# family, so they run apart.
+# The process fails on FAIL or worse. WARN does not fail it.
+set -euo pipefail
+WEFT="$(cd "$(dirname "$0")/.." && pwd)"
+ROOT="$(cd "$WEFT/.." && pwd)"
+FONTS="$ROOT/fonts/weft"
+
+FS="${1:-fontspector}"
+REPORTS="$WEFT/build/reports"
+mkdir -p "$REPORTS"
+status=0
+run() {
+ local label="$1"; shift
+ "$FS" --profile universal \
+ --skip-network --error-code-on fail \
+ --ghmarkdown "$REPORTS/fontspector-${label}.md" \
+ --json "$REPORTS/fontspector-${label}.json" \
+ "$@" || status=1
+}
+run variable "$FONTS"/variable/*.ttf
+run ttf "$FONTS"/ttf/*.ttf
+run otf "$FONTS"/otf/*.otf
+exit "$status"
diff --git a/weft/tests/test_glyphs.py b/weft/tests/test_glyphs.py
new file mode 100644
index 0000000..5ddb971
--- /dev/null
+++ b/weft/tests/test_glyphs.py
@@ -0,0 +1,303 @@
+"""Tests for the Weft 0.1 generator: glyph data, pieces, masters, statics."""
+
+from __future__ import annotations
+
+import json
+import math
+from pathlib import Path
+
+import pathops
+import pytest
+
+from loom import build as B
+from loom import extended as ext
+from loom import glyphs as data
+from loom import interp, pieces
+from loom.outline import glyph_outline, path_contours, signed_area, stroke_polygons
+
+DRAWN_NAMES = B.drawn_names()
+SKETCH_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,:;!?-–—_/\\|()[]'\"+=*#<>%"
+MASTERS = [(w, f) for w in pieces.W_MASTERS for f in pieces.F_MASTERS]
+
+
+def union(contours) -> pathops.Path:
+ path = pathops.Path()
+ path.fillType = pathops.FillType.WINDING
+ pen = path.getPen()
+ for c in contours:
+ pen.moveTo(c[0])
+ for p in c[1:]:
+ pen.lineTo(p)
+ pen.closePath()
+ path.simplify(fix_winding=True, keep_starting_points=False, clockwise=False)
+ return path
+
+
+def area(path: pathops.Path) -> float:
+ return sum(signed_area(c) for c in path_contours(path)) if list(path.contours) else 0.0
+
+
+@pytest.fixture(scope="module")
+def regular():
+ return B.build_static(400, "Regular")
+
+
+@pytest.fixture(scope="module")
+def default_master():
+ return B.build_master(400, 0)
+
+
+# ------------------------------------------------------------ metrics, data
+
+def test_scale_maps_units_to_upm():
+ assert B.UPM == 1000
+ assert B.SCALE == 62.5
+ assert B.ADVANCE == 750
+
+
+def test_stroke_width_formula():
+ assert data.stroke_width(100) == pytest.approx(0.6)
+ assert data.stroke_width(400) == pytest.approx(1.35)
+ assert data.stroke_width(900) == pytest.approx(2.6)
+ assert pieces.W_MASTERS == tuple(data.stroke_width(w) for w in B.WGHT_MASTERS)
+
+
+def test_character_set_covers_scope():
+ chars = set(B.charset())
+ printable_ascii = {chr(c) for c in range(0x20, 0x7F)}
+ assert printable_ascii <= chars
+ assert set(SKETCH_CHARS) <= chars
+ latin1 = {chr(c) for c in range(0xA0, 0x100)} - {""}
+ assert latin1 <= chars
+ assert set("€’‘“”…•₹") <= chars
+
+
+def test_every_char_has_one_glyph_name():
+ names = [B.glyph_name(c) for c in B.CHARS]
+ assert len(names) == len(set(names))
+
+
+def test_composites_use_existing_glyphs():
+ for ch, (base, accent, kind) in ext.COMPOSITES.items():
+ assert accent in ext.ACCENTS
+ assert base is None or base in B.DRAWN
+ assert kind in ("lc", "case", "base")
+
+
+def test_accent_offset_is_linear_in_width():
+ for kind in ("lc", "case"):
+ a, b, c = (ext.accent_offset(kind, w, f"acute.{kind}") for w in pieces.W_MASTERS)
+ k = (pieces.W_MASTERS[1] - pieces.W_MASTERS[0]) / (pieces.W_MASTERS[2] - pieces.W_MASTERS[0])
+ assert b == pytest.approx(a + k * (c - a))
+
+
+# ----------------------------------------------------------- piece outlines
+
+@pytest.mark.parametrize("name", DRAWN_NAMES)
+def test_same_structure_in_every_master(name):
+ gp = B.plan(name)
+ shapes = {pieces.structure(pieces.glyph_contours(gp, w, f)) for w, f in MASTERS}
+ assert len(shapes) == 1, f"Glyph {name!r} has a different point structure between masters."
+
+
+@pytest.mark.parametrize("name", DRAWN_NAMES)
+def test_pieces_are_counter_clockwise_and_closed(name):
+ gp = B.plan(name)
+ for w in pieces.W_MASTERS:
+ for c in pieces.glyph_contours(gp, w, 0):
+ assert len(c) >= 3
+ assert signed_area(c) > 0, f"Glyph {name!r} has a piece with no area or wrong direction."
+
+
+@pytest.mark.parametrize("ch", list(data.STROKES))
+def test_frag0_union_matches_phase1_outline(ch):
+ # At FRAG 0 the union of the pieces is the phase 1 stroke outline.
+ gp = pieces.plan_glyph(ch, data.STROKES[ch])
+ for w in pieces.W_MASTERS:
+ pieces_union = union(pieces.glyph_contours(gp, w, 0))
+ ref = glyph_outline(data.STROKES[ch], w, data.MITER_LIMIT)
+ xor = pathops.op(pieces_union, ref, pathops.PathOp.XOR)
+ assert abs(area(xor)) < 0.02, f"Glyph {ch!r} at w={w}: pieces differ from the stroke outline."
+
+
+@pytest.mark.parametrize("name", DRAWN_NAMES)
+def test_frag15_notches_only_remove_ink(name):
+ gp = B.plan(name)
+ w = data.stroke_width(400)
+ clean = union(pieces.glyph_contours(gp, w, 0))
+ notched = union(pieces.glyph_contours(gp, w, 15))
+ added = pathops.op(notched, clean, pathops.PathOp.DIFFERENCE)
+ assert abs(area(added)) < 1e-3
+ loss = area(clean) - area(notched)
+ assert loss >= -1e-6
+ # Notches are small: less than 6 % of the ink.
+ assert loss <= 0.06 * area(clean) + 1e-6
+
+
+def test_notches_exist_at_corners_and_tees():
+ assert B.plan("T").notches, "T must have T-junction notches."
+ assert B.plan("L").notches, "L must have a corner notch."
+ assert not B.plan("o").notches, "o has only 135 degree corners: no notch."
+
+
+@pytest.mark.parametrize("name", DRAWN_NAMES)
+def test_cuts_are_narrower_than_the_stroke(name):
+ gp = B.plan(name)
+ for w in pieces.W_MASTERS:
+ for f in (15, 30, 50, 100):
+ for si, sp in enumerate(gp.strokes):
+ if sp.kind == "dot":
+ continue
+ for _, _, gap in pieces._stroke_cutpos(gp, si, w, f):
+ assert gap < w
+
+
+def test_cuts_closed_at_frag15_and_open_at_frag50():
+ gp = B.plan("T")
+ w = data.stroke_width(400)
+ assert any(sp.cuts for sp in gp.strokes)
+ for si, sp in enumerate(gp.strokes):
+ for _, _, gap in pieces._stroke_cutpos(gp, si, w, 15):
+ assert gap < 0 # overlap: closed
+ gaps50 = [g for si in range(len(gp.strokes)) for _, _, g in pieces._stroke_cutpos(gp, si, w, 50)]
+ assert any(g > 0 for g in gaps50)
+
+
+def test_frag_plan_is_reproducible():
+ a = pieces.glyph_contours(pieces.plan_glyph("g", data.STROKES["g"]), 1.35, 70)
+ b = pieces.glyph_contours(pieces.plan_glyph("g", data.STROKES["g"]), 1.35, 70)
+ assert a == b
+
+
+def test_drift_is_on_quarter_grid():
+ for direction in ((1.0, 0.0), (0.6, 0.8)):
+ dx, dy = pieces._drift(direction, 1.35, 0.9, 0.2)
+ assert (dx / 0.25).is_integer() and (dy / 0.25).is_integer()
+
+
+def test_drift_lock_is_current():
+ lock = json.loads((Path(B.__file__).with_name("drift_lock.json")).read_text())
+ assert lock["seed"] == pieces.SEED
+ for name in ("a", "bullet", "zero", "T"):
+ gp = B.plan(name)
+ assert gp.attempt == lock["attempts"].get(name, 0)
+ assert not interp.problems(gp, B.to_font), f"Run tools/relock.py: {name!r} fails."
+
+
+# ------------------------------------------------------------------ masters
+
+def test_master_glyph_set(default_master):
+ assert list(default_master.keys()) == B.glyph_order() or set(default_master.keys()) == set(B.glyph_order())
+ for ch in B.CHARS:
+ assert default_master[B.glyph_name(ch)].unicodes == [ord(ch)]
+ assert default_master["space"].unicodes == [0x20, 0xA0]
+
+
+def test_master_advance_is_constant(default_master):
+ assert {g.width for g in default_master} == {B.ADVANCE}
+
+
+def test_composites_have_components(default_master):
+ g = default_master["Aacute"]
+ assert [c.baseGlyph for c in g.components] == ["A", "acute.case"]
+ assert not g.contours
+ assert default_master["idieresis"].components[0].baseGlyph == "dotlessi"
+
+
+@pytest.mark.parametrize("accent", [a for a in ext.ACCENTS if not a.startswith("cedilla")])
+def test_accents_clear_the_base(accent):
+ # The accent ink must start above the base ink at every weight. A
+ # slanted accent has square caps that reach a little below w / 2.
+ kind = accent.split(".")[1]
+ top = ext.ACCENT_BASE_TOP[kind]
+ for w in pieces.W_MASTERS:
+ cs = pieces.glyph_contours(B.plan(accent), w, 0)
+ low = min(y for c in cs for _, y in c) + ext.accent_offset(kind, w, accent)
+ assert low - (top + w / 2) == pytest.approx(ext.ACCENT_GAP[kind], abs=0.02)
+
+
+# ------------------------------------------------------------------ statics
+
+def test_static_glyph_set(regular):
+ expected = set(B.glyph_order()) - set(ext.ACCENTS)
+ assert set(regular.keys()) == expected
+
+
+def test_static_advance_is_constant(regular):
+ assert {g.width for g in regular} == {B.ADVANCE}
+
+
+@pytest.mark.parametrize("name", [n for n in B.glyph_order() if n not in ext.ACCENTS and n != "space"])
+def test_static_has_no_overlaps(regular, name):
+ contours = [[(p.x, p.y) for p in c.points] for c in regular[name].contours]
+ assert contours, f"Glyph {name!r} is empty."
+ signed = sum(signed_area(c) for c in contours)
+ assert signed > 0
+ assert area(union(contours)) == pytest.approx(signed, abs=1.0)
+
+
+def test_static_names_and_weight():
+ for wght, style in B.STATICS:
+ f = B.build_static(wght, style) if style in ("Thin", "Bold") else None
+ if f is None:
+ continue
+ assert f.info.openTypeOS2WeightClass == wght
+ assert f.info.postscriptFontName == f"MachineMadeWeft-{style}"
+ assert f.info.familyName == "machine-made Weft"
+ if style == "Bold":
+ assert f.info.styleMapStyleName == "bold"
+ assert f.info.styleMapFamilyName == "machine-made Weft"
+ else:
+ assert f.info.styleMapFamilyName == f"machine-made Weft {style}"
+
+
+def test_vertical_metrics(regular):
+ info = regular.info
+ assert info.unitsPerEm == 1000
+ assert info.xHeight == 500
+ assert info.capHeight == 688
+ assert info.openTypeOS2TypoAscender == info.openTypeHheaAscender
+ assert info.openTypeOS2TypoDescender == info.openTypeHheaDescender
+ assert info.postscriptIsFixedPitch is True
+ assert info.openTypeOS2Panose[3] == 9
+
+
+def test_stem_width_matches_stroke(regular):
+ glyph = regular["l"]
+ xs = sorted({p.x for c in glyph.contours for p in c.points})
+ centre = (3.5 + data.METRICS["box_offset"]) * B.SCALE
+ half = data.stroke_width(400) / 2 * B.SCALE
+ assert min(xs, key=lambda x: abs(x - (centre - half))) == pytest.approx(centre - half, abs=1)
+ assert min(xs, key=lambda x: abs(x - (centre + half))) == pytest.approx(centre + half, abs=1)
+
+
+# ------------------------------------------------------ outline primitives
+
+def test_square_caps_extend_by_half_width():
+ polys = stroke_polygons("open", [(0, 0), (0, 10)], 2.0, data.MITER_LIMIT)
+ ys = [p[1] for poly in polys for p in poly]
+ assert min(ys) == pytest.approx(-1.0)
+ assert max(ys) == pytest.approx(11.0)
+
+
+def test_miter_join_within_limit():
+ polys = stroke_polygons("open", [(0, 0), (10, 0), (10, 10)], 2.0, data.MITER_LIMIT)
+ pts = {(round(x, 6), round(y, 6)) for poly in polys for x, y in poly}
+ assert (11.0, -1.0) in pts
+
+
+def test_bevel_join_beyond_limit():
+ a = math.radians(30)
+ pts_in = [(0, 0), (10, 0), (10 - 10 * math.cos(a), 10 * math.sin(a))]
+ polys = stroke_polygons("open", pts_in, 2.0, data.MITER_LIMIT)
+ far = max(x for poly in polys for x, _ in poly)
+ assert far < 11.5
+
+
+def test_dot_is_square_of_stroke_width():
+ gp = pieces.plan_glyph("period", data.STROKES["."])
+ (c,) = pieces.glyph_contours(gp, 1.35, 0)
+ xs = [p[0] for p in c]
+ ys = [p[1] for p in c]
+ assert max(xs) - min(xs) == pytest.approx(1.35)
+ assert max(ys) - min(ys) == pytest.approx(1.35)
diff --git a/weft/tools/finish.py b/weft/tools/finish.py
new file mode 100644
index 0000000..a4cb8de
--- /dev/null
+++ b/weft/tools/finish.py
@@ -0,0 +1,91 @@
+"""Finish the compiled Weft fonts.
+
+Usage:
+ python tools/finish.py --variable FONT.ttf
+ python tools/finish.py --static FONT.ttf|FONT.otf ...
+ python tools/finish.py --woff2 FONT.ttf ...
+
+--variable: set the OVERLAP_SIMPLE flag on every simple glyph and the
+ OVERLAP_COMPOUND flag on every composite. The pieces overlap on
+ purpose, and some rasterizers need the flag.
+--static: add a STAT table that names the position of the static on the
+ wght and FRAG axes (one value per axis, as the OpenType spec shows
+ for static fonts).
+--woff2: write a WOFF2 copy next to the font.
+"""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from fontTools.otlLib.builder import buildStatTable
+from fontTools.ttLib import TTFont
+from fontTools.ttLib.tables._g_l_y_f import OVERLAP_COMPOUND, flagOverlapSimple
+
+WEIGHT_NAMES = {
+ 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular", 500: "Medium",
+ 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black",
+}
+
+
+def finish_variable(path: Path) -> None:
+ font = TTFont(path)
+ glyf = font["glyf"]
+ for name in font.getGlyphOrder():
+ g = glyf[name]
+ if g.isComposite():
+ g.components[0].flags |= OVERLAP_COMPOUND
+ elif g.numberOfContours > 0:
+ g.flags[0] |= flagOverlapSimple
+ font.save(path)
+ print(f"Set the overlap flags in {path}.")
+
+
+def finish_static(path: Path) -> None:
+ font = TTFont(path)
+ wght = font["OS/2"].usWeightClass
+ name = WEIGHT_NAMES[wght]
+ flags = 0x2 if wght == 400 else 0
+ value = {"value": wght, "name": name, "flags": flags}
+ if wght == 400:
+ value["linkedValue"] = 700
+ axes = [
+ {"tag": "wght", "name": "Weight", "values": [value]},
+ {"tag": "FRAG", "name": "Fragmentation",
+ "values": [{"value": 0, "name": "Clean", "flags": 0x2}]},
+ ]
+ buildStatTable(font, axes, elidedFallbackName=2, macNames=False)
+ font.save(path)
+ print(f"Added STAT to {path}.")
+
+
+def write_woff2(path: Path, out_dir: Path) -> None:
+ font = TTFont(path)
+ font.flavor = "woff2"
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out = out_dir / (path.stem + ".woff2")
+ font.save(out)
+ print(f"Wrote {out}.")
+
+
+def main(argv: list[str] | None = None) -> int:
+ ap = argparse.ArgumentParser(description="Finish the compiled Weft fonts.")
+ ap.add_argument("--variable", nargs="*", default=[], type=Path)
+ ap.add_argument("--static", nargs="*", default=[], type=Path)
+ ap.add_argument("--woff2", nargs="*", default=[], type=Path)
+ ap.add_argument("--woff2-dir", type=Path, help="Output folder for WOFF2 files.")
+ args = ap.parse_args(argv)
+ if not (args.variable or args.static or args.woff2):
+ ap.error("Give at least one font file.")
+ for p in args.variable:
+ finish_variable(p)
+ for p in args.static:
+ finish_static(p)
+ for p in args.woff2:
+ write_woff2(p, args.woff2_dir or p.parent)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/weft/tools/proof.py b/weft/tools/proof.py
new file mode 100644
index 0000000..7fcaaff
--- /dev/null
+++ b/weft/tools/proof.py
@@ -0,0 +1,178 @@
+"""Render the Weft 0.1 proof sheets from the compiled variable font.
+
+Usage:
+ python tools/proof.py [--font VF.ttf] [--out-dir ../proofs/weft]
+
+Writes five PNGs (FreeType through Pillow, no sketch code):
+
+ weft-charset.png every encoded character, wght 400, FRAG 0
+ weft-weight-waterfall.png wght 100 to 900, FRAG 0
+ weft-frag-waterfall.png FRAG 0 to 100 at wght 400 and 900
+ weft-body-text.png body text at 16, 18, 20, 22, 24 px (FRAG 0 and 15)
+ weft-lookalikes.png Il1| O0 rn/m cl/d 5S 2Z B8 g9 at FRAG 0 and 15
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+from PIL import Image, ImageDraw, ImageFont
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from loom import build # noqa: E402
+
+PAPER = 244
+INK = 18
+MUTED = 110
+WIDTH = 1600
+PAD = 32
+
+BODY = (
+ "Every result a machine hands you has been checked by another machine. "
+ "Weft is set on a grid by code, compiled by fontmake and checked on every "
+ "pull request: 1,204 of 1,204 glyph tests pass. Façade, naïve, Øresund, "
+ "São Paulo, Zürich, 25 °C, 3/4 cup, «quotes» and “quotes” — all in one face."
+)
+LOOKALIKES = "Il1| O0 rn m cl d 5S 2Z B8 g9"
+
+
+def font_at(path: str, size: int, wght: float, frag: float) -> ImageFont.FreeTypeFont:
+ font = ImageFont.truetype(path, size)
+ names = [a["name"] if isinstance(a["name"], str) else a["name"].decode()
+ for a in font.get_variation_axes()]
+ values = {"Weight": wght, "Fragmentation": frag}
+ font.set_variation_by_axes([values[n] for n in names])
+ return font
+
+
+def label_font(size: int = 18) -> ImageFont.FreeTypeFont:
+ return ImageFont.load_default(size)
+
+
+class Sheet:
+ def __init__(self, title: str):
+ self.blocks: list[Image.Image] = []
+ self.text(title, 26)
+
+ def text(self, s: str, size: int = 18, fill: int = MUTED) -> None:
+ img = Image.new("L", (WIDTH, size + 14), PAPER)
+ ImageDraw.Draw(img).text((PAD, 4), s, font=label_font(size), fill=fill)
+ self.blocks.append(img)
+
+ def line(self, s: str, font: ImageFont.FreeTypeFont, size: int, note: str = "") -> None:
+ h = round(size * 1.25) + 8
+ img = Image.new("L", (WIDTH, h), PAPER)
+ d = ImageDraw.Draw(img)
+ x = PAD
+ if note:
+ d.text((PAD, h // 2 - 8), note, font=label_font(14), fill=MUTED)
+ x = PAD + 150
+ d.text((x, round(size * 0.95) + 4), s, font=font, fill=INK, anchor="ls")
+ self.blocks.append(img)
+
+ def gap(self, h: int = 16) -> None:
+ self.blocks.append(Image.new("L", (WIDTH, h), PAPER))
+
+ def save(self, path: Path) -> None:
+ h = sum(b.height for b in self.blocks) + PAD
+ out = Image.new("L", (WIDTH, h), PAPER)
+ y = PAD // 2
+ for b in self.blocks:
+ out.paste(b, (0, y))
+ y += b.height
+ out.save(path)
+ print(f"Wrote {path}.")
+
+
+def wrap(text: str, cols: int) -> list[str]:
+ lines, line = [], ""
+ for word in text.split():
+ if line and len(line) + 1 + len(word) > cols:
+ lines.append(line)
+ line = word
+ else:
+ line = f"{line} {word}" if line else word
+ return lines + [line] if line else lines
+
+
+def charset(vf: str, out: Path) -> None:
+ sheet = Sheet(f"machine-made Weft 0.1: full character set ({len(build.charset()) - 1} characters), wght 400, FRAG 0")
+ chars = [c for c in build.charset() if c not in " "]
+ size, cols = 48, 32
+ font = font_at(vf, size, 400, 0)
+ for i in range(0, len(chars), cols):
+ sheet.line("".join(chars[i:i + cols]), font, size)
+ sheet.gap()
+ sheet.text("Same set at wght 900")
+ font = font_at(vf, size, 900, 0)
+ for i in range(0, len(chars), cols):
+ sheet.line("".join(chars[i:i + cols]), font, size)
+ sheet.save(out)
+
+
+def weight_waterfall(vf: str, out: Path) -> None:
+ sheet = Sheet("machine-made Weft 0.1: weight waterfall, FRAG 0 (the nine static instances)")
+ for wght, style in build.STATICS:
+ sheet.line("Weft 0042 Hamburgefonstiv", font_at(vf, 56, wght, 0), 56, f"{wght} {style}")
+ sheet.save(out)
+
+
+def frag_waterfall(vf: str, out: Path) -> None:
+ sheet = Sheet("machine-made Weft 0.1: FRAG waterfall (0 Clean, 15 Notched, 50 Cut, 100 Broken)")
+ for wght in (400, 900):
+ sheet.text(f"wght {wght}")
+ for frag in (0, 8, 15, 30, 50, 62, 80, 100):
+ sheet.line("Weft 0042 machine", font_at(vf, 64, wght, frag), 64, f"FRAG {frag}")
+ sheet.gap()
+ sheet.save(out)
+
+
+def body_text(vf: str, out: Path) -> None:
+ sheet = Sheet("machine-made Weft 0.1: body text, wght 400, at FRAG 0 (left) and FRAG 15 (right)")
+ half = WIDTH // 2
+ for size in (16, 18, 20, 22, 24):
+ sheet.text(f"{size} px")
+ cols = int((half - PAD - 20) / (size * 0.75))
+ lines = wrap(BODY, cols)
+ step = round(size * 1.45)
+ img = Image.new("L", (WIDTH, step * len(lines) + 8), PAPER)
+ d = ImageDraw.Draw(img)
+ for col, frag in enumerate((0, 15)):
+ font = font_at(vf, size, 400, frag)
+ for i, line in enumerate(lines):
+ d.text((PAD + col * half, size + i * step), line, font=font, fill=INK, anchor="ls")
+ sheet.blocks.append(img)
+ sheet.gap(10)
+ sheet.save(out)
+
+
+def lookalikes(vf: str, out: Path) -> None:
+ sheet = Sheet("machine-made Weft 0.1: look-alike pairs")
+ for frag in (0, 15):
+ for wght in (400, 700):
+ sheet.line(LOOKALIKES, font_at(vf, 52, wght, frag), 52, f"wght {wght} FRAG {frag}")
+ for frag in (0, 15):
+ sheet.line(LOOKALIKES, font_at(vf, 20, 400, frag), 20, f"20 px FRAG {frag}")
+ sheet.save(out)
+
+
+def main(argv: list[str] | None = None) -> int:
+ ap = argparse.ArgumentParser(description="Render the Weft proof sheets.")
+ ap.add_argument("--font", default="../fonts/weft/variable/MachineMadeWeft[FRAG,wght].ttf")
+ ap.add_argument("--out-dir", default="../proofs/weft")
+ args = ap.parse_args(argv)
+ out = Path(args.out_dir)
+ out.mkdir(parents=True, exist_ok=True)
+ charset(args.font, out / "weft-charset.png")
+ weight_waterfall(args.font, out / "weft-weight-waterfall.png")
+ frag_waterfall(args.font, out / "weft-frag-waterfall.png")
+ body_text(args.font, out / "weft-body-text.png")
+ lookalikes(args.font, out / "weft-lookalikes.png")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/weft/tools/readability.py b/weft/tools/readability.py
new file mode 100644
index 0000000..0448607
--- /dev/null
+++ b/weft/tools/readability.py
@@ -0,0 +1,257 @@
+"""Readability gate: OCR the Weft font and compare it with a reference.
+
+Usage:
+ python tools/readability.py [--font VF.ttf] [--reference REF.ttf]
+ [--out ../proofs/weft/readability.json] [--gate]
+
+The tool renders a fixed paragraph and a look-alike test line with
+FreeType (Pillow), runs Tesseract OCR (the `tesseract` command, English
+data), and computes the character error rate (CER): the Levenshtein
+distance between the OCR text and the true text, divided by the length
+of the true text. Whitespace is normalized first.
+
+* Paragraph: FRAG 0, 15, 50 and 100 at 16, 20 and 24 px, wght 400.
+* Look-alikes: fixed lines of the characters I l 1 | O 0 r n m c l d
+ 5 S 2 Z B 8 g 9 in groups of five, at 20, 24 and 32 px, FRAG 0 and 15. Tesseract
+ reads it with a whitelist of only these characters, so each error is a
+ confusion between look-alikes. Spaces are removed before the compare
+ (Tesseract often joins the groups). Per-pair confusion counts are in
+ the report.
+
+The reference is a free monospace (default: DejaVu Sans Mono from the
+distribution). It goes through the same steps.
+
+Gate (--gate): for FRAG 0 and 15, the mean paragraph CER of Weft must
+not be more than the reference plus MARGIN, and the mean look-alike CER
+not more than the reference plus LOOKALIKE_MARGIN. FRAG 15 must also
+stay within FRAG15_MARGIN of FRAG 0. FRAG 50 and 100 are reported only.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import random
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+from PIL import Image, ImageDraw, ImageFont
+
+PARAGRAPH = (
+ "Every result a machine hands you has been checked by another machine. "
+ "Weft is set on a grid by code, compiled by fontmake and checked on every "
+ "pull request: 1,204 of 1,204 glyph tests pass. The quick brown fox jumps "
+ "over the lazy dog; pack my box with five dozen liquor jugs (no. 37, 2026)."
+)
+COLUMNS = 48
+SIZES = (16, 20, 24)
+FRAGS = (0, 15, 50, 100)
+GATED_FRAGS = (0, 15)
+PAIRS = ["Il1|", "O0", "rnm", "cld", "5S", "2Z", "B8", "g9"]
+LOOKALIKE_SIZES = (20, 24, 32)
+UPSCALE = 3 # Tesseract reads small text better at about 30 px x-height
+# Absolute CER margins over the reference. The look-alike line has
+# 240 characters, and Tesseract (trained on common fonts) reads Weft's
+# flagged l and serifed I as 1 more often than DejaVu's. The margin for
+# it was set after the first run (Weft 0.1: about 0.11 against 0.07);
+# it stops a regression, it does not prove parity with the reference.
+MARGIN = 0.02
+LOOKALIKE_MARGIN = 0.06
+# FRAG 15 must read like FRAG 0 (the brief: "body text at FRAG 15 reads
+# the same as FRAG 0").
+FRAG15_MARGIN = 0.02
+SEED = "machine-made Weft readability"
+DEFAULT_REFERENCE = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
+
+
+def wrap(text: str, cols: int) -> list[str]:
+ lines, line = [], ""
+ for word in text.split():
+ if line and len(line) + 1 + len(word) > cols:
+ lines.append(line)
+ line = word
+ else:
+ line = f"{line} {word}" if line else word
+ if line:
+ lines.append(line)
+ return lines
+
+
+def lookalike_text() -> str:
+ rnd = random.Random(SEED)
+ chars = "".join(PAIRS)
+ groups = ["".join(rnd.choice(chars) for _ in range(5)) for _ in range(48)]
+ return "\n".join(" ".join(groups[i:i + 6]) for i in range(0, len(groups), 6))
+
+
+def render(font_path: str, lines: list[str], size: int, axes: dict | None) -> Image.Image:
+ font = ImageFont.truetype(font_path, size)
+ if axes:
+ names = [a["name"] if isinstance(a["name"], str) else a["name"].decode()
+ for a in font.get_variation_axes()]
+ tags = {"Weight": "wght", "Fragmentation": "FRAG"}
+ font.set_variation_by_axes([axes[tags.get(n, n)] for n in names])
+ step = round(size * 1.5)
+ width = max(int(font.getlength(line)) for line in lines) + 2 * size
+ img = Image.new("L", (width, step * len(lines) + 2 * size), 255)
+ draw = ImageDraw.Draw(img)
+ for i, line in enumerate(lines):
+ draw.text((size, size + i * step), line, font=font, fill=0)
+ return img.resize((img.width * UPSCALE, img.height * UPSCALE), Image.Resampling.LANCZOS)
+
+
+def ocr(img: Image.Image, whitelist: str | None = None) -> str:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "in.png"
+ img.save(path)
+ cmd = ["tesseract", str(path), "stdout", "-l", "eng", "--psm", "6",
+ "-c", "preserve_interword_spaces=0"]
+ if whitelist:
+ cmd += ["-c", f"tessedit_char_whitelist={whitelist}"]
+ res = subprocess.run(cmd, capture_output=True, text=True, check=True)
+ return res.stdout
+
+
+def norm_ws(s: str) -> str:
+ return " ".join(s.split())
+
+
+def levenshtein_ops(a: str, b: str) -> tuple[int, list[tuple[str, str]]]:
+ """Return the edit distance and the substitution pairs (true, read)."""
+ n, m = len(a), len(b)
+ d = [[0] * (m + 1) for _ in range(n + 1)]
+ for i in range(n + 1):
+ d[i][0] = i
+ for j in range(m + 1):
+ d[0][j] = j
+ for i in range(1, n + 1):
+ for j in range(1, m + 1):
+ d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1,
+ d[i - 1][j - 1] + (a[i - 1] != b[j - 1]))
+ subs = []
+ i, j = n, m
+ while i > 0 and j > 0:
+ if d[i][j] == d[i - 1][j - 1] + (a[i - 1] != b[j - 1]):
+ if a[i - 1] != b[j - 1]:
+ subs.append((a[i - 1], b[j - 1]))
+ i, j = i - 1, j - 1
+ elif d[i][j] == d[i - 1][j] + 1:
+ i -= 1
+ else:
+ j -= 1
+ return d[n][m], subs
+
+
+def cer(truth: str, read: str, *, strip_spaces: bool = False) -> tuple[float, list[tuple[str, str]]]:
+ t, r = norm_ws(truth), norm_ws(read)
+ if strip_spaces:
+ t, r = t.replace(" ", ""), r.replace(" ", "")
+ dist, subs = levenshtein_ops(t, r)
+ return dist / max(1, len(t)), subs
+
+
+def measure(font_path: str, axes_list: list[dict | None], label: str) -> dict:
+ out: dict = {"font": label, "paragraph": {}, "lookalikes": {}}
+ para_lines = wrap(PARAGRAPH, COLUMNS)
+ look = lookalike_text()
+ for axes in axes_list:
+ key = f"FRAG {axes['FRAG']}" if axes else "static"
+ per_size = {}
+ for size in SIZES:
+ text = ocr(render(font_path, para_lines, size, axes))
+ value, _ = cer(PARAGRAPH, text)
+ per_size[str(size)] = round(value, 4)
+ out["paragraph"][key] = {"cer_by_px": per_size,
+ "cer_mean": round(sum(per_size.values()) / len(per_size), 4)}
+ if axes is None or axes["FRAG"] in GATED_FRAGS:
+ values: dict[str, float] = {}
+ confusions: dict[str, int] = {}
+ for size in LOOKALIKE_SIZES:
+ text = ocr(render(font_path, look.split("\n"), size, axes),
+ whitelist="".join(sorted(set("".join(PAIRS)))))
+ value, subs = cer(look, text, strip_spaces=True)
+ values[str(size)] = round(value, 4)
+ for t, r in subs:
+ pair = next((p for p in PAIRS if t in p and r in p), "other")
+ name = f"{t}->{r}" if pair != "other" else "other"
+ confusions[name] = confusions.get(name, 0) + 1
+ out["lookalikes"][key] = {
+ "cer_by_px": values,
+ "cer": round(sum(values.values()) / len(values), 4),
+ "confusions": dict(sorted(confusions.items(), key=lambda kv: -kv[1])),
+ }
+ return out
+
+
+def main(argv: list[str] | None = None) -> int:
+ ap = argparse.ArgumentParser(description="OCR readability gate for Weft.")
+ ap.add_argument("--font", default="../fonts/weft/variable/MachineMadeWeft[FRAG,wght].ttf")
+ ap.add_argument("--reference", default=DEFAULT_REFERENCE)
+ ap.add_argument("--out", default="../proofs/weft/readability.json")
+ ap.add_argument("--gate", action="store_true", help="Exit 1 if the gate fails.")
+ args = ap.parse_args(argv)
+ if shutil.which("tesseract") is None:
+ print("Error: tesseract is not installed. Install tesseract-ocr.", file=sys.stderr)
+ return 2
+ if not Path(args.reference).exists():
+ print(f"Error: reference font {args.reference} is missing. Install fonts-dejavu-core.",
+ file=sys.stderr)
+ return 2
+ version = subprocess.run(["tesseract", "--version"], capture_output=True, text=True).stdout.split("\n")[0]
+ weft = measure(args.font, [{"wght": 400, "FRAG": f} for f in FRAGS], "machine-made Weft (variable, wght 400)")
+ ref = measure(args.reference, [None], Path(args.reference).name)
+ ref_para = ref["paragraph"]["static"]["cer_mean"]
+ ref_look = ref["lookalikes"]["static"]["cer"]
+ gate = {"margin": MARGIN, "lookalike_margin": LOOKALIKE_MARGIN,
+ "frag15_margin": FRAG15_MARGIN, "reference_paragraph_cer": ref_para,
+ "reference_lookalike_cer": ref_look, "checks": [], "pass": True}
+ for f in GATED_FRAGS:
+ key = f"FRAG {f}"
+ for kind, value, limit in (
+ ("paragraph", weft["paragraph"][key]["cer_mean"], ref_para + MARGIN),
+ ("lookalikes", weft["lookalikes"][key]["cer"], ref_look + LOOKALIKE_MARGIN),
+ ):
+ ok = value <= limit + 1e-9
+ gate["checks"].append({"frag": f, "test": kind, "cer": value,
+ "limit": round(limit, 4), "pass": ok})
+ gate["pass"] &= ok
+ for kind, k0, k15 in (
+ ("paragraph vs FRAG 0", weft["paragraph"]["FRAG 0"]["cer_mean"], weft["paragraph"]["FRAG 15"]["cer_mean"]),
+ ("lookalikes vs FRAG 0", weft["lookalikes"]["FRAG 0"]["cer"], weft["lookalikes"]["FRAG 15"]["cer"]),
+ ):
+ ok = k15 <= k0 + FRAG15_MARGIN + 1e-9
+ gate["checks"].append({"frag": 15, "test": kind, "cer": k15,
+ "limit": round(k0 + FRAG15_MARGIN, 4), "pass": ok})
+ gate["pass"] &= ok
+ report = {
+ "about": "OCR character error rate (CER). Lower is better. See weft/tools/readability.py.",
+ "tesseract": version,
+ "paragraph": PARAGRAPH,
+ "sizes_px": list(SIZES),
+ "upscale": UPSCALE,
+ "lookalike_text": lookalike_text(),
+ "weft": weft,
+ "reference": ref,
+ "gate": gate,
+ }
+ out = Path(args.out)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
+ print(f"Wrote {out}.")
+ for c in gate["checks"]:
+ print(f" FRAG {c['frag']:>3} {c['test']:<20} CER {c['cer']:.4f} limit {c['limit']:.4f} "
+ f"{'pass' if c['pass'] else 'FAIL'}")
+ for f in FRAGS:
+ if f not in GATED_FRAGS:
+ print(f" FRAG {f:>3} paragraph CER {weft['paragraph'][f'FRAG {f}']['cer_mean']:.4f} (report only)")
+ if args.gate and not gate["pass"]:
+ print("Error: the readability gate failed.", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/weft/tools/relock.py b/weft/tools/relock.py
new file mode 100644
index 0000000..74ed472
--- /dev/null
+++ b/weft/tools/relock.py
@@ -0,0 +1,42 @@
+"""Choose the drift attempt of every glyph and write loom/drift_lock.json.
+
+Usage:
+ python tools/relock.py
+
+Run this after a change to the stroke tables or to loom/pieces.py. For
+each glyph it takes the first drift attempt that passes the fontTools
+interpolatable check (see loom/interp.py). The build reads the file, so
+it does not need to search again. The tests check that the file is
+current.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from loom import build, interp, pieces # noqa: E402
+
+
+def main() -> int:
+ attempts = {}
+ for name in build.drawn_names():
+ gp = pieces.plan_glyph(name, build.strokes_of(name))
+ attempt = interp.make_clean(gp, build.to_font)
+ if attempt:
+ attempts[name] = attempt
+ out = Path(build.__file__).with_name("drift_lock.json")
+ out.write_text(json.dumps({
+ "about": "Drift attempt per glyph (0 if not listed). Made by tools/relock.py.",
+ "seed": pieces.SEED,
+ "attempts": dict(sorted(attempts.items())),
+ }, indent=2, ensure_ascii=False) + "\n")
+ print(f"Wrote {out} ({len(attempts)} glyphs need a new drift attempt).")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/weft/tools/sketch_compare.py b/weft/tools/sketch_compare.py
new file mode 100644
index 0000000..8d1fc1c
--- /dev/null
+++ b/weft/tools/sketch_compare.py
@@ -0,0 +1,174 @@
+"""Render the phase 1 proof lines and compare them with the sketch renderer.
+
+Usage:
+ python tools/sketch_compare.py [--font ../fonts/weft/ttf/MachineMadeWeft-Regular.ttf] [--out build/sketch-proof.png]
+ python tools/sketch_compare.py --compare SKETCH_PNG # also write the comparison
+
+The proof uses FreeType (Pillow) on the compiled TTF. It does not use the
+sketch. The --compare option reads a PNG that tools/sketch_render.js made
+from the same layout spec and writes a side-by-side image and an overlay.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+from PIL import Image, ImageChops, ImageDraw, ImageFont
+
+PAPER = (244, 245, 243)
+INK = (18, 19, 20)
+MUTED = (91, 96, 100)
+
+# Phase 1 has no '.' and no '|'. The proof drops them.
+LINES = [
+ "abcdefghijklmnopqrstuvwxyz",
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
+ "0123456789",
+ "Made by machines Proven for machines",
+ "Il1 O0 rn m cl d 5S 2Z B8 g9",
+]
+
+
+def layout(size: int) -> dict:
+ s = size / 16
+ cols = max(len(line) for line in LINES)
+ left = round(2 * s)
+ return {
+ "size": size,
+ "left": left,
+ "top": round(15 * s), # baseline of the first line
+ "lineStep": round(19 * s),
+ "width": round(left * 2 + cols * 12 * s),
+ "height": round(15 * s + (len(LINES) - 1) * 19 * s + 6 * s),
+ "lines": LINES,
+ }
+
+
+def render_font(font_path: Path, spec: dict) -> Image.Image:
+ img = Image.new("RGB", (spec["width"], spec["height"]), PAPER)
+ draw = ImageDraw.Draw(img)
+ font = ImageFont.truetype(str(font_path), spec["size"])
+ for i, line in enumerate(spec["lines"]):
+ y = spec["top"] + i * spec["lineStep"]
+ draw.text((spec["left"], y), line, font=font, fill=INK, anchor="ls")
+ return img
+
+
+def label(img: Image.Image, text: str, pad: int = 36) -> Image.Image:
+ out = Image.new("RGB", (img.width, img.height + pad), PAPER)
+ out.paste(img, (0, pad))
+ draw = ImageDraw.Draw(out)
+ draw.text((12, 8), text, font=ImageFont.load_default(18), fill=MUTED)
+ return out
+
+
+def ink_mask(img: Image.Image) -> Image.Image:
+ return img.convert("L").point(lambda v: 255 if v < 128 else 0)
+
+
+def coverage(img: Image.Image) -> Image.Image:
+ """Return ink coverage per pixel (0 = paper, 255 = full ink)."""
+ lo, hi = INK[0], PAPER[0]
+ return img.convert("L").point(lambda v: max(0, min(255, round((hi - v) * 255 / (hi - lo)))))
+
+
+def soft_iou(a: Image.Image, b: Image.Image) -> float:
+ """Return sum(min) / sum(max) of two coverage maps.
+
+ This uses the anti-aliased grey levels, so a stem edge on a half
+ pixel does not count as a shape change.
+ """
+ lo = ImageChops.darker(a, b).histogram()
+ hi = ImageChops.lighter(a, b).histogram()
+ s_lo = sum(i * n for i, n in enumerate(lo))
+ s_hi = sum(i * n for i, n in enumerate(hi))
+ return round(s_lo / s_hi, 4) if s_hi else 1.0
+
+
+def _count(m: Image.Image) -> int:
+ return m.histogram()[255]
+
+
+def best_shift(a: Image.Image, b: Image.Image, reach: int = 2) -> tuple[int, int]:
+ """Return the pixel shift of b that gives the most shared ink.
+
+ The canvas renderer does not snap the pen origin to pixels and
+ FreeType does. A shift of 1 px or less is a raster offset, not a
+ shape change.
+ """
+ best, best_n = (0, 0), -1
+ for dx in range(-reach, reach + 1):
+ for dy in range(-reach, reach + 1):
+ n = _count(ImageChops.multiply(a, ImageChops.offset(b, dx, dy)))
+ if n > best_n:
+ best, best_n = (dx, dy), n
+ return best
+
+
+def compare(font_img: Image.Image, sketch_img: Image.Image, out: Path) -> dict:
+ a, b = ink_mask(font_img), ink_mask(sketch_img)
+ dx, dy = best_shift(a, b)
+ b = ImageChops.offset(b, dx, dy)
+ sketch_img = ImageChops.offset(sketch_img, dx, dy)
+ both = ImageChops.multiply(a, b)
+ either = ImageChops.lighter(a, b)
+ only_a = ImageChops.subtract(a, b)
+ only_b = ImageChops.subtract(b, a)
+ count = _count
+ n_both, n_either = count(both), count(either)
+ stats = {
+ "ink_pixels_font": count(a),
+ "ink_pixels_sketch": count(b),
+ "only_font": count(only_a),
+ "only_sketch": count(only_b),
+ "iou": round(n_both / n_either, 4) if n_either else 1.0,
+ "sketch_shift_px": [dx, dy],
+ "soft_iou": soft_iou(coverage(font_img), coverage(sketch_img)),
+ }
+ # Overlay: black where both agree, red only in the font, blue only in
+ # the sketch.
+ ov = Image.new("RGB", a.size, PAPER)
+ ov.paste((18, 19, 20), mask=both)
+ ov.paste((222, 42, 31), mask=only_a)
+ ov.paste((30, 90, 220), mask=only_b)
+ left = label(font_img, "Compiled font: MachineMadeWeft-Regular.ttf (FreeType)")
+ right = label(sketch_img, "Sketch 0.1 renderer (canvas), wght 400, FRAG 0")
+ bottom = label(ov, f"Overlay: black = both, red = font only, blue = sketch only. coverage IoU {stats['soft_iou']}, binary IoU {stats['iou']}")
+ gap = 24
+ w = left.width + right.width + gap
+ sheet = Image.new("RGB", (w, left.height + bottom.height + gap), PAPER)
+ sheet.paste(left, (0, 0))
+ sheet.paste(right, (left.width + gap, 0))
+ sheet.paste(bottom, ((w - bottom.width) // 2, left.height + gap))
+ out.parent.mkdir(parents=True, exist_ok=True)
+ sheet.save(out)
+ return stats
+
+
+def main(argv: list[str] | None = None) -> int:
+ ap = argparse.ArgumentParser(description="Render the Weft proof sheet.")
+ ap.add_argument("--font", default="../fonts/weft/ttf/MachineMadeWeft-Regular.ttf")
+ ap.add_argument("--out", default="build/sketch-proof.png")
+ ap.add_argument("--size", type=int, default=64)
+ ap.add_argument("--spec-out", help="Write the layout spec as JSON.")
+ ap.add_argument("--compare", help="Sketch PNG made from the same spec.")
+ ap.add_argument("--compare-out", default="build/sketch-compare.png")
+ args = ap.parse_args(argv)
+ spec = layout(args.size)
+ if args.spec_out:
+ Path(args.spec_out).write_text(json.dumps(spec))
+ img = render_font(Path(args.font), spec)
+ out = Path(args.out)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ img.save(out)
+ print(f"Wrote {out}.")
+ if args.compare:
+ stats = compare(img, Image.open(args.compare).convert("RGB"), Path(args.compare_out))
+ print(f"Wrote {args.compare_out}. {json.dumps(stats)}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/weft/tools/sketch_render.js b/weft/tools/sketch_render.js
new file mode 100644
index 0000000..3537d43
--- /dev/null
+++ b/weft/tools/sketch_render.js
@@ -0,0 +1,23 @@
+// Render proof lines with the sketch 0.1 renderer (reference only).
+// Usage: node sketch_render.js
+// The spec gives {width, height, size, lineStep, top, left, lines}.
+// Needs @napi-rs/canvas. CI does not run this file.
+const fs = require('fs');
+const { createCanvas } = require('@napi-rs/canvas');
+const [html, outPath, specPath] = process.argv.slice(2);
+const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
+const src = fs.readFileSync(html, 'utf8');
+const js = src.split('