anchorsfactory 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. anchorsfactory-0.2.0/LICENSE +21 -0
  2. anchorsfactory-0.2.0/MANIFEST.in +4 -0
  3. anchorsfactory-0.2.0/PKG-INFO +126 -0
  4. anchorsfactory-0.2.0/README.md +97 -0
  5. anchorsfactory-0.2.0/anchorsfactory/__init__.py +36 -0
  6. anchorsfactory-0.2.0/anchorsfactory/__main__.py +6 -0
  7. anchorsfactory-0.2.0/anchorsfactory/apply.py +137 -0
  8. anchorsfactory-0.2.0/anchorsfactory/cli.py +124 -0
  9. anchorsfactory-0.2.0/anchorsfactory/convert.py +102 -0
  10. anchorsfactory-0.2.0/anchorsfactory/dsl.py +240 -0
  11. anchorsfactory-0.2.0/anchorsfactory/geometry.py +233 -0
  12. anchorsfactory-0.2.0/anchorsfactory/model.py +298 -0
  13. anchorsfactory-0.2.0/anchorsfactory/parser.py +165 -0
  14. anchorsfactory-0.2.0/anchorsfactory/presets.py +37 -0
  15. anchorsfactory-0.2.0/anchorsfactory/rules/__init__.py +0 -0
  16. anchorsfactory-0.2.0/anchorsfactory/rules/default-italics.af +154 -0
  17. anchorsfactory-0.2.0/anchorsfactory/rules/default.af +162 -0
  18. anchorsfactory-0.2.0/anchorsfactory/runner.py +128 -0
  19. anchorsfactory-0.2.0/anchorsfactory.egg-info/PKG-INFO +126 -0
  20. anchorsfactory-0.2.0/anchorsfactory.egg-info/SOURCES.txt +33 -0
  21. anchorsfactory-0.2.0/anchorsfactory.egg-info/dependency_links.txt +1 -0
  22. anchorsfactory-0.2.0/anchorsfactory.egg-info/entry_points.txt +3 -0
  23. anchorsfactory-0.2.0/anchorsfactory.egg-info/requires.txt +6 -0
  24. anchorsfactory-0.2.0/anchorsfactory.egg-info/top_level.txt +1 -0
  25. anchorsfactory-0.2.0/docs/anchor-rules.md +226 -0
  26. anchorsfactory-0.2.0/pyproject.toml +53 -0
  27. anchorsfactory-0.2.0/setup.cfg +4 -0
  28. anchorsfactory-0.2.0/tests/test_convert.py +50 -0
  29. anchorsfactory-0.2.0/tests/test_dsl.py +172 -0
  30. anchorsfactory-0.2.0/tests/test_geometry.py +132 -0
  31. anchorsfactory-0.2.0/tests/test_inherit.py +63 -0
  32. anchorsfactory-0.2.0/tests/test_model.py +73 -0
  33. anchorsfactory-0.2.0/tests/test_parser.py +94 -0
  34. anchorsfactory-0.2.0/tests/test_runner.py +65 -0
  35. anchorsfactory-0.2.0/tests/test_validate.py +40 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Alexander Lubovenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ include LICENSE
2
+ include README.md
3
+ recursive-include anchorsfactory/rules *.af
4
+ recursive-include docs *.md
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: anchorsfactory
3
+ Version: 0.2.0
4
+ Summary: Rule-driven anchor placement for UFO fonts
5
+ Author-email: Alexander Lubovenko <lubovenko@me.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/typedev/AnchorsFactory
8
+ Project-URL: Repository, https://github.com/typedev/AnchorsFactory
9
+ Project-URL: Issues, https://github.com/typedev/AnchorsFactory/issues
10
+ Keywords: ufo,font,fonts,anchors,diacritics,marks,typography,type-design,fonttools,fontparts,opentype
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Text Processing :: Fonts
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: fontParts
24
+ Requires-Dist: fontTools
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: build; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # AnchorsFactory
31
+
32
+ Rule-driven **anchor placement** for [UFO](https://unifiedfontobject.org/)
33
+ fonts. You describe, in a compact text file, where anchors should sit on your
34
+ glyphs; AnchorsFactory computes the coordinates from each glyph's own geometry
35
+ and writes the anchors into the font.
36
+
37
+ It does the *pre-marking* step of accent handling: place `top`/`bottom`/`_top`/…
38
+ anchors consistently across hundreds of glyphs, so a tool like
39
+ [GlyphConstruction](https://github.com/typemytype/GlyphConstruction) can then
40
+ assemble composite glyphs by snapping mark anchors to base anchors.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install anchorsfactory # once published
46
+ # or, from a checkout:
47
+ pip install -e .
48
+ ```
49
+
50
+ Requires Python 3.10+, `fontParts` and `fontTools`.
51
+
52
+ ## Quick start
53
+
54
+ ```bash
55
+ # place anchors using the bundled default ruleset, save to font_anchored.ufo
56
+ anchorsfactory MyFont.ufo --rules default
57
+
58
+ # your own rules, overwrite in place, with a backup of existing anchors
59
+ anchorsfactory MyFont.ufo --rules my-rules.af --in-place --backup-dir backups/
60
+
61
+ # a whole folder of UFOs
62
+ anchorsfactory masters/ --rules default
63
+ ```
64
+
65
+ By default the source UFO is never overwritten — output goes to
66
+ `*_anchored.ufo` unless you pass `--in-place`.
67
+
68
+ ## The rule language
69
+
70
+ Rules are stacked: define reusable **labels**, then **mark** glyphs with them,
71
+ mixing labels and one-off anchors. An anchor is a name and a parenthesised
72
+ `X Y` placement.
73
+
74
+ ```
75
+ # a label
76
+ @ = top (box.center capHeight), bottom (box.center 0)
77
+
78
+ # apply it, by name / unicode / range
79
+ A = @, ogonek (outline.right 0)
80
+ U+0410..U+044F = @ # all Russian Cyrillic
81
+ U+0413 += desc (outline.right 0) # Г also gets a descender anchor
82
+ ```
83
+
84
+ - **X** is `frame.position`: `width.*` (advance), `box.*` (bounding box) or
85
+ `outline.*` (the contour at height Y, with `.first`/`.last` to pick a stem).
86
+ - **Y** is a number, a font metric (`capHeight`, `xHeight`, `ascender`, …), or a
87
+ reference glyph (`$H`, `$H.bottom`, `$H*5/6`).
88
+ - Selectors: name, `U+XXXX`, range `U+A..U+B`, glob `*.sc`, category `{Lu}`.
89
+ - `=` replace · `+=` add · `-=` remove; `!extends default` inherits a ruleset.
90
+
91
+ Full reference: **[docs/anchor-rules.md](docs/anchor-rules.md)**.
92
+
93
+ ### Presets and migration
94
+
95
+ Bundled rulesets `default` and `default-italics` are usable by name in
96
+ `--rules` or `!extends`. Old `.txt` rule files (see `examples/`) convert to the
97
+ new syntax — verified lossless:
98
+
99
+ ```bash
100
+ anchorsfactory-convert examples/default-anchors-list.txt -o my-rules.af
101
+ ```
102
+
103
+ ## Library API
104
+
105
+ ```python
106
+ from anchorsfactory import process_ufo, load_document, apply_document
107
+
108
+ process_ufo("MyFont.ufo", "default") # high-level: open, apply, save
109
+
110
+ from fontParts.world import OpenFont
111
+ font = OpenFont("MyFont.ufo")
112
+ apply_document(font, load_document("my-rules.af"))
113
+ font.save()
114
+ ```
115
+
116
+ ## Development
117
+
118
+ ```bash
119
+ make venv # create .venv, install the package (editable) + dev deps, via uv
120
+ make test # run the test suite
121
+ make build # build sdist + wheel into dist/
122
+ ```
123
+
124
+ ## License
125
+
126
+ See [LICENSE](LICENSE).
@@ -0,0 +1,97 @@
1
+ # AnchorsFactory
2
+
3
+ Rule-driven **anchor placement** for [UFO](https://unifiedfontobject.org/)
4
+ fonts. You describe, in a compact text file, where anchors should sit on your
5
+ glyphs; AnchorsFactory computes the coordinates from each glyph's own geometry
6
+ and writes the anchors into the font.
7
+
8
+ It does the *pre-marking* step of accent handling: place `top`/`bottom`/`_top`/…
9
+ anchors consistently across hundreds of glyphs, so a tool like
10
+ [GlyphConstruction](https://github.com/typemytype/GlyphConstruction) can then
11
+ assemble composite glyphs by snapping mark anchors to base anchors.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install anchorsfactory # once published
17
+ # or, from a checkout:
18
+ pip install -e .
19
+ ```
20
+
21
+ Requires Python 3.10+, `fontParts` and `fontTools`.
22
+
23
+ ## Quick start
24
+
25
+ ```bash
26
+ # place anchors using the bundled default ruleset, save to font_anchored.ufo
27
+ anchorsfactory MyFont.ufo --rules default
28
+
29
+ # your own rules, overwrite in place, with a backup of existing anchors
30
+ anchorsfactory MyFont.ufo --rules my-rules.af --in-place --backup-dir backups/
31
+
32
+ # a whole folder of UFOs
33
+ anchorsfactory masters/ --rules default
34
+ ```
35
+
36
+ By default the source UFO is never overwritten — output goes to
37
+ `*_anchored.ufo` unless you pass `--in-place`.
38
+
39
+ ## The rule language
40
+
41
+ Rules are stacked: define reusable **labels**, then **mark** glyphs with them,
42
+ mixing labels and one-off anchors. An anchor is a name and a parenthesised
43
+ `X Y` placement.
44
+
45
+ ```
46
+ # a label
47
+ @ = top (box.center capHeight), bottom (box.center 0)
48
+
49
+ # apply it, by name / unicode / range
50
+ A = @, ogonek (outline.right 0)
51
+ U+0410..U+044F = @ # all Russian Cyrillic
52
+ U+0413 += desc (outline.right 0) # Г also gets a descender anchor
53
+ ```
54
+
55
+ - **X** is `frame.position`: `width.*` (advance), `box.*` (bounding box) or
56
+ `outline.*` (the contour at height Y, with `.first`/`.last` to pick a stem).
57
+ - **Y** is a number, a font metric (`capHeight`, `xHeight`, `ascender`, …), or a
58
+ reference glyph (`$H`, `$H.bottom`, `$H*5/6`).
59
+ - Selectors: name, `U+XXXX`, range `U+A..U+B`, glob `*.sc`, category `{Lu}`.
60
+ - `=` replace · `+=` add · `-=` remove; `!extends default` inherits a ruleset.
61
+
62
+ Full reference: **[docs/anchor-rules.md](docs/anchor-rules.md)**.
63
+
64
+ ### Presets and migration
65
+
66
+ Bundled rulesets `default` and `default-italics` are usable by name in
67
+ `--rules` or `!extends`. Old `.txt` rule files (see `examples/`) convert to the
68
+ new syntax — verified lossless:
69
+
70
+ ```bash
71
+ anchorsfactory-convert examples/default-anchors-list.txt -o my-rules.af
72
+ ```
73
+
74
+ ## Library API
75
+
76
+ ```python
77
+ from anchorsfactory import process_ufo, load_document, apply_document
78
+
79
+ process_ufo("MyFont.ufo", "default") # high-level: open, apply, save
80
+
81
+ from fontParts.world import OpenFont
82
+ font = OpenFont("MyFont.ufo")
83
+ apply_document(font, load_document("my-rules.af"))
84
+ font.save()
85
+ ```
86
+
87
+ ## Development
88
+
89
+ ```bash
90
+ make venv # create .venv, install the package (editable) + dev deps, via uv
91
+ make test # run the test suite
92
+ make build # build sdist + wheel into dist/
93
+ ```
94
+
95
+ ## License
96
+
97
+ See [LICENSE](LICENSE).
@@ -0,0 +1,36 @@
1
+ """AnchorsFactory — rule-driven anchor placement for UFO fonts."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
4
+
5
+ try:
6
+ __version__ = _pkg_version("anchorsfactory")
7
+ except PackageNotFoundError: # running from a source tree without an install
8
+ __version__ = "0.0.0+unknown"
9
+
10
+ from .apply import apply_document, accumulate, validate_document
11
+ from .parser import parse_document, parse_file, ParseError
12
+ from .dsl import parse_dsl, parse_dsl_file, DSLError
13
+ from .convert import convert_file, render_document, verify_conversion
14
+ from .presets import list_presets, preset_text, is_preset
15
+ from .runner import process_ufo, load_document
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "apply_document",
20
+ "accumulate",
21
+ "validate_document",
22
+ "parse_document",
23
+ "parse_file",
24
+ "ParseError",
25
+ "parse_dsl",
26
+ "parse_dsl_file",
27
+ "DSLError",
28
+ "convert_file",
29
+ "render_document",
30
+ "verify_conversion",
31
+ "list_presets",
32
+ "preset_text",
33
+ "is_preset",
34
+ "process_ufo",
35
+ "load_document",
36
+ ]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,137 @@
1
+ """Apply a parsed :class:`Document` to a font: place the anchors.
2
+
3
+ Resolution follows the accumulation model: rules are scanned in file order and
4
+ every rule whose selector matches a glyph mutates that glyph's anchor list —
5
+ ``=`` replaces it, ``+=`` appends. This single path serves both front-ends
6
+ (the legacy parser emits all-``REPLACE`` rules, so each glyph matched once
7
+ behaves exactly as before).
8
+
9
+ Coordinate maths is delegated to :mod:`anchorsfactory.geometry`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import fnmatch
15
+ import logging
16
+ import unicodedata
17
+
18
+ from .geometry import resolve
19
+ from .model import (
20
+ Document, Op, LabelRef,
21
+ GlyphName, Unicode, UnicodeRange, Glob, Category,
22
+ )
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ def _resolve_items(items, labels, _seen=()):
28
+ """Expand LabelRefs to concrete AnchorSpecs against *labels* (late binding)."""
29
+ specs = []
30
+ for it in items:
31
+ if isinstance(it, LabelRef):
32
+ if it.name in _seen:
33
+ raise ValueError(f"label cycle through {it.name}")
34
+ if it.name not in labels:
35
+ raise ValueError(f"undefined label {it.name}")
36
+ specs.extend(_resolve_items(labels[it.name], labels, _seen + (it.name,)))
37
+ else:
38
+ specs.append(it)
39
+ return specs
40
+
41
+
42
+ def _remove_targets(items, labels):
43
+ """Names to drop for a REMOVE rule: bare names plus the names a label defines."""
44
+ names = set()
45
+ for it in items:
46
+ if isinstance(it, LabelRef):
47
+ names.update(s.name for s in _resolve_items([it], labels))
48
+ else:
49
+ names.add(it)
50
+ return names
51
+
52
+
53
+ def _matches(selector, name: str, unicodes) -> bool:
54
+ if isinstance(selector, GlyphName):
55
+ return name == selector.name
56
+ if isinstance(selector, Unicode):
57
+ return selector.codepoint in unicodes
58
+ if isinstance(selector, UnicodeRange):
59
+ return any(selector.start <= u <= selector.end for u in unicodes)
60
+ if isinstance(selector, Glob):
61
+ return fnmatch.fnmatchcase(name, selector.pattern)
62
+ if isinstance(selector, Category):
63
+ return any(unicodedata.category(chr(u)).startswith(selector.value) for u in unicodes)
64
+ raise TypeError(f"unknown selector {selector!r}")
65
+
66
+
67
+ def validate_document(doc: Document) -> list[str]:
68
+ """Pre-flight check (font-independent): every @label reference resolves.
69
+
70
+ Returns a list of human-readable problems (empty = ok). Catches typo'd
71
+ label names up front instead of at apply time, glyph by glyph.
72
+ """
73
+ problems = []
74
+ for lname, items in doc.labels.items():
75
+ for it in items:
76
+ if isinstance(it, LabelRef) and it.name not in doc.labels:
77
+ problems.append(f"label {lname}: undefined label {it.name}")
78
+ for sel, op, items in doc.rules:
79
+ for it in items:
80
+ if isinstance(it, LabelRef) and it.name not in doc.labels:
81
+ problems.append(f"rule {sel}: undefined label {it.name}")
82
+ return problems
83
+
84
+
85
+ def accumulate(doc: Document, name: str, unicodes) -> list:
86
+ """Build a glyph's anchor list by applying matching rules in order.
87
+
88
+ ``=`` replaces, ``+=`` appends, ``-=`` drops by anchor name. Labels are
89
+ resolved here, against ``doc.labels``, so overrides take effect late.
90
+ """
91
+ acc: list = []
92
+ for selector, op, items in doc.rules:
93
+ if not _matches(selector, name, unicodes):
94
+ continue
95
+ if op is Op.REMOVE:
96
+ drop = _remove_targets(items, doc.labels)
97
+ acc = [s for s in acc if s.name not in drop]
98
+ else:
99
+ specs = _resolve_items(items, doc.labels)
100
+ acc = specs if op is Op.REPLACE else acc + specs
101
+ return acc
102
+
103
+
104
+ def apply_document(font, doc: Document, *, clear=True, replace=True, round_coords=True):
105
+ """Place all anchors described by *doc* onto *font* (in place).
106
+
107
+ ``round_coords`` rounds placed anchors to whole units (the usual choice for
108
+ a UFO); the golden regression passes ``False`` to compare raw precision.
109
+ """
110
+ for glyph in font:
111
+ specs = accumulate(doc, glyph.name, list(glyph.unicodes))
112
+ if not specs:
113
+ continue
114
+ for sfx in doc.suffixes:
115
+ gname = glyph.name + sfx
116
+ if gname in font:
117
+ _place(font, font[gname], specs, doc.shift_x, clear, replace, round_coords)
118
+
119
+
120
+ def _remove_named(glyph, name):
121
+ for anchor in list(glyph.anchors):
122
+ if anchor.name == name:
123
+ glyph.removeAnchor(anchor)
124
+
125
+
126
+ def _place(font, glyph, specs, shift_x, clear, replace, round_coords):
127
+ if clear:
128
+ for anchor in list(glyph.anchors):
129
+ glyph.removeAnchor(anchor)
130
+ for spec in specs:
131
+ x, y = resolve(font, glyph, spec)
132
+ if replace:
133
+ _remove_named(glyph, spec.name)
134
+ x += shift_x
135
+ if round_coords:
136
+ x, y = round(x), round(y)
137
+ glyph.appendAnchor(spec.name, (x, y))
@@ -0,0 +1,124 @@
1
+ """Command-line interface for AnchorsFactory.
2
+
3
+ Replaces the legacy module-level script and batch.py. Accepts one or more
4
+ UFO paths (a directory expands to its ``*.ufo`` files), applies a rule file,
5
+ and saves — safely by default (never overwriting the source unless asked).
6
+
7
+ Logging is configured here, at the application entry point — never via
8
+ ``logging.basicConfig`` inside the library — so batch runs get a clean,
9
+ per-font log file instead of everything landing in the first font's log.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import glob
16
+ import logging
17
+ import os
18
+ import sys
19
+ from datetime import datetime
20
+
21
+ from .apply import validate_document
22
+ from .runner import load_document, process_ufo
23
+
24
+ log = logging.getLogger("anchorsfactory")
25
+
26
+
27
+ def _expand_inputs(paths: list[str]) -> list[str]:
28
+ ufos: list[str] = []
29
+ for p in paths:
30
+ if os.path.isdir(p) and not p.lower().endswith(".ufo"):
31
+ ufos.extend(sorted(glob.glob(os.path.join(p, "*.ufo"))))
32
+ else:
33
+ ufos.append(p)
34
+ return ufos
35
+
36
+
37
+ def _setup_console_logging(verbose: bool) -> None:
38
+ log.setLevel(logging.DEBUG if verbose else logging.INFO)
39
+ handler = logging.StreamHandler()
40
+ handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
41
+ log.addHandler(handler)
42
+
43
+
44
+ def _font_log_handler(log_dir: str, ufo_path: str) -> logging.Handler:
45
+ os.makedirs(log_dir, exist_ok=True)
46
+ stem = os.path.splitext(os.path.basename(ufo_path.rstrip(os.sep)))[0]
47
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
48
+ handler = logging.FileHandler(os.path.join(log_dir, f"{ts}_{stem}.log"), encoding="utf-8")
49
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
50
+ return handler
51
+
52
+
53
+ def build_parser() -> argparse.ArgumentParser:
54
+ p = argparse.ArgumentParser(
55
+ prog="anchorsfactory",
56
+ description="Place anchors in UFO fonts from a rule file.",
57
+ )
58
+ p.add_argument("ufo", nargs="+", help="UFO file(s) or a directory of UFOs")
59
+ p.add_argument("-r", "--rules", required=True, help="path to the anchor rules file")
60
+ out = p.add_mutually_exclusive_group()
61
+ out.add_argument("-o", "--output", help="output UFO path (single input only)")
62
+ out.add_argument("--in-place", action="store_true", help="overwrite the source UFO")
63
+ p.add_argument("--backup-dir", help="dump existing anchors here before applying")
64
+ p.add_argument("--keep-existing", action="store_true",
65
+ help="do not clear existing anchors before applying")
66
+ p.add_argument("--no-round", action="store_true", help="keep fractional anchor coordinates")
67
+ p.add_argument("--log-dir", help="write a per-font log file into this directory")
68
+ p.add_argument("-v", "--verbose", action="store_true")
69
+ return p
70
+
71
+
72
+ def main(argv: list[str] | None = None) -> int:
73
+ args = build_parser().parse_args(argv)
74
+ _setup_console_logging(args.verbose)
75
+
76
+ inputs = _expand_inputs(args.ufo)
77
+ if not inputs:
78
+ log.error("no UFO inputs found")
79
+ return 2
80
+ if args.output and len(inputs) > 1:
81
+ log.error("--output cannot be used with multiple inputs; use --in-place or default naming")
82
+ return 2
83
+
84
+ # Load + validate the rules once, up front: fail fast on rule errors before
85
+ # touching any font.
86
+ try:
87
+ document = load_document(args.rules)
88
+ except Exception as e: # noqa: BLE001 — surface a clean message, not a traceback
89
+ log.error("cannot load rules %s: %s", args.rules, e)
90
+ return 2
91
+ problems = validate_document(document)
92
+ if problems:
93
+ for msg in problems:
94
+ log.error("rules: %s", msg)
95
+ return 2
96
+
97
+ failures = 0
98
+ for ufo in inputs:
99
+ fh = _font_log_handler(args.log_dir, ufo) if args.log_dir else None
100
+ if fh:
101
+ log.addHandler(fh)
102
+ try:
103
+ process_ufo(
104
+ ufo, args.rules,
105
+ output=args.output,
106
+ in_place=args.in_place,
107
+ backup_dir=args.backup_dir,
108
+ clear=not args.keep_existing,
109
+ round_coords=not args.no_round,
110
+ document=document,
111
+ )
112
+ except Exception as e: # noqa: BLE001 — report per-font, continue the batch
113
+ log.error("Failed on %s: %s", ufo, e)
114
+ failures += 1
115
+ finally:
116
+ if fh:
117
+ log.removeHandler(fh)
118
+ fh.close()
119
+
120
+ return 1 if failures else 0
121
+
122
+
123
+ if __name__ == "__main__":
124
+ sys.exit(main())
@@ -0,0 +1,102 @@
1
+ """Convert a legacy ``.txt`` rule file to the new DSL (docs/anchor-rules.md).
2
+
3
+ Reuses the IR as the bridge: parse the legacy file to a :class:`Document`
4
+ (via :mod:`anchorsfactory.parser`), then render that Document back out in the
5
+ new surface syntax. Rendering relies on the model's ``__str__``, which already
6
+ emits canonical new-syntax tokens.
7
+
8
+ Note: rule lines come out with anchors inlined (the legacy parser expands
9
+ label references), so the result is faithful but not re-compressed — you can
10
+ hand-edit it to use labels / ranges afterwards.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .dsl import parse_dsl
16
+ from .parser import parse_file
17
+
18
+
19
+ def render_selector(sel) -> str:
20
+ return str(sel)
21
+
22
+
23
+ def render_document(doc) -> str:
24
+ lines: list[str] = []
25
+ if doc.shift_x:
26
+ lines.append(f"!shiftx = {doc.shift_x}")
27
+ suffixes = [s for s in doc.suffixes if s]
28
+ if suffixes:
29
+ lines.append("!suffixes = " + ", ".join(suffixes))
30
+ if lines:
31
+ lines.append("")
32
+
33
+ if doc.labels:
34
+ lines.append("# labels")
35
+ for name, specs in doc.labels.items():
36
+ lines.append(f"{name} = " + ", ".join(str(s) for s in specs))
37
+ lines.append("")
38
+
39
+ lines.append("# rules")
40
+ for sel, op, specs in doc.rules:
41
+ lines.append(f"{render_selector(sel)} {op.value} " + ", ".join(str(s) for s in specs))
42
+ return "\n".join(lines) + "\n"
43
+
44
+
45
+ def convert_file(legacy_path: str) -> str:
46
+ """Return the new-DSL text for a legacy rule file."""
47
+ return render_document(parse_file(legacy_path))
48
+
49
+
50
+ def verify_conversion(legacy_path: str) -> list[str]:
51
+ """Round-trip check: legacy -> new text -> IR must equal the legacy IR.
52
+
53
+ Returns a list of human-readable mismatches (empty = lossless). Guarantees
54
+ the conversion preserved every rule, label and directive.
55
+ """
56
+ legacy = parse_file(legacy_path)
57
+ roundtrip = parse_dsl(render_document(legacy).splitlines())
58
+ problems = []
59
+ if roundtrip.rules != legacy.rules:
60
+ problems.append("rules differ after round-trip")
61
+ if roundtrip.labels != legacy.labels:
62
+ problems.append("labels differ after round-trip")
63
+ if roundtrip.shift_x != legacy.shift_x:
64
+ problems.append("shift_x differs after round-trip")
65
+ if roundtrip.suffixes != legacy.suffixes:
66
+ problems.append("suffixes differ after round-trip")
67
+ return problems
68
+
69
+
70
+ def main(argv=None) -> int:
71
+ import argparse
72
+ import sys
73
+
74
+ p = argparse.ArgumentParser(
75
+ prog="anchorsfactory-convert",
76
+ description="Convert a legacy .txt rule file to the new DSL.",
77
+ )
78
+ p.add_argument("legacy", help="path to the legacy .txt rule file")
79
+ p.add_argument("-o", "--output", help="write here instead of stdout")
80
+ p.add_argument("--no-verify", action="store_true",
81
+ help="skip the lossless round-trip check")
82
+ args = p.parse_args(argv)
83
+
84
+ text = convert_file(args.legacy)
85
+ if args.output:
86
+ with open(args.output, "w", encoding="utf-8") as f:
87
+ f.write(text)
88
+ else:
89
+ sys.stdout.write(text)
90
+
91
+ if not args.no_verify:
92
+ problems = verify_conversion(args.legacy)
93
+ if problems:
94
+ for msg in problems:
95
+ print(f"verify: {msg}", file=sys.stderr)
96
+ return 1
97
+ print("verify: round-trip OK — conversion is lossless", file=sys.stderr)
98
+ return 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ raise SystemExit(main())