gerberdiff 0.29.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.
Files changed (38) hide show
  1. gerberdiff/__init__.py +86 -0
  2. gerberdiff/cli.py +527 -0
  3. gerberdiff/diff/__init__.py +0 -0
  4. gerberdiff/diff/diff_engine.py +409 -0
  5. gerberdiff/diff/layer_matcher.py +202 -0
  6. gerberdiff/export/__init__.py +0 -0
  7. gerberdiff/export/json_report.py +184 -0
  8. gerberdiff/export/png_export.py +92 -0
  9. gerberdiff/export/svg_export.py +187 -0
  10. gerberdiff/geometry/__init__.py +37 -0
  11. gerberdiff/geometry/attribute.py +203 -0
  12. gerberdiff/geometry/driver.py +227 -0
  13. gerberdiff/geometry/expand.py +232 -0
  14. gerberdiff/geometry/geom_diff.py +153 -0
  15. gerberdiff/geometry/layer_geometry.py +665 -0
  16. gerberdiff/geometry/macro_geom.py +215 -0
  17. gerberdiff/geometry/primitives.py +108 -0
  18. gerberdiff/geometry/types.py +85 -0
  19. gerberdiff/parse/__init__.py +0 -0
  20. gerberdiff/parse/arc_math.py +162 -0
  21. gerberdiff/parse/excellon_parser.py +338 -0
  22. gerberdiff/parse/gerber_parser.py +244 -0
  23. gerberdiff/parse/gerber_state.py +780 -0
  24. gerberdiff/parse/macro_parser.py +604 -0
  25. gerberdiff/parse/tokenizer.py +153 -0
  26. gerberdiff/py.typed +0 -0
  27. gerberdiff/render/__init__.py +0 -0
  28. gerberdiff/render/compiled_render.py +240 -0
  29. gerberdiff/render/draw_ops.py +205 -0
  30. gerberdiff/render/macro_renderer.py +343 -0
  31. gerberdiff/render/renderer.py +283 -0
  32. gerberdiff/render/viewport.py +85 -0
  33. gerberdiff/types.py +360 -0
  34. gerberdiff-0.29.0.dist-info/METADATA +105 -0
  35. gerberdiff-0.29.0.dist-info/RECORD +38 -0
  36. gerberdiff-0.29.0.dist-info/WHEEL +4 -0
  37. gerberdiff-0.29.0.dist-info/entry_points.txt +2 -0
  38. gerberdiff-0.29.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,780 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from gerberdiff.parse.arc_math import (
7
+ arc_bounding_box,
8
+ compute_arc_multi_quadrant,
9
+ compute_arc_single_quadrant,
10
+ )
11
+ from gerberdiff.parse.gerber_parser import (
12
+ FormatStatement,
13
+ convert_coordinate,
14
+ parse_aperture_definition,
15
+ parse_format_statement,
16
+ )
17
+ from gerberdiff.parse.macro_parser import MacroDef, parse_macro_body
18
+ from gerberdiff.parse.tokenizer import TokenType, tokenize_gerber
19
+ from gerberdiff.types import (
20
+ Aperture,
21
+ ApertureState,
22
+ BlockAperture,
23
+ BoundingBox,
24
+ CircleAperture,
25
+ CoordinateMode,
26
+ CoordState,
27
+ Diagnostic,
28
+ DiagnosticSeverity,
29
+ DrawOp,
30
+ InterpolationMode,
31
+ LayerState,
32
+ MirrorState,
33
+ ObroundAperture,
34
+ ParsedImage,
35
+ Polarity,
36
+ PolygonAperture,
37
+ RectangleAperture,
38
+ RegionFill,
39
+ StepAndRepeat,
40
+ UnitType,
41
+ ZeroOmission,
42
+ )
43
+
44
+ # Default format statement used when no %FS...% is present in the file.
45
+ # FSLAX25Y25 is the most common real-world default.
46
+ _DEFAULT_FORMAT = FormatStatement(
47
+ zero_omission=ZeroOmission.Leading,
48
+ coordinate_mode=CoordinateMode.Absolute,
49
+ x_integer=2,
50
+ x_decimal=5,
51
+ y_integer=2,
52
+ y_decimal=5,
53
+ )
54
+
55
+ # Two-character prefix strings that begin a top-level extended command.
56
+ # Any EXTENDED token whose prefix is NOT in this set, when we're inside a
57
+ # macro definition, is treated as a macro body line.
58
+ _COMMAND_PREFIXES: frozenset[str] = frozenset(
59
+ [
60
+ "FS",
61
+ "MO",
62
+ "AD",
63
+ "AM",
64
+ "LP",
65
+ "LM",
66
+ "LR",
67
+ "LS",
68
+ "LN",
69
+ "SR",
70
+ "AB",
71
+ "TO",
72
+ "TA",
73
+ "TD",
74
+ "TF",
75
+ "IA",
76
+ "AS",
77
+ "MI",
78
+ "OF",
79
+ "SF",
80
+ ]
81
+ )
82
+
83
+
84
+ @dataclass
85
+ class _BlockFrame:
86
+ """Saved state for a single level of block-aperture nesting."""
87
+
88
+ d_code: int
89
+ block_ap: BlockAperture
90
+ saved_nets: list[DrawOp | RegionFill]
91
+ saved_layers: list[LayerState]
92
+ saved_apertures: dict[int, Aperture]
93
+ saved_bbox: BoundingBox
94
+ saved_layer_idx: int
95
+ saved_net_state_idx: int
96
+ saved_current_aperture: int
97
+ saved_aperture_state: ApertureState
98
+ saved_interpolation: InterpolationMode
99
+ saved_multi_quadrant: bool
100
+ saved_unit: UnitType
101
+ saved_macro_map: dict[str, MacroDef]
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Internal parser class
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ class _GerberParser:
110
+ """Stateful RS-274X parser. Instantiate once per file; call parse()."""
111
+
112
+ def __init__(self, source_path: Path | None) -> None:
113
+ # ---- accumulated output ----
114
+ self._fmt: FormatStatement = _DEFAULT_FORMAT
115
+ self._fmt_seen: bool = False
116
+ self._apertures: dict[int, Aperture] = {}
117
+ self._nets: list[DrawOp | RegionFill] = []
118
+ self._layers: list[LayerState] = [LayerState()]
119
+ self._net_states: list[CoordState] = [CoordState()]
120
+ self._bbox: BoundingBox = BoundingBox()
121
+ self._diagnostics: list[Diagnostic] = []
122
+ self._source_path = source_path
123
+
124
+ # ---- drawing cursor ----
125
+ self._prev_x: float = 0.0
126
+ self._prev_y: float = 0.0
127
+
128
+ # per-block raw coordinate storage (reset each END_OF_BLOCK)
129
+ self._raw_x_int: int = 0
130
+ self._raw_x_str: str = "0"
131
+ self._raw_y_int: int = 0
132
+ self._raw_y_str: str = "0"
133
+ self._raw_i_int: int = 0
134
+ self._raw_i_str: str = "0"
135
+ self._raw_j_int: int = 0
136
+ self._raw_j_str: str = "0"
137
+ self._x_in_block: bool = False
138
+ self._y_in_block: bool = False
139
+ self._i_in_block: bool = False
140
+ self._j_in_block: bool = False
141
+ self._coord_changed: bool = False
142
+
143
+ # ---- drawing state ----
144
+ self._current_aperture: int = 0
145
+ self._aperture_state: ApertureState = ApertureState.Off
146
+ self._interpolation: InterpolationMode = InterpolationMode.Linear
147
+ self._multi_quadrant: bool = False
148
+ self._in_region_fill: bool = False
149
+ self._region_start_layer_idx: int = 0
150
+ self._region_start_net_state_idx: int = 0
151
+ self._region_segments: list[DrawOp] = []
152
+ self._current_layer_idx: int = 0
153
+ self._current_net_state_idx: int = 0
154
+ self._unit: UnitType = UnitType.Inch
155
+ self._done: bool = False
156
+
157
+ # ---- macro assembly ----
158
+ self._macro_map: dict[str, MacroDef] = {}
159
+ self._macro_name: str | None = None
160
+ self._macro_lines: list[str] = []
161
+
162
+ # ---- object / aperture attributes ----
163
+ self._net_attrs: dict[str, str] = {}
164
+ self._aperture_attrs: dict[str, str] = {}
165
+
166
+ # ---- block aperture stack ----
167
+ # Each frame saves state so that when %AB*% closes the block the
168
+ # parent drawing context is fully restored.
169
+ self._block_stack: list[_BlockFrame] = []
170
+
171
+ # ------------------------------------------------------------------
172
+ # Small helpers
173
+ # ------------------------------------------------------------------
174
+
175
+ def _warn(self, msg: str, line: int | None = None) -> None:
176
+ self._diagnostics.append(Diagnostic(DiagnosticSeverity.Warning, msg, line))
177
+
178
+ def _error(self, msg: str, line: int | None = None) -> None:
179
+ self._diagnostics.append(Diagnostic(DiagnosticSeverity.Error, msg, line))
180
+
181
+ def _info(self, msg: str, line: int | None = None) -> None:
182
+ self._diagnostics.append(Diagnostic(DiagnosticSeverity.Info, msg, line))
183
+
184
+ def _current_layer(self) -> LayerState:
185
+ return self._layers[self._current_layer_idx]
186
+
187
+ def _convert_x(self, raw_int: int, raw_str: str) -> float:
188
+ return convert_coordinate(
189
+ raw_int,
190
+ raw_str,
191
+ self._fmt.x_integer,
192
+ self._fmt.x_decimal,
193
+ self._fmt.zero_omission,
194
+ self._unit,
195
+ )
196
+
197
+ def _convert_y(self, raw_int: int, raw_str: str) -> float:
198
+ return convert_coordinate(
199
+ raw_int,
200
+ raw_str,
201
+ self._fmt.y_integer,
202
+ self._fmt.y_decimal,
203
+ self._fmt.zero_omission,
204
+ self._unit,
205
+ )
206
+
207
+ def _aperture_radius(self) -> float:
208
+ ap = self._apertures.get(self._current_aperture)
209
+ if ap is None:
210
+ return 0.0
211
+ if isinstance(ap, CircleAperture):
212
+ return ap.diameter / 2.0
213
+ if isinstance(ap, (RectangleAperture, ObroundAperture)):
214
+ return max(ap.width, ap.height) / 2.0
215
+ if isinstance(ap, PolygonAperture):
216
+ return ap.outer_diameter / 2.0
217
+ # MacroAperture, BlockAperture: conservative -- renderer computes exact bbox
218
+ return 0.0
219
+
220
+ def _flush_macro(self) -> None:
221
+ if self._macro_name is None:
222
+ return
223
+ body = "*".join(self._macro_lines)
224
+ try:
225
+ mdef = parse_macro_body(self._macro_name, body)
226
+ self._macro_map[self._macro_name] = mdef
227
+ except Exception as exc:
228
+ self._error(f"Macro parse failed for {self._macro_name!r}: {exc}")
229
+ self._macro_name = None
230
+ self._macro_lines = []
231
+
232
+ def _reset_block(self) -> None:
233
+ """Reset per-block state after END_OF_BLOCK."""
234
+ self._x_in_block = False
235
+ self._y_in_block = False
236
+ self._i_in_block = False
237
+ self._j_in_block = False
238
+ self._coord_changed = False
239
+
240
+ # ------------------------------------------------------------------
241
+ # Net emission
242
+ # ------------------------------------------------------------------
243
+
244
+ def _emit_net(self) -> None:
245
+ fmt = self._fmt
246
+
247
+ # Resolve stop position (use prev if coordinate not updated this block)
248
+ if self._x_in_block:
249
+ stop_x = self._convert_x(self._raw_x_int, self._raw_x_str)
250
+ if fmt.coordinate_mode == CoordinateMode.Incremental:
251
+ stop_x += self._prev_x
252
+ else:
253
+ stop_x = self._prev_x
254
+
255
+ if self._y_in_block:
256
+ stop_y = self._convert_y(self._raw_y_int, self._raw_y_str)
257
+ if fmt.coordinate_mode == CoordinateMode.Incremental:
258
+ stop_y += self._prev_y
259
+ else:
260
+ stop_y = self._prev_y
261
+
262
+ # Arc centre offsets (I uses X format, J uses Y format per RS-274X spec)
263
+ arc_i = self._convert_x(self._raw_i_int, self._raw_i_str) if self._i_in_block else 0.0
264
+ arc_j = self._convert_y(self._raw_j_int, self._raw_j_str) if self._j_in_block else 0.0
265
+
266
+ # Compute arc geometry when drawing in arc mode
267
+ arc_segment = None
268
+ if self._aperture_state == ApertureState.On and self._interpolation in (
269
+ InterpolationMode.ClockwiseCircular,
270
+ InterpolationMode.CounterClockwiseCircular,
271
+ ):
272
+ clockwise = self._interpolation == InterpolationMode.ClockwiseCircular
273
+ if self._multi_quadrant:
274
+ arc_segment = compute_arc_multi_quadrant(
275
+ self._prev_x,
276
+ self._prev_y,
277
+ stop_x,
278
+ stop_y,
279
+ arc_i,
280
+ arc_j,
281
+ clockwise,
282
+ )
283
+ else:
284
+ arc_segment = compute_arc_single_quadrant(
285
+ self._prev_x,
286
+ self._prev_y,
287
+ stop_x,
288
+ stop_y,
289
+ arc_i,
290
+ arc_j,
291
+ clockwise,
292
+ )
293
+
294
+ net = DrawOp(
295
+ start_x=self._prev_x,
296
+ start_y=self._prev_y,
297
+ stop_x=stop_x,
298
+ stop_y=stop_y,
299
+ aperture_index=self._current_aperture,
300
+ aperture_state=self._aperture_state,
301
+ interpolation=self._interpolation,
302
+ layer_index=self._current_layer_idx,
303
+ net_state_index=self._current_net_state_idx,
304
+ arc_segment=arc_segment,
305
+ attributes=dict(self._net_attrs) if self._net_attrs else None,
306
+ )
307
+ if self._in_region_fill:
308
+ self._region_segments.append(net)
309
+ else:
310
+ self._nets.append(net)
311
+
312
+ # Expand bounding box
313
+ r = self._aperture_radius()
314
+ if arc_segment is not None:
315
+ ab = arc_bounding_box(arc_segment, r)
316
+ self._bbox.expand(ab.min_x, ab.min_y)
317
+ self._bbox.expand(ab.max_x, ab.max_y)
318
+ else:
319
+ self._bbox.expand(stop_x, stop_y, r)
320
+ if self._aperture_state == ApertureState.On:
321
+ self._bbox.expand(self._prev_x, self._prev_y, r)
322
+
323
+ # Also expand for all step-and-repeat instances of the current layer.
324
+ sr = self._current_layer().step_and_repeat
325
+ if sr.x > 1 or sr.y > 1:
326
+ for ix in range(sr.x):
327
+ for iy in range(sr.y):
328
+ if ix == 0 and iy == 0:
329
+ continue # already handled above
330
+ ox, oy = ix * sr.dist_x, iy * sr.dist_y
331
+ self._bbox.expand(stop_x + ox, stop_y + oy, r)
332
+ if self._aperture_state == ApertureState.On:
333
+ self._bbox.expand(self._prev_x + ox, self._prev_y + oy, r)
334
+
335
+ self._prev_x = stop_x
336
+ self._prev_y = stop_y
337
+
338
+ # ------------------------------------------------------------------
339
+ # Token handlers
340
+ # ------------------------------------------------------------------
341
+
342
+ def _handle_g_code(self, value: int, line: int) -> None:
343
+ if value == 1:
344
+ self._interpolation = InterpolationMode.Linear
345
+ elif value == 2:
346
+ self._interpolation = InterpolationMode.ClockwiseCircular
347
+ elif value == 3:
348
+ self._interpolation = InterpolationMode.CounterClockwiseCircular
349
+ elif value == 36:
350
+ self._in_region_fill = True
351
+ self._region_start_layer_idx = self._current_layer_idx
352
+ self._region_start_net_state_idx = self._current_net_state_idx
353
+ self._region_segments = []
354
+ elif value == 37:
355
+ self._in_region_fill = False
356
+ self._nets.append(
357
+ RegionFill(
358
+ layer_index=self._region_start_layer_idx,
359
+ net_state_index=self._region_start_net_state_idx,
360
+ segments=self._region_segments,
361
+ )
362
+ )
363
+ self._region_segments = []
364
+ elif value in (54, 55, 70, 71):
365
+ # 54/55: deprecated aperture select/flash -- ignore
366
+ # 70/71: deprecated inch/mm (should use MO instead) -- update unit
367
+ if value == 70:
368
+ self._unit = UnitType.Inch
369
+ elif value == 71:
370
+ self._unit = UnitType.Millimeter
371
+ elif value == 74:
372
+ self._multi_quadrant = False
373
+ elif value == 75:
374
+ self._multi_quadrant = True
375
+ elif value == 90:
376
+ self._fmt = FormatStatement(
377
+ zero_omission=self._fmt.zero_omission,
378
+ coordinate_mode=CoordinateMode.Absolute,
379
+ x_integer=self._fmt.x_integer,
380
+ x_decimal=self._fmt.x_decimal,
381
+ y_integer=self._fmt.y_integer,
382
+ y_decimal=self._fmt.y_decimal,
383
+ )
384
+ elif value == 91:
385
+ self._fmt = FormatStatement(
386
+ zero_omission=self._fmt.zero_omission,
387
+ coordinate_mode=CoordinateMode.Incremental,
388
+ x_integer=self._fmt.x_integer,
389
+ x_decimal=self._fmt.x_decimal,
390
+ y_integer=self._fmt.y_integer,
391
+ y_decimal=self._fmt.y_decimal,
392
+ )
393
+ else:
394
+ self._warn(f"Unknown G code G{value:02d}", line)
395
+
396
+ def _handle_d_code(self, value: int, line: int) -> None:
397
+ if value == 1:
398
+ self._aperture_state = ApertureState.On
399
+ self._coord_changed = True
400
+ elif value == 2:
401
+ self._aperture_state = ApertureState.Off
402
+ self._coord_changed = True
403
+ elif value == 3:
404
+ self._aperture_state = ApertureState.Flash
405
+ self._coord_changed = True
406
+ elif value >= 10:
407
+ self._current_aperture = value
408
+ else:
409
+ self._warn(f"Unknown D code D{value:02d}", line)
410
+
411
+ def _handle_extended(self, body: str, line: int) -> None:
412
+ prefix = body[:2].upper()
413
+
414
+ # When accumulating a macro body, all unrecognised token bodies are
415
+ # macro primitive / assignment lines. Any top-level command ends it.
416
+ if self._macro_name is not None:
417
+ if prefix not in _COMMAND_PREFIXES:
418
+ self._macro_lines.append(body)
419
+ return
420
+ # Recognised command -- flush macro first, then dispatch normally
421
+ self._flush_macro()
422
+
423
+ if prefix == "FS":
424
+ fs = parse_format_statement(body)
425
+ if fs is None:
426
+ self._warn(f"Could not parse format statement: {body!r}", line)
427
+ else:
428
+ self._fmt = fs
429
+ self._fmt_seen = True
430
+
431
+ elif prefix == "MO":
432
+ code = body[2:4].upper()
433
+ if code == "IN":
434
+ self._unit = UnitType.Inch
435
+ elif code == "MM":
436
+ self._unit = UnitType.Millimeter
437
+ # Push a NetState capturing the unit change
438
+ self._net_states.append(CoordState(unit=self._unit))
439
+ self._current_net_state_idx = len(self._net_states) - 1
440
+
441
+ elif prefix == "AD":
442
+ result = parse_aperture_definition(body, self._unit, self._macro_map)
443
+ if result is None:
444
+ self._warn(f"Could not parse aperture definition: {body!r}", line)
445
+ elif isinstance(result, str):
446
+ # result == "MACRO_NOT_FOUND:<name>" -- the aperture is permanently absent
447
+ macro_name = result.split(":", 1)[1] if ":" in result else result
448
+ self._error(f"Aperture definition references undefined macro {macro_name!r}", line)
449
+ else:
450
+ d_code, aperture = result
451
+ self._apertures[d_code] = aperture
452
+ self._aperture_attrs = {} # aperture attributes consumed
453
+
454
+ elif prefix == "AM":
455
+ # Start a new macro definition
456
+ name = body[2:].strip()
457
+ if name:
458
+ self._macro_name = name
459
+ self._macro_lines = []
460
+
461
+ elif prefix == "LP":
462
+ code = body[2:3].upper()
463
+ polarity = Polarity.Clear if code == "C" else Polarity.Dark
464
+ prev = self._current_layer()
465
+ new_layer = LayerState(
466
+ polarity=polarity,
467
+ rotation=prev.rotation,
468
+ mirror=prev.mirror,
469
+ scale=prev.scale,
470
+ name=prev.name,
471
+ )
472
+ self._layers.append(new_layer)
473
+ self._current_layer_idx = len(self._layers) - 1
474
+
475
+ elif prefix == "LM":
476
+ code = body[2:].strip().upper()
477
+ mirror = {
478
+ "N": MirrorState.None_,
479
+ "X": MirrorState.FlipA,
480
+ "Y": MirrorState.FlipB,
481
+ "XY": MirrorState.FlipAB,
482
+ }.get(code, MirrorState.None_)
483
+ self._current_layer().mirror = mirror
484
+
485
+ elif prefix == "LR":
486
+ try:
487
+ self._current_layer().rotation = float(body[2:])
488
+ except ValueError:
489
+ self._warn(f"Invalid LR value: {body!r}", line)
490
+
491
+ elif prefix == "LS":
492
+ try:
493
+ self._current_layer().scale = float(body[2:])
494
+ except ValueError:
495
+ self._warn(f"Invalid LS value: {body!r}", line)
496
+
497
+ elif prefix == "LN":
498
+ self._current_layer().name = body[2:]
499
+
500
+ elif prefix == "SR":
501
+ self._handle_sr(body[2:], line)
502
+
503
+ elif prefix == "AB":
504
+ self._handle_ab(body[2:], line)
505
+
506
+ elif prefix == "TO":
507
+ # Object attribute: %TO.<name>,<value>*%
508
+ rest = body[2:]
509
+ if rest.startswith("."):
510
+ comma = rest.find(",")
511
+ if comma > 0:
512
+ self._net_attrs[rest[1:comma]] = rest[comma + 1 :]
513
+
514
+ elif prefix == "TA":
515
+ # Aperture attribute
516
+ rest = body[2:]
517
+ if rest.startswith("."):
518
+ comma = rest.find(",")
519
+ if comma > 0:
520
+ self._aperture_attrs[rest[1:comma]] = rest[comma + 1 :]
521
+
522
+ elif prefix == "TD":
523
+ # Delete attribute(s)
524
+ name = body[2:].strip()
525
+ if name:
526
+ self._net_attrs.pop(name.lstrip("."), None)
527
+ self._aperture_attrs.pop(name.lstrip("."), None)
528
+ else:
529
+ self._net_attrs.clear()
530
+ self._aperture_attrs.clear()
531
+
532
+ elif prefix == "TF":
533
+ pass # File attribute -- informational, ignored
534
+
535
+ elif prefix in ("IA", "AS", "MI", "OF", "SF"):
536
+ self._info(f"Deprecated RS-274X command {prefix!r} ignored; transforms not applied")
537
+
538
+ else:
539
+ self._warn(f"Unknown extended command prefix {prefix!r}", line)
540
+
541
+ def _handle_ab(self, params: str, line: int) -> None:
542
+ """Open or close a block aperture definition.
543
+
544
+ ``%ABD<n>*%`` opens a block for D-code *n*; ``%AB*%`` closes it.
545
+ Nesting is supported up to depth 10 (matches the reference tool).
546
+ """
547
+ body = params.strip()
548
+ if body:
549
+ # Open: %ABD<n>*%
550
+ if not body.upper().startswith("D"):
551
+ self._warn(f"Invalid aperture block spec: {body!r}", line)
552
+ return
553
+ try:
554
+ d_code = int(body[1:])
555
+ except ValueError:
556
+ self._warn(f"Invalid aperture block D-code: {body!r}", line)
557
+ return
558
+ if d_code < 10:
559
+ self._warn(f"Invalid aperture block D-code: D{d_code} (must be >=10)", line)
560
+ return
561
+ if len(self._block_stack) >= 10:
562
+ self._warn("Aperture block nesting too deep (max 10)", line)
563
+ return
564
+
565
+ block_ap = BlockAperture()
566
+
567
+ # Save parent state and redirect emission into the block.
568
+ self._block_stack.append(
569
+ _BlockFrame(
570
+ d_code=d_code,
571
+ block_ap=block_ap,
572
+ saved_nets=self._nets,
573
+ saved_layers=self._layers,
574
+ saved_apertures=self._apertures,
575
+ saved_bbox=self._bbox,
576
+ saved_layer_idx=self._current_layer_idx,
577
+ saved_net_state_idx=self._current_net_state_idx,
578
+ saved_current_aperture=self._current_aperture,
579
+ saved_aperture_state=self._aperture_state,
580
+ saved_interpolation=self._interpolation,
581
+ saved_multi_quadrant=self._multi_quadrant,
582
+ saved_unit=self._unit,
583
+ saved_macro_map=self._macro_map,
584
+ )
585
+ )
586
+
587
+ # Block gets a fresh single-layer state and an empty bbox.
588
+ block_ap.layers.append(LayerState())
589
+ self._nets = block_ap.draw_ops
590
+ self._layers = block_ap.layers
591
+ self._current_layer_idx = 0
592
+ # Copy parent apertures so the block can reference them.
593
+ self._apertures = dict(self._apertures)
594
+ self._bbox = BoundingBox()
595
+ # Reset drawing cursor state to safe defaults inside the block.
596
+ self._current_aperture = 0
597
+ self._aperture_state = ApertureState.Off
598
+ self._interpolation = InterpolationMode.Linear
599
+ self._multi_quadrant = False
600
+ # Block gets a copy of the macro map; new macros defined inside
601
+ # the block do not leak back to the parent.
602
+ self._macro_map = dict(self._macro_map)
603
+
604
+ else:
605
+ # Close: %AB*%
606
+ if not self._block_stack:
607
+ self._warn("Unexpected AB close without matching open", line)
608
+ return
609
+ frame = self._block_stack.pop()
610
+
611
+ # Capture the block's accumulated state.
612
+ frame.block_ap.apertures = self._apertures
613
+ frame.block_ap.bounding_box = self._bbox
614
+
615
+ # Restore parent state.
616
+ self._nets = frame.saved_nets
617
+ self._layers = frame.saved_layers
618
+ self._apertures = frame.saved_apertures
619
+ self._bbox = frame.saved_bbox
620
+ self._current_layer_idx = frame.saved_layer_idx
621
+ self._current_net_state_idx = frame.saved_net_state_idx
622
+ self._current_aperture = frame.saved_current_aperture
623
+ self._aperture_state = frame.saved_aperture_state
624
+ self._interpolation = frame.saved_interpolation
625
+ self._multi_quadrant = frame.saved_multi_quadrant
626
+ self._unit = frame.saved_unit
627
+ self._macro_map = frame.saved_macro_map
628
+
629
+ # Register the completed block aperture in the parent aperture dict.
630
+ self._apertures[frame.d_code] = frame.block_ap
631
+
632
+ def _handle_sr(self, params: str, line: int) -> None:
633
+ """Handle the SR body after stripping the 'SR' prefix."""
634
+ if not params.strip():
635
+ # Close SR block -- push a new layer that copies the parent's
636
+ # polarity/rotation/mirror/scale/name but resets step_and_repeat.
637
+ prev = self._current_layer()
638
+ new_layer = LayerState(
639
+ polarity=prev.polarity,
640
+ rotation=prev.rotation,
641
+ mirror=prev.mirror,
642
+ scale=prev.scale,
643
+ name=prev.name,
644
+ # step_and_repeat intentionally left at default (1, 1, 0, 0)
645
+ )
646
+ self._layers.append(new_layer)
647
+ self._current_layer_idx = len(self._layers) - 1
648
+ return
649
+
650
+ # Parse SRX<count>Y<count>I<step>J<step>
651
+ x_count = y_count = 1
652
+ step_x = step_y = 0.0
653
+ s = params.strip()
654
+ pos = 0
655
+ while pos < len(s):
656
+ letter = s[pos].upper()
657
+ if letter not in "XYIJ":
658
+ pos += 1
659
+ continue
660
+ pos += 1
661
+ j = pos
662
+ while j < len(s) and s[j] not in "XYIJxyij":
663
+ j += 1
664
+ try:
665
+ val = float(s[pos:j])
666
+ if letter == "X":
667
+ x_count = max(1, int(val))
668
+ elif letter == "Y":
669
+ y_count = max(1, int(val))
670
+ elif letter == "I":
671
+ step_x = val / 25.4 if self._unit == UnitType.Millimeter else val
672
+ elif letter == "J":
673
+ step_y = val / 25.4 if self._unit == UnitType.Millimeter else val
674
+ except ValueError:
675
+ self._warn(f"Invalid SR parameter {letter}={s[pos:j]!r}", line)
676
+ pos = j
677
+
678
+ self._current_layer().step_and_repeat = StepAndRepeat(
679
+ x=x_count,
680
+ y=y_count,
681
+ dist_x=step_x,
682
+ dist_y=step_y,
683
+ )
684
+
685
+ # ------------------------------------------------------------------
686
+ # Main parse loop
687
+ # ------------------------------------------------------------------
688
+
689
+ def parse(self, content: str) -> ParsedImage:
690
+ for token in tokenize_gerber(content):
691
+ if self._done:
692
+ break
693
+ tt = token.type
694
+ line = token.line
695
+
696
+ if tt == TokenType.G:
697
+ if isinstance(token.value, int):
698
+ self._handle_g_code(token.value, line)
699
+
700
+ elif tt == TokenType.D:
701
+ if isinstance(token.value, int):
702
+ self._handle_d_code(token.value, line)
703
+
704
+ elif tt == TokenType.M:
705
+ if isinstance(token.value, int) and token.value == 2:
706
+ if self._in_region_fill:
707
+ self._warn("Region fill not closed at end of file", line)
708
+ self._done = True
709
+ break
710
+
711
+ elif tt == TokenType.X:
712
+ if isinstance(token.value, int):
713
+ self._raw_x_int = token.value
714
+ self._raw_x_str = token.raw or str(token.value)
715
+ self._x_in_block = True
716
+ self._coord_changed = True
717
+
718
+ elif tt == TokenType.Y:
719
+ if isinstance(token.value, int):
720
+ self._raw_y_int = token.value
721
+ self._raw_y_str = token.raw or str(token.value)
722
+ self._y_in_block = True
723
+ self._coord_changed = True
724
+
725
+ elif tt == TokenType.I:
726
+ if isinstance(token.value, int):
727
+ self._raw_i_int = token.value
728
+ self._raw_i_str = token.raw or str(token.value)
729
+ self._i_in_block = True
730
+
731
+ elif tt == TokenType.J:
732
+ if isinstance(token.value, int):
733
+ self._raw_j_int = token.value
734
+ self._raw_j_str = token.raw or str(token.value)
735
+ self._j_in_block = True
736
+
737
+ elif tt == TokenType.END_OF_BLOCK:
738
+ if self._coord_changed:
739
+ self._emit_net()
740
+ self._reset_block()
741
+
742
+ elif tt == TokenType.EXTENDED:
743
+ if isinstance(token.value, str):
744
+ self._handle_extended(token.value, line)
745
+
746
+ elif tt == TokenType.EOF:
747
+ if self._in_region_fill:
748
+ self._warn("Region fill not closed at end of file", line)
749
+ break
750
+
751
+ # Final cleanup
752
+ self._flush_macro()
753
+
754
+ if not self._fmt_seen:
755
+ self._info("No format statement found; using default FSLAX25Y25")
756
+
757
+ return ParsedImage(
758
+ draw_ops=self._nets,
759
+ apertures=self._apertures,
760
+ layers=self._layers,
761
+ coord_states=self._net_states,
762
+ bounding_box=self._bbox,
763
+ diagnostics=self._diagnostics,
764
+ source_path=self._source_path,
765
+ )
766
+
767
+
768
+ # ---------------------------------------------------------------------------
769
+ # Public API
770
+ # ---------------------------------------------------------------------------
771
+
772
+
773
+ def parse_gerber(content: str, source_path: Path | None = None) -> ParsedImage:
774
+ """Parse a Gerber RS-274X file string into a ParsedImage.
775
+
776
+ All diagnostics (errors, warnings, info) are collected on
777
+ ``image.diagnostics``. This function never raises for parse-level
778
+ problems; only genuine Python-level exceptions (e.g. MemoryError) propagate.
779
+ """
780
+ return _GerberParser(source_path).parse(content)