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.
- package/README.md +24 -6
- package/docs/api.md +42 -4
- package/docs/model-format.md +95 -5
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
- package/docs/testing.md +7 -2
- package/package.json +21 -2
- package/spec/library-scope.md +7 -1
- package/src/core/altium/AltiumParser.mjs +22 -325
- package/src/core/altium/NormalizedModelSchema.mjs +28 -0
- package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
- package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
- package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
- package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
- package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
- package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
- package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
- package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
- package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
- package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
- package/src/core/altium/PcbLibModelParser.mjs +202 -0
- package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
- package/src/core/altium/PcbModelParser.mjs +618 -66
- package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
- package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
- package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
- package/src/core/altium/PcbPadStackParser.mjs +903 -0
- package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
- package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
- package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
- package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
- package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
- package/src/core/altium/PcbRuleParser.mjs +587 -0
- package/src/core/altium/PcbStreamExtractor.mjs +127 -4
- package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
- package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
- package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
- package/src/core/altium/PcbViaStackParser.mjs +548 -0
- package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
- package/src/core/altium/PrjPcbModelParser.mjs +797 -0
- package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
- package/src/parser.mjs +13 -0
- package/src/renderers.mjs +5 -0
- package/src/styles/altium-renderers.css +11 -6
- package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
- package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
- package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
- package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
- package/src/ui/PcbSvgRenderer.mjs +101 -109
- package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
- 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
|
+
}
|