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.
@@ -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))
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())