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,549 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+ import { SchematicRecordTypeRegistry } from './SchematicRecordTypeRegistry.mjs'
7
+
8
+ /**
9
+ * Builds deterministic schematic parser field-coverage reports.
10
+ */
11
+ export class SchematicFieldCoverageReportBuilder {
12
+ static SCHEMA_ID = 'altium-toolkit.schematic.field-coverage.a1'
13
+
14
+ static #COMMON_FIELDS = new Set([
15
+ 'Alignment',
16
+ 'AreaColor',
17
+ 'Color',
18
+ 'Corner.X',
19
+ 'Corner.Y',
20
+ 'GraphicallyLocked',
21
+ 'HEADER',
22
+ 'IndexInSheet',
23
+ 'IsHidden',
24
+ 'IsMirrored',
25
+ 'IsNotAccesible',
26
+ 'IsNotAccessible',
27
+ 'LineStyle',
28
+ 'LineWidth',
29
+ 'Location.X',
30
+ 'Location.Y',
31
+ 'Name',
32
+ 'Orientation',
33
+ 'OwnerIndex',
34
+ 'OwnerPartDisplayMode',
35
+ 'OwnerPartID',
36
+ 'OwnerPartId',
37
+ 'RECORD',
38
+ 'ReadOnlyState',
39
+ 'Text',
40
+ 'TextColor',
41
+ 'UniqueID',
42
+ 'UniqueId'
43
+ ])
44
+
45
+ static #COMMON_FIELD_KEYS =
46
+ SchematicFieldCoverageReportBuilder.#normalizedFieldSet(
47
+ SchematicFieldCoverageReportBuilder.#COMMON_FIELDS
48
+ )
49
+
50
+ static #TEXT_FIELDS = new Set([
51
+ 'ClipToRect',
52
+ 'FontID',
53
+ 'IsSolid',
54
+ 'Justification',
55
+ 'NotAutoposition',
56
+ 'ShowBorder',
57
+ 'ShowName',
58
+ 'TextMargin',
59
+ 'WordWrap'
60
+ ])
61
+
62
+ static #FIELDS_BY_RECORD_TYPE = new Map([
63
+ [
64
+ '1',
65
+ new Set([
66
+ 'AreaColor',
67
+ 'ComponentDescription',
68
+ 'ComponentKind',
69
+ 'CurrentPartId',
70
+ 'DesignItemId',
71
+ 'DesignatorLocked',
72
+ 'DisplayMode',
73
+ 'DisplayModeCount',
74
+ 'LibReference',
75
+ 'PartCount',
76
+ 'PartIdLocked',
77
+ 'PinsMoveable',
78
+ 'SourceLibraryName'
79
+ ])
80
+ ],
81
+ [
82
+ '2',
83
+ new Set([
84
+ 'Description',
85
+ 'Designator',
86
+ 'Electrical',
87
+ 'FormalType',
88
+ 'Name',
89
+ 'PinConglomerate',
90
+ 'PinLength',
91
+ 'PinName_CustomPositionMargin',
92
+ 'PinName_PositionMode',
93
+ 'PinPackageLength',
94
+ 'ShowDesignator',
95
+ 'ShowName',
96
+ 'SwapIdPart',
97
+ 'SwapIdPin',
98
+ 'Symbol_Inner',
99
+ 'Symbol_Outer',
100
+ 'SymBol_Inner',
101
+ 'SymBol_Outer'
102
+ ])
103
+ ],
104
+ ['4', SchematicFieldCoverageReportBuilder.#TEXT_FIELDS],
105
+ ['5', new Set(['LocationCount', 'ShowMarker', 'StartLineShape'])],
106
+ [
107
+ '6',
108
+ new Set([
109
+ 'EndLineShape',
110
+ 'EndLineShapeSize',
111
+ 'LocationCount',
112
+ 'StartLineShape',
113
+ 'StartLineShapeSize'
114
+ ])
115
+ ],
116
+ [
117
+ '7',
118
+ new Set([
119
+ 'EndLineShape',
120
+ 'EndLineShapeSize',
121
+ 'IsSolid',
122
+ 'LocationCount',
123
+ 'StartLineShape',
124
+ 'StartLineShapeSize',
125
+ 'Transparent'
126
+ ])
127
+ ],
128
+ ['8', new Set(['IsSolid', 'Radius', 'SecondaryRadius', 'Transparent'])],
129
+ [
130
+ '9',
131
+ new Set([
132
+ 'EndAngle',
133
+ 'IsSolid',
134
+ 'Radius',
135
+ 'SecondaryRadius',
136
+ 'StartAngle',
137
+ 'Transparent'
138
+ ])
139
+ ],
140
+ [
141
+ '10',
142
+ new Set([
143
+ 'CornerXRadius',
144
+ 'CornerYRadius',
145
+ 'IsSolid',
146
+ 'Transparent'
147
+ ])
148
+ ],
149
+ [
150
+ '11',
151
+ new Set(['EndAngle', 'Radius', 'SecondaryRadius', 'StartAngle'])
152
+ ],
153
+ ['12', new Set(['EndAngle', 'Radius', 'StartAngle'])],
154
+ ['14', new Set(['IsSolid', 'Transparent'])],
155
+ ['15', new Set(['AreaColor', 'Style', 'UniqueId', 'XSize', 'YSize'])],
156
+ [
157
+ '16',
158
+ new Set([
159
+ 'DistanceFromTop',
160
+ 'DistanceFromTop_Frac',
161
+ 'DistanceFromTop_Frac1',
162
+ 'IOType',
163
+ 'Side',
164
+ 'Style'
165
+ ])
166
+ ],
167
+ ['17', new Set(['FontID', 'ShowNetName', 'Style'])],
168
+ [
169
+ '18',
170
+ new Set(['Alignment', 'AreaColor', 'FontID', 'IOType', 'Style'])
171
+ ],
172
+ ['22', new Set(['Symbol'])],
173
+ ['25', SchematicFieldCoverageReportBuilder.#TEXT_FIELDS],
174
+ ['26', new Set(['LocationCount'])],
175
+ ['27', new Set(['LocationCount'])],
176
+ [
177
+ '28',
178
+ new Set([
179
+ ...SchematicFieldCoverageReportBuilder.#TEXT_FIELDS,
180
+ 'AreaColor'
181
+ ])
182
+ ],
183
+ ['29', new Set([])],
184
+ [
185
+ '30',
186
+ new Set([
187
+ 'EmbedImage',
188
+ 'FileName',
189
+ 'ImageIndex',
190
+ 'KeepAspect',
191
+ 'Storage'
192
+ ])
193
+ ],
194
+ [
195
+ '31',
196
+ new Set([
197
+ 'BorderOn',
198
+ 'CustomMarginWidth',
199
+ 'CustomX',
200
+ 'CustomXZones',
201
+ 'CustomY',
202
+ 'CustomYZones',
203
+ 'FontIdCount',
204
+ 'SheetStyle',
205
+ 'ShowTemplateGraphics',
206
+ 'SnapGridSize',
207
+ 'TemplateFileName',
208
+ 'TemplateItemGUID',
209
+ 'TemplateRevisionGUID',
210
+ 'TemplateRevisionHRID',
211
+ 'TemplateVaultGUID',
212
+ 'TemplateVaultHRID',
213
+ 'TitleBlockOn',
214
+ 'VisibleGridSize',
215
+ 'WorkspaceOrientation'
216
+ ])
217
+ ],
218
+ ['34', SchematicFieldCoverageReportBuilder.#TEXT_FIELDS],
219
+ ['37', new Set([])],
220
+ ['39', new Set(['TemplateFileName', 'UniqueID', 'UniqueId'])],
221
+ [
222
+ '41',
223
+ new Set([
224
+ ...SchematicFieldCoverageReportBuilder.#TEXT_FIELDS,
225
+ 'DataType',
226
+ 'ParameterName'
227
+ ])
228
+ ],
229
+ ['43', new Set(['FontID', 'Justification', 'ShowName', 'Symbol'])],
230
+ ['44', new Set([])],
231
+ [
232
+ '45',
233
+ new Set([
234
+ 'DatafileCount',
235
+ 'Description',
236
+ 'IsCurrent',
237
+ 'ModelName',
238
+ 'ModelType',
239
+ 'SearchPathCount'
240
+ ])
241
+ ],
242
+ ['46', new Set([])],
243
+ ['47', new Set(['DesImpCount', 'DesIntf'])],
244
+ ['48', SchematicFieldCoverageReportBuilder.#TEXT_FIELDS],
245
+ [
246
+ '209',
247
+ new Set([
248
+ ...SchematicFieldCoverageReportBuilder.#TEXT_FIELDS,
249
+ 'AreaColor'
250
+ ])
251
+ ],
252
+ ['210', new Set([])],
253
+ ['211', new Set(['IsSolid'])],
254
+ ['215', new Set(['AreaColor', 'UniqueId', 'XSize', 'YSize'])],
255
+ [
256
+ '216',
257
+ new Set([
258
+ 'DistanceFromTop',
259
+ 'DistanceFromTop_Frac',
260
+ 'DistanceFromTop_Frac1',
261
+ 'IOType',
262
+ 'Side',
263
+ 'Style'
264
+ ])
265
+ ],
266
+ ['217', SchematicFieldCoverageReportBuilder.#TEXT_FIELDS],
267
+ ['218', new Set(['LocationCount'])],
268
+ [
269
+ '220',
270
+ new Set([
271
+ 'ExportedRoutineCount',
272
+ 'ExternalMemoryCount',
273
+ 'InternalMemoryAddressWidth',
274
+ 'InternalMemoryCount',
275
+ 'InternalMemoryDataWidth',
276
+ 'InternalMemoryInterface',
277
+ 'InternalMemorySize',
278
+ 'IsSolid',
279
+ 'SymbolType',
280
+ 'XSize',
281
+ 'YSize'
282
+ ])
283
+ ],
284
+ [
285
+ '221',
286
+ new Set([
287
+ 'DataIdentifier',
288
+ 'DataType',
289
+ 'DataWidth',
290
+ 'DistanceFromTop',
291
+ 'DistanceFromTop_Frac',
292
+ 'DistanceFromTop_Frac1',
293
+ 'EntryType',
294
+ 'IOType',
295
+ 'OwnerIndexAdditionalList',
296
+ 'ParentRoutine',
297
+ 'Side',
298
+ 'Style',
299
+ 'TextFontID'
300
+ ])
301
+ ],
302
+ ['222', SchematicFieldCoverageReportBuilder.#TEXT_FIELDS],
303
+ ['223', SchematicFieldCoverageReportBuilder.#TEXT_FIELDS],
304
+ ['225', new Set(['IsSolid', 'LocationCount', 'Transparent'])],
305
+ [
306
+ '226',
307
+ new Set([
308
+ ...SchematicFieldCoverageReportBuilder.#TEXT_FIELDS,
309
+ 'AreaColor',
310
+ 'URL'
311
+ ])
312
+ ]
313
+ ])
314
+
315
+ static #FIELDS_BY_RECORD_TYPE_KEYS =
316
+ SchematicFieldCoverageReportBuilder.#normalizedFieldsByRecordType(
317
+ SchematicFieldCoverageReportBuilder.#FIELDS_BY_RECORD_TYPE
318
+ )
319
+
320
+ static #KNOWN_PATTERNS = [
321
+ /^UTF8:/iu,
322
+ /^[XY]\d+$/iu,
323
+ /^(?:Size|FontName|Bold|Italic|Rotation)\d+$/iu,
324
+ /^(?:ModelDatafileEntity|ModelDatafileKind|SearchPath|DesImp)\d+$/iu,
325
+ /^(?:RoutineName|InterfaceMode|DataWidth|AddressWidth|Scope|Mau|NoWait|IsLinked)\d+$/iu,
326
+ /^ExternalMemory_(?:Name|Interface|DataWidth|AddressWidth|Scope|Mau|IsReserved)\d+$/iu,
327
+ /^DistanceFromTop_Frac\d+$/iu
328
+ ]
329
+
330
+ /**
331
+ * Builds a read-only field-coverage report grouped by native record type.
332
+ * @param {{ fields?: Record<string, string | string[]>, recordIndex?: number }[]} records Parsed schematic records.
333
+ * @returns {{ schema: string, summary: object, recordTypes: object[] }}
334
+ */
335
+ static build(records) {
336
+ const rowsByRecordType =
337
+ SchematicFieldCoverageReportBuilder.#collectRowsByRecordType(
338
+ records
339
+ )
340
+ const recordTypes = [...rowsByRecordType.values()]
341
+ .filter((row) => row.unrecognizedFields.length)
342
+ .sort(
343
+ (left, right) =>
344
+ left.recordType - right.recordType ||
345
+ left.name.localeCompare(right.name)
346
+ )
347
+ const unrecognizedFieldCount = recordTypes.reduce(
348
+ (total, row) => total + row.unrecognizedFields.length,
349
+ 0
350
+ )
351
+ const unrecognizedOccurrenceCount = recordTypes.reduce(
352
+ (total, row) =>
353
+ total +
354
+ row.unrecognizedFields.reduce(
355
+ (fieldTotal, field) => fieldTotal + field.count,
356
+ 0
357
+ ),
358
+ 0
359
+ )
360
+
361
+ return {
362
+ schema: SchematicFieldCoverageReportBuilder.SCHEMA_ID,
363
+ summary: {
364
+ recordTypeCount: recordTypes.length,
365
+ unrecognizedFieldCount,
366
+ unrecognizedOccurrenceCount
367
+ },
368
+ recordTypes
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Collects per-record-type coverage rows.
374
+ * @param {{ fields?: Record<string, string | string[]>, recordIndex?: number }[]} records Parsed schematic records.
375
+ * @returns {Map<number, object>}
376
+ */
377
+ static #collectRowsByRecordType(records) {
378
+ const rows = new Map()
379
+
380
+ for (const record of records || []) {
381
+ const recordType = ParserUtils.getField(record.fields, 'RECORD')
382
+ const descriptor = SchematicRecordTypeRegistry.get(recordType)
383
+ if (
384
+ !Number.isInteger(descriptor.recordType) ||
385
+ descriptor.recordType < 0
386
+ ) {
387
+ continue
388
+ }
389
+
390
+ if (!rows.has(descriptor.recordType)) {
391
+ rows.set(
392
+ descriptor.recordType,
393
+ SchematicFieldCoverageReportBuilder.#emptyRow(descriptor)
394
+ )
395
+ }
396
+
397
+ const row = rows.get(descriptor.recordType)
398
+ row.recordCount += 1
399
+ SchematicFieldCoverageReportBuilder.#collectUnrecognizedFields(
400
+ row,
401
+ record,
402
+ String(descriptor.recordType)
403
+ )
404
+ }
405
+
406
+ for (const row of rows.values()) {
407
+ row.unrecognizedFields = [...row.unrecognizedFields.values()]
408
+ .map((field) => ({
409
+ ...field,
410
+ recordKeys: [...field.recordKeys].sort()
411
+ }))
412
+ .sort((left, right) => left.name.localeCompare(right.name))
413
+ }
414
+
415
+ return rows
416
+ }
417
+
418
+ /**
419
+ * Builds an empty coverage row for one record descriptor.
420
+ * @param {{ recordType: number, name: string, family: string, supported: boolean }} descriptor Record descriptor.
421
+ * @returns {object}
422
+ */
423
+ static #emptyRow(descriptor) {
424
+ return {
425
+ recordType: descriptor.recordType,
426
+ name: descriptor.name,
427
+ family: descriptor.family,
428
+ supported: descriptor.supported,
429
+ recordCount: 0,
430
+ unrecognizedFields: new Map()
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Adds unrecognized fields from one record to its coverage row.
436
+ * @param {{ unrecognizedFields: Map<string, object> }} row Coverage row.
437
+ * @param {{ fields?: Record<string, string | string[]>, recordIndex?: number }} record Parsed record.
438
+ * @param {string} recordType Native record type.
439
+ * @returns {void}
440
+ */
441
+ static #collectUnrecognizedFields(row, record, recordType) {
442
+ for (const key of Object.keys(record?.fields || {})) {
443
+ if (
444
+ SchematicFieldCoverageReportBuilder.#isKnownField(
445
+ recordType,
446
+ key
447
+ )
448
+ ) {
449
+ continue
450
+ }
451
+
452
+ if (!row.unrecognizedFields.has(key)) {
453
+ row.unrecognizedFields.set(key, {
454
+ name: key,
455
+ count: 0,
456
+ recordKeys: new Set()
457
+ })
458
+ }
459
+
460
+ const field = row.unrecognizedFields.get(key)
461
+ field.count += 1
462
+ field.recordKeys.add(
463
+ SchematicFieldCoverageReportBuilder.#recordKey(record)
464
+ )
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Returns true when a field is known for one record type.
470
+ * @param {string} recordType Native record type.
471
+ * @param {string} key Native field name.
472
+ * @returns {boolean}
473
+ */
474
+ static #isKnownField(recordType, key) {
475
+ const normalizedKey =
476
+ SchematicFieldCoverageReportBuilder.#normalizeFieldKey(key)
477
+
478
+ if (
479
+ SchematicFieldCoverageReportBuilder.#COMMON_FIELD_KEYS.has(
480
+ normalizedKey
481
+ )
482
+ ) {
483
+ return true
484
+ }
485
+
486
+ if (
487
+ SchematicFieldCoverageReportBuilder.#KNOWN_PATTERNS.some(
488
+ (pattern) => pattern.test(key)
489
+ )
490
+ ) {
491
+ return true
492
+ }
493
+
494
+ return Boolean(
495
+ SchematicFieldCoverageReportBuilder.#FIELDS_BY_RECORD_TYPE_KEYS
496
+ .get(recordType)
497
+ ?.has(normalizedKey)
498
+ )
499
+ }
500
+
501
+ /**
502
+ * Builds a case-normalized set of known field keys.
503
+ * @param {Set<string>} fields Known fields.
504
+ * @returns {Set<string>}
505
+ */
506
+ static #normalizedFieldSet(fields) {
507
+ return new Set(
508
+ [...(fields || [])].map((field) =>
509
+ SchematicFieldCoverageReportBuilder.#normalizeFieldKey(field)
510
+ )
511
+ )
512
+ }
513
+
514
+ /**
515
+ * Builds case-normalized known-field sets by record type.
516
+ * @param {Map<string, Set<string>>} fieldsByRecordType Known fields.
517
+ * @returns {Map<string, Set<string>>}
518
+ */
519
+ static #normalizedFieldsByRecordType(fieldsByRecordType) {
520
+ return new Map(
521
+ [...(fieldsByRecordType || new Map()).entries()].map(
522
+ ([recordType, fields]) => [
523
+ recordType,
524
+ SchematicFieldCoverageReportBuilder.#normalizedFieldSet(
525
+ fields
526
+ )
527
+ ]
528
+ )
529
+ )
530
+ }
531
+
532
+ /**
533
+ * Normalizes a native schematic field key for catalog comparisons.
534
+ * @param {string} key Native field key.
535
+ * @returns {string}
536
+ */
537
+ static #normalizeFieldKey(key) {
538
+ return String(key || '').toLocaleLowerCase('en-US')
539
+ }
540
+
541
+ /**
542
+ * Builds a stable schematic record key.
543
+ * @param {{ recordIndex?: number }} record Schematic record.
544
+ * @returns {string}
545
+ */
546
+ static #recordKey(record) {
547
+ return 'schematic-record-' + String(record?.recordIndex ?? 0)
548
+ }
549
+ }
@@ -4,7 +4,13 @@
4
4
 
5
5
  import { ParserUtils } from './ParserUtils.mjs'
6
6
 
7
- const { getDisplayText, getField, parseNumericField, toColor } = ParserUtils
7
+ const {
8
+ getDisplayText,
9
+ getField,
10
+ parseNumericField,
11
+ parseSchematicLineWidth,
12
+ toColor
13
+ } = ParserUtils
8
14
 
9
15
  /**
10
16
  * Parses schematic harness connector records into a first-class read model.
@@ -79,7 +85,7 @@ export class SchematicHarnessParser {
79
85
  primaryConnectionPosition:
80
86
  parseNumericField(record.fields, 'PrimaryConnectionPosition') ||
81
87
  0,
82
- lineWidth: parseNumericField(record.fields, 'LineWidth') || 0,
88
+ lineWidth: parseSchematicLineWidth(record.fields),
83
89
  color: toColor(record.fields.Color, '#000000'),
84
90
  fill: toColor(record.fields.AreaColor, '#ffffff'),
85
91
  entries,
@@ -145,7 +151,7 @@ export class SchematicHarnessParser {
145
151
  recordKey: SchematicHarnessParser.#recordKey(record),
146
152
  points,
147
153
  color: toColor(record.fields.Color, '#9fc5e8'),
148
- lineWidth: parseNumericField(record.fields, 'LineWidth') || 1
154
+ lineWidth: parseSchematicLineWidth(record.fields)
149
155
  })
150
156
  }
151
157
 
@@ -0,0 +1,122 @@
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
+ /**
8
+ * Parses schematic hyperlink records into a normalized read model.
9
+ */
10
+ export class SchematicHyperlinkParser {
11
+ /**
12
+ * Parses hyperlink records from indexed schematic records.
13
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }[]} records Indexed schematic records.
14
+ * @param {{ fonts?: Record<string, { size?: number, family?: string, bold?: boolean, italic?: boolean }> }} [sheet] Resolved sheet metadata.
15
+ * @returns {object[]}
16
+ */
17
+ static parse(records, sheet = {}) {
18
+ return (records || [])
19
+ .filter(
20
+ (record) =>
21
+ ParserUtils.getField(record.fields, 'RECORD') === '226'
22
+ )
23
+ .map((record, index) =>
24
+ SchematicHyperlinkParser.#parseRecord(record, index, sheet)
25
+ )
26
+ .filter(Boolean)
27
+ }
28
+
29
+ /**
30
+ * Parses one hyperlink record.
31
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }} record Indexed schematic record.
32
+ * @param {number} fallbackIndex Fallback source order.
33
+ * @param {{ fonts?: Record<string, { size?: number, family?: string, bold?: boolean, italic?: boolean }> }} sheet Resolved sheet metadata.
34
+ * @returns {object | null}
35
+ */
36
+ static #parseRecord(record, fallbackIndex, sheet) {
37
+ const fields = record.fields || {}
38
+ const text = ParserUtils.getDisplayText(fields)
39
+ const url = ParserUtils.getField(fields, 'URL')
40
+ if (!text && !url) return null
41
+
42
+ const indexInSheet = ParserUtils.parseNumericField(
43
+ fields,
44
+ 'IndexInSheet'
45
+ )
46
+ const fontId = ParserUtils.getField(fields, 'FontID')
47
+ const font = SchematicHyperlinkParser.#font(sheet, fontId)
48
+ const orientation = ParserUtils.parseNumericField(fields, 'Orientation')
49
+
50
+ return SchematicHyperlinkParser.#stripUndefined({
51
+ key:
52
+ 'schematic-hyperlink-' +
53
+ String(indexInSheet ?? record.recordIndex ?? fallbackIndex),
54
+ recordKey: SchematicHyperlinkParser.#recordKey(record),
55
+ indexInSheet: indexInSheet ?? undefined,
56
+ ownerIndex: ParserUtils.getField(fields, 'OwnerIndex') || undefined,
57
+ ownerPartId:
58
+ ParserUtils.parseNumericField(fields, 'OwnerPartID') ??
59
+ undefined,
60
+ uniqueId: ParserUtils.getField(fields, 'UniqueID') || undefined,
61
+ text: text || undefined,
62
+ url: url || undefined,
63
+ x: ParserUtils.parseNumericField(fields, 'Location.X') ?? 0,
64
+ y: ParserUtils.parseNumericField(fields, 'Location.Y') ?? 0,
65
+ fontId: fontId || undefined,
66
+ fontSize: font.size,
67
+ fontFamily: font.family,
68
+ fontWeight: font.bold ? 700 : 400,
69
+ fontStyle: font.italic ? 'italic' : undefined,
70
+ color: ParserUtils.toColor(fields.Color, '#000000'),
71
+ areaColor: ParserUtils.toColor(fields.AreaColor, undefined),
72
+ orientation: orientation ?? undefined,
73
+ rotation:
74
+ orientation === null || orientation === undefined
75
+ ? undefined
76
+ : orientation * 90,
77
+ justification:
78
+ ParserUtils.parseNumericField(fields, 'Justification') ??
79
+ undefined,
80
+ isNotAccessible:
81
+ ParserUtils.parseBoolean(
82
+ fields.IsNotAccesible ?? fields.IsNotAccessible
83
+ ) || undefined
84
+ })
85
+ }
86
+
87
+ /**
88
+ * Resolves one schematic font descriptor.
89
+ * @param {{ fonts?: Record<string, { size?: number, family?: string, bold?: boolean, italic?: boolean }> }} sheet Resolved sheet metadata.
90
+ * @param {string} fontId Font identifier.
91
+ * @returns {{ size: number, family: string, bold: boolean, italic: boolean }}
92
+ */
93
+ static #font(sheet, fontId) {
94
+ const font = sheet?.fonts?.[fontId] || {}
95
+ return {
96
+ size: Number.isFinite(font.size) ? font.size : 10,
97
+ family: font.family || 'Arial',
98
+ bold: Boolean(font.bold),
99
+ italic: Boolean(font.italic)
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Builds a stable source record key.
105
+ * @param {{ recordIndex?: number }} record Indexed schematic record.
106
+ * @returns {string}
107
+ */
108
+ static #recordKey(record) {
109
+ return 'schematic-record-' + String(record?.recordIndex ?? 0)
110
+ }
111
+
112
+ /**
113
+ * Removes undefined values from one object.
114
+ * @param {object} value Object to clean.
115
+ * @returns {object}
116
+ */
117
+ static #stripUndefined(value) {
118
+ return Object.fromEntries(
119
+ Object.entries(value).filter(([, entry]) => entry !== undefined)
120
+ )
121
+ }
122
+ }