altium-toolkit 0.1.0

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 (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. package/src/workers/altium-parser.worker.mjs +29 -0
@@ -0,0 +1,1007 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { AsciiRecordParser } from './AsciiRecordParser.mjs'
6
+ import { ParserUtils } from './ParserUtils.mjs'
7
+ import { SchematicTextParser } from './SchematicTextParser.mjs'
8
+ import { SchematicTextPostProcessor } from './SchematicTextPostProcessor.mjs'
9
+ import { SchematicStandaloneCalloutNormalizer } from './SchematicStandaloneCalloutNormalizer.mjs'
10
+ import { SchematicAnnotationParser } from './SchematicAnnotationParser.mjs'
11
+ import { SchematicDirectiveParser } from './SchematicDirectiveParser.mjs'
12
+ import { SchematicPinParser } from './SchematicPinParser.mjs'
13
+ import { SchematicPrimitiveParser } from './SchematicPrimitiveParser.mjs'
14
+ import { AltiumLayoutParser } from './AltiumLayoutParser.mjs'
15
+ import { PcbModelParser } from './PcbModelParser.mjs'
16
+ import { PcbStreamExtractor } from './PcbStreamExtractor.mjs'
17
+ import { SchematicMultipartOwnerMatcher } from './SchematicMultipartOwnerMatcher.mjs'
18
+ import { SchematicSheetStyleResolver } from './SchematicSheetStyleResolver.mjs'
19
+ import { SchematicSheetParser } from './SchematicSheetParser.mjs'
20
+ import { SchematicJunctionParser } from './SchematicJunctionParser.mjs'
21
+ import { SchematicBusEntryParser } from './SchematicBusEntryParser.mjs'
22
+ import { SchematicImageParser } from './SchematicImageParser.mjs'
23
+ import { SchematicNetlistBuilder } from './SchematicNetlistBuilder.mjs'
24
+ const {
25
+ countMatchingKeys,
26
+ getDisplayText,
27
+ getField,
28
+ parseBoolean,
29
+ parseNumericField,
30
+ toColor,
31
+ dedupeByDesignator,
32
+ stripExtension
33
+ } = ParserUtils
34
+ const {
35
+ extractSchematicFonts,
36
+ extractSchematicMetadata,
37
+ extractSchematicTitleBlock,
38
+ normalizeSchematicTextRecord
39
+ } = SchematicTextParser
40
+ const { buildSchematicSyntheticTexts } = SchematicAnnotationParser
41
+ const {
42
+ parseSchematicCrosses,
43
+ parseSchematicPins,
44
+ parseSchematicPolygon,
45
+ parseSchematicPolyline,
46
+ parseSchematicPorts
47
+ } = SchematicPinParser
48
+
49
+ /**
50
+ * Parses native Altium files into normalized viewer models.
51
+ */
52
+ export class AltiumParser {
53
+ /**
54
+ * Parses a native Altium buffer into a normalized viewer model.
55
+ * @param {string} fileName
56
+ * @param {ArrayBuffer} arrayBuffer
57
+ * @returns {{ kind: 'schematic' | 'pcb', fileType: 'SchDoc' | 'PcbDoc', fileName: string, summary: Record<string, number | string>, diagnostics: { severity: 'info' | 'warning', message: string }[], schematic?: Record<string, unknown>, pcb?: Record<string, unknown>, bom: { designators: string[], quantity: number, pattern: string, source: string, value: string }[] }}
58
+ */
59
+ static parseArrayBuffer(fileName, arrayBuffer) {
60
+ const records = AsciiRecordParser.parse(arrayBuffer)
61
+ const fileType = AltiumParser.#sniffFileType(fileName, records)
62
+ if (fileType === 'SchDoc') {
63
+ return AltiumParser.#parseSchematic(fileName, records, arrayBuffer)
64
+ }
65
+ if (fileType === 'PcbDoc') {
66
+ const pcbExtraction =
67
+ PcbStreamExtractor.extractFromArrayBuffer(arrayBuffer)
68
+ return PcbModelParser.parse(
69
+ fileName,
70
+ pcbExtraction?.records || records,
71
+ pcbExtraction
72
+ )
73
+ }
74
+ throw new Error('Unsupported file type: ' + fileName)
75
+ }
76
+
77
+ /**
78
+ * Chooses the format based on extension and content.
79
+ * @param {string} fileName
80
+ * @param {{ fields: Record<string, string | string[]> }[]} records
81
+ * @returns {'SchDoc' | 'PcbDoc'}
82
+ */
83
+ static #sniffFileType(fileName, records) {
84
+ const normalized = String(fileName || '').toLowerCase()
85
+ if (normalized.endsWith('.schdoc')) return 'SchDoc'
86
+ if (normalized.endsWith('.pcbdoc')) return 'PcbDoc'
87
+
88
+ const hasSchematicHeader = records.some((record) =>
89
+ getField(record.fields, 'HEADER').includes('Schematic')
90
+ )
91
+ return hasSchematicHeader ? 'SchDoc' : 'PcbDoc'
92
+ }
93
+ /**
94
+ * Normalizes a schematic document.
95
+ * @param {string} fileName
96
+ * @param {{ raw: string, fields: Record<string, string | string[]> }[]} records
97
+ * @param {ArrayBuffer} arrayBuffer
98
+ * @returns {ReturnType<typeof AltiumParser.parseArrayBuffer>}
99
+ */
100
+ static #parseSchematic(fileName, records, arrayBuffer) {
101
+ const componentRecords = records.filter(
102
+ (record) => getField(record.fields, 'RECORD') === '1'
103
+ )
104
+ const ownersWithImplicitDisplayMode =
105
+ AltiumParser.#collectOwnersWithImplicitDisplayMode(records)
106
+ const activeMultipartOwnerParts =
107
+ SchematicMultipartOwnerMatcher.collectActiveMultipartOwnerParts(
108
+ records,
109
+ componentRecords
110
+ )
111
+ const sheetRecord = records.find(
112
+ (record) => getField(record.fields, 'RECORD') === '31'
113
+ )
114
+ const textRecords = records.filter((record) =>
115
+ AltiumParser.#hasDisplayText(record.fields)
116
+ )
117
+ const drawableRecords = records.filter((record) =>
118
+ AltiumParser.#isDrawableSchematicRecord(
119
+ record.fields,
120
+ ownersWithImplicitDisplayMode,
121
+ activeMultipartOwnerParts
122
+ )
123
+ )
124
+ const drawableTextRecords = textRecords.filter((record) =>
125
+ AltiumParser.#isDrawableSchematicRecord(
126
+ record.fields,
127
+ ownersWithImplicitDisplayMode,
128
+ activeMultipartOwnerParts
129
+ )
130
+ )
131
+ const lineRecords = records.filter(
132
+ (record) =>
133
+ AltiumParser.#isDrawableSchematicRecord(
134
+ record.fields,
135
+ ownersWithImplicitDisplayMode,
136
+ activeMultipartOwnerParts
137
+ ) &&
138
+ getField(record.fields, 'RECORD') !== '211' &&
139
+ getField(record.fields, 'RECORD') !== '30' &&
140
+ getField(record.fields, 'RECORD') !== '37' &&
141
+ !SchematicPrimitiveParser.isRectangleRecord(record.fields) &&
142
+ !AltiumParser.#hasDisplayText(record.fields) &&
143
+ AltiumParser.#hasCoordinatePair(record.fields, 'Location') &&
144
+ AltiumParser.#hasCoordinatePair(record.fields, 'Corner')
145
+ )
146
+ const regionRecords = drawableRecords.filter(
147
+ (record) =>
148
+ getField(record.fields, 'RECORD') === '211' &&
149
+ AltiumParser.#hasCoordinatePair(record.fields, 'Location') &&
150
+ AltiumParser.#hasCoordinatePair(record.fields, 'Corner')
151
+ )
152
+ const rectangleRecords = drawableRecords.filter(
153
+ (record) =>
154
+ SchematicPrimitiveParser.isRectangleRecord(record.fields) &&
155
+ AltiumParser.#hasCoordinatePair(record.fields, 'Location') &&
156
+ AltiumParser.#hasCoordinatePair(record.fields, 'Corner')
157
+ )
158
+ const arcRecords = drawableRecords.filter(
159
+ (record) =>
160
+ ['11', '12'].includes(getField(record.fields, 'RECORD')) &&
161
+ AltiumParser.#hasCoordinatePair(record.fields, 'Location') &&
162
+ parseNumericField(record.fields, 'Radius') !== null
163
+ )
164
+ const ellipseRecords = drawableRecords.filter(
165
+ (record) =>
166
+ getField(record.fields, 'RECORD') === '8' &&
167
+ AltiumParser.#hasCoordinatePair(record.fields, 'Location') &&
168
+ parseNumericField(record.fields, 'Radius') !== null
169
+ )
170
+ const polylineRecords = drawableRecords.filter(
171
+ (record) =>
172
+ getField(record.fields, 'RECORD') === '26' ||
173
+ getField(record.fields, 'RECORD') === '27' ||
174
+ getField(record.fields, 'RECORD') === '6'
175
+ )
176
+ const polygonRecords = drawableRecords.filter(
177
+ (record) => getField(record.fields, 'RECORD') === '7'
178
+ )
179
+ const pinRecords = drawableRecords.filter(
180
+ (record) => getField(record.fields, 'RECORD') === '2'
181
+ )
182
+ const portRecords = drawableRecords.filter(
183
+ (record) => getField(record.fields, 'RECORD') === '18'
184
+ )
185
+ const directiveRecords = drawableRecords.filter(
186
+ (record) =>
187
+ getField(record.fields, 'RECORD') === '43' &&
188
+ AltiumParser.#hasCoordinatePair(record.fields, 'Location')
189
+ )
190
+ const crossRecords = drawableRecords.filter(
191
+ (record) => getField(record.fields, 'RECORD') === '22'
192
+ )
193
+ const recordIndexAwareRecords = records.map((record, recordIndex) => ({
194
+ ...record,
195
+ recordIndex
196
+ }))
197
+ const relatedTexts = new Map()
198
+
199
+ for (const record of records) {
200
+ const ownerIndex = getField(record.fields, 'OwnerIndex')
201
+ if (!ownerIndex) continue
202
+ if (!relatedTexts.has(ownerIndex)) {
203
+ relatedTexts.set(ownerIndex, [])
204
+ }
205
+ relatedTexts.get(ownerIndex).push(record)
206
+ }
207
+
208
+ const metadataTexts = extractSchematicMetadata(textRecords)
209
+ const schematicFonts = extractSchematicFonts(sheetRecord?.fields)
210
+ const sheetWidth =
211
+ parseNumericField(sheetRecord?.fields, 'CustomX') || 1500
212
+ const sheetHeight =
213
+ parseNumericField(sheetRecord?.fields, 'CustomY') || 950
214
+ const sheetMargin =
215
+ parseNumericField(sheetRecord?.fields, 'CustomMarginWidth') || 20
216
+ const sheet = {
217
+ width: sheetWidth,
218
+ height: sheetHeight,
219
+ sourceWidth: sheetWidth,
220
+ sourceHeight: sheetHeight,
221
+ visibleGrid:
222
+ parseNumericField(sheetRecord?.fields, 'VisibleGridSize') || 10,
223
+ snapGrid:
224
+ parseNumericField(sheetRecord?.fields, 'SnapGridSize') || 5,
225
+ borderOn: parseBoolean(sheetRecord?.fields.BorderOn),
226
+ titleBlockOn: parseBoolean(sheetRecord?.fields.TitleBlockOn),
227
+ marginWidth: sheetMargin,
228
+ xZones: Math.max(
229
+ (parseNumericField(sheetRecord?.fields, 'CustomXZones') || 6) -
230
+ 2,
231
+ 1
232
+ ),
233
+ yZones: Math.max(
234
+ parseNumericField(sheetRecord?.fields, 'CustomYZones') || 4,
235
+ 1
236
+ ),
237
+ fonts: schematicFonts,
238
+ sheetStyle:
239
+ parseNumericField(sheetRecord?.fields, 'SheetStyle') || 0,
240
+ titleBlock: extractSchematicTitleBlock(
241
+ textRecords,
242
+ metadataTexts,
243
+ sheetWidth,
244
+ schematicFonts
245
+ )
246
+ }
247
+
248
+ const lines = [
249
+ ...lineRecords.map((record, index) => ({
250
+ x1: parseNumericField(record.fields, 'Location.X') || 0,
251
+ y1: parseNumericField(record.fields, 'Location.Y') || 0,
252
+ x2: parseNumericField(record.fields, 'Corner.X') || 0,
253
+ y2: parseNumericField(record.fields, 'Corner.Y') || 0,
254
+ color: toColor(record.fields.Color, '#a44a1b'),
255
+ width: parseNumericField(record.fields, 'LineWidth') || 1,
256
+ lineStyle: parseNumericField(record.fields, 'LineStyle') || 0,
257
+ renderOrder:
258
+ parseNumericField(record.fields, 'IndexInSheet') ?? index,
259
+ ownerIndex: getField(record.fields, 'OwnerIndex') || undefined
260
+ })),
261
+ ...polylineRecords.flatMap((record, index) =>
262
+ parseSchematicPolyline(record.fields, {
263
+ isBus: getField(record.fields, 'RECORD') === '26'
264
+ }).map((line, segmentIndex) => ({
265
+ ...line,
266
+ renderOrder:
267
+ (parseNumericField(record.fields, 'IndexInSheet') ??
268
+ index) +
269
+ segmentIndex / 100,
270
+ ownerIndex:
271
+ getField(record.fields, 'OwnerIndex') || undefined
272
+ }))
273
+ ),
274
+ ...polygonRecords.flatMap((record, index) =>
275
+ parseSchematicPolygon(record.fields).map(
276
+ (line, segmentIndex) => ({
277
+ ...line,
278
+ renderOrder:
279
+ (parseNumericField(record.fields, 'IndexInSheet') ??
280
+ index) +
281
+ segmentIndex / 100,
282
+ ownerIndex:
283
+ getField(record.fields, 'OwnerIndex') || undefined
284
+ })
285
+ )
286
+ )
287
+ ]
288
+ const polygons =
289
+ SchematicPrimitiveParser.parseSchematicPolygons(polygonRecords)
290
+ const arcs = SchematicPrimitiveParser.parseSchematicArcs(arcRecords)
291
+ const ellipses =
292
+ SchematicPrimitiveParser.parseSchematicEllipses(ellipseRecords)
293
+ const rectangles =
294
+ SchematicPrimitiveParser.inferMissingOwnerRectangleRenderOrders(
295
+ rectangleRecords,
296
+ SchematicPrimitiveParser.parseSchematicRectangles(
297
+ rectangleRecords
298
+ ),
299
+ lines,
300
+ polygons,
301
+ ellipses,
302
+ arcs
303
+ )
304
+ const regions =
305
+ SchematicPrimitiveParser.parseSchematicRegions(regionRecords)
306
+ const directives =
307
+ SchematicDirectiveParser.parseSchematicDirectives(directiveRecords)
308
+ const { sheetSymbols, sheetEntries } = SchematicSheetParser.parse(
309
+ recordIndexAwareRecords
310
+ )
311
+ const junctions = SchematicJunctionParser.parseSchematicJunctions(
312
+ recordIndexAwareRecords
313
+ )
314
+ const busEntries = SchematicBusEntryParser.parseSchematicBusEntries(
315
+ recordIndexAwareRecords
316
+ )
317
+ const { images, diagnostics: imageDiagnostics } =
318
+ SchematicImageParser.parseSchematicImages(
319
+ recordIndexAwareRecords,
320
+ arrayBuffer
321
+ )
322
+
323
+ const pins = parseSchematicPins(pinRecords)
324
+ const ports = parseSchematicPorts(portRecords, lines)
325
+ const crosses = parseSchematicCrosses(crossRecords)
326
+ let texts = drawableTextRecords
327
+ .map((record) =>
328
+ normalizeSchematicTextRecord(
329
+ record.fields,
330
+ metadataTexts,
331
+ sheet,
332
+ schematicFonts
333
+ )
334
+ )
335
+ .filter(Boolean)
336
+ const normalizedStandaloneCallouts =
337
+ SchematicStandaloneCalloutNormalizer.normalize(lines, texts)
338
+ const normalizedLines = normalizedStandaloneCallouts.lines
339
+ texts = normalizedStandaloneCallouts.texts
340
+ texts = SchematicTextPostProcessor.dropDuplicatePortLabels(texts, ports)
341
+ texts = SchematicTextPostProcessor.decorateMultipartDesignators(
342
+ texts,
343
+ activeMultipartOwnerParts
344
+ )
345
+ texts.push(
346
+ ...buildSchematicSyntheticTexts(
347
+ records,
348
+ pins,
349
+ schematicFonts
350
+ ).filter(
351
+ (syntheticText) =>
352
+ !texts.some(
353
+ (text) =>
354
+ text.text === syntheticText.text &&
355
+ Math.abs(text.x - syntheticText.x) <= 80 &&
356
+ Math.abs(text.y - syntheticText.y) <= 20
357
+ )
358
+ )
359
+ )
360
+ const anchoredTexts =
361
+ SchematicTextPostProcessor.anchorWireLabelsNearDesignators(
362
+ SchematicTextPostProcessor.anchorComponentTextsFromOwnerBounds(
363
+ texts,
364
+ normalizedLines,
365
+ pins,
366
+ ports
367
+ ),
368
+ normalizedLines,
369
+ pins,
370
+ ports
371
+ )
372
+
373
+ const components = componentRecords.map((record) => {
374
+ const x = parseNumericField(record.fields, 'Location.X') || 0
375
+ const y = parseNumericField(record.fields, 'Location.Y') || 0
376
+ const libReference =
377
+ getField(record.fields, 'LibReference') ||
378
+ getField(record.fields, 'DesignItemId')
379
+ const ownerIndex = String(
380
+ (parseNumericField(record.fields, 'IndexInSheet') || 0) + 1
381
+ )
382
+ const ownerTexts = relatedTexts.get(ownerIndex) || []
383
+
384
+ return {
385
+ x,
386
+ y,
387
+ libReference,
388
+ designator:
389
+ AltiumParser.#resolveComponentDesignator(
390
+ ownerTexts,
391
+ anchoredTexts,
392
+ {
393
+ x,
394
+ y,
395
+ libReference
396
+ }
397
+ ) || 'U?',
398
+ value: AltiumParser.#resolveComponentValue(
399
+ ownerTexts,
400
+ anchoredTexts,
401
+ { x, y, libReference }
402
+ ),
403
+ uniqueId: getField(record.fields, 'UniqueID')
404
+ }
405
+ })
406
+ const resolvedSheet = AltiumLayoutParser.resolveSchematicSheetSize(
407
+ sheet,
408
+ textRecords,
409
+ normalizedLines,
410
+ anchoredTexts,
411
+ components,
412
+ pins,
413
+ rectangles,
414
+ regions,
415
+ ports,
416
+ crosses
417
+ )
418
+
419
+ resolvedSheet.xZones =
420
+ SchematicSheetStyleResolver.resolveXZones(resolvedSheet)
421
+ delete resolvedSheet.sheetStyle
422
+
423
+ const title =
424
+ AltiumParser.#findNamedText(textRecords, 'Title') ||
425
+ stripExtension(fileName)
426
+ const bom = AltiumParser.#groupBomRows(
427
+ components.map((component) => ({
428
+ designator: component.designator,
429
+ pattern: '',
430
+ source: component.libReference,
431
+ value: component.value || component.libReference
432
+ }))
433
+ )
434
+
435
+ const diagnostics = [
436
+ {
437
+ severity: 'info',
438
+ message:
439
+ 'Recovered ' +
440
+ records.length +
441
+ ' printable schematic records.'
442
+ },
443
+ {
444
+ severity: 'info',
445
+ message:
446
+ 'Recovered ' + components.length + ' schematic components.'
447
+ },
448
+ {
449
+ severity: 'info',
450
+ message:
451
+ 'Recovered ' +
452
+ normalizedLines.length +
453
+ ' drawable line segments.'
454
+ }
455
+ ]
456
+
457
+ if (!sheetRecord) {
458
+ diagnostics.push({
459
+ severity: 'warning',
460
+ message:
461
+ 'Sheet metadata record 31 was not found. Using fallback dimensions.'
462
+ })
463
+ }
464
+ diagnostics.push(...imageDiagnostics)
465
+ const { nets, diagnostics: netDiagnostics } =
466
+ SchematicNetlistBuilder.build({
467
+ lines: normalizedLines,
468
+ texts: anchoredTexts,
469
+ pins,
470
+ ports,
471
+ junctions,
472
+ busEntries,
473
+ sheetEntries
474
+ })
475
+ diagnostics.push(...netDiagnostics)
476
+
477
+ return {
478
+ kind: 'schematic',
479
+ fileType: 'SchDoc',
480
+ fileName,
481
+ summary: {
482
+ title,
483
+ componentCount: components.length,
484
+ lineCount: lines.length,
485
+ textCount: anchoredTexts.length,
486
+ bomRowCount: bom.length
487
+ },
488
+ diagnostics,
489
+ schematic: {
490
+ sheet: resolvedSheet,
491
+ lines: normalizedLines,
492
+ polygons,
493
+ rectangles,
494
+ regions,
495
+ ellipses,
496
+ arcs,
497
+ directives,
498
+ texts: anchoredTexts,
499
+ components,
500
+ pins,
501
+ ports,
502
+ crosses,
503
+ sheetSymbols: sheetSymbols.map(
504
+ ({ sourceRecordIndex, indexInSheet, ...sheetSymbol }) =>
505
+ sheetSymbol
506
+ ),
507
+ sheetEntries,
508
+ junctions,
509
+ busEntries,
510
+ images,
511
+ nets
512
+ },
513
+ bom
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Finds a visible text string with a given logical name.
519
+ * @param {{ fields: Record<string, string | string[]> }[]} records
520
+ * @param {string} logicalName
521
+ * @returns {string}
522
+ */
523
+ static #findNamedText(records, logicalName) {
524
+ const match = records.find(
525
+ (record) =>
526
+ getField(record.fields, 'Name').toLowerCase() ===
527
+ logicalName.toLowerCase()
528
+ )
529
+ return match ? getDisplayText(match.fields) : ''
530
+ }
531
+
532
+ /**
533
+ * Finds a related text value by name.
534
+ * @param {{ fields: Record<string, string | string[]> }[]} records
535
+ * @param {string} logicalName
536
+ * @returns {string}
537
+ */
538
+ static #findRelatedText(records, logicalName) {
539
+ const match = records.find(
540
+ (record) =>
541
+ getField(record.fields, 'Name').toLowerCase() ===
542
+ logicalName.toLowerCase()
543
+ )
544
+ return match ? getDisplayText(match.fields) : ''
545
+ }
546
+
547
+ /**
548
+ * Collects owners whose active symbol primitives already exist without an
549
+ * explicit display-mode selector.
550
+ * @param {{ fields: Record<string, string | string[]> }[]} records
551
+ * @returns {Set<string>}
552
+ */
553
+ static #collectOwnersWithImplicitDisplayMode(records) {
554
+ const owners = new Set()
555
+ for (const record of records) {
556
+ const ownerIndex = getField(record.fields, 'OwnerIndex')
557
+ const ownerPartId = getField(record.fields, 'OwnerPartId')
558
+
559
+ if (
560
+ ownerIndex &&
561
+ ownerPartId &&
562
+ ownerPartId !== '-1' &&
563
+ !getField(record.fields, 'OwnerPartDisplayMode') &&
564
+ AltiumParser.#isDisplayModeSelectablePrimitive(record.fields)
565
+ ) {
566
+ owners.add(ownerIndex)
567
+ }
568
+ }
569
+
570
+ return owners
571
+ }
572
+
573
+ /**
574
+ * Returns true when one schematic record belongs to the active symbol
575
+ * display mode for its owner.
576
+ * @param {Record<string, string | string[]>} fields
577
+ * @param {Set<string>} ownersWithImplicitDisplayMode
578
+ * @returns {boolean}
579
+ */
580
+ static #isActiveSchematicDisplayModeRecord(
581
+ fields,
582
+ ownersWithImplicitDisplayMode
583
+ ) {
584
+ const ownerIndex = getField(fields, 'OwnerIndex')
585
+ const ownerPartDisplayMode = getField(fields, 'OwnerPartDisplayMode')
586
+ if (!ownerIndex || !ownerPartDisplayMode) {
587
+ return true
588
+ }
589
+
590
+ return !ownersWithImplicitDisplayMode.has(ownerIndex)
591
+ }
592
+
593
+ /**
594
+ * Returns true when one schematic record belongs to both the active
595
+ * display mode and the active multipart owner part for its owner.
596
+ * @param {Record<string, string | string[]>} fields
597
+ * @param {Set<string>} ownersWithImplicitDisplayMode
598
+ * @param {Map<string, string>} activeMultipartOwnerParts
599
+ * @returns {boolean}
600
+ */
601
+ static #isDrawableSchematicRecord(
602
+ fields,
603
+ ownersWithImplicitDisplayMode,
604
+ activeMultipartOwnerParts
605
+ ) {
606
+ return (
607
+ AltiumParser.#isActiveSchematicDisplayModeRecord(
608
+ fields,
609
+ ownersWithImplicitDisplayMode
610
+ ) &&
611
+ SchematicMultipartOwnerMatcher.isActiveOwnerPartRecord(
612
+ fields,
613
+ activeMultipartOwnerParts
614
+ )
615
+ )
616
+ }
617
+
618
+ /**
619
+ * Returns true when a schematic primitive participates in owner display
620
+ * mode selection.
621
+ * @param {Record<string, string | string[]>} fields
622
+ * @returns {boolean}
623
+ */
624
+ static #isDisplayModeSelectablePrimitive(fields) {
625
+ const recordType = getField(fields, 'RECORD')
626
+
627
+ return (
628
+ recordType === '2' ||
629
+ recordType === '6' ||
630
+ recordType === '11' ||
631
+ recordType === '12' ||
632
+ recordType === '13' ||
633
+ recordType === '27' ||
634
+ (AltiumParser.#hasCoordinatePair(fields, 'Location') &&
635
+ AltiumParser.#hasCoordinatePair(fields, 'Corner'))
636
+ )
637
+ }
638
+
639
+ /**
640
+ * Resolves a component designator from owner-linked text or nearby visible
641
+ * schematic labels when the owner link is missing.
642
+ * @param {{ fields: Record<string, string | string[]> }[]} ownerTexts
643
+ * @param {{ x: number, y: number, text: string, name: string }[]} texts
644
+ * @param {{ x: number, y: number, libReference: string }} component
645
+ * @returns {string}
646
+ */
647
+ static #resolveComponentDesignator(ownerTexts, texts, component) {
648
+ const ownerDesignator = AltiumParser.#findRelatedText(
649
+ ownerTexts,
650
+ 'Designator'
651
+ )
652
+ if (AltiumParser.#isResolvedComponentText(ownerDesignator)) {
653
+ return ownerDesignator
654
+ }
655
+
656
+ return AltiumParser.#findNearbyComponentDesignator(texts, component)
657
+ }
658
+
659
+ /**
660
+ * Resolves a component value from owner-linked text or nearby visible
661
+ * schematic labels when the owner link still contains template placeholders.
662
+ * @param {{ fields: Record<string, string | string[]> }[]} ownerTexts
663
+ * @param {{ x: number, y: number, text: string, name: string }[]} texts
664
+ * @param {{ x: number, y: number, libReference: string }} component
665
+ * @returns {string}
666
+ */
667
+ static #resolveComponentValue(ownerTexts, texts, component) {
668
+ const ownerValue =
669
+ AltiumParser.#findRelatedText(ownerTexts, 'Comment') ||
670
+ AltiumParser.#findRelatedText(ownerTexts, 'VALUE')
671
+ if (AltiumParser.#isResolvedComponentText(ownerValue)) {
672
+ return ownerValue
673
+ }
674
+
675
+ return (
676
+ AltiumParser.#findNearbyComponentText(
677
+ texts,
678
+ component,
679
+ ['comment', 'value'],
680
+ '',
681
+ AltiumParser.#inferComponentValueHint(component.libReference)
682
+ ) || ownerValue
683
+ )
684
+ }
685
+
686
+ /**
687
+ * Finds the closest nearby designator text for one component.
688
+ * @param {{ x: number, y: number, text: string, name: string }[]} texts
689
+ * @param {{ x: number, y: number, libReference: string }} component
690
+ * @returns {string}
691
+ */
692
+ static #findNearbyComponentDesignator(texts, component) {
693
+ const expectedPrefix = AltiumParser.#inferComponentDesignatorPrefix(
694
+ component.libReference
695
+ )
696
+ const expectedValueHint = AltiumParser.#inferComponentValueHint(
697
+ component.libReference
698
+ )
699
+ const candidates = AltiumParser.#collectNearbyComponentTextCandidates(
700
+ texts,
701
+ component,
702
+ ['designator']
703
+ )
704
+ const scopedCandidates = expectedPrefix
705
+ ? candidates.filter((candidate) =>
706
+ candidate.text
707
+ .toUpperCase()
708
+ .startsWith(expectedPrefix.toUpperCase())
709
+ )
710
+ : candidates
711
+ const usableCandidates = scopedCandidates.length
712
+ ? scopedCandidates
713
+ : candidates
714
+ const rankedCandidates = usableCandidates
715
+ .map((candidate) => ({
716
+ ...candidate,
717
+ score:
718
+ candidate.distance +
719
+ AltiumParser.#scoreAssociatedValueMismatch(
720
+ texts,
721
+ candidate,
722
+ expectedValueHint
723
+ )
724
+ }))
725
+ .sort((left, right) => left.score - right.score)
726
+
727
+ return rankedCandidates[0]?.text || ''
728
+ }
729
+
730
+ /**
731
+ * Finds the closest nearby visible text for one component.
732
+ * @param {{ x: number, y: number, text: string, name: string }[]} texts
733
+ * @param {{ x: number, y: number }} component
734
+ * @param {string[]} logicalNames
735
+ * @param {string} expectedPrefix
736
+ * @param {string} expectedTextHint
737
+ * @returns {string}
738
+ */
739
+ static #findNearbyComponentText(
740
+ texts,
741
+ component,
742
+ logicalNames,
743
+ expectedPrefix = '',
744
+ expectedTextHint = ''
745
+ ) {
746
+ const candidates = AltiumParser.#collectNearbyComponentTextCandidates(
747
+ texts,
748
+ component,
749
+ logicalNames
750
+ )
751
+ const prefixedCandidates = expectedPrefix
752
+ ? candidates.filter((candidate) =>
753
+ candidate.text
754
+ .toUpperCase()
755
+ .startsWith(expectedPrefix.toUpperCase())
756
+ )
757
+ : candidates
758
+ const scopedCandidates = prefixedCandidates.length
759
+ ? prefixedCandidates
760
+ : candidates
761
+ const hintedCandidates = expectedTextHint
762
+ ? scopedCandidates.filter((candidate) =>
763
+ AltiumParser.#normalizeTextMatch(candidate.text).includes(
764
+ AltiumParser.#normalizeTextMatch(expectedTextHint)
765
+ )
766
+ )
767
+ : scopedCandidates
768
+ const usableCandidates = hintedCandidates.length
769
+ ? hintedCandidates
770
+ : scopedCandidates
771
+
772
+ return usableCandidates.sort(
773
+ (left, right) => left.distance - right.distance
774
+ )[0]?.text
775
+ }
776
+
777
+ /**
778
+ * Collects nearby visible schematic text candidates around one component.
779
+ * @param {{ x: number, y: number, text: string, name: string }[]} texts
780
+ * @param {{ x: number, y: number }} component
781
+ * @param {string[]} logicalNames
782
+ * @returns {{ x: number, y: number, text: string, distance: number }[]}
783
+ */
784
+ static #collectNearbyComponentTextCandidates(
785
+ texts,
786
+ component,
787
+ logicalNames
788
+ ) {
789
+ const allowedNames = new Set(
790
+ logicalNames.map((name) => name.toLowerCase())
791
+ )
792
+
793
+ return texts
794
+ .filter((text) =>
795
+ allowedNames.has(
796
+ String(text.name || '')
797
+ .trim()
798
+ .toLowerCase()
799
+ )
800
+ )
801
+ .map((text) => ({
802
+ x: text.x,
803
+ y: text.y,
804
+ text: text.text,
805
+ distance:
806
+ Math.abs(text.x - component.x) +
807
+ Math.abs(text.y - component.y)
808
+ }))
809
+ .filter(
810
+ (text) =>
811
+ Math.abs(text.x - component.x) <= 80 &&
812
+ Math.abs(text.y - component.y) <= 80
813
+ )
814
+ }
815
+
816
+ /**
817
+ * Penalizes a designator candidate when its nearby value text does not
818
+ * match the library-derived value hint.
819
+ * @param {{ x: number, y: number, text: string, name: string }[]} texts
820
+ * @param {{ x: number, y: number }} candidate
821
+ * @param {string} expectedValueHint
822
+ * @returns {number}
823
+ */
824
+ static #scoreAssociatedValueMismatch(texts, candidate, expectedValueHint) {
825
+ if (!expectedValueHint) {
826
+ return 0
827
+ }
828
+
829
+ const associatedValue = AltiumParser.#findNearbyComponentText(
830
+ texts,
831
+ candidate,
832
+ ['comment', 'value']
833
+ )
834
+ if (!associatedValue) {
835
+ return 0
836
+ }
837
+
838
+ return AltiumParser.#normalizeTextMatch(associatedValue).includes(
839
+ AltiumParser.#normalizeTextMatch(expectedValueHint)
840
+ )
841
+ ? -30
842
+ : 30
843
+ }
844
+
845
+ /**
846
+ * Returns true when a recovered owner-linked text is usable as a component
847
+ * display value.
848
+ * @param {string} value
849
+ * @returns {boolean}
850
+ */
851
+ static #isResolvedComponentText(value) {
852
+ const normalized = String(value || '').trim()
853
+
854
+ return Boolean(
855
+ normalized && normalized !== '*' && !normalized.startsWith('=')
856
+ )
857
+ }
858
+
859
+ /**
860
+ * Infers the visible designator prefix from a library reference.
861
+ * @param {string} libReference
862
+ * @returns {string}
863
+ */
864
+ static #inferComponentDesignatorPrefix(libReference) {
865
+ const normalized = String(libReference || '')
866
+ .trim()
867
+ .toUpperCase()
868
+
869
+ if (normalized.startsWith('RES/')) return 'R'
870
+ if (normalized.startsWith('CAP/')) return 'C'
871
+ if (normalized.startsWith('DIODE/')) return 'D'
872
+ if (normalized.startsWith('CON/')) return 'J'
873
+ if (normalized.startsWith('IC/')) return 'U'
874
+
875
+ return ''
876
+ }
877
+
878
+ /**
879
+ * Infers the visible value label from a library reference.
880
+ * @param {string} libReference
881
+ * @returns {string}
882
+ */
883
+ static #inferComponentValueHint(libReference) {
884
+ const segments = String(libReference || '')
885
+ .split('/')
886
+ .map((segment) => segment.trim())
887
+ .filter(Boolean)
888
+
889
+ for (let index = segments.length - 1; index >= 0; index -= 1) {
890
+ const segment = segments[index]
891
+
892
+ if (
893
+ AltiumParser.#isPackageLikeComponentSegment(segment) ||
894
+ /\s/.test(segment)
895
+ ) {
896
+ continue
897
+ }
898
+
899
+ if (
900
+ /^(?:\d+(?:\.\d+)?(?:R|K|M|UF|NF|PF)|1N[A-Z0-9-]+)$/i.test(
901
+ segment
902
+ )
903
+ ) {
904
+ return segment
905
+ }
906
+
907
+ if (
908
+ /[A-Z]/i.test(segment) &&
909
+ /\d/.test(segment) &&
910
+ segment.length >= 6
911
+ ) {
912
+ return segment
913
+ }
914
+ }
915
+
916
+ return ''
917
+ }
918
+
919
+ /**
920
+ * Returns true when one library segment behaves like a package or rating
921
+ * rather than a user-facing value.
922
+ * @param {string} segment
923
+ * @returns {boolean}
924
+ */
925
+ static #isPackageLikeComponentSegment(segment) {
926
+ return /^(?:CE|\d{4}|SC\d+|SOD-\d+|\d+(?:\.\d+)?V|\d+(?:\.\d+)?[%%])$/i.test(
927
+ String(segment || '').trim()
928
+ )
929
+ }
930
+
931
+ /**
932
+ * Normalizes a text fragment for proximity matching.
933
+ * @param {string} value
934
+ * @returns {string}
935
+ */
936
+ static #normalizeTextMatch(value) {
937
+ return String(value || '')
938
+ .toUpperCase()
939
+ .replaceAll(/\s+/g, '')
940
+ .replaceAll('%', '%')
941
+ }
942
+
943
+ /**
944
+ * Groups designators into BOM rows.
945
+ * @param {{ designator: string, pattern: string, source: string, value: string }[]} entries
946
+ * @returns {{ designators: string[], quantity: number, pattern: string, source: string, value: string }[]}
947
+ */
948
+ static #groupBomRows(entries) {
949
+ const groups = new Map()
950
+
951
+ for (const entry of entries) {
952
+ const key = [entry.pattern, entry.source, entry.value].join('::')
953
+ if (!groups.has(key)) {
954
+ groups.set(key, {
955
+ designators: [],
956
+ quantity: 0,
957
+ pattern: entry.pattern,
958
+ source: entry.source,
959
+ value: entry.value
960
+ })
961
+ }
962
+
963
+ const row = groups.get(key)
964
+ row.designators.push(entry.designator)
965
+ row.quantity += 1
966
+ }
967
+
968
+ return [...groups.values()]
969
+ .map((row) => ({
970
+ ...row,
971
+ designators: row.designators.sort((left, right) =>
972
+ left.localeCompare(right, undefined, { numeric: true })
973
+ )
974
+ }))
975
+ .sort((left, right) =>
976
+ left.designators[0].localeCompare(
977
+ right.designators[0],
978
+ undefined,
979
+ {
980
+ numeric: true
981
+ }
982
+ )
983
+ )
984
+ }
985
+
986
+ /**
987
+ * Returns true when a record has a text payload.
988
+ * @param {Record<string, string | string[]>} fields
989
+ * @returns {boolean}
990
+ */
991
+ static #hasDisplayText(fields) {
992
+ return Boolean(getDisplayText(fields))
993
+ }
994
+
995
+ /**
996
+ * Returns true when both X and Y exist for a point prefix.
997
+ * @param {Record<string, string | string[]>} fields
998
+ * @param {string} prefix
999
+ * @returns {boolean}
1000
+ */
1001
+ static #hasCoordinatePair(fields, prefix) {
1002
+ return (
1003
+ parseNumericField(fields, prefix + '.X') !== null &&
1004
+ parseNumericField(fields, prefix + '.Y') !== null
1005
+ )
1006
+ }
1007
+ }