altium-toolkit 1.0.8 → 1.0.10

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 (102) hide show
  1. package/README.md +18 -6
  2. package/docs/api.md +78 -16
  3. package/docs/model-format.md +229 -8
  4. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +76 -0
  5. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +35 -0
  6. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +53 -0
  7. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1826 -110
  8. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  9. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +86 -0
  10. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +63 -0
  11. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  12. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  13. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  14. package/docs/testing.md +9 -3
  15. package/package.json +1 -1
  16. package/spec/library-scope.md +7 -1
  17. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  18. package/src/core/altium/AltiumParser.mjs +196 -45
  19. package/src/core/altium/CiArtifactBundleBuilder.mjs +202 -0
  20. package/src/core/altium/DraftsmanDigestParser.mjs +689 -0
  21. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  22. package/src/core/altium/IntLibModelParser.mjs +240 -0
  23. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  24. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  25. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  26. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  27. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  28. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  29. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  30. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  31. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  32. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  33. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  34. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  35. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  36. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  37. package/src/core/altium/PcbModelParser.mjs +495 -32
  38. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  39. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  40. package/src/core/altium/PcbPadStackParser.mjs +229 -2
  41. package/src/core/altium/PcbPickPlacePositionResolver.mjs +224 -0
  42. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  43. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  44. package/src/core/altium/PcbRegionPrimitiveParser.mjs +76 -3
  45. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  46. package/src/core/altium/PcbRuleParser.mjs +354 -33
  47. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  48. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  49. package/src/core/altium/PcbStatisticsBuilder.mjs +541 -0
  50. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  51. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  52. package/src/core/altium/PcbUnionParser.mjs +307 -0
  53. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  54. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  55. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  56. package/src/core/altium/PrjPcbModelParser.mjs +281 -7
  57. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  58. package/src/core/altium/ProjectDesignBundleBuilder.mjs +492 -0
  59. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  60. package/src/core/altium/ProjectNetlistExporter.mjs +503 -0
  61. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  62. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  63. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  64. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  65. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  66. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  67. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  68. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  69. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  70. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  71. package/src/core/altium/SchematicImageParser.mjs +474 -3
  72. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  73. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  74. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  75. package/src/core/altium/SchematicPinParser.mjs +84 -1
  76. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  77. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  78. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  79. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  80. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  81. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  82. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  83. package/src/core/altium/SchematicTextParser.mjs +123 -0
  84. package/src/core/altium/SvgModelCrossLinkValidator.mjs +402 -0
  85. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +136 -96
  86. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  87. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  88. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  89. package/src/parser.mjs +35 -0
  90. package/src/styles/altium-renderers.css +19 -0
  91. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  92. package/src/ui/PcbInteractionIndex.mjs +9 -4
  93. package/src/ui/PcbScene3dBuilder.mjs +137 -3
  94. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  95. package/src/ui/PcbSvgRenderer.mjs +1252 -34
  96. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  97. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  98. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  99. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  100. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  101. package/src/ui/SchematicTypography.mjs +48 -5
  102. package/src/ui/TextGeometrySidecarBuilder.mjs +147 -0
@@ -3,6 +3,8 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { NormalizedModelSchema } from './NormalizedModelSchema.mjs'
6
+ import { ProjectOutJobDigestBuilder } from './ProjectOutJobDigestBuilder.mjs'
7
+ import { ProjectDocumentGraphBuilder } from './ProjectDocumentGraphBuilder.mjs'
6
8
 
7
9
  /**
8
10
  * Parses Altium PrjPcb INI-style project files into a normalized project
@@ -35,7 +37,15 @@ export class PrjPcbModelParser {
35
37
  )
36
38
  const currentVariant =
37
39
  PrjPcbModelParser.#stringField(design, 'CurrentVariant') || ''
38
- const documents = PrjPcbModelParser.#extractDocuments(sections)
40
+ let documents = PrjPcbModelParser.#extractDocuments(sections)
41
+ const classGeneration = PrjPcbModelParser.#extractClassGeneration(
42
+ sections,
43
+ documents
44
+ )
45
+ documents = PrjPcbModelParser.#attachDocumentClassGeneration(
46
+ documents,
47
+ classGeneration
48
+ )
39
49
  const documentGroups = PrjPcbModelParser.#buildDocumentGroups(documents)
40
50
  const parameters = PrjPcbModelParser.#extractParameters(sections)
41
51
  const variants = PrjPcbModelParser.#extractVariants(
@@ -45,6 +55,15 @@ export class PrjPcbModelParser {
45
55
  const configurations =
46
56
  PrjPcbModelParser.#extractConfigurations(sections)
47
57
  const outputGroups = PrjPcbModelParser.#extractOutputGroups(sections)
58
+ const outJobDigest = ProjectOutJobDigestBuilder.build({
59
+ documents,
60
+ outputGroups
61
+ })
62
+ const documentGraph = ProjectDocumentGraphBuilder.build({
63
+ documents,
64
+ documentGroups,
65
+ outputGroups
66
+ })
48
67
  const summary = PrjPcbModelParser.#buildSummary(
49
68
  fileName,
50
69
  documents,
@@ -73,6 +92,9 @@ export class PrjPcbModelParser {
73
92
  variants,
74
93
  configurations,
75
94
  outputGroups,
95
+ outJobDigest,
96
+ documentGraph,
97
+ classGeneration,
76
98
  sections: PrjPcbModelParser.#serializeSections(sections)
77
99
  },
78
100
  bom: []
@@ -214,6 +236,9 @@ export class PrjPcbModelParser {
214
236
  integratedLibraries: documents.filter(
215
237
  (document) => document.kind === 'integrated-library'
216
238
  ),
239
+ harnessFiles: documents.filter(
240
+ (document) => document.kind === 'harness'
241
+ ),
217
242
  outJobs: documents.filter(
218
243
  (document) => document.kind === 'output-job'
219
244
  ),
@@ -251,6 +276,188 @@ export class PrjPcbModelParser {
251
276
  return { list, map }
252
277
  }
253
278
 
279
+ /**
280
+ * Extracts project and per-document class-generation policy sections.
281
+ * @param {{ name: string, entries: { key: string, value: string }[] }[]} sections
282
+ * @param {object[]} documents
283
+ * @returns {{ section: string, policies: object, options: Record<string, string | string[]>, documents: object[], byDocumentPath: Record<string, object>, byDocumentIndex: Record<string, object> }}
284
+ */
285
+ static #extractClassGeneration(sections, documents) {
286
+ const projectSection =
287
+ PrjPcbModelParser.#findSection(sections, 'PrjClassGen') || null
288
+ const projectFields = PrjPcbModelParser.#sectionFields(projectSection)
289
+ const documentPolicies = PrjPcbModelParser.#numberedSections(
290
+ sections,
291
+ 'DocumentClassGen'
292
+ ).map(({ section, number }) => {
293
+ const fields = PrjPcbModelParser.#sectionFields(section)
294
+ const documentPath =
295
+ PrjPcbModelParser.#stringField(fields, 'DocumentPath') || ''
296
+ const normalizedPath =
297
+ PrjPcbModelParser.#normalizeDocumentPath(documentPath)
298
+ const documentIndex =
299
+ PrjPcbModelParser.#integerField(fields, 'DocumentIndex') ||
300
+ PrjPcbModelParser.#documentIndexForPath(
301
+ documents,
302
+ normalizedPath
303
+ ) ||
304
+ number
305
+
306
+ return {
307
+ index: number,
308
+ section: section.name,
309
+ documentIndex,
310
+ documentPath,
311
+ normalizedPath,
312
+ policies: PrjPcbModelParser.#classGenerationPolicies(fields),
313
+ options: fields
314
+ }
315
+ })
316
+ const byDocumentPath = {}
317
+ const byDocumentIndex = {}
318
+
319
+ for (const policy of documentPolicies) {
320
+ if (policy.normalizedPath) {
321
+ byDocumentPath[policy.normalizedPath] = policy
322
+ }
323
+ byDocumentIndex[String(policy.documentIndex)] = policy
324
+ }
325
+
326
+ return {
327
+ section: projectSection?.name || '',
328
+ policies: PrjPcbModelParser.#classGenerationPolicies(projectFields),
329
+ options: projectFields,
330
+ documents: documentPolicies,
331
+ byDocumentPath,
332
+ byDocumentIndex
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Attaches per-document class-generation options to document rows.
338
+ * @param {object[]} documents Project documents.
339
+ * @param {object} classGeneration Class-generation model.
340
+ * @returns {object[]}
341
+ */
342
+ static #attachDocumentClassGeneration(documents, classGeneration) {
343
+ return (documents || []).map((document) => {
344
+ const inlinePolicies =
345
+ PrjPcbModelParser.#classGenerationPoliciesFromDocument(
346
+ document.options || {}
347
+ )
348
+ const sectionPolicy =
349
+ classGeneration.byDocumentPath?.[document.normalizedPath] ||
350
+ classGeneration.byDocumentIndex?.[String(document.index)] ||
351
+ null
352
+ const policies = {
353
+ ...inlinePolicies,
354
+ ...(sectionPolicy?.policies || {})
355
+ }
356
+
357
+ return Object.keys(policies).length
358
+ ? {
359
+ ...document,
360
+ classGeneration: {
361
+ documentIndex: document.index,
362
+ policies,
363
+ section: sectionPolicy?.section || '',
364
+ options:
365
+ sectionPolicy?.options ||
366
+ PrjPcbModelParser.#classGenerationDocumentOptions(
367
+ document.options || {}
368
+ )
369
+ }
370
+ }
371
+ : document
372
+ })
373
+ }
374
+
375
+ /**
376
+ * Extracts class-generation policies from inline document options.
377
+ * @param {Record<string, string | string[]>} fields Document option fields.
378
+ * @returns {Record<string, boolean>}
379
+ */
380
+ static #classGenerationPoliciesFromDocument(fields) {
381
+ const options =
382
+ PrjPcbModelParser.#classGenerationDocumentOptions(fields)
383
+ return PrjPcbModelParser.#classGenerationPolicies(options)
384
+ }
385
+
386
+ /**
387
+ * Selects only inline class-generation options from one document section.
388
+ * @param {Record<string, string | string[]>} fields Document option fields.
389
+ * @returns {Record<string, string | string[]>}
390
+ */
391
+ static #classGenerationDocumentOptions(fields) {
392
+ const options = {}
393
+ for (const [key, value] of Object.entries(fields || {})) {
394
+ if (/^ClassGen/i.test(key)) {
395
+ options[key] = value
396
+ }
397
+ }
398
+ return options
399
+ }
400
+
401
+ /**
402
+ * Converts class-generation option fields into stable camelCase policy
403
+ * names.
404
+ * @param {Record<string, string | string[]>} fields Class-generation fields.
405
+ * @returns {Record<string, boolean>}
406
+ */
407
+ static #classGenerationPolicies(fields) {
408
+ const policies = {}
409
+ for (const [key, value] of Object.entries(fields || {})) {
410
+ const policyName = PrjPcbModelParser.#classGenerationPolicyName(key)
411
+ if (!policyName) continue
412
+ policies[policyName] = PrjPcbModelParser.#booleanValue(value)
413
+ }
414
+ return policies
415
+ }
416
+
417
+ /**
418
+ * Resolves a public class-generation policy name.
419
+ * @param {string} key Raw option key.
420
+ * @returns {string}
421
+ */
422
+ static #classGenerationPolicyName(key) {
423
+ const normalized = String(key || '')
424
+ .replace(/^ClassGen/i, '')
425
+ .replace(/[^a-z0-9]/gi, '')
426
+ .toLowerCase()
427
+
428
+ return (
429
+ {
430
+ generateclasses: 'generateClasses',
431
+ generatenetclasses: 'generateNetClasses',
432
+ generatecomponentclasses: 'generateComponentClasses',
433
+ generatedifferentialpairclasses:
434
+ 'generateDifferentialPairClasses',
435
+ generaterooms: 'generateRooms',
436
+ generatesheetclasses: 'generateSheetClasses',
437
+ generatepolygonclasses: 'generatePolygonClasses',
438
+ transfernetclasses: 'transferNetClasses',
439
+ transfercomponentclasses: 'transferComponentClasses',
440
+ transferdifferentialpairclasses:
441
+ 'transferDifferentialPairClasses',
442
+ transferroomdirectives: 'transferRoomDirectives'
443
+ }[normalized] || ''
444
+ )
445
+ }
446
+
447
+ /**
448
+ * Finds a document index for a normalized path.
449
+ * @param {object[]} documents Project document rows.
450
+ * @param {string} normalizedPath Normalized document path.
451
+ * @returns {number}
452
+ */
453
+ static #documentIndexForPath(documents, normalizedPath) {
454
+ if (!normalizedPath) return 0
455
+ const match = (documents || []).find(
456
+ (document) => document.normalizedPath === normalizedPath
457
+ )
458
+ return Number(match?.index) || 0
459
+ }
460
+
254
461
  /**
255
462
  * Extracts project variants and their variation rows.
256
463
  * @param {{ name: string, entries: { key: string, value: string }[] }[]} sections
@@ -316,6 +523,8 @@ export class PrjPcbModelParser {
316
523
  PrjPcbModelParser.#buildParameterOverrideMap(
317
524
  paramVariations
318
525
  ),
526
+ alternateFitted:
527
+ PrjPcbModelParser.#buildAlternateFittedMap(variations),
319
528
  dnp: variations
320
529
  .filter((variation) => variation.Kind === '1')
321
530
  .map((variation) => variation.Designator || '')
@@ -448,6 +657,41 @@ export class PrjPcbModelParser {
448
657
  return overrides
449
658
  }
450
659
 
660
+ /**
661
+ * Groups alternate fitted component rows by designator.
662
+ * @param {Record<string, string>[]} rows Variant variation rows.
663
+ * @returns {Record<string, object>}
664
+ */
665
+ static #buildAlternateFittedMap(rows) {
666
+ const alternates = {}
667
+ for (const row of rows || []) {
668
+ const designator = String(row.Designator || '').trim()
669
+ if (!designator) continue
670
+ const alternatePart = String(row.AlternatePart || '').trim()
671
+ const isAlternate =
672
+ String(row.Kind || '').trim() === '2' || Boolean(alternatePart)
673
+ if (!isAlternate) continue
674
+
675
+ alternates[designator] = {
676
+ designator,
677
+ alternatePart,
678
+ libReference:
679
+ row.AlternateLibReference ||
680
+ row.AlternateLibraryRef ||
681
+ row.LibraryRef ||
682
+ '',
683
+ footprint:
684
+ row.AlternateFootprint ||
685
+ row.Footprint ||
686
+ row.Pattern ||
687
+ '',
688
+ comment: row.AlternateComment || row.Comment || '',
689
+ description: row.AlternateDescription || row.Description || ''
690
+ }
691
+ }
692
+ return alternates
693
+ }
694
+
451
695
  /**
452
696
  * Extracts numbered output rows from one OutputGroup section.
453
697
  * @param {Record<string, string | string[]>} fields
@@ -465,7 +709,14 @@ export class PrjPcbModelParser {
465
709
  PrjPcbModelParser.#stringField(fields, 'OutputType' + index) ||
466
710
  ''
467
711
  if (!type) continue
468
- rows.push({
712
+ const targetPath =
713
+ PrjPcbModelParser.#stringField(
714
+ fields,
715
+ 'OutputTargetPath' + index
716
+ ) ||
717
+ PrjPcbModelParser.#stringField(fields, 'OutputPath' + index) ||
718
+ ''
719
+ const row = {
469
720
  index,
470
721
  type,
471
722
  name:
@@ -487,7 +738,9 @@ export class PrjPcbModelParser {
487
738
  fields,
488
739
  'OutputDefault' + index
489
740
  )
490
- })
741
+ }
742
+ if (targetPath) row.targetPath = targetPath
743
+ rows.push(row)
491
744
  }
492
745
 
493
746
  return rows
@@ -662,10 +915,28 @@ export class PrjPcbModelParser {
662
915
  * @returns {boolean}
663
916
  */
664
917
  static #booleanField(fields, key) {
665
- const value = String(
666
- PrjPcbModelParser.#stringField(fields, key) || ''
667
- ).toLowerCase()
668
- return value === '1' || value === 'true' || value === 'yes'
918
+ return PrjPcbModelParser.#booleanValue(
919
+ PrjPcbModelParser.#stringField(fields, key)
920
+ )
921
+ }
922
+
923
+ /**
924
+ * Parses one boolean-ish value.
925
+ * @param {unknown} value Raw field value.
926
+ * @returns {boolean}
927
+ */
928
+ static #booleanValue(value) {
929
+ const raw = Array.isArray(value)
930
+ ? String(value[0] || '')
931
+ : String(value || '')
932
+ const normalized = raw.toLowerCase()
933
+
934
+ return (
935
+ normalized === '1' ||
936
+ normalized === 't' ||
937
+ normalized === 'true' ||
938
+ normalized === 'yes'
939
+ )
669
940
  }
670
941
 
671
942
  /**
@@ -734,6 +1005,9 @@ export class PrjPcbModelParser {
734
1005
  return 'pcb-library'
735
1006
  case '.intlib':
736
1007
  return 'integrated-library'
1008
+ case '.harness':
1009
+ case '.harnessdoc':
1010
+ return 'harness'
737
1011
  case '.outjob':
738
1012
  return 'output-job'
739
1013
  default:
@@ -0,0 +1,205 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { NormalizedModelSchema } from './NormalizedModelSchema.mjs'
6
+
7
+ /**
8
+ * Parses annotation mapping files used to relate logical and compiled
9
+ * designators.
10
+ */
11
+ export class ProjectAnnotationParser {
12
+ /**
13
+ * Parses one annotation ArrayBuffer.
14
+ * @param {string} fileName Annotation file name.
15
+ * @param {ArrayBuffer} arrayBuffer Annotation bytes.
16
+ * @returns {object}
17
+ */
18
+ static parse(fileName, arrayBuffer) {
19
+ return ProjectAnnotationParser.parseText(
20
+ fileName,
21
+ new TextDecoder('windows-1252')
22
+ .decode(arrayBuffer || new ArrayBuffer(0))
23
+ .replace(/^\uFEFF/u, '')
24
+ )
25
+ }
26
+
27
+ /**
28
+ * Parses one annotation text payload.
29
+ * @param {string} fileName Annotation file name.
30
+ * @param {string} text Annotation text.
31
+ * @returns {object}
32
+ */
33
+ static parseText(fileName, text) {
34
+ const sections = ProjectAnnotationParser.#parseIniSections(text)
35
+ const mappings = ProjectAnnotationParser.#extractMappings(
36
+ fileName,
37
+ sections
38
+ )
39
+
40
+ return NormalizedModelSchema.attach({
41
+ kind: 'project-annotation',
42
+ fileType: 'Annotation',
43
+ fileName,
44
+ summary: {
45
+ title: fileName,
46
+ mappingCount: mappings.length
47
+ },
48
+ diagnostics: [
49
+ {
50
+ severity: 'info',
51
+ message:
52
+ 'Recovered ' +
53
+ mappings.length +
54
+ ' annotation designator mappings.'
55
+ }
56
+ ],
57
+ annotations: {
58
+ mappings,
59
+ bySourceDesignator: ProjectAnnotationParser.#indexBy(
60
+ mappings,
61
+ 'sourceDesignator'
62
+ ),
63
+ byCompiledDesignator: ProjectAnnotationParser.#indexBy(
64
+ mappings,
65
+ 'compiledDesignator'
66
+ )
67
+ },
68
+ bom: []
69
+ })
70
+ }
71
+
72
+ /**
73
+ * Parses INI-like sections.
74
+ * @param {string} text Text payload.
75
+ * @returns {{ name: string, entries: { key: string, value: string }[] }[]}
76
+ */
77
+ static #parseIniSections(text) {
78
+ const sections = []
79
+ let current = null
80
+
81
+ for (const rawLine of String(text || '')
82
+ .replace(/\r\n?/gu, '\n')
83
+ .split('\n')) {
84
+ const trimmed = rawLine.trim()
85
+ if (
86
+ !trimmed ||
87
+ trimmed.startsWith(';') ||
88
+ trimmed.startsWith('#')
89
+ ) {
90
+ continue
91
+ }
92
+
93
+ const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/u)
94
+ if (sectionMatch) {
95
+ current = {
96
+ name: sectionMatch[1].trim(),
97
+ entries: []
98
+ }
99
+ sections.push(current)
100
+ continue
101
+ }
102
+
103
+ if (!current) continue
104
+ const separatorIndex = rawLine.indexOf('=')
105
+ if (separatorIndex === -1) continue
106
+ current.entries.push({
107
+ key: rawLine.slice(0, separatorIndex).trim(),
108
+ value: rawLine.slice(separatorIndex + 1).trim()
109
+ })
110
+ }
111
+
112
+ return sections
113
+ }
114
+
115
+ /**
116
+ * Extracts designator mapping rows.
117
+ * @param {string} fileName Annotation file name.
118
+ * @param {{ name: string, entries: { key: string, value: string }[] }[]} sections Parsed sections.
119
+ * @returns {object[]}
120
+ */
121
+ static #extractMappings(fileName, sections) {
122
+ return (sections || [])
123
+ .map((section, sectionIndex) => {
124
+ const match = String(section.name || '').match(
125
+ /^Annotation(\d*)$/iu
126
+ )
127
+ if (!match) return null
128
+ const fields = ProjectAnnotationParser.#sectionFields(section)
129
+ const sourceDesignator =
130
+ ProjectAnnotationParser.#field(fields, [
131
+ 'SourceDesignator',
132
+ 'LogicalDesignator',
133
+ 'OriginalDesignator'
134
+ ]) || ''
135
+ const compiledDesignator =
136
+ ProjectAnnotationParser.#field(fields, [
137
+ 'CompiledDesignator',
138
+ 'PhysicalDesignator',
139
+ 'NewDesignator'
140
+ ]) || ''
141
+ if (!sourceDesignator || !compiledDesignator) return null
142
+
143
+ return {
144
+ index: Number.parseInt(match[1] || '', 10) || sectionIndex,
145
+ sourceDesignator,
146
+ compiledDesignator,
147
+ uniqueId:
148
+ ProjectAnnotationParser.#field(fields, [
149
+ 'UniqueId',
150
+ 'UniqueID'
151
+ ]) || '',
152
+ sourceFileName: fileName,
153
+ options: fields
154
+ }
155
+ })
156
+ .filter(Boolean)
157
+ .sort((left, right) => left.index - right.index)
158
+ }
159
+
160
+ /**
161
+ * Builds a field map from one section.
162
+ * @param {{ entries: { key: string, value: string }[] }} section Section row.
163
+ * @returns {Record<string, string>}
164
+ */
165
+ static #sectionFields(section) {
166
+ const fields = {}
167
+ for (const entry of section?.entries || []) {
168
+ fields[entry.key] = entry.value
169
+ }
170
+ return fields
171
+ }
172
+
173
+ /**
174
+ * Reads the first matching field.
175
+ * @param {Record<string, string>} fields Field map.
176
+ * @param {string[]} names Candidate names.
177
+ * @returns {string}
178
+ */
179
+ static #field(fields, names) {
180
+ for (const name of names) {
181
+ const lower = name.toLowerCase()
182
+ for (const [key, value] of Object.entries(fields || {})) {
183
+ if (key.toLowerCase() === lower) {
184
+ return String(value || '').trim()
185
+ }
186
+ }
187
+ }
188
+ return ''
189
+ }
190
+
191
+ /**
192
+ * Builds a compact index by field.
193
+ * @param {object[]} rows Rows.
194
+ * @param {string} key Key.
195
+ * @returns {Record<string, object>}
196
+ */
197
+ static #indexBy(rows, key) {
198
+ const index = {}
199
+ for (const row of rows || []) {
200
+ const value = String(row?.[key] || '').trim()
201
+ if (value) index[value] = row
202
+ }
203
+ return index
204
+ }
205
+ }