altium-toolkit 1.1.22 → 1.1.24

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 (99) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +134 -23
  3. package/docs/model-format.md +174 -20
  4. package/docs/schemas/altium_toolkit/embedded_assets_a1.schema.json +56 -0
  5. package/docs/schemas/altium_toolkit/fixture_coverage_matrix_a1.schema.json +89 -0
  6. package/docs/schemas/altium_toolkit/geometry_bounds_a1.schema.json +86 -0
  7. package/docs/schemas/altium_toolkit/library_catalog_a1.schema.json +65 -0
  8. package/docs/schemas/altium_toolkit/library_diff_a1.schema.json +54 -0
  9. package/docs/schemas/altium_toolkit/library_inspection_a1.schema.json +94 -0
  10. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +4 -0
  11. package/docs/schemas/altium_toolkit/native_stream_inventory_a1.schema.json +66 -0
  12. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +511 -1
  13. package/docs/schemas/altium_toolkit/parameter_record_inventory_a1.schema.json +84 -0
  14. package/docs/schemas/altium_toolkit/parser_diagnostics_a1.schema.json +63 -0
  15. package/docs/schemas/altium_toolkit/parser_value_verification_a1.schema.json +74 -0
  16. package/docs/schemas/altium_toolkit/pcb_class_report_a1.schema.json +79 -0
  17. package/docs/schemas/altium_toolkit/pcb_inspection_a1.schema.json +65 -0
  18. package/docs/schemas/altium_toolkit/pcb_net_membership_a1.schema.json +98 -0
  19. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +3 -0
  20. package/docs/schemas/altium_toolkit/project_hierarchy_a1.schema.json +79 -0
  21. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +212 -0
  22. package/docs/testing.md +2 -0
  23. package/examples/README.md +21 -0
  24. package/examples/cli-utils.mjs +148 -0
  25. package/examples/corpus-smoke.mjs +523 -0
  26. package/examples/extract-bom.mjs +47 -0
  27. package/examples/generate-pnp.mjs +59 -0
  28. package/examples/inspect-board.mjs +70 -0
  29. package/examples/inspect-schematic.mjs +406 -0
  30. package/examples/library-catalog.mjs +115 -0
  31. package/examples/net-report.mjs +61 -0
  32. package/examples/validate-library.mjs +59 -0
  33. package/package.json +1 -1
  34. package/src/core/BinaryReader.mjs +213 -2
  35. package/src/core/altium/AltiumParser.mjs +352 -14
  36. package/src/core/altium/AltiumUnits.mjs +205 -0
  37. package/src/core/altium/AsciiRecordParser.mjs +9 -0
  38. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  39. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  40. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  41. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  42. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  43. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  44. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  45. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  46. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  47. package/src/core/altium/ParameterCollection.mjs +431 -0
  48. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  49. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  50. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  51. package/src/core/altium/ParserErrors.mjs +90 -0
  52. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  53. package/src/core/altium/ParserUtils.mjs +24 -0
  54. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  55. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  56. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  57. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  58. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  59. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  60. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  61. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  62. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  63. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  64. package/src/core/altium/PcbModelParser.mjs +223 -4
  65. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  66. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  67. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  68. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  69. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  70. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  71. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  72. package/src/core/altium/SchLibModelParser.mjs +840 -0
  73. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  74. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  75. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  76. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  77. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  78. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  79. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  80. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  81. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  82. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  83. package/src/core/altium/SchematicPinParser.mjs +12 -45
  84. package/src/core/altium/SchematicPrimitiveParser.mjs +9 -14
  85. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  86. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  87. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  88. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  89. package/src/core/altium/SchematicStreamExtractor.mjs +64 -25
  90. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  91. package/src/core/altium/SchematicTextParser.mjs +28 -12
  92. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  93. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  94. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  95. package/src/parser.mjs +35 -1
  96. package/src/renderers.mjs +1 -0
  97. package/src/ui/SchematicShapeRenderer.mjs +49 -6
  98. package/src/ui/SchematicSvgRenderer.mjs +37 -8
  99. package/src/ui/SchematicTypography.mjs +4 -3
@@ -0,0 +1,663 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+
7
+ const { getDisplayText, getField, parseBoolean, parseNumericField, toColor } =
8
+ ParserUtils
9
+
10
+ /**
11
+ * Preserves auxiliary schematic code-symbol records as a read-only sidecar.
12
+ */
13
+ export class SchematicCodeSymbolParser {
14
+ static SCHEMA_ID = 'altium-toolkit.schematic.code-symbols.a1'
15
+
16
+ /**
17
+ * Parses code-symbol family records from schematic records.
18
+ * @param {{ fields: Record<string, string | string[]>, recordIndex: number }[]} records Parsed records.
19
+ * @returns {{ schema: string, summary: object, symbols: object[], entries: object[], texts: object[], markers: object[] } | null}
20
+ */
21
+ static parse(records) {
22
+ const symbols = records
23
+ .map((record) =>
24
+ SchematicCodeSymbolParser.#parseSymbolRecord(record)
25
+ )
26
+ .filter(Boolean)
27
+ const symbolLookup =
28
+ SchematicCodeSymbolParser.#buildSymbolLookup(symbols)
29
+ const entries = records
30
+ .map((record) =>
31
+ SchematicCodeSymbolParser.#parseEntryRecord(
32
+ record,
33
+ symbolLookup
34
+ )
35
+ )
36
+ .filter(Boolean)
37
+ const texts = records
38
+ .map((record) =>
39
+ SchematicCodeSymbolParser.#parseTextRecord(record, symbolLookup)
40
+ )
41
+ .filter(Boolean)
42
+ const markers = records
43
+ .map((record) =>
44
+ SchematicCodeSymbolParser.#parseMarkerRecord(record)
45
+ )
46
+ .filter(Boolean)
47
+
48
+ if (
49
+ !symbols.length &&
50
+ !entries.length &&
51
+ !texts.length &&
52
+ !markers.length
53
+ ) {
54
+ return null
55
+ }
56
+
57
+ SchematicCodeSymbolParser.#attachChildKeys(symbols, entries, texts)
58
+
59
+ return {
60
+ schema: SchematicCodeSymbolParser.SCHEMA_ID,
61
+ summary: {
62
+ symbolCount: symbols.length,
63
+ entryCount: entries.length,
64
+ textCount: texts.length,
65
+ markerCount: markers.length
66
+ },
67
+ symbols: symbols.map((symbol) =>
68
+ SchematicCodeSymbolParser.#publicSymbol(symbol)
69
+ ),
70
+ entries,
71
+ texts,
72
+ markers
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Parses one `RECORD=220` code symbol.
78
+ * @param {{ fields: Record<string, string | string[]>, recordIndex: number }} record Parsed record.
79
+ * @returns {object | null}
80
+ */
81
+ static #parseSymbolRecord(record) {
82
+ if (getField(record.fields, 'RECORD') !== '220') {
83
+ return null
84
+ }
85
+
86
+ const indexInSheet = parseNumericField(record.fields, 'IndexInSheet')
87
+ const stableIndex =
88
+ indexInSheet === null ? record.recordIndex : indexInSheet
89
+ const routines = SchematicCodeSymbolParser.#parseRoutineRows(
90
+ record.fields
91
+ )
92
+ const internalMemory = SchematicCodeSymbolParser.#parseInternalMemory(
93
+ record.fields
94
+ )
95
+ const externalMemory =
96
+ SchematicCodeSymbolParser.#parseExternalMemoryRows(record.fields)
97
+
98
+ return {
99
+ key: 'schematic-code-symbol-' + stableIndex,
100
+ recordKey: SchematicCodeSymbolParser.#recordKey(record),
101
+ recordId: 'record-' + stableIndex,
102
+ x: parseNumericField(record.fields, 'Location.X') || 0,
103
+ y: parseNumericField(record.fields, 'Location.Y') || 0,
104
+ width: parseNumericField(record.fields, 'XSize') || 0,
105
+ height: parseNumericField(record.fields, 'YSize') || 0,
106
+ color: toColor(record.fields.Color, '#2c3134'),
107
+ fill: toColor(record.fields.AreaColor, '#f6f0d8'),
108
+ isSolid: parseBoolean(record.fields.IsSolid),
109
+ ...(getField(record.fields, 'SymbolType')
110
+ ? { symbolType: getField(record.fields, 'SymbolType') }
111
+ : {}),
112
+ ...(getField(record.fields, 'UniqueID') ||
113
+ getField(record.fields, 'UniqueId')
114
+ ? {
115
+ uniqueId:
116
+ getField(record.fields, 'UniqueID') ||
117
+ getField(record.fields, 'UniqueId')
118
+ }
119
+ : {}),
120
+ ...(routines.length ? { routines } : {}),
121
+ ...(Object.keys(internalMemory).length ? { internalMemory } : {}),
122
+ ...(externalMemory.length ? { externalMemory } : {}),
123
+ entryKeys: [],
124
+ textKeys: [],
125
+ sourceRecordIndex: record.recordIndex,
126
+ indexInSheet
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Parses one `RECORD=221` code symbol entry.
132
+ * @param {{ fields: Record<string, string | string[]>, recordIndex: number }} record Parsed record.
133
+ * @param {Map<string, object>} symbolLookup Symbol lookup by owner index.
134
+ * @returns {object | null}
135
+ */
136
+ static #parseEntryRecord(record, symbolLookup) {
137
+ if (getField(record.fields, 'RECORD') !== '221') {
138
+ return null
139
+ }
140
+
141
+ const indexInSheet = parseNumericField(record.fields, 'IndexInSheet')
142
+ const stableIndex =
143
+ indexInSheet === null ? record.recordIndex : indexInSheet
144
+ const ownerIndex = getField(record.fields, 'OwnerIndex')
145
+ const parentSymbol = symbolLookup.get(ownerIndex)
146
+ const side = SchematicCodeSymbolParser.#resolveSide(
147
+ parseNumericField(record.fields, 'Side')
148
+ )
149
+ const distance = SchematicCodeSymbolParser.#parseEntryDistance(
150
+ record.fields
151
+ )
152
+ const point = parentSymbol
153
+ ? SchematicCodeSymbolParser.#resolveEntryPoint(
154
+ parentSymbol,
155
+ side,
156
+ distance
157
+ )
158
+ : null
159
+ const entryType = parseNumericField(record.fields, 'EntryType')
160
+ const style = parseNumericField(record.fields, 'Style')
161
+ const textFontId = parseNumericField(record.fields, 'TextFontID')
162
+ const dataWidth = parseNumericField(record.fields, 'DataWidth')
163
+
164
+ return {
165
+ key: 'schematic-code-entry-' + stableIndex,
166
+ recordKey: SchematicCodeSymbolParser.#recordKey(record),
167
+ ...(parentSymbol ? { ownerSymbolKey: parentSymbol.key } : {}),
168
+ ...(ownerIndex ? { ownerIndex } : {}),
169
+ ...(getField(record.fields, 'Name')
170
+ ? { name: getField(record.fields, 'Name') }
171
+ : {}),
172
+ ...(getField(record.fields, 'DataIdentifier')
173
+ ? {
174
+ dataIdentifier: getField(record.fields, 'DataIdentifier')
175
+ }
176
+ : {}),
177
+ ...(getField(record.fields, 'DataType')
178
+ ? { dataType: getField(record.fields, 'DataType') }
179
+ : {}),
180
+ ...(dataWidth !== null ? { dataWidth } : {}),
181
+ side,
182
+ direction: SchematicCodeSymbolParser.#resolveDirection(
183
+ parseNumericField(record.fields, 'IOType')
184
+ ),
185
+ ...(entryType !== null ? { entryType } : {}),
186
+ ...(style !== null ? { style } : {}),
187
+ ...(point ? { x: point.x, y: point.y } : {}),
188
+ color: toColor(record.fields.Color, '#2c3134'),
189
+ fill: toColor(record.fields.AreaColor, '#f6f0d8'),
190
+ textColor: toColor(
191
+ record.fields.TextColor || record.fields.Color,
192
+ '#2c3134'
193
+ ),
194
+ ...(textFontId !== null ? { textFontId } : {}),
195
+ ...(getField(record.fields, 'ParentRoutine')
196
+ ? { parentRoutine: getField(record.fields, 'ParentRoutine') }
197
+ : {}),
198
+ ...(parseBoolean(record.fields.OwnerIndexAdditionalList)
199
+ ? { ownerIndexAdditionalList: true }
200
+ : {})
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Parses one `RECORD=222` or `RECORD=223` text row.
206
+ * @param {{ fields: Record<string, string | string[]>, recordIndex: number }} record Parsed record.
207
+ * @param {Map<string, object>} symbolLookup Symbol lookup by owner index.
208
+ * @returns {object | null}
209
+ */
210
+ static #parseTextRecord(record, symbolLookup) {
211
+ const recordType = getField(record.fields, 'RECORD')
212
+ if (recordType !== '222' && recordType !== '223') {
213
+ return null
214
+ }
215
+
216
+ const indexInSheet = parseNumericField(record.fields, 'IndexInSheet')
217
+ const stableIndex =
218
+ indexInSheet === null ? record.recordIndex : indexInSheet
219
+ const ownerIndex = getField(record.fields, 'OwnerIndex')
220
+ const parentSymbol = symbolLookup.get(ownerIndex)
221
+ const x = parseNumericField(record.fields, 'Location.X')
222
+ const y = parseNumericField(record.fields, 'Location.Y')
223
+ const fontId = parseNumericField(record.fields, 'FontID')
224
+
225
+ return {
226
+ key: 'schematic-code-text-' + stableIndex,
227
+ recordKey: SchematicCodeSymbolParser.#recordKey(record),
228
+ ...(parentSymbol ? { ownerSymbolKey: parentSymbol.key } : {}),
229
+ ...(ownerIndex ? { ownerIndex } : {}),
230
+ kind: recordType === '222' ? 'title' : 'source',
231
+ text: getDisplayText(record.fields),
232
+ ...(x !== null ? { x } : {}),
233
+ ...(y !== null ? { y } : {}),
234
+ ...(fontId !== null ? { fontId } : {}),
235
+ color: toColor(record.fields.Color, '#2c3134')
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Parses one `RECORD=210` marker row.
241
+ * @param {{ fields: Record<string, string | string[]>, recordIndex: number }} record Parsed record.
242
+ * @returns {object | null}
243
+ */
244
+ static #parseMarkerRecord(record) {
245
+ if (getField(record.fields, 'RECORD') !== '210') {
246
+ return null
247
+ }
248
+
249
+ const indexInSheet = parseNumericField(record.fields, 'IndexInSheet')
250
+ const stableIndex =
251
+ indexInSheet === null ? record.recordIndex : indexInSheet
252
+ const x = parseNumericField(record.fields, 'Location.X')
253
+ const y = parseNumericField(record.fields, 'Location.Y')
254
+
255
+ return {
256
+ key: 'schematic-code-marker-' + stableIndex,
257
+ recordKey: SchematicCodeSymbolParser.#recordKey(record),
258
+ recordId: 'record-' + stableIndex,
259
+ ...(getField(record.fields, 'Name')
260
+ ? { name: getField(record.fields, 'Name') }
261
+ : {}),
262
+ ...(x !== null ? { x } : {}),
263
+ ...(y !== null ? { y } : {}),
264
+ color: toColor(record.fields.Color, '#2c3134'),
265
+ ...(getField(record.fields, 'OwnerPartID') ||
266
+ getField(record.fields, 'OwnerPartId')
267
+ ? {
268
+ ownerPartId:
269
+ getField(record.fields, 'OwnerPartID') ||
270
+ getField(record.fields, 'OwnerPartId')
271
+ }
272
+ : {})
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Parses indexed exported routine metadata.
278
+ * @param {Record<string, string | string[]>} fields Native fields.
279
+ * @returns {object[]}
280
+ */
281
+ static #parseRoutineRows(fields) {
282
+ const count = SchematicCodeSymbolParser.#resolveIndexedRowCount(
283
+ fields,
284
+ 'ExportedRoutineCount',
285
+ /^(?:RoutineName|InterfaceMode|DataWidth|AddressWidth|Scope|Mau|NoWait|IsLinked)(\d+)$/u
286
+ )
287
+ const rows = []
288
+
289
+ for (let index = 0; index < count; index += 1) {
290
+ const row = {
291
+ index,
292
+ ...(getField(fields, 'RoutineName' + index)
293
+ ? { name: getField(fields, 'RoutineName' + index) }
294
+ : {})
295
+ }
296
+ SchematicCodeSymbolParser.#assignNumericField(
297
+ row,
298
+ 'interfaceMode',
299
+ fields,
300
+ 'InterfaceMode' + index
301
+ )
302
+ SchematicCodeSymbolParser.#assignNumericField(
303
+ row,
304
+ 'dataWidth',
305
+ fields,
306
+ 'DataWidth' + index
307
+ )
308
+ SchematicCodeSymbolParser.#assignNumericField(
309
+ row,
310
+ 'addressWidth',
311
+ fields,
312
+ 'AddressWidth' + index
313
+ )
314
+ SchematicCodeSymbolParser.#assignNumericField(
315
+ row,
316
+ 'scope',
317
+ fields,
318
+ 'Scope' + index
319
+ )
320
+ SchematicCodeSymbolParser.#assignNumericField(
321
+ row,
322
+ 'mau',
323
+ fields,
324
+ 'Mau' + index
325
+ )
326
+ SchematicCodeSymbolParser.#assignBooleanField(
327
+ row,
328
+ 'noWait',
329
+ fields,
330
+ 'NoWait' + index
331
+ )
332
+ SchematicCodeSymbolParser.#assignBooleanField(
333
+ row,
334
+ 'isLinked',
335
+ fields,
336
+ 'IsLinked' + index
337
+ )
338
+
339
+ if (Object.keys(row).length > 1) {
340
+ rows.push(row)
341
+ }
342
+ }
343
+
344
+ return rows
345
+ }
346
+
347
+ /**
348
+ * Parses code-symbol internal memory summary fields.
349
+ * @param {Record<string, string | string[]>} fields Native fields.
350
+ * @returns {object}
351
+ */
352
+ static #parseInternalMemory(fields) {
353
+ const row = {}
354
+ SchematicCodeSymbolParser.#assignNumericField(
355
+ row,
356
+ 'count',
357
+ fields,
358
+ 'InternalMemoryCount'
359
+ )
360
+ SchematicCodeSymbolParser.#assignNumericField(
361
+ row,
362
+ 'size',
363
+ fields,
364
+ 'InternalMemorySize'
365
+ )
366
+ SchematicCodeSymbolParser.#assignNumericField(
367
+ row,
368
+ 'interfaceMode',
369
+ fields,
370
+ 'InternalMemoryInterface'
371
+ )
372
+ SchematicCodeSymbolParser.#assignNumericField(
373
+ row,
374
+ 'dataWidth',
375
+ fields,
376
+ 'InternalMemoryDataWidth'
377
+ )
378
+ SchematicCodeSymbolParser.#assignNumericField(
379
+ row,
380
+ 'addressWidth',
381
+ fields,
382
+ 'InternalMemoryAddressWidth'
383
+ )
384
+
385
+ return row
386
+ }
387
+
388
+ /**
389
+ * Parses indexed external memory metadata.
390
+ * @param {Record<string, string | string[]>} fields Native fields.
391
+ * @returns {object[]}
392
+ */
393
+ static #parseExternalMemoryRows(fields) {
394
+ const count = SchematicCodeSymbolParser.#resolveIndexedRowCount(
395
+ fields,
396
+ 'ExternalMemoryCount',
397
+ /^ExternalMemory_(?:Name|Interface|DataWidth|AddressWidth|Scope|Mau|IsReserved)(\d+)$/u
398
+ )
399
+ const rows = []
400
+
401
+ for (let index = 0; index < count; index += 1) {
402
+ const row = {
403
+ index,
404
+ ...(getField(fields, 'ExternalMemory_Name' + index)
405
+ ? {
406
+ name: getField(fields, 'ExternalMemory_Name' + index)
407
+ }
408
+ : {})
409
+ }
410
+ SchematicCodeSymbolParser.#assignNumericField(
411
+ row,
412
+ 'interfaceMode',
413
+ fields,
414
+ 'ExternalMemory_Interface' + index
415
+ )
416
+ SchematicCodeSymbolParser.#assignNumericField(
417
+ row,
418
+ 'dataWidth',
419
+ fields,
420
+ 'ExternalMemory_DataWidth' + index
421
+ )
422
+ SchematicCodeSymbolParser.#assignNumericField(
423
+ row,
424
+ 'addressWidth',
425
+ fields,
426
+ 'ExternalMemory_AddressWidth' + index
427
+ )
428
+ SchematicCodeSymbolParser.#assignNumericField(
429
+ row,
430
+ 'scope',
431
+ fields,
432
+ 'ExternalMemory_Scope' + index
433
+ )
434
+ SchematicCodeSymbolParser.#assignNumericField(
435
+ row,
436
+ 'mau',
437
+ fields,
438
+ 'ExternalMemory_Mau' + index
439
+ )
440
+ SchematicCodeSymbolParser.#assignBooleanField(
441
+ row,
442
+ 'isReserved',
443
+ fields,
444
+ 'ExternalMemory_IsReserved' + index
445
+ )
446
+
447
+ if (Object.keys(row).length > 1) {
448
+ rows.push(row)
449
+ }
450
+ }
451
+
452
+ return rows
453
+ }
454
+
455
+ /**
456
+ * Resolves indexed row count from an explicit count or discovered fields.
457
+ * @param {Record<string, string | string[]>} fields Native fields.
458
+ * @param {string} countKey Explicit count field.
459
+ * @param {RegExp} pattern Indexed field matcher with an index capture.
460
+ * @returns {number}
461
+ */
462
+ static #resolveIndexedRowCount(fields, countKey, pattern) {
463
+ const explicitCount = parseNumericField(fields, countKey)
464
+ let discoveredCount = 0
465
+
466
+ for (const key of Object.keys(fields || {})) {
467
+ const match = key.match(pattern)
468
+ if (!match) continue
469
+ const index = Number(match[1])
470
+ if (Number.isInteger(index)) {
471
+ discoveredCount = Math.max(discoveredCount, index + 1)
472
+ }
473
+ }
474
+
475
+ return Math.max(explicitCount || 0, discoveredCount)
476
+ }
477
+
478
+ /**
479
+ * Adds a numeric field to a row when present.
480
+ * @param {object} row Target row.
481
+ * @param {string} property Public property.
482
+ * @param {Record<string, string | string[]>} fields Native fields.
483
+ * @param {string} fieldName Native field name.
484
+ * @returns {void}
485
+ */
486
+ static #assignNumericField(row, property, fields, fieldName) {
487
+ const value = parseNumericField(fields, fieldName)
488
+ if (value !== null) {
489
+ row[property] = value
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Adds a boolean field to a row when the source field exists.
495
+ * @param {object} row Target row.
496
+ * @param {string} property Public property.
497
+ * @param {Record<string, string | string[]>} fields Native fields.
498
+ * @param {string} fieldName Native field name.
499
+ * @returns {void}
500
+ */
501
+ static #assignBooleanField(row, property, fields, fieldName) {
502
+ if (getField(fields, fieldName)) {
503
+ row[property] = parseBoolean(fields[fieldName])
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Builds a lookup that tolerates owner-index variants.
509
+ * @param {{ key: string, sourceRecordIndex: number, indexInSheet: number | null }[]} symbols Parsed symbols.
510
+ * @returns {Map<string, object>}
511
+ */
512
+ static #buildSymbolLookup(symbols) {
513
+ const lookup = new Map()
514
+
515
+ for (const symbol of symbols) {
516
+ const candidateKeys = new Set([
517
+ String(symbol.sourceRecordIndex),
518
+ String(symbol.sourceRecordIndex + 1)
519
+ ])
520
+
521
+ if (symbol.indexInSheet !== null) {
522
+ candidateKeys.add(String(symbol.indexInSheet))
523
+ candidateKeys.add(String(symbol.indexInSheet + 1))
524
+ }
525
+
526
+ for (const key of candidateKeys) {
527
+ lookup.set(key, symbol)
528
+ }
529
+ }
530
+
531
+ return lookup
532
+ }
533
+
534
+ /**
535
+ * Attaches entry and text references to parent symbols.
536
+ * @param {object[]} symbols Parsed symbols.
537
+ * @param {object[]} entries Parsed entries.
538
+ * @param {object[]} texts Parsed texts.
539
+ * @returns {void}
540
+ */
541
+ static #attachChildKeys(symbols, entries, texts) {
542
+ const symbolsByKey = new Map(
543
+ symbols.map((symbol) => [symbol.key, symbol])
544
+ )
545
+
546
+ for (const entry of entries) {
547
+ const symbol = symbolsByKey.get(entry.ownerSymbolKey)
548
+ if (symbol) {
549
+ symbol.entryKeys.push(entry.key)
550
+ }
551
+ }
552
+
553
+ for (const text of texts) {
554
+ const symbol = symbolsByKey.get(text.ownerSymbolKey)
555
+ if (symbol) {
556
+ symbol.textKeys.push(text.key)
557
+ }
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Removes internal lookup metadata from a public symbol row.
563
+ * @param {object} symbol Internal symbol row.
564
+ * @returns {object}
565
+ */
566
+ static #publicSymbol(symbol) {
567
+ const { sourceRecordIndex, indexInSheet, ...publicSymbol } = symbol
568
+ return publicSymbol
569
+ }
570
+
571
+ /**
572
+ * Parses an entry distance field and optional fractional companion.
573
+ * @param {Record<string, string | string[]>} fields Native fields.
574
+ * @returns {number}
575
+ */
576
+ static #parseEntryDistance(fields) {
577
+ const whole = parseNumericField(fields, 'DistanceFromTop') || 0
578
+ const fraction = parseNumericField(fields, 'DistanceFromTop_FRAC1') || 0
579
+ const sign = whole < 0 ? -1 : 1
580
+
581
+ return whole * 10 + (fraction / 100000) * sign
582
+ }
583
+
584
+ /**
585
+ * Resolves an entry point on the parent symbol perimeter.
586
+ * @param {{ x: number, y: number, width: number, height: number }} parentSymbol Parent symbol.
587
+ * @param {'left' | 'right' | 'top' | 'bottom'} side Symbol side.
588
+ * @param {number} distance Distance from the side origin.
589
+ * @returns {{ x: number, y: number }}
590
+ */
591
+ static #resolveEntryPoint(parentSymbol, side, distance) {
592
+ switch (side) {
593
+ case 'right':
594
+ return {
595
+ x: parentSymbol.x + parentSymbol.width,
596
+ y: parentSymbol.y - distance
597
+ }
598
+ case 'top':
599
+ return {
600
+ x: parentSymbol.x + distance,
601
+ y: parentSymbol.y
602
+ }
603
+ case 'bottom':
604
+ return {
605
+ x: parentSymbol.x + distance,
606
+ y: parentSymbol.y - parentSymbol.height
607
+ }
608
+ case 'left':
609
+ default:
610
+ return {
611
+ x: parentSymbol.x,
612
+ y: parentSymbol.y - distance
613
+ }
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Resolves a side code into a public side label.
619
+ * @param {number | null} side Native side code.
620
+ * @returns {'left' | 'right' | 'top' | 'bottom'}
621
+ */
622
+ static #resolveSide(side) {
623
+ switch (side) {
624
+ case 1:
625
+ return 'right'
626
+ case 2:
627
+ return 'top'
628
+ case 3:
629
+ return 'bottom'
630
+ case 0:
631
+ default:
632
+ return 'left'
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Resolves an I/O code into a public direction label.
638
+ * @param {number | null} ioType Native I/O code.
639
+ * @returns {'unspecified' | 'output' | 'input' | 'bidirectional'}
640
+ */
641
+ static #resolveDirection(ioType) {
642
+ switch (ioType) {
643
+ case 1:
644
+ return 'output'
645
+ case 2:
646
+ return 'input'
647
+ case 3:
648
+ return 'bidirectional'
649
+ case 0:
650
+ default:
651
+ return 'unspecified'
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Builds a stable schematic record key.
657
+ * @param {{ recordIndex?: number }} record Parsed record.
658
+ * @returns {string}
659
+ */
660
+ static #recordKey(record) {
661
+ return 'schematic-record-' + String(record?.recordIndex ?? 0)
662
+ }
663
+ }