altium-toolkit 0.1.0 → 0.1.16

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 (54) hide show
  1. package/README.md +24 -6
  2. package/docs/api.md +42 -4
  3. package/docs/model-format.md +95 -5
  4. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
  5. package/docs/testing.md +7 -2
  6. package/package.json +21 -2
  7. package/spec/library-scope.md +7 -1
  8. package/src/core/altium/AltiumParser.mjs +22 -325
  9. package/src/core/altium/NormalizedModelSchema.mjs +28 -0
  10. package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
  11. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
  12. package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
  13. package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
  14. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
  15. package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
  16. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
  17. package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
  18. package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
  19. package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
  20. package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
  21. package/src/core/altium/PcbLibModelParser.mjs +202 -0
  22. package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
  23. package/src/core/altium/PcbModelParser.mjs +618 -66
  24. package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
  25. package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
  26. package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
  27. package/src/core/altium/PcbPadStackParser.mjs +903 -0
  28. package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
  29. package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
  30. package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
  31. package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
  32. package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
  33. package/src/core/altium/PcbRuleParser.mjs +587 -0
  34. package/src/core/altium/PcbStreamExtractor.mjs +127 -4
  35. package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
  36. package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
  37. package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
  38. package/src/core/altium/PcbViaStackParser.mjs +548 -0
  39. package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
  40. package/src/core/altium/PrjPcbModelParser.mjs +797 -0
  41. package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
  42. package/src/parser.mjs +13 -0
  43. package/src/renderers.mjs +5 -0
  44. package/src/styles/altium-renderers.css +11 -6
  45. package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
  46. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
  47. package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
  48. package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
  49. package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
  50. package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
  51. package/src/ui/PcbSvgRenderer.mjs +101 -109
  52. package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
  53. package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
  54. package/src/ui/SchematicSheetZoneRenderer.mjs +104 -0
@@ -0,0 +1,797 @@
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 Altium PrjPcb INI-style project files into a normalized project
9
+ * context model.
10
+ */
11
+ export class PrjPcbModelParser {
12
+ /**
13
+ * Parses one PrjPcb ArrayBuffer into the public project model.
14
+ * @param {string} fileName
15
+ * @param {ArrayBuffer} arrayBuffer
16
+ * @returns {{ schema: string, kind: 'project', fileType: 'PrjPcb', fileName: string, summary: Record<string, number | string>, diagnostics: { severity: 'info' | 'warning', message: string }[], project: Record<string, unknown>, bom: [] }}
17
+ */
18
+ static parse(fileName, arrayBuffer) {
19
+ return PrjPcbModelParser.parseText(
20
+ fileName,
21
+ PrjPcbModelParser.#decodeText(arrayBuffer)
22
+ )
23
+ }
24
+
25
+ /**
26
+ * Parses one PrjPcb text payload into the public project model.
27
+ * @param {string} fileName
28
+ * @param {string} text
29
+ * @returns {ReturnType<typeof PrjPcbModelParser.parse>}
30
+ */
31
+ static parseText(fileName, text) {
32
+ const sections = PrjPcbModelParser.#parseIniSections(text)
33
+ const design = PrjPcbModelParser.#sectionFields(
34
+ PrjPcbModelParser.#findSection(sections, 'Design')
35
+ )
36
+ const currentVariant =
37
+ PrjPcbModelParser.#stringField(design, 'CurrentVariant') || ''
38
+ const documents = PrjPcbModelParser.#extractDocuments(sections)
39
+ const documentGroups = PrjPcbModelParser.#buildDocumentGroups(documents)
40
+ const parameters = PrjPcbModelParser.#extractParameters(sections)
41
+ const variants = PrjPcbModelParser.#extractVariants(
42
+ sections,
43
+ currentVariant
44
+ )
45
+ const configurations =
46
+ PrjPcbModelParser.#extractConfigurations(sections)
47
+ const outputGroups = PrjPcbModelParser.#extractOutputGroups(sections)
48
+ const summary = PrjPcbModelParser.#buildSummary(
49
+ fileName,
50
+ documents,
51
+ documentGroups,
52
+ parameters,
53
+ variants,
54
+ currentVariant
55
+ )
56
+
57
+ return NormalizedModelSchema.attach({
58
+ kind: 'project',
59
+ fileType: 'PrjPcb',
60
+ fileName,
61
+ summary,
62
+ diagnostics: PrjPcbModelParser.#buildDiagnostics(
63
+ sections,
64
+ documents,
65
+ variants
66
+ ),
67
+ project: {
68
+ name: PrjPcbModelParser.#stripExtension(fileName),
69
+ design,
70
+ documents,
71
+ documentGroups,
72
+ parameters,
73
+ variants,
74
+ configurations,
75
+ outputGroups,
76
+ sections: PrjPcbModelParser.#serializeSections(sections)
77
+ },
78
+ bom: []
79
+ })
80
+ }
81
+
82
+ /**
83
+ * Decodes PrjPcb bytes with the common Altium text encodings.
84
+ * @param {ArrayBuffer} arrayBuffer
85
+ * @returns {string}
86
+ */
87
+ static #decodeText(arrayBuffer) {
88
+ const bytes = new Uint8Array(arrayBuffer || new ArrayBuffer(0))
89
+ for (const encoding of ['utf-8', 'windows-1252']) {
90
+ try {
91
+ return new TextDecoder(encoding, { fatal: true })
92
+ .decode(bytes)
93
+ .replace(/^\uFEFF/, '')
94
+ } catch {
95
+ // Try the next legacy-compatible project encoding.
96
+ }
97
+ }
98
+
99
+ return new TextDecoder('windows-1252')
100
+ .decode(bytes)
101
+ .replace(/^\uFEFF/, '')
102
+ }
103
+
104
+ /**
105
+ * Parses INI sections while preserving section and option order.
106
+ * @param {string} text
107
+ * @returns {{ name: string, index: number, entries: { key: string, value: string, line: number }[] }[]}
108
+ */
109
+ static #parseIniSections(text) {
110
+ const sections = []
111
+ let current = null
112
+ const lines = String(text || '')
113
+ .replace(/\r\n?/g, '\n')
114
+ .split('\n')
115
+
116
+ for (let index = 0; index < lines.length; index += 1) {
117
+ const rawLine = lines[index]
118
+ const trimmed = rawLine.trim()
119
+ if (
120
+ !trimmed ||
121
+ trimmed.startsWith(';') ||
122
+ trimmed.startsWith('#')
123
+ ) {
124
+ continue
125
+ }
126
+
127
+ const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/)
128
+ if (sectionMatch) {
129
+ current = {
130
+ name: sectionMatch[1].trim(),
131
+ index: sections.length,
132
+ entries: []
133
+ }
134
+ sections.push(current)
135
+ continue
136
+ }
137
+
138
+ if (!current) continue
139
+ const separatorIndex = rawLine.indexOf('=')
140
+ if (separatorIndex === -1) continue
141
+
142
+ current.entries.push({
143
+ key: rawLine.slice(0, separatorIndex).trim(),
144
+ value: rawLine.slice(separatorIndex + 1).trim(),
145
+ line: index + 1
146
+ })
147
+ }
148
+
149
+ return sections
150
+ }
151
+
152
+ /**
153
+ * Extracts numbered document entries.
154
+ * @param {{ name: string, entries: { key: string, value: string }[] }[]} sections
155
+ * @returns {object[]}
156
+ */
157
+ static #extractDocuments(sections) {
158
+ return PrjPcbModelParser.#numberedSections(sections, 'Document').map(
159
+ ({ section, number }) => {
160
+ const fields = PrjPcbModelParser.#sectionFields(section)
161
+ const path =
162
+ PrjPcbModelParser.#stringField(fields, 'DocumentPath') || ''
163
+ const optionKeys = section.entries.map((entry) => entry.key)
164
+ const isStub =
165
+ PrjPcbModelParser.#isDocumentStub(optionKeys) &&
166
+ PrjPcbModelParser.#documentKind(path) === 'schematic'
167
+
168
+ return {
169
+ index: number,
170
+ section: section.name,
171
+ path,
172
+ normalizedPath:
173
+ PrjPcbModelParser.#normalizeDocumentPath(path),
174
+ fileName: PrjPcbModelParser.#basename(path),
175
+ extension: PrjPcbModelParser.#extension(path),
176
+ kind: PrjPcbModelParser.#documentKind(path),
177
+ uniqueId:
178
+ PrjPcbModelParser.#stringField(
179
+ fields,
180
+ 'DocumentUniqueId'
181
+ ) || '',
182
+ isStub,
183
+ options: fields
184
+ }
185
+ }
186
+ )
187
+ }
188
+
189
+ /**
190
+ * Groups documents by role for project consumers.
191
+ * @param {object[]} documents
192
+ * @returns {Record<string, object[]>}
193
+ */
194
+ static #buildDocumentGroups(documents) {
195
+ const schematics = documents.filter(
196
+ (document) => document.kind === 'schematic'
197
+ )
198
+ const reachableSchematics = schematics.filter(
199
+ (document) => !document.isStub
200
+ )
201
+
202
+ return {
203
+ schematics,
204
+ reachableSchematics: reachableSchematics.length
205
+ ? reachableSchematics
206
+ : schematics,
207
+ pcbs: documents.filter((document) => document.kind === 'pcb'),
208
+ schematicLibraries: documents.filter(
209
+ (document) => document.kind === 'schematic-library'
210
+ ),
211
+ pcbLibraries: documents.filter(
212
+ (document) => document.kind === 'pcb-library'
213
+ ),
214
+ integratedLibraries: documents.filter(
215
+ (document) => document.kind === 'integrated-library'
216
+ ),
217
+ outJobs: documents.filter(
218
+ (document) => document.kind === 'output-job'
219
+ ),
220
+ others: documents.filter((document) => document.kind === 'other')
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Extracts project parameter sections.
226
+ * @param {{ name: string, entries: { key: string, value: string }[] }[]} sections
227
+ * @returns {{ list: object[], map: Record<string, string> }}
228
+ */
229
+ static #extractParameters(sections) {
230
+ const list = []
231
+ const map = {}
232
+
233
+ for (const { section, number } of PrjPcbModelParser.#numberedSections(
234
+ sections,
235
+ 'Parameter'
236
+ )) {
237
+ const fields = PrjPcbModelParser.#sectionFields(section)
238
+ const name = PrjPcbModelParser.#stringField(fields, 'Name')
239
+ if (!name) continue
240
+ const value = PrjPcbModelParser.#stringField(fields, 'Value') || ''
241
+ list.push({
242
+ index: number,
243
+ section: section.name,
244
+ name,
245
+ value,
246
+ options: fields
247
+ })
248
+ map[name] = value
249
+ }
250
+
251
+ return { list, map }
252
+ }
253
+
254
+ /**
255
+ * Extracts project variants and their variation rows.
256
+ * @param {{ name: string, entries: { key: string, value: string }[] }[]} sections
257
+ * @param {string} currentVariant
258
+ * @returns {object[]}
259
+ */
260
+ static #extractVariants(sections, currentVariant) {
261
+ return PrjPcbModelParser.#numberedSections(
262
+ sections,
263
+ 'ProjectVariant'
264
+ ).map(({ section, number }) => {
265
+ const fields = PrjPcbModelParser.#sectionFields(section)
266
+ const variationCount = PrjPcbModelParser.#integerField(
267
+ fields,
268
+ 'VariationCount'
269
+ )
270
+ const parameterCount = PrjPcbModelParser.#integerField(
271
+ fields,
272
+ 'ParameterCount'
273
+ )
274
+ const paramVariationCount = PrjPcbModelParser.#integerField(
275
+ fields,
276
+ 'ParamVariationCount'
277
+ )
278
+ const variations = PrjPcbModelParser.#extractPipeRows(
279
+ fields,
280
+ 'Variation',
281
+ variationCount
282
+ )
283
+ const parameters = PrjPcbModelParser.#extractPipeRows(
284
+ fields,
285
+ 'Parameter',
286
+ parameterCount
287
+ )
288
+ const paramVariations =
289
+ PrjPcbModelParser.#extractParamVariationRows(
290
+ fields,
291
+ paramVariationCount
292
+ )
293
+ const description =
294
+ PrjPcbModelParser.#stringField(fields, 'Description') || ''
295
+
296
+ return {
297
+ index: number,
298
+ section: section.name,
299
+ uniqueId:
300
+ PrjPcbModelParser.#stringField(fields, 'UniqueId') || '',
301
+ description,
302
+ allowFabrication: PrjPcbModelParser.#booleanField(
303
+ fields,
304
+ 'AllowFabrication'
305
+ ),
306
+ isCurrent:
307
+ !!currentVariant &&
308
+ description.toLowerCase() === currentVariant.toLowerCase(),
309
+ variationCount,
310
+ variations,
311
+ parameterCount,
312
+ parameters,
313
+ paramVariationCount,
314
+ paramVariations,
315
+ parameterOverrides:
316
+ PrjPcbModelParser.#buildParameterOverrideMap(
317
+ paramVariations
318
+ ),
319
+ dnp: variations
320
+ .filter((variation) => variation.Kind === '1')
321
+ .map((variation) => variation.Designator || '')
322
+ .filter(Boolean),
323
+ options: fields
324
+ }
325
+ })
326
+ }
327
+
328
+ /**
329
+ * Extracts project configuration sections.
330
+ * @param {{ name: string, entries: { key: string, value: string }[] }[]} sections
331
+ * @returns {object[]}
332
+ */
333
+ static #extractConfigurations(sections) {
334
+ return PrjPcbModelParser.#numberedSections(
335
+ sections,
336
+ 'Configuration'
337
+ ).map(({ section, number }) => {
338
+ const fields = PrjPcbModelParser.#sectionFields(section)
339
+ return {
340
+ index: number,
341
+ section: section.name,
342
+ name: PrjPcbModelParser.#stringField(fields, 'Name') || '',
343
+ variant:
344
+ PrjPcbModelParser.#stringField(fields, 'Variant') || '',
345
+ options: fields
346
+ }
347
+ })
348
+ }
349
+
350
+ /**
351
+ * Extracts output group sections and their numbered output rows.
352
+ * @param {{ name: string, entries: { key: string, value: string }[] }[]} sections
353
+ * @returns {object[]}
354
+ */
355
+ static #extractOutputGroups(sections) {
356
+ return PrjPcbModelParser.#numberedSections(sections, 'OutputGroup').map(
357
+ ({ section, number }) => {
358
+ const fields = PrjPcbModelParser.#sectionFields(section)
359
+ return {
360
+ index: number,
361
+ section: section.name,
362
+ name: PrjPcbModelParser.#stringField(fields, 'Name') || '',
363
+ description:
364
+ PrjPcbModelParser.#stringField(fields, 'Description') ||
365
+ '',
366
+ outputs: PrjPcbModelParser.#extractOutputRows(fields),
367
+ options: fields
368
+ }
369
+ }
370
+ )
371
+ }
372
+
373
+ /**
374
+ * Extracts pipe-delimited variation rows from numbered fields.
375
+ * @param {Record<string, string | string[]>} fields
376
+ * @param {string} prefix
377
+ * @param {number} declaredCount
378
+ * @returns {Record<string, string>[]}
379
+ */
380
+ static #extractPipeRows(fields, prefix, declaredCount) {
381
+ const rows = []
382
+ const count = Math.max(
383
+ declaredCount,
384
+ PrjPcbModelParser.#highestNumberedField(fields, prefix)
385
+ )
386
+
387
+ for (let index = 1; index <= count; index += 1) {
388
+ const value = PrjPcbModelParser.#stringField(fields, prefix + index)
389
+ if (!value) continue
390
+ rows.push(PrjPcbModelParser.#parsePipeFields(value))
391
+ }
392
+
393
+ return rows
394
+ }
395
+
396
+ /**
397
+ * Extracts parameter override rows and joins companion designator fields.
398
+ * @param {Record<string, string | string[]>} fields
399
+ * @param {number} declaredCount
400
+ * @returns {Record<string, string>[]}
401
+ */
402
+ static #extractParamVariationRows(fields, declaredCount) {
403
+ const rows = []
404
+ const count = Math.max(
405
+ declaredCount,
406
+ PrjPcbModelParser.#highestNumberedField(fields, 'ParamVariation')
407
+ )
408
+
409
+ for (let index = 1; index <= count; index += 1) {
410
+ const value = PrjPcbModelParser.#stringField(
411
+ fields,
412
+ 'ParamVariation' + index
413
+ )
414
+ if (!value) continue
415
+ const row = PrjPcbModelParser.#parsePipeFields(value)
416
+ const designator = PrjPcbModelParser.#stringField(
417
+ fields,
418
+ 'ParamDesignator' + index
419
+ )
420
+ if (designator) {
421
+ row.ParamDesignator = designator
422
+ row.Designator ||= designator
423
+ }
424
+ rows.push(row)
425
+ }
426
+
427
+ return rows
428
+ }
429
+
430
+ /**
431
+ * Groups parameter override rows by designator and parameter name.
432
+ * @param {Record<string, string>[]} rows
433
+ * @returns {Record<string, Record<string, string>>}
434
+ */
435
+ static #buildParameterOverrideMap(rows) {
436
+ const overrides = {}
437
+ for (const row of rows) {
438
+ const designator = String(
439
+ row.ParamDesignator || row.Designator || ''
440
+ ).trim()
441
+ const parameterName = String(row.ParameterName || '').trim()
442
+ if (!designator || !parameterName) continue
443
+ overrides[designator] ||= {}
444
+ overrides[designator][parameterName] = String(
445
+ row.VariantValue || ''
446
+ )
447
+ }
448
+ return overrides
449
+ }
450
+
451
+ /**
452
+ * Extracts numbered output rows from one OutputGroup section.
453
+ * @param {Record<string, string | string[]>} fields
454
+ * @returns {object[]}
455
+ */
456
+ static #extractOutputRows(fields) {
457
+ const count = PrjPcbModelParser.#highestNumberedField(
458
+ fields,
459
+ 'OutputType'
460
+ )
461
+ const rows = []
462
+
463
+ for (let index = 1; index <= count; index += 1) {
464
+ const type =
465
+ PrjPcbModelParser.#stringField(fields, 'OutputType' + index) ||
466
+ ''
467
+ if (!type) continue
468
+ rows.push({
469
+ index,
470
+ type,
471
+ name:
472
+ PrjPcbModelParser.#stringField(
473
+ fields,
474
+ 'OutputName' + index
475
+ ) || '',
476
+ documentPath:
477
+ PrjPcbModelParser.#stringField(
478
+ fields,
479
+ 'OutputDocumentPath' + index
480
+ ) || '',
481
+ variantName:
482
+ PrjPcbModelParser.#stringField(
483
+ fields,
484
+ 'OutputVariantName' + index
485
+ ) || '',
486
+ isDefault: PrjPcbModelParser.#booleanField(
487
+ fields,
488
+ 'OutputDefault' + index
489
+ )
490
+ })
491
+ }
492
+
493
+ return rows
494
+ }
495
+
496
+ /**
497
+ * Builds model summary counts.
498
+ * @param {string} fileName
499
+ * @param {object[]} documents
500
+ * @param {Record<string, object[]>} documentGroups
501
+ * @param {{ list: object[], map: Record<string, string> }} parameters
502
+ * @param {object[]} variants
503
+ * @param {string} currentVariant
504
+ * @returns {Record<string, number | string>}
505
+ */
506
+ static #buildSummary(
507
+ fileName,
508
+ documents,
509
+ documentGroups,
510
+ parameters,
511
+ variants,
512
+ currentVariant
513
+ ) {
514
+ return {
515
+ title:
516
+ parameters.map.PROJECT_TITLE ||
517
+ PrjPcbModelParser.#stripExtension(fileName),
518
+ documentCount: documents.length,
519
+ schematicCount: documentGroups.schematics.length,
520
+ reachableSchematicCount: documentGroups.reachableSchematics.length,
521
+ pcbCount: documentGroups.pcbs.length,
522
+ schematicLibraryCount: documentGroups.schematicLibraries.length,
523
+ pcbLibraryCount: documentGroups.pcbLibraries.length,
524
+ integratedLibraryCount: documentGroups.integratedLibraries.length,
525
+ outJobCount: documentGroups.outJobs.length,
526
+ variantCount: variants.length,
527
+ parameterCount: parameters.list.length,
528
+ currentVariant
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Builds parser diagnostics for suspicious project content.
534
+ * @param {object[]} sections
535
+ * @param {object[]} documents
536
+ * @param {object[]} variants
537
+ * @returns {{ severity: 'info' | 'warning', message: string }[]}
538
+ */
539
+ static #buildDiagnostics(sections, documents, variants) {
540
+ const diagnostics = []
541
+ if (!PrjPcbModelParser.#findSection(sections, 'Design')) {
542
+ diagnostics.push({
543
+ severity: 'warning',
544
+ message: 'PrjPcb file does not contain a [Design] section.'
545
+ })
546
+ }
547
+ if (!documents.length) {
548
+ diagnostics.push({
549
+ severity: 'warning',
550
+ message: 'PrjPcb file does not declare any project documents.'
551
+ })
552
+ }
553
+ if (!variants.length) {
554
+ diagnostics.push({
555
+ severity: 'info',
556
+ message: 'PrjPcb file does not declare project variants.'
557
+ })
558
+ }
559
+ return diagnostics
560
+ }
561
+
562
+ /**
563
+ * Returns numbered sections with a given prefix, sorted by numeric suffix.
564
+ * @param {{ name: string }[]} sections
565
+ * @param {string} prefix
566
+ * @returns {{ section: object, number: number }[]}
567
+ */
568
+ static #numberedSections(sections, prefix) {
569
+ const pattern = new RegExp('^' + prefix + '(\\d+)$', 'i')
570
+ return sections
571
+ .map((section) => {
572
+ const match = String(section.name || '').match(pattern)
573
+ return match
574
+ ? { section, number: Number.parseInt(match[1], 10) }
575
+ : null
576
+ })
577
+ .filter(Boolean)
578
+ .sort((left, right) => left.number - right.number)
579
+ }
580
+
581
+ /**
582
+ * Finds one section by name case-insensitively.
583
+ * @param {{ name: string }[]} sections
584
+ * @param {string} name
585
+ * @returns {object | null}
586
+ */
587
+ static #findSection(sections, name) {
588
+ const lowerName = name.toLowerCase()
589
+ return (
590
+ sections.find(
591
+ (section) =>
592
+ String(section.name || '').toLowerCase() === lowerName
593
+ ) || null
594
+ )
595
+ }
596
+
597
+ /**
598
+ * Builds a field map for one section.
599
+ * @param {{ entries?: { key: string, value: string }[] } | null} section
600
+ * @returns {Record<string, string | string[]>}
601
+ */
602
+ static #sectionFields(section) {
603
+ const fields = {}
604
+ for (const entry of section?.entries || []) {
605
+ PrjPcbModelParser.#appendField(fields, entry.key, entry.value)
606
+ }
607
+ return fields
608
+ }
609
+
610
+ /**
611
+ * Appends one value while preserving duplicate options.
612
+ * @param {Record<string, string | string[]>} fields
613
+ * @param {string} key
614
+ * @param {string} value
615
+ */
616
+ static #appendField(fields, key, value) {
617
+ if (!(key in fields)) {
618
+ fields[key] = value
619
+ return
620
+ }
621
+ const previous = fields[key]
622
+ if (Array.isArray(previous)) {
623
+ previous.push(value)
624
+ return
625
+ }
626
+ fields[key] = [previous, value]
627
+ }
628
+
629
+ /**
630
+ * Reads the first string value for a field case-insensitively.
631
+ * @param {Record<string, string | string[]>} fields
632
+ * @param {string} key
633
+ * @returns {string | null}
634
+ */
635
+ static #stringField(fields, key) {
636
+ const keyLower = key.toLowerCase()
637
+ for (const [candidateKey, value] of Object.entries(fields || {})) {
638
+ if (candidateKey.toLowerCase() !== keyLower) continue
639
+ return Array.isArray(value) ? value[0] || '' : value
640
+ }
641
+ return null
642
+ }
643
+
644
+ /**
645
+ * Reads an integer field with zero fallback.
646
+ * @param {Record<string, string | string[]>} fields
647
+ * @param {string} key
648
+ * @returns {number}
649
+ */
650
+ static #integerField(fields, key) {
651
+ const value = Number.parseInt(
652
+ PrjPcbModelParser.#stringField(fields, key) || '0',
653
+ 10
654
+ )
655
+ return Number.isFinite(value) ? value : 0
656
+ }
657
+
658
+ /**
659
+ * Reads a boolean-ish Altium field.
660
+ * @param {Record<string, string | string[]>} fields
661
+ * @param {string} key
662
+ * @returns {boolean}
663
+ */
664
+ static #booleanField(fields, key) {
665
+ const value = String(
666
+ PrjPcbModelParser.#stringField(fields, key) || ''
667
+ ).toLowerCase()
668
+ return value === '1' || value === 'true' || value === 'yes'
669
+ }
670
+
671
+ /**
672
+ * Parses a pipe-delimited Altium value string into key/value fields.
673
+ * @param {string} value
674
+ * @returns {Record<string, string>}
675
+ */
676
+ static #parsePipeFields(value) {
677
+ const fields = {}
678
+ for (const segment of String(value || '').split('|')) {
679
+ const separatorIndex = segment.indexOf('=')
680
+ if (separatorIndex === -1) continue
681
+ const key = segment.slice(0, separatorIndex).trim()
682
+ if (!key) continue
683
+ fields[key] = segment.slice(separatorIndex + 1)
684
+ }
685
+ return fields
686
+ }
687
+
688
+ /**
689
+ * Returns the highest numeric suffix for fields matching a prefix.
690
+ * @param {Record<string, unknown>} fields
691
+ * @param {string} prefix
692
+ * @returns {number}
693
+ */
694
+ static #highestNumberedField(fields, prefix) {
695
+ const pattern = new RegExp('^' + prefix + '(\\d+)$', 'i')
696
+ let highest = 0
697
+ for (const key of Object.keys(fields || {})) {
698
+ const match = key.match(pattern)
699
+ if (!match) continue
700
+ highest = Math.max(highest, Number.parseInt(match[1], 10))
701
+ }
702
+ return highest
703
+ }
704
+
705
+ /**
706
+ * Determines if a document has only durable identity fields.
707
+ * @param {string[]} optionKeys
708
+ * @returns {boolean}
709
+ */
710
+ static #isDocumentStub(optionKeys) {
711
+ const extraKeys = optionKeys.filter(
712
+ (key) =>
713
+ !['documentpath', 'documentuniqueid'].includes(
714
+ String(key).toLowerCase()
715
+ )
716
+ )
717
+ return extraKeys.length === 0
718
+ }
719
+
720
+ /**
721
+ * Resolves document kind from its suffix.
722
+ * @param {string} path
723
+ * @returns {string}
724
+ */
725
+ static #documentKind(path) {
726
+ switch (PrjPcbModelParser.#extension(path)) {
727
+ case '.schdoc':
728
+ return 'schematic'
729
+ case '.pcbdoc':
730
+ return 'pcb'
731
+ case '.schlib':
732
+ return 'schematic-library'
733
+ case '.pcblib':
734
+ return 'pcb-library'
735
+ case '.intlib':
736
+ return 'integrated-library'
737
+ case '.outjob':
738
+ return 'output-job'
739
+ default:
740
+ return 'other'
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Normalizes Altium path separators without resolving on the host machine.
746
+ * @param {string} path
747
+ * @returns {string}
748
+ */
749
+ static #normalizeDocumentPath(path) {
750
+ return String(path || '').replace(/\\/g, '/')
751
+ }
752
+
753
+ /**
754
+ * Extracts a basename from either Windows or POSIX separators.
755
+ * @param {string} path
756
+ * @returns {string}
757
+ */
758
+ static #basename(path) {
759
+ const parts = String(path || '').split(/[\\/]/)
760
+ return parts[parts.length - 1] || ''
761
+ }
762
+
763
+ /**
764
+ * Extracts a lowercase file extension from a path.
765
+ * @param {string} path
766
+ * @returns {string}
767
+ */
768
+ static #extension(path) {
769
+ const basename = PrjPcbModelParser.#basename(path)
770
+ const dotIndex = basename.lastIndexOf('.')
771
+ return dotIndex === -1 ? '' : basename.slice(dotIndex).toLowerCase()
772
+ }
773
+
774
+ /**
775
+ * Removes one file extension from a filename.
776
+ * @param {string} fileName
777
+ * @returns {string}
778
+ */
779
+ static #stripExtension(fileName) {
780
+ const basename = PrjPcbModelParser.#basename(fileName)
781
+ return basename.replace(/\.[^.]+$/, '')
782
+ }
783
+
784
+ /**
785
+ * Serializes raw sections into plain JSON-friendly objects.
786
+ * @param {{ name: string, index: number, entries: { key: string, value: string, line: number }[] }[]} sections
787
+ * @returns {object[]}
788
+ */
789
+ static #serializeSections(sections) {
790
+ return sections.map((section) => ({
791
+ name: section.name,
792
+ index: section.index,
793
+ fields: PrjPcbModelParser.#sectionFields(section),
794
+ entries: section.entries.map((entry) => ({ ...entry }))
795
+ }))
796
+ }
797
+ }