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.
- anchorsfactory-0.2.0/LICENSE +21 -0
- anchorsfactory-0.2.0/MANIFEST.in +4 -0
- anchorsfactory-0.2.0/PKG-INFO +126 -0
- anchorsfactory-0.2.0/README.md +97 -0
- anchorsfactory-0.2.0/anchorsfactory/__init__.py +36 -0
- anchorsfactory-0.2.0/anchorsfactory/__main__.py +6 -0
- anchorsfactory-0.2.0/anchorsfactory/apply.py +137 -0
- anchorsfactory-0.2.0/anchorsfactory/cli.py +124 -0
- anchorsfactory-0.2.0/anchorsfactory/convert.py +102 -0
- anchorsfactory-0.2.0/anchorsfactory/dsl.py +240 -0
- anchorsfactory-0.2.0/anchorsfactory/geometry.py +233 -0
- anchorsfactory-0.2.0/anchorsfactory/model.py +298 -0
- anchorsfactory-0.2.0/anchorsfactory/parser.py +165 -0
- anchorsfactory-0.2.0/anchorsfactory/presets.py +37 -0
- anchorsfactory-0.2.0/anchorsfactory/rules/__init__.py +0 -0
- anchorsfactory-0.2.0/anchorsfactory/rules/default-italics.af +154 -0
- anchorsfactory-0.2.0/anchorsfactory/rules/default.af +162 -0
- anchorsfactory-0.2.0/anchorsfactory/runner.py +128 -0
- anchorsfactory-0.2.0/anchorsfactory.egg-info/PKG-INFO +126 -0
- anchorsfactory-0.2.0/anchorsfactory.egg-info/SOURCES.txt +33 -0
- anchorsfactory-0.2.0/anchorsfactory.egg-info/dependency_links.txt +1 -0
- anchorsfactory-0.2.0/anchorsfactory.egg-info/entry_points.txt +3 -0
- anchorsfactory-0.2.0/anchorsfactory.egg-info/requires.txt +6 -0
- anchorsfactory-0.2.0/anchorsfactory.egg-info/top_level.txt +1 -0
- anchorsfactory-0.2.0/docs/anchor-rules.md +226 -0
- anchorsfactory-0.2.0/pyproject.toml +53 -0
- anchorsfactory-0.2.0/setup.cfg +4 -0
- anchorsfactory-0.2.0/tests/test_convert.py +50 -0
- anchorsfactory-0.2.0/tests/test_dsl.py +172 -0
- anchorsfactory-0.2.0/tests/test_geometry.py +132 -0
- anchorsfactory-0.2.0/tests/test_inherit.py +63 -0
- anchorsfactory-0.2.0/tests/test_model.py +73 -0
- anchorsfactory-0.2.0/tests/test_parser.py +94 -0
- anchorsfactory-0.2.0/tests/test_runner.py +65 -0
- 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,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,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())
|