Coverage for klayout_pex/pex25d/reader.py: 88%

572 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-17 19:08 +0000

1# 

2# -------------------------------------------------------------------------------- 

3# SPDX-FileCopyrightText: 2024-2026 Martin Jan Köhler and Harald Pretl 

4# Johannes Kepler University, Institute for Integrated Circuits. 

5# 

6# This file is part of KPEX  

7# (see https://github.com/iic-jku/klayout-pex). 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <http://www.gnu.org/licenses/>. 

21# SPDX-License-Identifier: GPL-3.0-or-later 

22# -------------------------------------------------------------------------------- 

23# 

24 

25"""PEX25D text format reader.""" 

26 

27from __future__ import annotations 

28 

29import os 

30import re 

31from dataclasses import dataclass, field 

32from fractions import Fraction 

33from typing import * 

34 

35from .diagnostics import Diagnostic, DiagnosticsReport, Severity, SourceRef, Tier 

36from .format_version import FORMAT_VERSION_MAJOR 

37from .protobuf import ( 

38 pex25d_dielectric_pb2, 

39 pex25d_file_pb2, 

40 pex25d_terminal_pb2, 

41) 

42 

43STDIO_NAME = '-' 

44 

45# Decimal or scientific notation, optional leading sign, '.5' and '5.' both 

46# legal. Deliberately stricter than Fraction(str), which would also accept 

47# ratios like '1/2' and digit separators like '1_0'. 

48NUMBER = re.compile(r'[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\Z') 

49 

50VERSION = re.compile(r'(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?\Z') 

51 

52LENGTH_UNITS = {'um': 1, 'nm': 2, 'm': 3} 

53 

54# A clause keyword appearing where a top-level record is expected nearly always 

55# means a dropped '\' continuation. Skipping it would silently build a different 

56# scene, so it is an error rather than an unknown record. 

57CLAUSE_KEYWORDS = { 

58 'WRAPS', 'PERMITTIVITY', 'BETWEEN', 'CONNECTS', 'Z_OFFSETS', 'LAYER', 

59 'LL', 'UR', 'OUTER', 'HOLE', 'X', 'Y', 'Z', 'SHEET', 'PER_CUT', 

60 'TC1', 'TC2', 'KIND', 'TEMPERATURE', 

61 'THICKNESS_OVER_WRAPPED', 'THICKNESS_BESIDE_WRAPPED', 'THICKNESS_ON_FIELD', 

62} 

63 

64TERMINAL_KINDS = {'PIN': 1, 'DEVICE_TERMINAL': 2} 

65 

66 

67class ReadError(Exception): 

68 """The file could not be read; the report carries the reasons.""" 

69 

70 

71@dataclass 

72class Record: 

73 """One logical record: a physical line, plus whatever '\\' continued it.""" 

74 

75 tokens: List[str] 

76 file: str 

77 line: int 

78 

79 @property 

80 def head(self) -> str: 

81 return self.tokens[0] 

82 

83 def ref(self) -> SourceRef: 

84 return SourceRef(file=self.file, line=self.line) 

85 

86 

87class Cursor: 

88 """Walks a record's tokens, reporting what it expected when they run out.""" 

89 

90 def __init__(self, record: Record, reader: 'Pex25DTextReader'): 

91 self.record = record 

92 self.reader = reader 

93 self.position = 1 # token 0 is the record keyword 

94 

95 @property 

96 def exhausted(self) -> bool: 

97 return self.position >= len(self.record.tokens) 

98 

99 def peek(self) -> Optional[str]: 

100 return None if self.exhausted else self.record.tokens[self.position] 

101 

102 def take(self, expected: str) -> Optional[str]: 

103 if self.exhausted: 

104 self.reader.error('PEX25D-E0101', 

105 f"{self.record.head}: expected {expected}, but the " 

106 f"record ends", self.record) 

107 return None 

108 value = self.record.tokens[self.position] 

109 self.position += 1 

110 return value 

111 

112 def keyword(self, *allowed: str) -> Optional[str]: 

113 value = self.take(' or '.join(allowed)) 

114 if value is None: 

115 return None 

116 if value not in allowed: 

117 self.reader.error('PEX25D-E0102', 

118 f"{self.record.head}: expected {' or '.join(allowed)}, " 

119 f"found '{value}'", self.record) 

120 return None 

121 return value 

122 

123 def grid(self, what: str) -> Optional[int]: 

124 value = self.take(what) 

125 return None if value is None else self.reader.to_grid(value, self.record, what) 

126 

127 def number(self, what: str) -> Optional[float]: 

128 value = self.take(what) 

129 return None if value is None else self.reader.to_double(value, self.record, what) 

130 

131 def end(self) -> None: 

132 if not self.exhausted: 

133 extra = ' '.join(self.record.tokens[self.position:]) 

134 self.reader.error('PEX25D-E0103', 

135 f"{self.record.head}: unexpected trailing tokens " 

136 f"'{extra}'", self.record) 

137 

138 

139def split_records(text: str, filename: str) -> Iterator[Record]: 

140 """ 

141 Turn UTF-8 text into logical records. 

142 

143 Comments run from an unquoted '#' to the end of the line; a trailing '\\' 

144 continues a record onto the next line. A record's reported line is the one 

145 its first token is on, which is what a diagnostic should point at. 

146 """ 

147 pending: List[str] = [] 

148 start_line = 0 

149 

150 for number, raw in enumerate(text.splitlines(), start=1): 

151 line, continued = strip_comment(raw) 

152 if not pending: 

153 start_line = number 

154 pending.append(line) 

155 if continued: 

156 continue 

157 

158 tokens = tokenize(' '.join(pending)) 

159 pending = [] 

160 if tokens: 

161 yield Record(tokens=tokens, file=filename, line=start_line) 

162 

163 if pending: 

164 tokens = tokenize(' '.join(pending)) 

165 if tokens: 

166 yield Record(tokens=tokens, file=filename, line=start_line) 

167 

168 

169def strip_comment(line: str) -> Tuple[str, bool]: 

170 """Remove an unquoted comment; report whether the record continues.""" 

171 out: List[str] = [] 

172 quoted = False 

173 for char in line: 

174 if char == '"': 

175 quoted = not quoted 

176 elif char == '#' and not quoted: 

177 break 

178 out.append(char) 

179 text = ''.join(out).rstrip() 

180 if text.endswith('\\'): 

181 return text[:-1], True 

182 return text, False 

183 

184 

185def tokenize(line: str) -> List[str]: 

186 """Whitespace-separated tokens, with a double-quoted run kept as one.""" 

187 tokens: List[str] = [] 

188 current: List[str] = [] 

189 quoted = False 

190 has_token = False 

191 

192 for char in line: 

193 if char == '"': 

194 quoted = not quoted 

195 has_token = True 

196 elif char.isspace() and not quoted: 

197 if has_token: 

198 tokens.append(''.join(current)) 

199 current, has_token = [], False 

200 else: 

201 current.append(char) 

202 has_token = True 

203 

204 if has_token: 

205 tokens.append(''.join(current)) 

206 return tokens 

207 

208 

209class Pex25DTextReader: 

210 def __init__(self, 

211 report: Optional[DiagnosticsReport] = None, 

212 with_source_refs: bool = False): 

213 self.report = report if report is not None else DiagnosticsReport() 

214 self.with_source_refs = with_source_refs 

215 self.errors = 0 

216 

217 self.file = pex25d_file_pb2().PEX25DFile() 

218 self.grid: Optional[Fraction] = None 

219 self.seen_header = False 

220 self.meta_keys: Dict[str, str] = {} 

221 self.include_stack: List[str] = [] 

222 

223 # ---------------------------------------------------------- diagnostics 

224 

225 def diagnose(self, code: str, message: str, record: Optional[Record], 

226 tier: Tier, severity: Severity) -> None: 

227 if severity == Severity.ERROR: 

228 self.errors += 1 

229 self.report.add(Diagnostic(code=code, severity=severity, tier=tier, 

230 message=message, 

231 source=record.ref() if record else None)) 

232 

233 def error(self, code: str, message: str, record: Optional[Record] = None, 

234 tier: Tier = Tier.SYNTAX) -> None: 

235 self.diagnose(code, message, record, tier, Severity.ERROR) 

236 

237 def warn(self, code: str, message: str, record: Optional[Record] = None, 

238 tier: Tier = Tier.SYNTAX) -> None: 

239 self.diagnose(code, message, record, tier, Severity.WARNING) 

240 

241 def source_of(self, record: Record) -> Optional[Any]: 

242 if not self.with_source_refs: 

243 return None 

244 from .protobuf import pex25d_source_ref_pb2 

245 ref = pex25d_source_ref_pb2().SourceRef() 

246 ref.file = record.file 

247 ref.line = record.line 

248 return ref 

249 

250 def attach_source(self, message: Any, record: Record) -> None: 

251 source = self.source_of(record) 

252 if source is not None: 

253 message.source.CopyFrom(source) 

254 

255 # -------------------------------------------------------------- numbers 

256 

257 def to_fraction(self, text: str, record: Record, what: str) -> Optional[Fraction]: 

258 if not NUMBER.match(text): 

259 self.error('PEX25D-E0104', 

260 f"{what}: '{text}' is not a numeric literal", record) 

261 return None 

262 return Fraction(text) 

263 

264 def to_grid(self, text: str, record: Record, what: str) -> Optional[int]: 

265 """ 

266 Convert a literal into an integer count of grid units. 

267 

268 Divide, round, compare — never a modulo. In binary floating point 

269 `0.3262 % 0.0001` is `9.9999999999e-05`, and an exact-modulo check would 

270 reject legal files. Here the arithmetic is exact rational anyway, and 

271 the 1e-6 window is what the specification asks for. 

272 """ 

273 if self.grid is None: 

274 self.error('PEX25D-E0106', 

275 f"{what}: UNITS must precede any record containing a number", 

276 record) 

277 return None 

278 value = self.to_fraction(text, record, what) 

279 if value is None: 

280 return None 

281 quotient = value / self.grid 

282 rounded = round(quotient) 

283 if abs(quotient - rounded) > Fraction(1, 10 ** 6): 

284 self.error('PEX25D-E0105', 

285 f"{what}: {text} is not an integer multiple of the grid", 

286 record) 

287 return None 

288 return int(rounded) 

289 

290 def to_double(self, text: str, record: Record, what: str) -> Optional[float]: 

291 """Permittivities, resistances and temperatures are not grid-quantized.""" 

292 value = self.to_fraction(text, record, what) 

293 return None if value is None else float(value) 

294 

295 # --------------------------------------------------------------- driving 

296 

297 def read(self, data: bytes, source_name: Optional[str]) -> Any: 

298 filename = source_name or STDIO_NAME 

299 # The top-level file goes on the include stack too, so that a file 

300 # including its way back to it is caught there rather than one level on. 

301 if filename != STDIO_NAME: 

302 self.include_stack.append(os.path.normpath(os.path.abspath(filename))) 

303 self.consume(data.decode('utf-8'), filename, top_level=True) 

304 

305 if not self.seen_header: 

306 self.error('PEX25D-E0107', "The file does not start with a PEX25D record") 

307 if self.errors: 

308 raise ReadError(f"{self.errors} error(s) while reading the file") 

309 return self.file 

310 

311 def consume(self, text: str, filename: str, top_level: bool = False) -> None: 

312 for record in split_records(text, filename): 

313 if top_level and not self.seen_header and record.head != 'PEX25D': 

314 self.error('PEX25D-E0107', 

315 f"PEX25D must be the first record; found " 

316 f"'{record.head}'", record) 

317 self.seen_header = True # report it once 

318 

319 handler = self.HANDLERS.get(record.head) 

320 if handler is not None: 

321 handler(self, record) 

322 elif record.head in CLAUSE_KEYWORDS: 

323 # Almost always a dropped '\' continuation. Skipping it the way 

324 # an unknown record is skipped would build a different scene. 

325 self.error('PEX25D-E0103', 

326 f"'{record.head}' is a clause keyword, not a record — " 

327 f"a continuation '\\' was probably dropped", record) 

328 else: 

329 self.warn('PEX25D-W0110', 

330 f"Skipping unrecognized top-level record " 

331 f"'{record.head}'", record) 

332 

333 def read_include(self, record: Record) -> None: 

334 cursor = Cursor(record, self) 

335 path = cursor.take('a path') 

336 cursor.end() 

337 if path is None: 

338 return 

339 

340 # Relative paths resolve against the directory of the including file; 

341 # for stdin there is no such directory, so the current one is used. 

342 base = os.path.dirname(os.path.abspath(record.file)) \ 

343 if record.file != STDIO_NAME else os.getcwd() 

344 resolved = os.path.normpath(os.path.join(base, path)) 

345 

346 if resolved in self.include_stack: 

347 chain = ' -> '.join(self.include_stack + [resolved]) 

348 self.error('PEX25D-E0109', f"INCLUDE cycle: {chain}", record) 

349 return 

350 

351 try: 

352 with open(resolved, 'rb') as f: 

353 text = f.read().decode('utf-8') 

354 except OSError as e: 

355 self.error('PEX25D-E0109', f"Can't read INCLUDE '{path}': {e}", record) 

356 return 

357 

358 # INCLUDE is textual: the included records are treated as if they 

359 # appeared in its place, which is why one PEX25DFile is a whole include 

360 # tree flattened, and why SourceRef.file is load-bearing. 

361 self.include_stack.append(resolved) 

362 self.consume(text, resolved) 

363 self.include_stack.pop() 

364 

365 # -------------------------------------------------------------- records 

366 

367 def read_header(self, record: Record) -> None: 

368 if self.seen_header: 

369 self.error('PEX25D-E0107', "Duplicate PEX25D record", record) 

370 return 

371 self.seen_header = True 

372 

373 cursor = Cursor(record, self) 

374 version = cursor.take('a version') 

375 cursor.end() 

376 if version is None: 

377 return 

378 

379 match = VERSION.match(version) 

380 if match is None: 

381 self.error('PEX25D-E0107', 

382 f"'{version}' is not a MAJOR.MINOR[-SUFFIX] version", record) 

383 return 

384 

385 major, minor, suffix = int(match.group(1)), int(match.group(2)), match.group(3) 

386 if major != FORMAT_VERSION_MAJOR: 

387 # A reader must accept any minor version of the major it supports, 

388 # and refuse a major it does not: a major bump can change how 

389 # existing keywords are interpreted. 

390 self.error('PEX25D-E0107', 

391 f"This implementation reads PEX25D {FORMAT_VERSION_MAJOR}.x, " 

392 f"the file declares {major}.{minor}", record) 

393 return 

394 

395 self.file.format_version_major = major 

396 self.file.format_version_minor = minor 

397 if suffix: 

398 self.file.format_version_suffix = suffix 

399 

400 def read_units(self, record: Record) -> None: 

401 cursor = Cursor(record, self) 

402 if cursor.keyword('LENGTH') is None: 

403 return 

404 unit = cursor.take('um, nm or m') 

405 if unit is None: 

406 return 

407 if unit not in LENGTH_UNITS: 

408 self.error('PEX25D-E0102', 

409 f"UNITS LENGTH: '{unit}' is not one of " 

410 f"{', '.join(LENGTH_UNITS)}", record) 

411 return 

412 if cursor.keyword('GRID') is None: 

413 return 

414 grid_text = cursor.take('the grid') 

415 cursor.end() 

416 if grid_text is None: 

417 return 

418 

419 grid = self.to_fraction(grid_text, record, 'UNITS GRID') 

420 if grid is None: 

421 return 

422 if grid <= 0: 

423 self.error('PEX25D-E0104', "UNITS GRID must be positive", record) 

424 return 

425 

426 self.grid = grid 

427 units = self.file.units 

428 units.length = LENGTH_UNITS[unit] 

429 units.grid_numerator = grid.numerator 

430 units.grid_denominator = grid.denominator 

431 

432 def read_meta(self, record: Record) -> None: 

433 cursor = Cursor(record, self) 

434 key = cursor.take('a key') 

435 value = cursor.take('a value') 

436 cursor.end() 

437 if key is None or value is None: 

438 return 

439 

440 # A key may appear at most once across the file and everything it 

441 # includes. Because record order is not significant, "the last one wins" 

442 # would have no meaning. 

443 if key in self.meta_keys: 

444 self.error('PEX25D-E0108', 

445 f"META key '{key}' is already set in {self.meta_keys[key]}", 

446 record, tier=Tier.SEMANTIC) 

447 return 

448 self.meta_keys[key] = f"{record.file}:{record.line}" 

449 

450 meta = self.file.meta.add() 

451 meta.key, meta.value = key, value 

452 self.attach_source(meta, record) 

453 

454 # The text format has no UNITS clause for the source DBU; META carries 

455 # it, and a reader fills the Units field from it. 

456 if key == 'source_dbu': 

457 dbu = self.to_fraction(value, record, 'META source_dbu') 

458 if dbu is not None and dbu > 0: 

459 self.file.units.source_dbu_numerator = dbu.numerator 

460 self.file.units.source_dbu_denominator = dbu.denominator 

461 

462 def read_ground_plane(self, record: Record) -> None: 

463 cursor = Cursor(record, self) 

464 name = cursor.take('a name') 

465 if name is None or cursor.keyword('Z_OFFSETS') is None: 

466 return 

467 zlow = cursor.grid('GROUND_PLANE zlow') 

468 zhigh = cursor.grid('GROUND_PLANE zhigh') 

469 cursor.end() 

470 if zlow is None or zhigh is None: 

471 return 

472 if self.file.HasField('ground_plane'): 

473 self.error('PEX25D-E0210', "More than one GROUND_PLANE", record, 

474 tier=Tier.SEMANTIC) 

475 return 

476 self.file.ground_plane.name = name 

477 self.file.ground_plane.zlow, self.file.ground_plane.zhigh = zlow, zhigh 

478 self.attach_source(self.file.ground_plane, record) 

479 

480 def read_metal(self, record: Record) -> None: 

481 cursor = Cursor(record, self) 

482 name = cursor.take('a name') 

483 if name is None or cursor.keyword('Z_OFFSETS') is None: 

484 return 

485 zlow = cursor.grid('METAL zlow') 

486 zhigh = cursor.grid('METAL zhigh') 

487 cursor.end() 

488 if zlow is None or zhigh is None: 

489 return 

490 metal = self.file.metals.add() 

491 metal.name, metal.zlow, metal.zhigh = name, zlow, zhigh 

492 self.attach_source(metal, record) 

493 

494 def read_via(self, record: Record) -> None: 

495 cursor = Cursor(record, self) 

496 name = cursor.take('a name') 

497 if name is None or cursor.keyword('CONNECTS') is None: 

498 return 

499 below = cursor.take('the layer below') 

500 above = cursor.take('the layer above') 

501 cursor.end() 

502 if below is None or above is None: 

503 return 

504 via = self.file.vias.add() 

505 via.name, via.connects_below, via.connects_above = name, below, above 

506 self.attach_source(via, record) 

507 

508 def read_dielectric_simple(self, record: Record) -> None: 

509 kinds = pex25d_dielectric_pb2() 

510 cursor = Cursor(record, self) 

511 name = cursor.take('a name') 

512 if name is None or cursor.keyword('WRAPS') is None: 

513 return 

514 wraps = cursor.take('the wrapped profile') 

515 if wraps is None or cursor.keyword('PERMITTIVITY') is None: 

516 return 

517 permittivity = cursor.number('PERMITTIVITY') 

518 if permittivity is None or cursor.keyword('BETWEEN') is None: 

519 return 

520 below = cursor.take('the object below') 

521 above = cursor.take('the object above') 

522 cursor.end() 

523 if below is None or above is None: 

524 return 

525 

526 dielectric = self.file.dielectrics.add() 

527 dielectric.name, dielectric.wraps = name, wraps 

528 dielectric.kind = kinds.DIELECTRIC_KIND_SIMPLE 

529 dielectric.permittivity = permittivity 

530 dielectric.simple.between_below, dielectric.simple.between_above = below, above 

531 self.attach_source(dielectric, record) 

532 

533 def read_dielectric_conformal(self, record: Record) -> None: 

534 kinds = pex25d_dielectric_pb2() 

535 cursor = Cursor(record, self) 

536 name = cursor.take('a name') 

537 if name is None or cursor.keyword('WRAPS') is None: 

538 return 

539 wraps = cursor.take('the wrapped profile') 

540 if wraps is None or cursor.keyword('PERMITTIVITY') is None: 

541 return 

542 permittivity = cursor.number('PERMITTIVITY') 

543 if permittivity is None: 

544 return 

545 

546 thicknesses: Dict[str, int] = {} 

547 for keyword in ('THICKNESS_OVER_WRAPPED', 'THICKNESS_BESIDE_WRAPPED', 

548 'THICKNESS_ON_FIELD'): 

549 if cursor.keyword(keyword) is None: 

550 return 

551 value = cursor.grid(keyword) 

552 if value is None: 

553 return 

554 thicknesses[keyword] = value 

555 cursor.end() 

556 

557 dielectric = self.file.dielectrics.add() 

558 dielectric.name, dielectric.wraps = name, wraps 

559 dielectric.kind = kinds.DIELECTRIC_KIND_CONFORMAL 

560 dielectric.permittivity = permittivity 

561 conformal = dielectric.conformal 

562 conformal.thickness_over_wrapped = thicknesses['THICKNESS_OVER_WRAPPED'] 

563 conformal.thickness_beside_wrapped = thicknesses['THICKNESS_BESIDE_WRAPPED'] 

564 conformal.thickness_on_field = thicknesses['THICKNESS_ON_FIELD'] 

565 self.attach_source(dielectric, record) 

566 

567 def read_dielectric_background(self, record: Record) -> None: 

568 cursor = Cursor(record, self) 

569 name = cursor.take('a name') 

570 if name is None or cursor.keyword('PERMITTIVITY') is None: 

571 return 

572 permittivity = cursor.number('PERMITTIVITY') 

573 cursor.end() 

574 if permittivity is None: 

575 return 

576 if self.file.HasField('background'): 

577 self.error('PEX25D-E0213', "More than one DIELECTRIC_BACKGROUND", record, 

578 tier=Tier.SEMANTIC) 

579 return 

580 self.file.background.name = name 

581 self.file.background.permittivity = permittivity 

582 self.attach_source(self.file.background, record) 

583 

584 def read_conductor(self, record: Record) -> None: 

585 cursor = Cursor(record, self) 

586 name = cursor.take('a shortname') 

587 net = cursor.take('a net name') 

588 cursor.end() 

589 if name is None or net is None: 

590 return 

591 conductor = self.file.conductors.add() 

592 conductor.name, conductor.net = name, net 

593 self.attach_source(conductor, record) 

594 

595 def read_box(self, record: Record) -> None: 

596 kinds = pex25d_file_pb2().ShapeRecord 

597 cursor = Cursor(record, self) 

598 if cursor.keyword('CONDUCTOR') is None: 

599 return 

600 conductor = cursor.take('a conductor shortname') 

601 if conductor is None or cursor.keyword('LAYER') is None: 

602 return 

603 layer = cursor.take('a layer name') 

604 if layer is None or cursor.keyword('LL') is None: 

605 return 

606 x0, y0 = cursor.grid('BOX LL x'), cursor.grid('BOX LL y') 

607 if cursor.keyword('UR') is None: 

608 return 

609 x1, y1 = cursor.grid('BOX UR x'), cursor.grid('BOX UR y') 

610 cursor.end() 

611 if None in (x0, y0, x1, y1): 

612 return 

613 

614 shape = self.file.shapes.add() 

615 shape.conductor, shape.layer = conductor, layer 

616 shape.kind = kinds.SHAPE_KIND_BOX 

617 shape.box.lower_left.x, shape.box.lower_left.y = x0, y0 

618 shape.box.upper_right.x, shape.box.upper_right.y = x1, y1 

619 self.attach_source(shape, record) 

620 

621 def read_polygon(self, record: Record) -> None: 

622 kinds = pex25d_file_pb2().ShapeRecord 

623 cursor = Cursor(record, self) 

624 if cursor.keyword('CONDUCTOR') is None: 

625 return 

626 conductor = cursor.take('a conductor shortname') 

627 if conductor is None or cursor.keyword('LAYER') is None: 

628 return 

629 layer = cursor.take('a layer name') 

630 if layer is None or cursor.keyword('OUTER') is None: 

631 return 

632 

633 outer = self.read_ring(cursor, record, 'OUTER') 

634 if outer is None: 

635 return 

636 holes = [] 

637 while cursor.peek() == 'HOLE': 

638 cursor.take('HOLE') 

639 hole = self.read_ring(cursor, record, 'HOLE') 

640 if hole is None: 

641 return 

642 holes.append(hole) 

643 cursor.end() 

644 

645 shape = self.file.shapes.add() 

646 shape.conductor, shape.layer = conductor, layer 

647 shape.kind = kinds.SHAPE_KIND_POLYGON 

648 fill_ring(shape.polygon.outer, outer) 

649 for hole in holes: 

650 fill_ring(shape.polygon.holes.add(), hole) 

651 self.attach_source(shape, record) 

652 

653 def read_ring(self, cursor: Cursor, record: Record, 

654 keyword: str) -> Optional[List[Tuple[int, int]]]: 

655 """ 

656 Read vertices until the next keyword or the end of the record. 

657 

658 Rings are implicitly closed, so the first vertex is not repeated. 

659 """ 

660 points: List[Tuple[int, int]] = [] 

661 while not cursor.exhausted and cursor.peek() not in ('HOLE',): 

662 x = cursor.grid(f"{keyword} x") 

663 y = cursor.grid(f"{keyword} y") 

664 if x is None or y is None: 

665 return None 

666 points.append((x, y)) 

667 

668 if len(points) < 3: 

669 self.error('PEX25D-E0101', 

670 f"POLYGON {keyword}: a ring needs at least three vertices, " 

671 f"found {len(points)}", record) 

672 return None 

673 return points 

674 

675 def read_terminal(self, record: Record) -> None: 

676 cursor = Cursor(record, self) 

677 name = cursor.take('a name') 

678 if name is None or cursor.keyword('CONDUCTOR') is None: 

679 return 

680 conductor = cursor.take('a conductor shortname') 

681 if conductor is None or cursor.keyword('LAYER') is None: 

682 return 

683 layer = cursor.take('a layer name') 

684 if layer is None: 

685 return 

686 

687 kind = 0 

688 if cursor.peek() == 'KIND': 

689 cursor.take('KIND') 

690 spelling = cursor.take('a terminal kind') 

691 if spelling is None: 

692 return 

693 if spelling not in TERMINAL_KINDS: 

694 self.error('PEX25D-E0102', 

695 f"TERMINAL KIND: '{spelling}' is not one of " 

696 f"{', '.join(TERMINAL_KINDS)}", record) 

697 return 

698 kind = TERMINAL_KINDS[spelling] 

699 

700 if cursor.keyword('LL') is None: 

701 return 

702 x0, y0 = cursor.grid('TERMINAL LL x'), cursor.grid('TERMINAL LL y') 

703 if cursor.keyword('UR') is None: 

704 return 

705 x1, y1 = cursor.grid('TERMINAL UR x'), cursor.grid('TERMINAL UR y') 

706 cursor.end() 

707 if None in (x0, y0, x1, y1): 

708 return 

709 

710 terminal = self.file.terminals.add() 

711 terminal.name, terminal.conductor, terminal.layer = name, conductor, layer 

712 terminal.kind = kind 

713 terminal.region.lower_left.x, terminal.region.lower_left.y = x0, y0 

714 terminal.region.upper_right.x, terminal.region.upper_right.y = x1, y1 

715 self.attach_source(terminal, record) 

716 

717 def read_resistance(self, record: Record) -> None: 

718 cursor = Cursor(record, self) 

719 what = cursor.keyword('TEMPERATURE', 'METAL', 'VIA') 

720 if what is None: 

721 return 

722 

723 if what == 'TEMPERATURE': 

724 celsius = cursor.number('RESISTANCE TEMPERATURE') 

725 cursor.end() 

726 if celsius is None: 

727 return 

728 self.file.resistance_temperature.celsius = celsius 

729 self.attach_source(self.file.resistance_temperature, record) 

730 return 

731 

732 name = cursor.take('a profile name') 

733 if name is None: 

734 return 

735 value_keyword = 'SHEET' if what == 'METAL' else 'PER_CUT' 

736 if cursor.keyword(value_keyword) is None: 

737 return 

738 value = cursor.number(f"RESISTANCE {what} {value_keyword}") 

739 if value is None: 

740 return 

741 

742 coefficients: Dict[str, float] = {} 

743 for keyword in ('TC1', 'TC2'): 

744 if cursor.peek() != keyword: 

745 break 

746 cursor.take(keyword) 

747 coefficient = cursor.number(f"RESISTANCE {what} {keyword}") 

748 if coefficient is None: 

749 return 

750 coefficients[keyword] = coefficient 

751 cursor.end() 

752 

753 if what == 'METAL': 

754 resistance = self.file.metal_resistances.add() 

755 resistance.metal, resistance.sheet = name, value 

756 else: 

757 resistance = self.file.via_resistances.add() 

758 resistance.via, resistance.per_cut = name, value 

759 if coefficients: 

760 resistance.tc.tc1 = coefficients.get('TC1', 0.0) 

761 resistance.tc.tc2 = coefficients.get('TC2', 0.0) 

762 self.attach_source(resistance, record) 

763 

764 def read_domain_margin(self, record: Record) -> None: 

765 cursor = Cursor(record, self) 

766 values: Dict[str, int] = {} 

767 for axis in ('X', 'Y', 'Z'): 

768 if cursor.keyword(axis) is None: 

769 return 

770 value = cursor.grid(f"DOMAIN_MARGIN {axis}") 

771 if value is None: 

772 return 

773 values[axis] = value 

774 cursor.end() 

775 margin = self.file.domain_margin 

776 margin.x, margin.y, margin.z = values['X'], values['Y'], values['Z'] 

777 

778 def read_domain_box(self, record: Record) -> None: 

779 cursor = Cursor(record, self) 

780 corners: Dict[str, List[int]] = {} 

781 for keyword in ('LL', 'UR'): 

782 if cursor.keyword(keyword) is None: 

783 return 

784 coordinates = [] 

785 for axis in 'xyz': 

786 value = cursor.grid(f"DOMAIN_BOX {keyword} {axis}") 

787 if value is None: 

788 return 

789 coordinates.append(value) 

790 corners[keyword] = coordinates 

791 cursor.end() 

792 

793 box = self.file.domain_box.box 

794 box.lower_left.x, box.lower_left.y, box.lower_left.z = corners['LL'] 

795 box.upper_right.x, box.upper_right.y, box.upper_right.z = corners['UR'] 

796 self.attach_source(self.file.domain_box, record) 

797 

798 HANDLERS: Dict[str, Callable[['Pex25DTextReader', Record], None]] = {} 

799 

800 

801Pex25DTextReader.HANDLERS = { 

802 'PEX25D': Pex25DTextReader.read_header, 

803 'UNITS': Pex25DTextReader.read_units, 

804 'META': Pex25DTextReader.read_meta, 

805 'INCLUDE': Pex25DTextReader.read_include, 

806 'GROUND_PLANE': Pex25DTextReader.read_ground_plane, 

807 'METAL': Pex25DTextReader.read_metal, 

808 'VIA': Pex25DTextReader.read_via, 

809 'DIELECTRIC_SIMPLE': Pex25DTextReader.read_dielectric_simple, 

810 'DIELECTRIC_CONFORMAL': Pex25DTextReader.read_dielectric_conformal, 

811 'DIELECTRIC_BACKGROUND': Pex25DTextReader.read_dielectric_background, 

812 'CONDUCTOR': Pex25DTextReader.read_conductor, 

813 'BOX': Pex25DTextReader.read_box, 

814 'POLYGON': Pex25DTextReader.read_polygon, 

815 'TERMINAL': Pex25DTextReader.read_terminal, 

816 'RESISTANCE': Pex25DTextReader.read_resistance, 

817 'DOMAIN_MARGIN': Pex25DTextReader.read_domain_margin, 

818 'DOMAIN_BOX': Pex25DTextReader.read_domain_box, 

819} 

820 

821 

822def fill_ring(ring: Any, points: Sequence[Tuple[int, int]]) -> None: 

823 for x, y in points: 

824 point = ring.points.add() 

825 point.x, point.y = x, y 

826 

827 

828def read_pex25d_text(data: bytes, 

829 source_name: Optional[str] = None, 

830 report: Optional[DiagnosticsReport] = None, 

831 with_source_refs: bool = False) -> Any: 

832 """ 

833 Parse the PEX25D text format into a ``kpex.pex25d.PEX25DFile``. 

834 

835 :param data: raw UTF-8 bytes of the top-level file. ``INCLUDE`` is textual, 

836 so the result covers the whole include tree, flattened. 

837 :param source_name: path used for ``SourceRef.file`` and for resolving 

838 relative includes; for ``-`` they resolve against the current directory. 

839 :param report: collects syntax- and semantic-tier diagnostics. 

840 :param with_source_refs: record where each message came from. Off by 

841 default, like every other source-ref switch, so that a text-to-protobuf 

842 conversion is idempotent; worth turning on when reading an include tree, 

843 which is the case the field exists for. Diagnostics carry positions 

844 either way. 

845 :raises ReadError: when the file could not be read; the reasons are in 

846 ``report``. 

847 """ 

848 return Pex25DTextReader(report=report, 

849 with_source_refs=with_source_refs).read(data, source_name)