altium-toolkit 1.0.8 → 1.0.9

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 (88) 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/netlist_a1.schema.json +47 -0
  5. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1661 -104
  6. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +59 -0
  7. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +57 -0
  8. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  9. package/docs/testing.md +9 -3
  10. package/package.json +1 -1
  11. package/spec/library-scope.md +7 -1
  12. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  13. package/src/core/altium/AltiumParser.mjs +191 -45
  14. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  15. package/src/core/altium/IntLibModelParser.mjs +240 -0
  16. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  17. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  18. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  19. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  20. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  21. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  22. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  23. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  24. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  25. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  26. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  27. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  28. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  29. package/src/core/altium/PcbModelParser.mjs +466 -28
  30. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  31. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  32. package/src/core/altium/PcbPadStackParser.mjs +58 -0
  33. package/src/core/altium/PcbPickPlacePositionResolver.mjs +217 -0
  34. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  35. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  36. package/src/core/altium/PcbRegionPrimitiveParser.mjs +5 -1
  37. package/src/core/altium/PcbRuleParser.mjs +354 -33
  38. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  39. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  40. package/src/core/altium/PcbStatisticsBuilder.mjs +532 -0
  41. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  42. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  43. package/src/core/altium/PcbUnionParser.mjs +307 -0
  44. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  45. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  46. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  47. package/src/core/altium/PrjPcbModelParser.mjs +257 -5
  48. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  49. package/src/core/altium/ProjectDesignBundleBuilder.mjs +477 -0
  50. package/src/core/altium/ProjectNetlistExporter.mjs +499 -0
  51. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  52. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  53. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  54. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  55. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  56. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  57. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  58. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  59. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  60. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  61. package/src/core/altium/SchematicImageParser.mjs +474 -3
  62. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  63. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  64. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  65. package/src/core/altium/SchematicPinParser.mjs +84 -1
  66. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  67. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  68. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  69. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  70. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  71. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  72. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  73. package/src/core/altium/SchematicTextParser.mjs +123 -0
  74. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  75. package/src/parser.mjs +29 -0
  76. package/src/styles/altium-renderers.css +19 -0
  77. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  78. package/src/ui/PcbInteractionIndex.mjs +9 -4
  79. package/src/ui/PcbScene3dBuilder.mjs +137 -3
  80. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  81. package/src/ui/PcbSvgRenderer.mjs +1187 -34
  82. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  83. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  84. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  85. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  86. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  87. package/src/ui/SchematicTypography.mjs +48 -5
  88. package/src/ui/TextGeometrySidecarBuilder.mjs +147 -0
@@ -3,6 +3,7 @@
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'
6
7
 
7
8
  /**
8
9
  * Parses Altium PrjPcb INI-style project files into a normalized project
@@ -35,7 +36,15 @@ export class PrjPcbModelParser {
35
36
  )
36
37
  const currentVariant =
37
38
  PrjPcbModelParser.#stringField(design, 'CurrentVariant') || ''
38
- const documents = PrjPcbModelParser.#extractDocuments(sections)
39
+ let documents = PrjPcbModelParser.#extractDocuments(sections)
40
+ const classGeneration = PrjPcbModelParser.#extractClassGeneration(
41
+ sections,
42
+ documents
43
+ )
44
+ documents = PrjPcbModelParser.#attachDocumentClassGeneration(
45
+ documents,
46
+ classGeneration
47
+ )
39
48
  const documentGroups = PrjPcbModelParser.#buildDocumentGroups(documents)
40
49
  const parameters = PrjPcbModelParser.#extractParameters(sections)
41
50
  const variants = PrjPcbModelParser.#extractVariants(
@@ -45,6 +54,10 @@ export class PrjPcbModelParser {
45
54
  const configurations =
46
55
  PrjPcbModelParser.#extractConfigurations(sections)
47
56
  const outputGroups = PrjPcbModelParser.#extractOutputGroups(sections)
57
+ const outJobDigest = ProjectOutJobDigestBuilder.build({
58
+ documents,
59
+ outputGroups
60
+ })
48
61
  const summary = PrjPcbModelParser.#buildSummary(
49
62
  fileName,
50
63
  documents,
@@ -73,6 +86,8 @@ export class PrjPcbModelParser {
73
86
  variants,
74
87
  configurations,
75
88
  outputGroups,
89
+ outJobDigest,
90
+ classGeneration,
76
91
  sections: PrjPcbModelParser.#serializeSections(sections)
77
92
  },
78
93
  bom: []
@@ -251,6 +266,188 @@ export class PrjPcbModelParser {
251
266
  return { list, map }
252
267
  }
253
268
 
269
+ /**
270
+ * Extracts project and per-document class-generation policy sections.
271
+ * @param {{ name: string, entries: { key: string, value: string }[] }[]} sections
272
+ * @param {object[]} documents
273
+ * @returns {{ section: string, policies: object, options: Record<string, string | string[]>, documents: object[], byDocumentPath: Record<string, object>, byDocumentIndex: Record<string, object> }}
274
+ */
275
+ static #extractClassGeneration(sections, documents) {
276
+ const projectSection =
277
+ PrjPcbModelParser.#findSection(sections, 'PrjClassGen') || null
278
+ const projectFields = PrjPcbModelParser.#sectionFields(projectSection)
279
+ const documentPolicies = PrjPcbModelParser.#numberedSections(
280
+ sections,
281
+ 'DocumentClassGen'
282
+ ).map(({ section, number }) => {
283
+ const fields = PrjPcbModelParser.#sectionFields(section)
284
+ const documentPath =
285
+ PrjPcbModelParser.#stringField(fields, 'DocumentPath') || ''
286
+ const normalizedPath =
287
+ PrjPcbModelParser.#normalizeDocumentPath(documentPath)
288
+ const documentIndex =
289
+ PrjPcbModelParser.#integerField(fields, 'DocumentIndex') ||
290
+ PrjPcbModelParser.#documentIndexForPath(
291
+ documents,
292
+ normalizedPath
293
+ ) ||
294
+ number
295
+
296
+ return {
297
+ index: number,
298
+ section: section.name,
299
+ documentIndex,
300
+ documentPath,
301
+ normalizedPath,
302
+ policies: PrjPcbModelParser.#classGenerationPolicies(fields),
303
+ options: fields
304
+ }
305
+ })
306
+ const byDocumentPath = {}
307
+ const byDocumentIndex = {}
308
+
309
+ for (const policy of documentPolicies) {
310
+ if (policy.normalizedPath) {
311
+ byDocumentPath[policy.normalizedPath] = policy
312
+ }
313
+ byDocumentIndex[String(policy.documentIndex)] = policy
314
+ }
315
+
316
+ return {
317
+ section: projectSection?.name || '',
318
+ policies: PrjPcbModelParser.#classGenerationPolicies(projectFields),
319
+ options: projectFields,
320
+ documents: documentPolicies,
321
+ byDocumentPath,
322
+ byDocumentIndex
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Attaches per-document class-generation options to document rows.
328
+ * @param {object[]} documents Project documents.
329
+ * @param {object} classGeneration Class-generation model.
330
+ * @returns {object[]}
331
+ */
332
+ static #attachDocumentClassGeneration(documents, classGeneration) {
333
+ return (documents || []).map((document) => {
334
+ const inlinePolicies =
335
+ PrjPcbModelParser.#classGenerationPoliciesFromDocument(
336
+ document.options || {}
337
+ )
338
+ const sectionPolicy =
339
+ classGeneration.byDocumentPath?.[document.normalizedPath] ||
340
+ classGeneration.byDocumentIndex?.[String(document.index)] ||
341
+ null
342
+ const policies = {
343
+ ...inlinePolicies,
344
+ ...(sectionPolicy?.policies || {})
345
+ }
346
+
347
+ return Object.keys(policies).length
348
+ ? {
349
+ ...document,
350
+ classGeneration: {
351
+ documentIndex: document.index,
352
+ policies,
353
+ section: sectionPolicy?.section || '',
354
+ options:
355
+ sectionPolicy?.options ||
356
+ PrjPcbModelParser.#classGenerationDocumentOptions(
357
+ document.options || {}
358
+ )
359
+ }
360
+ }
361
+ : document
362
+ })
363
+ }
364
+
365
+ /**
366
+ * Extracts class-generation policies from inline document options.
367
+ * @param {Record<string, string | string[]>} fields Document option fields.
368
+ * @returns {Record<string, boolean>}
369
+ */
370
+ static #classGenerationPoliciesFromDocument(fields) {
371
+ const options =
372
+ PrjPcbModelParser.#classGenerationDocumentOptions(fields)
373
+ return PrjPcbModelParser.#classGenerationPolicies(options)
374
+ }
375
+
376
+ /**
377
+ * Selects only inline class-generation options from one document section.
378
+ * @param {Record<string, string | string[]>} fields Document option fields.
379
+ * @returns {Record<string, string | string[]>}
380
+ */
381
+ static #classGenerationDocumentOptions(fields) {
382
+ const options = {}
383
+ for (const [key, value] of Object.entries(fields || {})) {
384
+ if (/^ClassGen/i.test(key)) {
385
+ options[key] = value
386
+ }
387
+ }
388
+ return options
389
+ }
390
+
391
+ /**
392
+ * Converts class-generation option fields into stable camelCase policy
393
+ * names.
394
+ * @param {Record<string, string | string[]>} fields Class-generation fields.
395
+ * @returns {Record<string, boolean>}
396
+ */
397
+ static #classGenerationPolicies(fields) {
398
+ const policies = {}
399
+ for (const [key, value] of Object.entries(fields || {})) {
400
+ const policyName = PrjPcbModelParser.#classGenerationPolicyName(key)
401
+ if (!policyName) continue
402
+ policies[policyName] = PrjPcbModelParser.#booleanValue(value)
403
+ }
404
+ return policies
405
+ }
406
+
407
+ /**
408
+ * Resolves a public class-generation policy name.
409
+ * @param {string} key Raw option key.
410
+ * @returns {string}
411
+ */
412
+ static #classGenerationPolicyName(key) {
413
+ const normalized = String(key || '')
414
+ .replace(/^ClassGen/i, '')
415
+ .replace(/[^a-z0-9]/gi, '')
416
+ .toLowerCase()
417
+
418
+ return (
419
+ {
420
+ generateclasses: 'generateClasses',
421
+ generatenetclasses: 'generateNetClasses',
422
+ generatecomponentclasses: 'generateComponentClasses',
423
+ generatedifferentialpairclasses:
424
+ 'generateDifferentialPairClasses',
425
+ generaterooms: 'generateRooms',
426
+ generatesheetclasses: 'generateSheetClasses',
427
+ generatepolygonclasses: 'generatePolygonClasses',
428
+ transfernetclasses: 'transferNetClasses',
429
+ transfercomponentclasses: 'transferComponentClasses',
430
+ transferdifferentialpairclasses:
431
+ 'transferDifferentialPairClasses',
432
+ transferroomdirectives: 'transferRoomDirectives'
433
+ }[normalized] || ''
434
+ )
435
+ }
436
+
437
+ /**
438
+ * Finds a document index for a normalized path.
439
+ * @param {object[]} documents Project document rows.
440
+ * @param {string} normalizedPath Normalized document path.
441
+ * @returns {number}
442
+ */
443
+ static #documentIndexForPath(documents, normalizedPath) {
444
+ if (!normalizedPath) return 0
445
+ const match = (documents || []).find(
446
+ (document) => document.normalizedPath === normalizedPath
447
+ )
448
+ return Number(match?.index) || 0
449
+ }
450
+
254
451
  /**
255
452
  * Extracts project variants and their variation rows.
256
453
  * @param {{ name: string, entries: { key: string, value: string }[] }[]} sections
@@ -316,6 +513,8 @@ export class PrjPcbModelParser {
316
513
  PrjPcbModelParser.#buildParameterOverrideMap(
317
514
  paramVariations
318
515
  ),
516
+ alternateFitted:
517
+ PrjPcbModelParser.#buildAlternateFittedMap(variations),
319
518
  dnp: variations
320
519
  .filter((variation) => variation.Kind === '1')
321
520
  .map((variation) => variation.Designator || '')
@@ -448,6 +647,41 @@ export class PrjPcbModelParser {
448
647
  return overrides
449
648
  }
450
649
 
650
+ /**
651
+ * Groups alternate fitted component rows by designator.
652
+ * @param {Record<string, string>[]} rows Variant variation rows.
653
+ * @returns {Record<string, object>}
654
+ */
655
+ static #buildAlternateFittedMap(rows) {
656
+ const alternates = {}
657
+ for (const row of rows || []) {
658
+ const designator = String(row.Designator || '').trim()
659
+ if (!designator) continue
660
+ const alternatePart = String(row.AlternatePart || '').trim()
661
+ const isAlternate =
662
+ String(row.Kind || '').trim() === '2' || Boolean(alternatePart)
663
+ if (!isAlternate) continue
664
+
665
+ alternates[designator] = {
666
+ designator,
667
+ alternatePart,
668
+ libReference:
669
+ row.AlternateLibReference ||
670
+ row.AlternateLibraryRef ||
671
+ row.LibraryRef ||
672
+ '',
673
+ footprint:
674
+ row.AlternateFootprint ||
675
+ row.Footprint ||
676
+ row.Pattern ||
677
+ '',
678
+ comment: row.AlternateComment || row.Comment || '',
679
+ description: row.AlternateDescription || row.Description || ''
680
+ }
681
+ }
682
+ return alternates
683
+ }
684
+
451
685
  /**
452
686
  * Extracts numbered output rows from one OutputGroup section.
453
687
  * @param {Record<string, string | string[]>} fields
@@ -662,10 +896,28 @@ export class PrjPcbModelParser {
662
896
  * @returns {boolean}
663
897
  */
664
898
  static #booleanField(fields, key) {
665
- const value = String(
666
- PrjPcbModelParser.#stringField(fields, key) || ''
667
- ).toLowerCase()
668
- return value === '1' || value === 'true' || value === 'yes'
899
+ return PrjPcbModelParser.#booleanValue(
900
+ PrjPcbModelParser.#stringField(fields, key)
901
+ )
902
+ }
903
+
904
+ /**
905
+ * Parses one boolean-ish value.
906
+ * @param {unknown} value Raw field value.
907
+ * @returns {boolean}
908
+ */
909
+ static #booleanValue(value) {
910
+ const raw = Array.isArray(value)
911
+ ? String(value[0] || '')
912
+ : String(value || '')
913
+ const normalized = raw.toLowerCase()
914
+
915
+ return (
916
+ normalized === '1' ||
917
+ normalized === 't' ||
918
+ normalized === 'true' ||
919
+ normalized === 'yes'
920
+ )
669
921
  }
670
922
 
671
923
  /**
@@ -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
+ }