altium-toolkit 1.1.3 → 1.1.23

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 (125) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +171 -23
  3. package/docs/model-format.md +192 -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 +513 -3
  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 +7 -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/spec/library-scope.md +5 -0
  35. package/src/core/BinaryReader.mjs +213 -2
  36. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  37. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  38. package/src/core/altium/AltiumParser.mjs +357 -16
  39. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  40. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  41. package/src/core/altium/AltiumUnits.mjs +205 -0
  42. package/src/core/altium/AsciiRecordParser.mjs +52 -11
  43. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  44. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  45. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  46. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  47. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  48. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  49. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  50. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  51. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  52. package/src/core/altium/ParameterCollection.mjs +431 -0
  53. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  54. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  55. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  56. package/src/core/altium/ParserErrors.mjs +90 -0
  57. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  58. package/src/core/altium/ParserUtils.mjs +24 -0
  59. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  60. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  61. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  62. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  63. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  64. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  65. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  66. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  67. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  68. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  69. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  70. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  71. package/src/core/altium/PcbModelParser.mjs +223 -4
  72. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  73. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  74. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  75. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  76. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  77. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  78. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  79. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  80. package/src/core/altium/SchLibModelParser.mjs +840 -0
  81. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  82. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  83. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  84. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  85. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  86. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  87. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  88. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  89. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  90. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  91. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  92. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  93. package/src/core/altium/SchematicPinParser.mjs +99 -65
  94. package/src/core/altium/SchematicPrimitiveParser.mjs +125 -22
  95. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  96. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  97. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  98. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  99. package/src/core/altium/SchematicStreamExtractor.mjs +107 -21
  100. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  101. package/src/core/altium/SchematicTextParser.mjs +28 -12
  102. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  103. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  104. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  105. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  106. package/src/core/altium/SourceComponentClient.mjs +239 -0
  107. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  108. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  109. package/src/parser.mjs +43 -1
  110. package/src/renderers.mjs +1 -0
  111. package/src/styles/altium-renderers.css +6 -6
  112. package/src/ui/PcbArcUtils.mjs +19 -2
  113. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  114. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  115. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  116. package/src/ui/SchematicColorResolver.mjs +185 -0
  117. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  118. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  119. package/src/ui/SchematicNoteRenderer.mjs +5 -1
  120. package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
  121. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  122. package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
  123. package/src/ui/SchematicShapeRenderer.mjs +86 -17
  124. package/src/ui/SchematicSvgRenderer.mjs +980 -58
  125. package/src/ui/SchematicTypography.mjs +4 -3
@@ -0,0 +1,840 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { LibraryQaReportBuilder } from './LibraryQaReportBuilder.mjs'
6
+ import { LibraryRenderManifestBuilder } from './LibraryRenderManifestBuilder.mjs'
7
+ import { NormalizedModelSchema } from './NormalizedModelSchema.mjs'
8
+ import { ParserUtils } from './ParserUtils.mjs'
9
+ import { SchematicImplementationParser } from './SchematicImplementationParser.mjs'
10
+
11
+ const { getField, parseBoolean, parseNumericField, stripExtension } =
12
+ ParserUtils
13
+
14
+ /**
15
+ * Normalizes extracted SchLib stream data into a read-only library model.
16
+ */
17
+ export class SchLibModelParser {
18
+ /**
19
+ * Parses one extracted SchLib into a normalized read-only model.
20
+ * @param {string} fileName Source file name.
21
+ * @param {{ libraryRecords?: object[], fileHeaderRecords?: object[], sectionKeys?: object[], symbols?: object[], embeddedFiles?: object, nativeStreams?: object, streamNames?: string[], opaqueRecords?: object[], diagnostics?: object } | null} extraction Extracted streams.
22
+ * @returns {object}
23
+ */
24
+ static parse(fileName, extraction = null) {
25
+ const safeExtraction = extraction || {}
26
+ const embeddedFiles = safeExtraction.embeddedFiles || {
27
+ schema: 'altium-toolkit.embedded-files.a1',
28
+ files: [],
29
+ diagnostics: []
30
+ }
31
+ const symbols = (safeExtraction.symbols || []).map((entry, index) =>
32
+ SchLibModelParser.#symbol(entry, index, embeddedFiles.files || [])
33
+ )
34
+ const schematicLibrary = {
35
+ libraryHeader: SchLibModelParser.#libraryHeader(
36
+ safeExtraction.libraryRecords || [],
37
+ safeExtraction.fileHeaderRecords || []
38
+ ),
39
+ sectionKeys: safeExtraction.sectionKeys || [],
40
+ streamNames: safeExtraction.streamNames || [],
41
+ ...(safeExtraction.nativeStreams
42
+ ? { nativeStreams: safeExtraction.nativeStreams }
43
+ : {}),
44
+ symbols,
45
+ indexes: SchLibModelParser.#indexes(symbols),
46
+ embeddedFiles,
47
+ opaqueRecords: safeExtraction.opaqueRecords || []
48
+ }
49
+ schematicLibrary.renderManifest =
50
+ LibraryRenderManifestBuilder.buildSchematicLibraryManifest(
51
+ schematicLibrary
52
+ )
53
+ const model = NormalizedModelSchema.attach({
54
+ kind: 'schematic-library',
55
+ fileType: 'SchLib',
56
+ fileName,
57
+ summary: SchLibModelParser.#summary(
58
+ fileName,
59
+ symbols,
60
+ schematicLibrary.opaqueRecords,
61
+ safeExtraction.nativeStreams
62
+ ),
63
+ diagnostics: SchLibModelParser.#diagnostics(
64
+ symbols,
65
+ safeExtraction
66
+ ),
67
+ schematicLibrary,
68
+ bom: []
69
+ })
70
+ model.schematicLibrary.qa = LibraryQaReportBuilder.build({
71
+ schematicLibraries: [model]
72
+ })
73
+
74
+ return model
75
+ }
76
+
77
+ /**
78
+ * Builds one symbol read-model row.
79
+ * @param {object} entry Extracted symbol entry.
80
+ * @param {number} index Symbol index.
81
+ * @param {object[]} embeddedFiles Embedded file inventory rows.
82
+ * @returns {object}
83
+ */
84
+ static #symbol(entry, index, embeddedFiles) {
85
+ const records = Array.isArray(entry.records) ? entry.records : []
86
+ const implementationRows =
87
+ SchLibModelParser.#implementationRows(records)
88
+ const componentRecord =
89
+ records.find((record) =>
90
+ ['component', 'symbol', '1'].includes(
91
+ SchLibModelParser.#recordType(record).toLowerCase()
92
+ )
93
+ ) || records[0]
94
+ const sourceStorage = String(entry.sourceStorage || '')
95
+ const name =
96
+ getField(componentRecord?.fields, 'Name') ||
97
+ getField(componentRecord?.fields, 'LibReference') ||
98
+ getField(componentRecord?.fields, 'DesignItemId') ||
99
+ SchLibModelParser.#basename(sourceStorage) ||
100
+ 'Symbol' + (index + 1)
101
+ const pins = records
102
+ .filter((record) =>
103
+ ['pin', '2'].includes(
104
+ SchLibModelParser.#recordType(record).toLowerCase()
105
+ )
106
+ )
107
+ .map((record, pinIndex) =>
108
+ SchLibModelParser.#pin(record, pinIndex, entry.pinSidecars)
109
+ )
110
+ const parameters = SchLibModelParser.#parameters(records)
111
+ const implementations = records
112
+ .filter((record) =>
113
+ ['implementation', '45'].includes(
114
+ SchLibModelParser.#recordType(record).toLowerCase()
115
+ )
116
+ )
117
+ .map((record) =>
118
+ SchLibModelParser.#implementation(record, implementationRows)
119
+ )
120
+ const primitives = records
121
+ .filter((record) => !SchLibModelParser.#isSemanticRecord(record))
122
+ .map((record) => SchLibModelParser.#primitive(record))
123
+ const parts = SchLibModelParser.#parts(pins, primitives)
124
+ const symbolEmbeddedAssets = [
125
+ ...(entry.embeddedAssets || []),
126
+ ...embeddedFiles.filter((file) =>
127
+ String(file.sourceStream || '').startsWith(sourceStorage + '/')
128
+ )
129
+ ]
130
+
131
+ return SchLibModelParser.#stripUndefined({
132
+ name,
133
+ displayName: getField(componentRecord?.fields, 'DisplayName'),
134
+ sourceId: getField(componentRecord?.fields, 'SourceId'),
135
+ sourceStorage,
136
+ sourceStream: entry.sourceStream,
137
+ declaredPinCount: SchLibModelParser.#optionalNumber(
138
+ getField(componentRecord?.fields, 'PinCount')
139
+ ),
140
+ declaredPrimitiveCount: SchLibModelParser.#optionalNumber(
141
+ getField(componentRecord?.fields, 'PrimitiveCount')
142
+ ),
143
+ pins,
144
+ parts,
145
+ parameters,
146
+ implementations,
147
+ primitives,
148
+ embeddedAssets: symbolEmbeddedAssets,
149
+ opaqueRecords: entry.opaqueRecords || []
150
+ })
151
+ }
152
+
153
+ /**
154
+ * Builds one pin row.
155
+ * @param {object} record Pin source record.
156
+ * @param {number} pinIndex Zero-based pin index.
157
+ * @param {Record<string, object[]>} pinSidecars Parsed pin side streams.
158
+ * @returns {object}
159
+ */
160
+ static #pin(record, pinIndex, pinSidecars = {}) {
161
+ const fracFields = SchLibModelParser.#sidecarFields(
162
+ pinSidecars,
163
+ 'PinFrac',
164
+ pinIndex
165
+ )
166
+ const descFields = SchLibModelParser.#sidecarFields(
167
+ pinSidecars,
168
+ 'PinDesc',
169
+ pinIndex
170
+ )
171
+ const textFields = SchLibModelParser.#sidecarFields(
172
+ pinSidecars,
173
+ 'PinTextData',
174
+ pinIndex
175
+ )
176
+ const packageFields = SchLibModelParser.#sidecarFields(
177
+ pinSidecars,
178
+ 'PinPackageLength',
179
+ pinIndex
180
+ )
181
+ const functionFields = SchLibModelParser.#sidecarFields(
182
+ pinSidecars,
183
+ 'PinFunctionData',
184
+ pinIndex
185
+ )
186
+ const symbolLineFields = SchLibModelParser.#sidecarFields(
187
+ pinSidecars,
188
+ 'PinSymbolLineWidth',
189
+ pinIndex
190
+ )
191
+ const textStyle = SchLibModelParser.#pinTextStyle(textFields)
192
+
193
+ return SchLibModelParser.#stripUndefined({
194
+ designator:
195
+ getField(record.fields, 'Designator') ||
196
+ getField(record.fields, 'PinNumber'),
197
+ name: getField(record.fields, 'Name'),
198
+ partId: SchLibModelParser.#partId(record.fields),
199
+ electricalType: SchLibModelParser.#lowerField(
200
+ record.fields,
201
+ 'ElectricalType'
202
+ ),
203
+ formalType: SchLibModelParser.#lowerField(
204
+ record.fields,
205
+ 'FormalType'
206
+ ),
207
+ description:
208
+ SchLibModelParser.#firstField(descFields, [
209
+ 'Description',
210
+ 'PinDescription',
211
+ 'Text'
212
+ ]) || getField(record.fields, 'Description'),
213
+ pinFunction: SchLibModelParser.#firstField(functionFields, [
214
+ 'PinFunction',
215
+ 'Function',
216
+ 'Text'
217
+ ]),
218
+ x:
219
+ SchLibModelParser.#optionalNumber(
220
+ SchLibModelParser.#firstField(fracFields, [
221
+ 'Location.X',
222
+ 'X'
223
+ ])
224
+ ) ?? parseNumericField(record.fields, 'Location.X'),
225
+ y:
226
+ SchLibModelParser.#optionalNumber(
227
+ SchLibModelParser.#firstField(fracFields, [
228
+ 'Location.Y',
229
+ 'Y'
230
+ ])
231
+ ) ?? parseNumericField(record.fields, 'Location.Y'),
232
+ length:
233
+ SchLibModelParser.#optionalNumber(
234
+ SchLibModelParser.#firstField(fracFields, [
235
+ 'PinLength',
236
+ 'Length'
237
+ ])
238
+ ) ??
239
+ parseNumericField(record.fields, 'PinLength') ??
240
+ parseNumericField(record.fields, 'Length'),
241
+ pinPackageLength:
242
+ SchLibModelParser.#optionalNumber(
243
+ SchLibModelParser.#firstField(packageFields, [
244
+ 'PinPackageLength',
245
+ 'PackageLength',
246
+ 'Length'
247
+ ])
248
+ ) ??
249
+ parseNumericField(record.fields, 'PinPackageLength') ??
250
+ parseNumericField(record.fields, 'PackageLength'),
251
+ symbolInner: SchLibModelParser.#lowerFirstField(record.fields, [
252
+ 'Symbol_Inner',
253
+ 'SymbolInner',
254
+ 'SymbolInnerEdge'
255
+ ]),
256
+ symbolOuter: SchLibModelParser.#lowerFirstField(record.fields, [
257
+ 'Symbol_Outer',
258
+ 'SymbolOuter',
259
+ 'SymbolOuterEdge'
260
+ ]),
261
+ symbolInside: SchLibModelParser.#lowerFirstField(record.fields, [
262
+ 'Symbol_Inside',
263
+ 'SymbolInside'
264
+ ]),
265
+ symbolOutside: SchLibModelParser.#lowerFirstField(record.fields, [
266
+ 'Symbol_Outside',
267
+ 'SymbolOutside'
268
+ ]),
269
+ symbolLineWidth:
270
+ SchLibModelParser.#optionalNumber(
271
+ SchLibModelParser.#firstField(symbolLineFields, [
272
+ 'SymbolLineWidth',
273
+ 'LineWidth',
274
+ 'Width'
275
+ ])
276
+ ) ?? parseNumericField(record.fields, 'SymbolLineWidth'),
277
+ swapIdPart: parseNumericField(record.fields, 'SwapIdPart'),
278
+ swapIdPair: parseNumericField(record.fields, 'SwapIdPair'),
279
+ swapIdPartPin: parseNumericField(record.fields, 'SwapIdPartPin'),
280
+ swapIdPin: parseNumericField(record.fields, 'SwapIdPin'),
281
+ defaultValue: getField(record.fields, 'DefaultValue'),
282
+ hidden: SchLibModelParser.#optionalBoolean(record.fields, [
283
+ 'IsHidden',
284
+ 'Hidden'
285
+ ]),
286
+ ...(Object.keys(textStyle).length ? { textStyle } : {})
287
+ })
288
+ }
289
+
290
+ /**
291
+ * Builds one implementation row.
292
+ * @param {object} record Implementation source record.
293
+ * @param {Map<number, object>} implementationRows Parsed implementation rows.
294
+ * @returns {object}
295
+ */
296
+ static #implementation(record, implementationRows) {
297
+ const implementationRow = implementationRows.get(record.recordIndex)
298
+ const detailedTargetLibraries = (
299
+ implementationRow?.targetLibraries || []
300
+ )
301
+ .map((target) => target.fileName || target.entity)
302
+ .filter(Boolean)
303
+ const targetLibraries = [
304
+ ...new Set([
305
+ ...SchLibModelParser.#targetLibraries(record.fields),
306
+ ...detailedTargetLibraries
307
+ ])
308
+ ]
309
+
310
+ return SchLibModelParser.#stripUndefined({
311
+ modelName: getField(record.fields, 'ModelName'),
312
+ modelType: SchLibModelParser.#lowerField(
313
+ record.fields,
314
+ 'ModelType'
315
+ ),
316
+ targetLibraries,
317
+ searchPaths: implementationRow?.searchPaths,
318
+ mapDefiners: implementationRow?.mapDefiners,
319
+ parameters: implementationRow?.parameters
320
+ })
321
+ }
322
+
323
+ /**
324
+ * Builds one primitive summary row.
325
+ * @param {object} record Primitive source record.
326
+ * @returns {object}
327
+ */
328
+ static #primitive(record) {
329
+ return SchLibModelParser.#stripUndefined({
330
+ recordType: SchLibModelParser.#recordType(record),
331
+ recordIndex: record.recordIndex,
332
+ partId: SchLibModelParser.#partId(record.fields)
333
+ })
334
+ }
335
+
336
+ /**
337
+ * Builds symbol parameters from parameter records.
338
+ * @param {object[]} records Source records.
339
+ * @returns {Record<string, string>}
340
+ */
341
+ static #parameters(records) {
342
+ const parameters = {}
343
+
344
+ for (const record of records) {
345
+ if (
346
+ !['parameter', '41'].includes(
347
+ SchLibModelParser.#recordType(record).toLowerCase()
348
+ )
349
+ ) {
350
+ continue
351
+ }
352
+
353
+ const name = getField(record.fields, 'Name')
354
+ const value =
355
+ getField(record.fields, 'Text') ||
356
+ getField(record.fields, 'Value')
357
+ if (name) {
358
+ parameters[name] = value
359
+ }
360
+ }
361
+
362
+ return parameters
363
+ }
364
+
365
+ /**
366
+ * Builds part summaries from pins and primitive rows.
367
+ * @param {object[]} pins Pin rows.
368
+ * @param {object[]} primitives Primitive rows.
369
+ * @returns {object[]}
370
+ */
371
+ static #parts(pins, primitives) {
372
+ const partIds = [
373
+ ...new Set(
374
+ [...pins, ...primitives]
375
+ .map((row) => String(row.partId || '').trim())
376
+ .filter(Boolean)
377
+ )
378
+ ].sort((left, right) => left.localeCompare(right))
379
+
380
+ return partIds.map((partId) => ({
381
+ partId,
382
+ pinCount: pins.filter((pin) => pin.partId === partId).length,
383
+ primitiveCount: primitives.filter(
384
+ (primitive) => primitive.partId === partId
385
+ ).length
386
+ }))
387
+ }
388
+
389
+ /**
390
+ * Builds library indexes.
391
+ * @param {object[]} symbols Symbol rows.
392
+ * @returns {object}
393
+ */
394
+ static #indexes(symbols) {
395
+ return {
396
+ symbolsByName: Object.fromEntries(
397
+ symbols.map((symbol, index) => [
398
+ symbol.name,
399
+ {
400
+ index,
401
+ name: symbol.name,
402
+ sourceStorage: symbol.sourceStorage,
403
+ pinCount: symbol.pins.length,
404
+ partCount: symbol.parts.length,
405
+ keywordCount: SchLibModelParser.#keywords(symbol).length
406
+ }
407
+ ])
408
+ )
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Builds a library header from Library/Data records.
414
+ * @param {object[]} records Library records.
415
+ * @param {object[]} fileHeaderRecords FileHeader records.
416
+ * @returns {Record<string, string>}
417
+ */
418
+ static #libraryHeader(records, fileHeaderRecords) {
419
+ const headerRecord = records.find((record) =>
420
+ getField(record.fields, 'HEADER')
421
+ )
422
+ const header = headerRecord
423
+ ? {
424
+ header: getField(headerRecord.fields, 'HEADER')
425
+ }
426
+ : {}
427
+ const fileHeader = SchLibModelParser.#fileHeader(fileHeaderRecords)
428
+
429
+ return SchLibModelParser.#stripUndefined({
430
+ ...header,
431
+ ...(fileHeader ? { fileHeader } : {})
432
+ })
433
+ }
434
+
435
+ /**
436
+ * Builds the parser summary.
437
+ * @param {string} fileName Source file name.
438
+ * @param {object[]} symbols Symbol rows.
439
+ * @param {object[]} opaqueRecords Opaque records.
440
+ * @param {object | undefined} nativeStreams Native stream inventory.
441
+ * @returns {object}
442
+ */
443
+ static #summary(fileName, symbols, opaqueRecords, nativeStreams) {
444
+ return {
445
+ title: stripExtension(fileName),
446
+ symbolCount: symbols.length,
447
+ pinCount: symbols.reduce(
448
+ (count, symbol) => count + symbol.pins.length,
449
+ 0
450
+ ),
451
+ partCount: symbols.reduce(
452
+ (count, symbol) => count + symbol.parts.length,
453
+ 0
454
+ ),
455
+ embeddedAssetCount: symbols.reduce(
456
+ (count, symbol) => count + (symbol.embeddedAssets || []).length,
457
+ 0
458
+ ),
459
+ ...(nativeStreams?.summary?.streamCount
460
+ ? {
461
+ nativeStreamCount: nativeStreams.summary.streamCount
462
+ }
463
+ : {}),
464
+ opaqueRecordCount: opaqueRecords.length
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Builds parser diagnostics.
470
+ * @param {object[]} symbols Symbol rows.
471
+ * @param {object} extraction Extraction metadata.
472
+ * @returns {object[]}
473
+ */
474
+ static #diagnostics(symbols, extraction) {
475
+ const diagnostics = [
476
+ {
477
+ severity: 'info',
478
+ message:
479
+ 'Recovered ' +
480
+ symbols.length +
481
+ ' schematic library symbol definitions.'
482
+ }
483
+ ]
484
+
485
+ if (Array.isArray(extraction.streamNames)) {
486
+ diagnostics.push({
487
+ severity: 'info',
488
+ message:
489
+ 'Recovered ' +
490
+ extraction.streamNames.length +
491
+ ' SchLib data streams from the compound document.'
492
+ })
493
+ }
494
+
495
+ return diagnostics
496
+ }
497
+
498
+ /**
499
+ * Returns true when the record has a dedicated semantic model row.
500
+ * @param {object} record Source record.
501
+ * @returns {boolean}
502
+ */
503
+ static #isSemanticRecord(record) {
504
+ return [
505
+ 'component',
506
+ 'symbol',
507
+ '1',
508
+ 'pin',
509
+ '2',
510
+ 'parameter',
511
+ '41',
512
+ 'implementation',
513
+ '45'
514
+ ].includes(SchLibModelParser.#recordType(record).toLowerCase())
515
+ }
516
+
517
+ /**
518
+ * Resolves one record type string.
519
+ * @param {object} record Source record.
520
+ * @returns {string}
521
+ */
522
+ static #recordType(record) {
523
+ return getField(record?.fields, 'RECORD') || 'unknown'
524
+ }
525
+
526
+ /**
527
+ * Resolves one part id from common source fields.
528
+ * @param {object} fields Source fields.
529
+ * @returns {string | undefined}
530
+ */
531
+ static #partId(fields) {
532
+ return (
533
+ getField(fields, 'PartId') ||
534
+ getField(fields, 'OwnerPartId') ||
535
+ getField(fields, 'OwnerPartID') ||
536
+ undefined
537
+ )
538
+ }
539
+
540
+ /**
541
+ * Resolves target library names from implementation fields.
542
+ * @param {object} fields Source fields.
543
+ * @returns {string[]}
544
+ */
545
+ static #targetLibraries(fields) {
546
+ const directTargets = (
547
+ getField(fields, 'TargetLibraries') ||
548
+ getField(fields, 'TargetLibrary') ||
549
+ ''
550
+ )
551
+ .split(/[;,]/u)
552
+ .map((value) => value.trim())
553
+ .filter(Boolean)
554
+ const indexedTargets = []
555
+ const count =
556
+ parseNumericField(fields, 'DatafileCount') ??
557
+ SchLibModelParser.#countIndexedFields(fields, 'ModelDatafileEntity')
558
+
559
+ for (let index = 0; index < count; index += 1) {
560
+ const entity = getField(fields, 'ModelDatafileEntity' + index)
561
+ const kind = getField(fields, 'ModelDatafileKind' + index)
562
+ const fileName = SchLibModelParser.#libraryFileName(entity, kind)
563
+ if (fileName) {
564
+ indexedTargets.push(fileName)
565
+ }
566
+ }
567
+
568
+ return [...new Set([...directTargets, ...indexedTargets])]
569
+ }
570
+
571
+ /**
572
+ * Builds parsed implementation rows keyed by source record index.
573
+ * @param {object[]} records Symbol records.
574
+ * @returns {Map<number, object>}
575
+ */
576
+ static #implementationRows(records) {
577
+ const parsed = SchematicImplementationParser.parse(records)
578
+
579
+ return new Map(
580
+ (parsed?.implementations || []).map((implementation) => [
581
+ Number(
582
+ String(implementation.recordKey || '').replace(
583
+ /^schematic-record-/u,
584
+ ''
585
+ )
586
+ ),
587
+ implementation
588
+ ])
589
+ )
590
+ }
591
+
592
+ /**
593
+ * Builds one file-header summary.
594
+ * @param {object[]} records FileHeader records.
595
+ * @returns {object | null}
596
+ */
597
+ static #fileHeader(records) {
598
+ const record = (records || []).find((entry) =>
599
+ getField(entry.fields, 'HEADER')
600
+ )
601
+ if (!record) {
602
+ return null
603
+ }
604
+
605
+ const fonts = SchLibModelParser.#fonts(record.fields)
606
+
607
+ return SchLibModelParser.#stripUndefined({
608
+ header: getField(record.fields, 'HEADER'),
609
+ fonts: fonts.length ? fonts : undefined
610
+ })
611
+ }
612
+
613
+ /**
614
+ * Parses indexed font entries from FileHeader fields.
615
+ * @param {object} fields FileHeader fields.
616
+ * @returns {object[]}
617
+ */
618
+ static #fonts(fields) {
619
+ return Object.keys(fields || {})
620
+ .map((key) => /^FontName(\d+)$/iu.exec(key))
621
+ .filter(Boolean)
622
+ .map((match) => {
623
+ const index = Number.parseInt(match[1], 10)
624
+ return SchLibModelParser.#stripUndefined({
625
+ index,
626
+ name: getField(fields, 'FontName' + index),
627
+ size: SchLibModelParser.#optionalNumber(
628
+ getField(fields, 'FontSize' + index)
629
+ ),
630
+ id: SchLibModelParser.#optionalNumber(
631
+ getField(fields, 'FontId' + index)
632
+ )
633
+ })
634
+ })
635
+ .sort((left, right) => left.index - right.index)
636
+ }
637
+
638
+ /**
639
+ * Returns the sidecar fields for one pin index.
640
+ * @param {Record<string, object[]>} pinSidecars Parsed sidecar records.
641
+ * @param {string} sidecarName Sidecar stream basename.
642
+ * @param {number} pinIndex Zero-based pin index.
643
+ * @returns {object}
644
+ */
645
+ static #sidecarFields(pinSidecars, sidecarName, pinIndex) {
646
+ const records = pinSidecars?.[sidecarName] || []
647
+
648
+ return (
649
+ records.find((record, recordIndex) => {
650
+ const explicitIndex =
651
+ parseNumericField(record.fields, 'PinIndex') ??
652
+ parseNumericField(record.fields, 'Index') ??
653
+ parseNumericField(record.fields, 'RecordIndex')
654
+
655
+ return explicitIndex === null
656
+ ? recordIndex === pinIndex
657
+ : explicitIndex === pinIndex
658
+ })?.fields || {}
659
+ )
660
+ }
661
+
662
+ /**
663
+ * Builds text-style metadata from PinTextData fields.
664
+ * @param {object} fields Pin text fields.
665
+ * @returns {object}
666
+ */
667
+ static #pinTextStyle(fields) {
668
+ return SchLibModelParser.#stripUndefined({
669
+ nameFontId: parseNumericField(fields, 'NameFontId'),
670
+ designatorFontId: parseNumericField(fields, 'DesignatorFontId'),
671
+ namePosition: SchLibModelParser.#lowerField(fields, 'NamePosition'),
672
+ designatorPosition: SchLibModelParser.#lowerField(
673
+ fields,
674
+ 'DesignatorPosition'
675
+ )
676
+ })
677
+ }
678
+
679
+ /**
680
+ * Returns the first non-empty field from a field set.
681
+ * @param {object} fields Source fields.
682
+ * @param {string[]} keys Candidate keys.
683
+ * @returns {string}
684
+ */
685
+ static #firstField(fields, keys) {
686
+ for (const key of keys || []) {
687
+ const value = getField(fields, key)
688
+ if (value) {
689
+ return value
690
+ }
691
+ }
692
+
693
+ return ''
694
+ }
695
+
696
+ /**
697
+ * Reads and lower-cases the first available field.
698
+ * @param {object} fields Source fields.
699
+ * @param {string[]} keys Candidate keys.
700
+ * @returns {string | undefined}
701
+ */
702
+ static #lowerFirstField(fields, keys) {
703
+ const value = SchLibModelParser.#firstField(fields, keys)
704
+
705
+ return value ? value.toLowerCase() : undefined
706
+ }
707
+
708
+ /**
709
+ * Reads an optional boolean field.
710
+ * @param {object} fields Source fields.
711
+ * @param {string[]} keys Candidate keys.
712
+ * @returns {boolean | undefined}
713
+ */
714
+ static #optionalBoolean(fields, keys) {
715
+ for (const key of keys || []) {
716
+ const value = getField(fields, key)
717
+ if (value) {
718
+ return parseBoolean(value)
719
+ }
720
+ }
721
+
722
+ return undefined
723
+ }
724
+
725
+ /**
726
+ * Counts fields with an indexed prefix.
727
+ * @param {object} fields Source fields.
728
+ * @param {string} prefix Indexed field prefix.
729
+ * @returns {number}
730
+ */
731
+ static #countIndexedFields(fields, prefix) {
732
+ const normalizedPrefix = prefix.toLowerCase()
733
+
734
+ return Object.keys(fields || {}).filter((key) =>
735
+ key.toLowerCase().startsWith(normalizedPrefix)
736
+ ).length
737
+ }
738
+
739
+ /**
740
+ * Infers a native library file name from entity and kind fields.
741
+ * @param {string} entity Library entity.
742
+ * @param {string} kind Library kind.
743
+ * @returns {string}
744
+ */
745
+ static #libraryFileName(entity, kind) {
746
+ if (!entity) {
747
+ return ''
748
+ }
749
+
750
+ if (/\.[^.]+$/u.test(entity)) {
751
+ return entity
752
+ }
753
+
754
+ const extension =
755
+ {
756
+ pcblib: 'PcbLib',
757
+ schlib: 'SchLib',
758
+ intlib: 'IntLib',
759
+ sim: 'SimModel'
760
+ }[
761
+ String(kind || '')
762
+ .trim()
763
+ .toLowerCase()
764
+ ] || kind
765
+
766
+ return extension ? entity + '.' + extension : entity
767
+ }
768
+
769
+ /**
770
+ * Builds symbol search keywords.
771
+ * @param {object} symbol Symbol row.
772
+ * @returns {string[]}
773
+ */
774
+ static #keywords(symbol) {
775
+ return [
776
+ symbol.name,
777
+ symbol.displayName,
778
+ symbol.sourceId,
779
+ ...Object.values(symbol.parameters || {})
780
+ ]
781
+ .map((value) => String(value || '').trim())
782
+ .filter(Boolean)
783
+ }
784
+
785
+ /**
786
+ * Reads a lower-case field value.
787
+ * @param {object} fields Source fields.
788
+ * @param {string} key Field key.
789
+ * @returns {string | undefined}
790
+ */
791
+ static #lowerField(fields, key) {
792
+ const value = getField(fields, key)
793
+ return value ? value.toLowerCase() : undefined
794
+ }
795
+
796
+ /**
797
+ * Normalizes an optional numeric value.
798
+ * @param {unknown} value Source value.
799
+ * @returns {number | undefined}
800
+ */
801
+ static #optionalNumber(value) {
802
+ if (value === undefined || value === null) {
803
+ return undefined
804
+ }
805
+ if (String(value).trim() === '') {
806
+ return undefined
807
+ }
808
+
809
+ const numeric = Number(value)
810
+ return Number.isFinite(numeric) ? numeric : undefined
811
+ }
812
+
813
+ /**
814
+ * Resolves the final path component.
815
+ * @param {string} value Path.
816
+ * @returns {string}
817
+ */
818
+ static #basename(value) {
819
+ return (
820
+ String(value || '')
821
+ .split('/')
822
+ .filter(Boolean)
823
+ .pop() || ''
824
+ )
825
+ }
826
+
827
+ /**
828
+ * Removes undefined values from an object.
829
+ * @param {object} row Row to strip.
830
+ * @returns {object}
831
+ */
832
+ static #stripUndefined(row) {
833
+ return Object.fromEntries(
834
+ Object.entries(row).filter(
835
+ ([, value]) =>
836
+ value !== undefined && value !== null && value !== ''
837
+ )
838
+ )
839
+ }
840
+ }