anchorsfactory 0.2.0__py3-none-any.whl
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/__init__.py +36 -0
- anchorsfactory/__main__.py +6 -0
- anchorsfactory/apply.py +137 -0
- anchorsfactory/cli.py +124 -0
- anchorsfactory/convert.py +102 -0
- anchorsfactory/dsl.py +240 -0
- anchorsfactory/geometry.py +233 -0
- anchorsfactory/model.py +298 -0
- anchorsfactory/parser.py +165 -0
- anchorsfactory/presets.py +37 -0
- anchorsfactory/rules/__init__.py +0 -0
- anchorsfactory/rules/default-italics.af +154 -0
- anchorsfactory/rules/default.af +162 -0
- anchorsfactory/runner.py +128 -0
- anchorsfactory-0.2.0.dist-info/METADATA +126 -0
- anchorsfactory-0.2.0.dist-info/RECORD +20 -0
- anchorsfactory-0.2.0.dist-info/WHEEL +5 -0
- anchorsfactory-0.2.0.dist-info/entry_points.txt +3 -0
- anchorsfactory-0.2.0.dist-info/licenses/LICENSE +21 -0
- anchorsfactory-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
anchorsfactory/apply.py
ADDED
|
@@ -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))
|
anchorsfactory/cli.py
ADDED
|
@@ -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())
|
anchorsfactory/dsl.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Parser for the new rule language (docs/anchor-rules.md) -> IR (:class:`Document`).
|
|
2
|
+
|
|
3
|
+
A second front-end alongside :mod:`anchorsfactory.parser`; both produce the
|
|
4
|
+
same :class:`Document`, so the engine is unchanged. Surface form::
|
|
5
|
+
|
|
6
|
+
@label = name (X Y), ...
|
|
7
|
+
selector = ... # replace
|
|
8
|
+
selector += ... # accumulate
|
|
9
|
+
!suffixes = .alt
|
|
10
|
+
!shiftx = -15
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from .model import (
|
|
18
|
+
Frame, HAlign, VEdge, Run, Frac, FONT_METRICS,
|
|
19
|
+
X, XAbs, Y, YAbs, FontMetric, YSum, AnchorSpec, LabelRef,
|
|
20
|
+
GlyphName, Unicode, UnicodeRange, Glob, Category, Op, Document,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DSLError(ValueError):
|
|
25
|
+
"""Raised on a malformed line, with line context."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_FRAME = {"width": Frame.ADVANCE, "box": Frame.BOX, "outline": Frame.OUTLINE}
|
|
29
|
+
_HALIGN = {"left": HAlign.LEFT, "center": HAlign.CENTER, "right": HAlign.RIGHT}
|
|
30
|
+
_RUN = {"first": Run.FIRST, "last": Run.LAST}
|
|
31
|
+
_EDGE = {"top": VEdge.TOP, "middle": VEdge.MIDDLE, "bottom": VEdge.BOTTOM}
|
|
32
|
+
|
|
33
|
+
_ANCHOR_RE = re.compile(r"^(\S+)\s*\(\s*(\S+)\s+(\S+)\s*\)$")
|
|
34
|
+
_RULE_RE = re.compile(r"^(.*?)\s*(\+=|-=|=)\s*(.*)$")
|
|
35
|
+
_NAME_RE = re.compile(r"^[\w.]+$")
|
|
36
|
+
_OPS = {"=": Op.REPLACE, "+=": Op.ADD, "-=": Op.REMOVE}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --------------------------------------------------------------------------- #
|
|
40
|
+
# X / Y tokens
|
|
41
|
+
# --------------------------------------------------------------------------- #
|
|
42
|
+
def _parse_x(tok: str):
|
|
43
|
+
try:
|
|
44
|
+
return XAbs(int(tok))
|
|
45
|
+
except ValueError:
|
|
46
|
+
pass
|
|
47
|
+
base, _, edge = tok.partition("@")
|
|
48
|
+
parts = base.split(".")
|
|
49
|
+
if parts[0] not in _FRAME:
|
|
50
|
+
raise DSLError(f"unknown X frame in {tok!r}")
|
|
51
|
+
frame = _FRAME[parts[0]]
|
|
52
|
+
rest = parts[1:]
|
|
53
|
+
run = None
|
|
54
|
+
if len(rest) == 2:
|
|
55
|
+
run_tok, align_tok = rest
|
|
56
|
+
if run_tok in _RUN:
|
|
57
|
+
run = _RUN[run_tok]
|
|
58
|
+
else:
|
|
59
|
+
try:
|
|
60
|
+
run = int(run_tok)
|
|
61
|
+
except ValueError:
|
|
62
|
+
raise DSLError(f"bad run {run_tok!r} in {tok!r}")
|
|
63
|
+
elif len(rest) == 1:
|
|
64
|
+
align_tok = rest[0]
|
|
65
|
+
else:
|
|
66
|
+
raise DSLError(f"malformed X token {tok!r}")
|
|
67
|
+
if align_tok not in _HALIGN:
|
|
68
|
+
raise DSLError(f"unknown X align {align_tok!r} in {tok!r}")
|
|
69
|
+
at = None
|
|
70
|
+
if edge:
|
|
71
|
+
# @top/@bottom = the glyph's own extreme; otherwise a fixed sample height
|
|
72
|
+
at = _EDGE[edge] if edge in _EDGE else _parse_y(edge)
|
|
73
|
+
return X(frame, _HALIGN[align_tok], run=run, at=at)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_y(tok: str):
|
|
77
|
+
if "+" in tok: # a sum of terms: a+b+c
|
|
78
|
+
return YSum(tuple(_parse_y_term(t) for t in tok.split("+") if t))
|
|
79
|
+
return _parse_y_term(tok)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_y_term(tok: str):
|
|
83
|
+
if not tok.startswith("$"):
|
|
84
|
+
base, star, frac = tok.partition("*")
|
|
85
|
+
if base in FONT_METRICS: # font metric, optionally *d1/d2
|
|
86
|
+
if not star:
|
|
87
|
+
return FontMetric(base)
|
|
88
|
+
if "/" not in frac:
|
|
89
|
+
raise DSLError(f"fraction must be d1/d2 in {tok!r}")
|
|
90
|
+
d1, d2 = frac.split("/", 1)
|
|
91
|
+
try:
|
|
92
|
+
return FontMetric(base, Frac(int(d1), int(d2)))
|
|
93
|
+
except ValueError as e:
|
|
94
|
+
raise DSLError(f"bad fraction in {tok!r}: {e}")
|
|
95
|
+
try:
|
|
96
|
+
return YAbs(int(tok))
|
|
97
|
+
except ValueError:
|
|
98
|
+
raise DSLError(f"invalid Y position {tok!r}")
|
|
99
|
+
body = tok[1:]
|
|
100
|
+
if "*" in body:
|
|
101
|
+
glyph, frac = body.split("*", 1)
|
|
102
|
+
if "/" not in frac:
|
|
103
|
+
raise DSLError(f"fraction must be d1/d2 in {tok!r}")
|
|
104
|
+
d1, d2 = frac.split("/", 1)
|
|
105
|
+
try:
|
|
106
|
+
return Y(glyph, Frac(int(d1), int(d2)))
|
|
107
|
+
except ValueError as e:
|
|
108
|
+
raise DSLError(f"bad fraction in {tok!r}: {e}")
|
|
109
|
+
if "." in body:
|
|
110
|
+
glyph, _, suf = body.rpartition(".")
|
|
111
|
+
if suf in _EDGE:
|
|
112
|
+
return Y(glyph, _EDGE[suf])
|
|
113
|
+
return Y(body, VEdge.TOP)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_anchor(tok: str) -> AnchorSpec:
|
|
117
|
+
m = _ANCHOR_RE.match(tok)
|
|
118
|
+
if not m:
|
|
119
|
+
raise DSLError(f"anchor must be 'name (X Y)', got {tok!r}")
|
|
120
|
+
name, xtok, ytok = m.groups()
|
|
121
|
+
return AnchorSpec(name, _parse_x(xtok), _parse_y(ytok))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --------------------------------------------------------------------------- #
|
|
125
|
+
# Selectors
|
|
126
|
+
# --------------------------------------------------------------------------- #
|
|
127
|
+
def _parse_cp(s: str) -> int:
|
|
128
|
+
return int(s.replace("U+", "").replace("u+", ""), 16)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_selector(tok: str):
|
|
132
|
+
if tok.startswith(("U+", "u+")):
|
|
133
|
+
if ".." in tok:
|
|
134
|
+
a, b = tok.split("..", 1)
|
|
135
|
+
return UnicodeRange(_parse_cp(a), _parse_cp(b))
|
|
136
|
+
return Unicode(_parse_cp(tok))
|
|
137
|
+
if tok.startswith("{") and tok.endswith("}"):
|
|
138
|
+
return Category(tok[1:-1])
|
|
139
|
+
if "*" in tok or "?" in tok:
|
|
140
|
+
return Glob(tok)
|
|
141
|
+
return GlyphName(tok)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --------------------------------------------------------------------------- #
|
|
145
|
+
# Lines
|
|
146
|
+
# --------------------------------------------------------------------------- #
|
|
147
|
+
def _split_items(rhs: str) -> list[str]:
|
|
148
|
+
return [p.strip() for p in rhs.split(",") if p.strip()]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_dsl(lines) -> Document:
|
|
152
|
+
labels: dict[str, list[AnchorSpec]] = {}
|
|
153
|
+
rules: list = []
|
|
154
|
+
shift_x = 0
|
|
155
|
+
suffixes = [""]
|
|
156
|
+
extends: list[str] = []
|
|
157
|
+
|
|
158
|
+
raw_lines = []
|
|
159
|
+
for n, line in enumerate(lines, 1):
|
|
160
|
+
line = line.split("#", 1)[0].strip()
|
|
161
|
+
if not line:
|
|
162
|
+
continue
|
|
163
|
+
for stmt in line.split(";"):
|
|
164
|
+
stmt = stmt.strip()
|
|
165
|
+
if stmt:
|
|
166
|
+
raw_lines.append((n, stmt))
|
|
167
|
+
|
|
168
|
+
def parse_items(rhs: str, n: int) -> list:
|
|
169
|
+
# anchors and label refs, unresolved (labels are bound late, at apply)
|
|
170
|
+
items = []
|
|
171
|
+
for item in _split_items(rhs):
|
|
172
|
+
if item.startswith("@"):
|
|
173
|
+
items.append(LabelRef(item))
|
|
174
|
+
else:
|
|
175
|
+
try:
|
|
176
|
+
items.append(_parse_anchor(item))
|
|
177
|
+
except DSLError as e:
|
|
178
|
+
raise DSLError(f"line {n}: {e}")
|
|
179
|
+
return items
|
|
180
|
+
|
|
181
|
+
def parse_remove(rhs: str, n: int) -> list:
|
|
182
|
+
# '-=' takes anchor names (bare) or @labels to strip
|
|
183
|
+
targets = []
|
|
184
|
+
for item in _split_items(rhs):
|
|
185
|
+
if item.startswith("@"):
|
|
186
|
+
targets.append(LabelRef(item))
|
|
187
|
+
elif _NAME_RE.match(item):
|
|
188
|
+
targets.append(item)
|
|
189
|
+
else:
|
|
190
|
+
raise DSLError(f"line {n}: '-=' takes anchor names or @labels, got {item!r}")
|
|
191
|
+
return targets
|
|
192
|
+
|
|
193
|
+
for n, stmt in raw_lines:
|
|
194
|
+
if stmt.startswith("!"):
|
|
195
|
+
body = stmt[1:].strip()
|
|
196
|
+
if "=" in body:
|
|
197
|
+
name, _, value = body.partition("=")
|
|
198
|
+
name, value = name.strip(), value.strip()
|
|
199
|
+
else: # e.g. "!extends default"
|
|
200
|
+
head, _, rest = body.partition(" ")
|
|
201
|
+
name, value = head.strip(), rest.strip()
|
|
202
|
+
if name == "suffixes":
|
|
203
|
+
suffixes.extend((s.strip() if s.strip().startswith(".") else "." + s.strip())
|
|
204
|
+
for s in value.split(",") if s.strip())
|
|
205
|
+
elif name == "shiftx":
|
|
206
|
+
try:
|
|
207
|
+
shift_x = int(value)
|
|
208
|
+
except ValueError:
|
|
209
|
+
raise DSLError(f"line {n}: !shiftx needs an integer, got {value!r}")
|
|
210
|
+
elif name == "extends":
|
|
211
|
+
if not value:
|
|
212
|
+
raise DSLError(f"line {n}: !extends needs a base name or path")
|
|
213
|
+
extends.append(value)
|
|
214
|
+
else:
|
|
215
|
+
raise DSLError(f"line {n}: unknown directive !{name}")
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
m = _RULE_RE.match(stmt)
|
|
219
|
+
if not m:
|
|
220
|
+
raise DSLError(f"line {n}: missing '=' or '+=' in {stmt!r}")
|
|
221
|
+
lhs, op_tok, rhs = m.group(1).strip(), m.group(2), m.group(3).strip()
|
|
222
|
+
if not rhs:
|
|
223
|
+
raise DSLError(f"line {n}: empty right-hand side")
|
|
224
|
+
|
|
225
|
+
if lhs.startswith("@"):
|
|
226
|
+
if op_tok != "=":
|
|
227
|
+
raise DSLError(f"line {n}: labels only support '='")
|
|
228
|
+
labels[lhs] = parse_items(rhs, n)
|
|
229
|
+
else:
|
|
230
|
+
op = _OPS[op_tok]
|
|
231
|
+
items = parse_remove(rhs, n) if op is Op.REMOVE else parse_items(rhs, n)
|
|
232
|
+
rules.append((_parse_selector(lhs), op, items))
|
|
233
|
+
|
|
234
|
+
return Document(labels=labels, rules=rules, shift_x=shift_x,
|
|
235
|
+
suffixes=suffixes, extends=extends)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def parse_dsl_file(path: str) -> Document:
|
|
239
|
+
with open(path, encoding="utf-8") as f:
|
|
240
|
+
return parse_dsl(f.readlines())
|