Skip to main content

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.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([…]).

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 AZ / az.

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

anchorsfactory-0.5.0.tar.gz (212.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

anchorsfactory-0.5.0-py3-none-any.whl (144.3 kB view details)

Uploaded Python 3

File details

Details for the file anchorsfactory-0.5.0.tar.gz.

File metadata

  • Download URL: anchorsfactory-0.5.0.tar.gz
  • Upload date:
  • Size: 212.9 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

Hashes for anchorsfactory-0.5.0.tar.gz
Algorithm Hash digest
SHA256 2a7f4e7291c4d0733304a5313147f9ffaa95064e4a95b3090538a3dd64edd2cd
MD5 8dad64bf09c66a20bf15dca2902d2ab2
BLAKE2b-256 437cdef3201c9347af39be9e7335d6e538f672e92016cd1f55a8d0e9d186a81e

See more details on using hashes here.

File details

Details for the file anchorsfactory-0.5.0-py3-none-any.whl.

File metadata

  • Download URL: anchorsfactory-0.5.0-py3-none-any.whl
  • Upload date:
  • Size: 144.3 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

Hashes for anchorsfactory-0.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bf87ff55a5957fd2e3ed80f0b520e84d32ee19b0fb4ddcc184b75e1f1bf3e2c1
MD5 d2ce834233dd4628702b4a0b61d706b0
BLAKE2b-256 a69ca56cbc5a92aded6796c23024be36a52280c7276dbdcfcadddf090cabc62e

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page