Skip to main content

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.position works on both axes: width.* (advance), box.* (bounding box) or outline.* (the contour, sampled on a scanline at the other coordinate, with .first/.last to pick a stem). On X the words are left/center/right, on Y bottom/middle/top; box*1/3 is a fractional position (same *n/m as capHeight*2/3) and outline.centroid the 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.top reads this glyph's own height.
  • Selectors: name, U+XXXX, range U+A..U+B, glob *.sc, category {Lu}.
  • = replace · += add · -= remove; !extends default inherits a ruleset.
  • Terms combine with +/- (on either axis) for a base position plus a bias — outline.centroid-25 nudges a slanted mark off the optical centre.
  • &name names a reusable X/Y value; @name a reusable set of anchors. Both are late-bound, so an extending file can override either.
  • %name places an anchor relative to another anchor on the same glyph — bottom (%top 0) reuses top'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 = composites makes composite glyphs inherit their components' anchors — write rules for the base letters, precomposed glyphs get theirs free.
  • @top-10 / @bottom+8 sample just inside an edge (the fix for a scanline grazing a smooth peak); @xHeight-20 and 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([…]) / add_search_path(dir). is_name(ref) tells a bare name from a path without touching the disk.

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 anchorsfactory.composites.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.

Both rule files and constructions accept per-master conditional blocks (!if ital.xvalue == 1 … !else … !endif). Pass the master as a context:

from anchorsfactory import RuleContext, contexts_from_designspace
ctx = contexts_from_designspace("Family.designspace")["/abs/path/Italic.ufo"]
apply_document(font, doc, context=ctx)
build_composites(font, gc_text, context=ctx)

See "Conditional blocks" in docs/anchor-rules.md.

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.

Metadata

Release files for anchorsfactory 0.6.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for anchorsfactory 0.6.0
File Size Uploaded
anchorsfactory-0.6.0.tar.gz 169.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for anchorsfactory 0.6.0
File Interpreter ABI Platform
anchorsfactory-0.6.0-py3-none-any.whl Python 3 none any Details

Total release size: 261.5 kB

Release files / anchorsfactory-0.6.0.tar.gz

Download URL anchorsfactory-0.6.0.tar.gz
Size 169.8 kB
Tags Source
SHA-256 checksum
How to use checksums
ff29b585aa6a07360456d1340d44a5e9079a8f758ece6ca217ec6416deb840d3
BLAKE2b-256 checksum
How to use checksums
6df96aaf816beb34d44f2543f7a49ebd5faa68a2384b03e6114cbf92768db862
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 Oct 11, 2026.

Transparency log

Release files / anchorsfactory-0.6.0-py3-none-any.whl

Download URL anchorsfactory-0.6.0-py3-none-any.whl
Size 91.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1b23fcf285148ece2e20cabcf2985e57eba47c51afb9978b374b2b3c2053054a
BLAKE2b-256 checksum
How to use checksums
721a5fe5cf27aefda547cd414bca116a6915235ee9e2b5d59ba99c62b1a04737
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 Oct 11, 2026.

Transparency log

Release history Release notifications | RSS feed

0.7.0

2 release files

This release

0.6.0 This release

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page