Rule-driven anchor placement for UFO fonts
Project description
AnchorsFactory
Rule-driven anchor placement for UFO fonts. You describe, in a compact text file, where anchors should sit on your glyphs; AnchorsFactory computes the coordinates from each glyph's own geometry and writes the anchors into the font.
It does the pre-marking step of accent handling: place top/bottom/_top/…
anchors consistently across hundreds of glyphs, so a tool like
GlyphConstruction can then
assemble composite glyphs by snapping mark anchors to base anchors.
Install
pip install anchorsfactory
# or, from a checkout:
pip install -e .
Requires Python 3.10+, fontParts and fontTools.
Quick start
# place anchors using a rule set, save to font_anchored.ufo
# (none ship with the package — start from a copy of examples/rules/default.anchors)
anchorsfactory MyFont.ufo --rules my-rules.anchors
# overwrite in place, with a backup of existing anchors
anchorsfactory MyFont.ufo --rules my-rules.anchors --in-place --backup-dir backups/
# a whole folder of UFOs, addressing the set by name off a rule library
anchorsfactory masters/ --rules-path my-rules --rules default
By default the source UFO is never overwritten — output goes to
*_anchored.ufo unless you pass --in-place.
The rule language
Rules are stacked: define reusable labels (@, a list of anchors) and
variables (&, one axis's value), then mark glyphs with them, mixing
them with one-off anchors. An anchor is a name and a parenthesised X Y
placement.
# a variable (reuse one axis value) and a label (reuse a set of anchors)
&mid = capHeight*1/2+xHeight*1/2
@ = top (box.center capHeight), bottom (box.center 0)
# apply it, by name / unicode / range
A = @, ogonek (outline.right 0)
U+0410..U+044F = @ # all Russian Cyrillic
U+0413 += desc (outline.right 0) # Г also gets a descender anchor
frame.positionworks on both axes:width.*(advance),box.*(bounding box) oroutline.*(the contour, sampled on a scanline at the other coordinate, with.first/.lastto pick a stem). On X the words areleft/center/right, on Ybottom/middle/top;box*1/3is a fractional position (same*n/mascapHeight*2/3) andoutline.centroidthe area centre of mass.- Y may also be a number, a font metric (
capHeight,xHeight, …), or a reference glyph ($H,$H.bottom,$H*5/6);box.topreads this glyph's own height. - Selectors: name,
U+XXXX, rangeU+A..U+B, glob*.sc, category{Lu}. =replace ·+=add ·-=remove;!extends defaultinherits a ruleset.- Terms combine with
+/-(on either axis) for a base position plus a bias —outline.centroid-25nudges a slanted mark off the optical centre. &namenames a reusable X/Y value;@namea reusable set of anchors. Both are late-bound, so an extending file can override either.%nameplaces an anchor relative to another anchor on the same glyph —bottom (%top 0)reusestop's x so the two don't drift apart.compN./complast.measure one component's outline (comp1.outline.center@top), for seating marks on ligature parts.!propagate = compositesmakes composite glyphs inherit their components' anchors — write rules for the base letters, precomposed glyphs get theirs free.@top-10/@bottom+8sample just inside an edge (the fix for a scanline grazing a smooth peak);@xHeight-20and other metric/variable insets too.
Full reference: docs/anchor-rules.md.
Rule sets: the host carries them
No rule sets ship with the package. Rules are data, not code: bundled, they sat where a user could not edit them, and a change of data went out on a version of code. The library carries the engine and a resolver; you carry the rules.
The sample sets live in this repository under examples/rules/ —
copy them and make them yours. Address one by path, or by bare name once a
search path is configured:
anchorsfactory MyFont.ufo -r path/to/default.anchors # by path
anchorsfactory MyFont.ufo --rules-path my-rules -r default # by name
export ANCHORSFACTORY_RULES_PATH=my-rules # or by environment
A name is also looked up next to the file doing the referencing, so a set
can !extends default its neighbour with no configuration at all. In Python,
load_document(ref, base_dir=…, search_paths=[…]); process-wide,
anchorsfactory.set_search_paths([…]).
resolve_ref(ref, base_dir=…, search_paths=…) answers which file a reference
loads from — the same decision load_document makes, and the same string that
lands in RuleSource.origin, so a host that lets a user edit an inherited set
writes back to the file the engine actually read. A name nothing answers raises
RuleSetNotFound (a KeyError), kept distinct from the FileNotFoundError of
a mistyped path and the DSLError of bad rule text — three different things to
tell a user. Nothing is cached: a reload sees an edit made mid-session.
default and default-italics are the samples to start from. The italic one is
an overlay: the shear itself is automatic, so it only restates what a slanted
drawing changes — eight rules on top of default rather than a second copy of
it.
examples/rules/default.anchors covers Latin core — Basic Latin, Latin-1
Supplement and Latin Extended-A — and is generated from the Unicode database:
every composite comes from a canonical decomposition, and every base carries
exactly the anchors those composites ask of it. It ships with a matching
default.glyphsConstruction, so the two halves of the pipeline stay in step —
AnchorsFactory places the anchors, GlyphConstruction assembles the composites.
Rules address glyphs by codepoint (U+00C1) wherever the name would be a
guess, so they make no assumption about a font's naming scheme; the ASCII
letters, which every font names alike, stay spelled out as A–Z / a–z.
Accents get both treatments, because fonts ship them both ways: each mark is
ruled on its combining codepoint and its spacing twin (U+0301 and
U+00B4), and again under the legacy names a font may use for glyphs that
carry no codepoint at all — acutecomb, and the capital-height set Acute /
acute.case, which is anchored at cap height rather than the lowercase
midpoint.
Wider Latin sits alongside it: examples/rules/latin-ext-b.* and
latin-ext-additional.* (Vietnamese and the dot-below accents), plus the
pre-generation legacy-* sets and the script sets devanagari / hebrew /
thai.
Old .txt rule files (see examples/) convert to the new syntax — verified
lossless:
anchorsfactory-convert examples/default-anchors-list.txt -o my-rules.anchors
Library API
from anchorsfactory import process_ufo, load_document, apply_document
process_ufo("MyFont.ufo", "my-rules.anchors") # high-level: open, apply, save
from fontParts.world import OpenFont
font = OpenFont("MyFont.ufo")
apply_document(font, load_document("my-rules.anchors"))
font.save()
Composites (GlyphConstruction)
anchorsfactory.composites assembles composite glyphs from the anchors just
placed, using a vendored, unmodified GlyphConstruction.
from anchorsfactory import load_document, apply_document
from anchorsfactory.composites import build_composites
from anchorsfactory.presets import construction_text
RULES = ["examples/rules"] # your rule library
apply_document(font, load_document("default", search_paths=RULES)) # anchors first
built = build_composites(font, construction_text("default", search_paths=RULES))
built["Aacute"].glyph # a ConstructionGlyph: .draw(pen) / .width / .components
built["Aacute"].source_line # the construction's line, for click-to-rule
built["Aacute"].problems # missing base/mark glyph or anchor, if any
Constructions use stock GlyphConstruction syntax with one AnchorsFactory
extension: a glyph may be addressed as U+XXXX, resolved to the font's own
glyph name before the engine sees it (the font's cmap first, then a combining
mark's spacing twin, then the AGL name, then uniXXXX). Plain names still work
and are the better choice where a name is unambiguous.
A mark may take a .case suffix, asking for the font's capital-height accent
set — a legacy cut that exists under a name (Acute, acute.case) but never a
codepoint. Fonts without one fall back to the plain mark, so the same file builds
either way:
U+00C1 = A + U+0301.case@top | 00C1 # Á · LATIN CAPITAL LETTER A WITH ACUTE
U+00E1 = a + U+0301@top | 00E1 # á · LATIN SMALL LETTER A WITH ACUTE
That keeps a rule set portable across naming schemes, at the cost of stock
GlyphConstruction not reading such a file — call resolve_unicode_refs(text, font)
to get plain-name text back. Files with no U+ token are untouched, so
name-based constructions keep working unchanged.
Compute without mutating (preview)
compute_document is the functional core: it returns the anchors a rule
document would place, keyed by glyph, without touching the font. It owns the
same orchestration as apply_document (suffix expansion, shift_x, rounding,
same-name dedup), so a preview never drifts from what gets written.
from anchorsfactory import compute_document, load_document, accumulate, resolve
doc = load_document("my-rules.anchors")
placed = compute_document(font, doc) # {glyph_name: [(anchor, x, y), ...]}
# resolve() is the pure, single-anchor primitive behind it:
specs = accumulate(doc, "A", font["A"].unicodes) # the anchors due on glyph A
x, y = resolve(font, font["A"], specs[0])
For an interactive editor, on_error="collect" never raises — it places what
it can and reports the rest as structured diagnostics:
result = compute_document(font, doc, on_error="collect")
for d in result.diagnostics:
# d.severity == "error" -> anchor skipped (geometry raised)
# d.severity == "warning" -> anchor placed via a fallback, but suspect
print(d.severity, d.glyph, d.anchor, d.reason)
result is a plain dict subclass (so it works anywhere a dict does) carrying
a .diagnostics list of ComputeDiagnostic(glyph, anchor, reason, severity, rule). The default on_error="raise" is unchanged and keeps .diagnostics
empty.
Editor support (anchorsfactory.vocabulary)
Everything an editor needs to highlight and complete the rule language, taken from the same tables the parser is built from — so a client never hard-codes a word the parser might later reject.
from anchorsfactory.vocabulary import (
FRAMES, X_ALIGNS, Y_EDGES, RUNS, METRICS, CENTROID,
DIRECTIVES, PROPAGATE_VALUES, SUFFIX_KEYWORDS, OPERATORS, SIGILS,
completions_after_dot, completions_for_slot, as_dict,
)
completions_after_dot("box", axis="y") # ("bottom", "middle", "top")
completions_after_dot("width", axis="y") # () — the advance frame is X-only
completions_for_slot("x") # what may open the X slot (no metrics)
as_dict() # the whole thing, JSON-serialisable
Both axes share one frame.position grammar but not one alignment table:
left/center/right is X, bottom/middle/top is Y, and a font metric only opens
a Y slot. Passing the slot the caret sits in therefore narrows a menu from six
words to three; pass axis=None when the slot is unknown and both come back.
Note it is deliberately not the IR: Frame.ADVANCE is a node name, the word a
user types is width. A client completing straight off anchorsfactory.model
gets the former and offers a token the parser rejects.
Development
make venv # create .venv, install the package (editable) + dev deps, via uv
make test # run the test suite
make build # build sdist + wheel into dist/
make release # bump minor, update CHANGELOG, build, upload to PyPI, tag + push
Releases are cut with make release: it bumps the minor version, fills in a
CHANGELOG section from the commit log, publishes to PyPI, and
tags vX.Y.Z. Set UV_PUBLISH_TOKEN first; use make release-test to rehearse
against TestPyPI.
License
MIT — see LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file anchorsfactory-0.5.1.tar.gz.
File metadata
- Download URL: anchorsfactory-0.5.1.tar.gz
- Upload date:
- Size: 173.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.9 {"installer":{"name":"uv","version":"0.9.9"},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"44","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
11e2f9934895df682bb7820d0d7e82ddf7fcfc6f2c69aa4ed0b949c9fe3adae4
|
|
| MD5 |
a75656c678e5b40195bf20a0092571ee
|
|
| BLAKE2b-256 |
cb171ff2f16bbae221d283cf6f21d9fe6162fa34e822d0394f623fb5dc4232e5
|
File details
Details for the file anchorsfactory-0.5.1-py3-none-any.whl.
File metadata
- Download URL: anchorsfactory-0.5.1-py3-none-any.whl
- Upload date:
- Size: 101.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.9 {"installer":{"name":"uv","version":"0.9.9"},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"44","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f1ea415639eed5de89d6fcba96e76052b42f3e414dae32db6403bd62be581d43
|
|
| MD5 |
18254d9528b58bfe29c0d34c5d147a68
|
|
| BLAKE2b-256 |
d656ceae6ff8bd851af5aa9b7a32f7ec1a0afe86d0041149e13aee6cfb408479
|