altium-toolkit 0.1.1 → 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 +6 -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,968 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbBinaryPrimitiveParser } from './PcbBinaryPrimitiveParser.mjs'
6
+ import { PcbEmbeddedFontExtractor } from './PcbEmbeddedFontExtractor.mjs'
7
+ import { PcbRawRecordRegistry } from './PcbRawRecordRegistry.mjs'
8
+ import { OleCompoundDocument } from '../ole/OleCompoundDocument.mjs'
9
+ import { OleConstants } from '../ole/OleConstants.mjs'
10
+
11
+ /**
12
+ * Extracts footprint-oriented content from OLE-backed Altium PcbLib files.
13
+ */
14
+ export class PcbLibStreamExtractor {
15
+ static #FOOTPRINT_RECORD_TYPES = {
16
+ 1: {
17
+ type: 'arc',
18
+ collection: 'arcs',
19
+ minimumSubrecordCount: 1,
20
+ minimumPayloadByteLength: 45,
21
+ parser: 'parseArcStream'
22
+ },
23
+ 2: {
24
+ type: 'pad',
25
+ collection: 'pads',
26
+ minimumSubrecordCount: 6,
27
+ validatedSubrecordIndex: 4,
28
+ minimumPayloadByteLength: 61,
29
+ parser: 'parsePadStream'
30
+ },
31
+ 3: {
32
+ type: 'via',
33
+ collection: 'vias',
34
+ minimumSubrecordCount: 1,
35
+ minimumPayloadByteLength: 321,
36
+ parser: 'parseViaStream'
37
+ },
38
+ 4: {
39
+ type: 'track',
40
+ collection: 'tracks',
41
+ minimumSubrecordCount: 1,
42
+ minimumPayloadByteLength: 33,
43
+ parser: 'parseTrackStream'
44
+ },
45
+ 5: {
46
+ type: 'text',
47
+ collection: 'texts',
48
+ minimumSubrecordCount: 2,
49
+ validatedSubrecordIndex: 0,
50
+ minimumPayloadByteLength: 64,
51
+ parser: 'parseTextStream'
52
+ },
53
+ 6: {
54
+ type: 'fill',
55
+ collection: 'fills',
56
+ minimumSubrecordCount: 1,
57
+ minimumPayloadByteLength: 50,
58
+ parser: 'parseFillStream'
59
+ },
60
+ 11: {
61
+ type: 'region',
62
+ collection: 'regions',
63
+ minimumSubrecordCount: 1,
64
+ minimumPayloadByteLength: 18,
65
+ parser: 'parseRegionStream'
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Returns true when one buffer starts with the OLE compound-document
71
+ * signature.
72
+ * @param {ArrayBuffer} arrayBuffer
73
+ * @returns {boolean}
74
+ */
75
+ static isCompoundDocument(arrayBuffer) {
76
+ const bytes = new Uint8Array(
77
+ arrayBuffer,
78
+ 0,
79
+ Math.min(
80
+ arrayBuffer.byteLength,
81
+ OleConstants.HEADER_SIGNATURE.length
82
+ )
83
+ )
84
+
85
+ if (bytes.byteLength < OleConstants.HEADER_SIGNATURE.length) {
86
+ return false
87
+ }
88
+
89
+ return OleConstants.HEADER_SIGNATURE.every(
90
+ (value, index) => bytes[index] === value
91
+ )
92
+ }
93
+
94
+ /**
95
+ * Extracts PcbLib content directly from one OLE-backed buffer.
96
+ * @param {ArrayBuffer} arrayBuffer
97
+ * @returns {{ libraryHeader: Record<string, string>, componentParamsToc: Record<string, object>, sectionKeys: Record<string, string>, footprints: object[], streamNames: string[], diagnostics: Record<string, number> } | null}
98
+ */
99
+ static extractFromArrayBuffer(arrayBuffer) {
100
+ if (!PcbLibStreamExtractor.isCompoundDocument(arrayBuffer)) {
101
+ return null
102
+ }
103
+
104
+ const compoundDocument =
105
+ OleCompoundDocument.fromArrayBuffer(arrayBuffer)
106
+ const streams = new Map()
107
+
108
+ for (const name of compoundDocument.listStreams()) {
109
+ streams.set(name, compoundDocument.getStream(name))
110
+ }
111
+
112
+ return PcbLibStreamExtractor.extractFromStreams(streams)
113
+ }
114
+
115
+ /**
116
+ * Extracts all declared footprints from one PcbLib stream map.
117
+ * @param {Map<string, Uint8Array>} streams
118
+ * @returns {{ libraryHeader: Record<string, string>, componentParamsToc: Record<string, object>, sectionKeys: Record<string, string>, footprints: object[], streamNames: string[], diagnostics: Record<string, number> }}
119
+ */
120
+ static extractFromStreams(streams) {
121
+ const libraryData = streams.get('Library/Data') || new Uint8Array()
122
+ const parsedLibraryData =
123
+ PcbLibStreamExtractor.#parseLibraryData(libraryData)
124
+ const componentParamsToc =
125
+ PcbLibStreamExtractor.#parseComponentParamsToc(
126
+ streams.get('Library/ComponentParamsTOC/Data')
127
+ )
128
+ const sectionKeys = PcbLibStreamExtractor.#parseSectionKeys(
129
+ streams.get('SectionKeys')
130
+ )
131
+ const footprints = parsedLibraryData.footprintNames.flatMap((name) => {
132
+ const resolvedStorage =
133
+ PcbLibStreamExtractor.#resolveFootprintStorageName(
134
+ streams,
135
+ name,
136
+ sectionKeys
137
+ )
138
+
139
+ if (!resolvedStorage) {
140
+ return []
141
+ }
142
+
143
+ return [
144
+ PcbLibStreamExtractor.#extractFootprint(
145
+ streams,
146
+ name,
147
+ resolvedStorage,
148
+ componentParamsToc[name] || {}
149
+ )
150
+ ]
151
+ })
152
+ const primitiveCount = footprints.reduce(
153
+ (sum, footprint) => sum + footprint.primitiveCount,
154
+ 0
155
+ )
156
+ const rawRecordCount = footprints.reduce(
157
+ (sum, footprint) => sum + footprint.rawRecords.length,
158
+ 0
159
+ )
160
+ const embeddedFonts =
161
+ PcbEmbeddedFontExtractor.extractFromStreams(streams)
162
+
163
+ return {
164
+ libraryHeader: parsedLibraryData.libraryHeader,
165
+ componentParamsToc,
166
+ sectionKeys,
167
+ footprints,
168
+ streamNames: PcbLibStreamExtractor.#collectUsedStreamNames(
169
+ footprints,
170
+ streams,
171
+ embeddedFonts
172
+ ),
173
+ embeddedFonts,
174
+ diagnostics: {
175
+ declaredFootprintCount: parsedLibraryData.footprintNames.length,
176
+ footprintCount: footprints.length,
177
+ primitiveCount,
178
+ rawRecordCount,
179
+ embeddedFontCount: embeddedFonts.fonts.length,
180
+ missingFootprintCount:
181
+ parsedLibraryData.footprintNames.length - footprints.length
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Parses one PcbLib Library/Data stream.
188
+ * @param {Uint8Array} bytes
189
+ * @returns {{ libraryHeader: Record<string, string>, footprintNames: string[] }}
190
+ */
191
+ static #parseLibraryData(bytes) {
192
+ const header = PcbLibStreamExtractor.#readLengthPrefixedTextAt(bytes, 0)
193
+ const libraryHeader = header
194
+ ? PcbLibStreamExtractor.#parsePipeProperties(header.text)
195
+ : {}
196
+ let offset = header ? header.nextOffset : 0
197
+
198
+ if (offset + 4 > bytes.byteLength) {
199
+ return { libraryHeader, footprintNames: [] }
200
+ }
201
+
202
+ const count = PcbLibStreamExtractor.#readUint32(bytes, offset)
203
+ offset += 4
204
+
205
+ const footprintNames = []
206
+ for (let index = 0; index < count; index += 1) {
207
+ const block = PcbLibStreamExtractor.#readStringBlockAt(
208
+ bytes,
209
+ offset
210
+ )
211
+ if (!block) {
212
+ break
213
+ }
214
+ footprintNames.push(block.text)
215
+ offset = block.nextOffset
216
+ }
217
+
218
+ return { libraryHeader, footprintNames }
219
+ }
220
+
221
+ /**
222
+ * Parses the optional ComponentParamsTOC stream into entries keyed by name.
223
+ * @param {Uint8Array | undefined} bytes
224
+ * @returns {Record<string, { name: string, padCount: number, height: string, description: string, properties: Record<string, string> }>}
225
+ */
226
+ static #parseComponentParamsToc(bytes) {
227
+ const entries = {}
228
+ let offset = 0
229
+
230
+ while (bytes && offset + 4 <= bytes.byteLength) {
231
+ const record = PcbLibStreamExtractor.#readLengthPrefixedTextAt(
232
+ bytes,
233
+ offset
234
+ )
235
+ if (!record) {
236
+ break
237
+ }
238
+
239
+ const properties = PcbLibStreamExtractor.#parsePipeProperties(
240
+ record.text
241
+ )
242
+ const name = properties.Name || properties.NAME || ''
243
+ if (name) {
244
+ entries[name] = {
245
+ name,
246
+ padCount: Number(
247
+ properties['Pad Count'] || properties.PADCOUNT || 0
248
+ ),
249
+ height: properties.Height || properties.HEIGHT || '',
250
+ description:
251
+ properties.Description || properties.DESCRIPTION || '',
252
+ properties
253
+ }
254
+ }
255
+
256
+ offset = record.nextOffset
257
+ }
258
+
259
+ return entries
260
+ }
261
+
262
+ /**
263
+ * Parses an optional SectionKeys stream that maps full footprint names to
264
+ * shortened OLE storage names.
265
+ * @param {Uint8Array | undefined} bytes
266
+ * @returns {Record<string, string>}
267
+ */
268
+ static #parseSectionKeys(bytes) {
269
+ if (!bytes || bytes.byteLength < 4) {
270
+ return {}
271
+ }
272
+
273
+ const count = PcbLibStreamExtractor.#readUint32(bytes, 0)
274
+ const entries = {}
275
+ let offset = 4
276
+
277
+ for (let index = 0; index < count; index += 1) {
278
+ const fullName = PcbLibStreamExtractor.#readStringBlockAt(
279
+ bytes,
280
+ offset
281
+ )
282
+ if (!fullName) {
283
+ break
284
+ }
285
+ offset = fullName.nextOffset
286
+
287
+ const storageName = PcbLibStreamExtractor.#readStringBlockAt(
288
+ bytes,
289
+ offset
290
+ )
291
+ if (!storageName) {
292
+ break
293
+ }
294
+ offset = storageName.nextOffset
295
+ entries[fullName.text] = storageName.text
296
+ }
297
+
298
+ return entries
299
+ }
300
+
301
+ /**
302
+ * Resolves the OLE storage name for one declared footprint.
303
+ * @param {Map<string, Uint8Array>} streams
304
+ * @param {string} footprintName
305
+ * @param {Record<string, string>} sectionKeys
306
+ * @returns {string}
307
+ */
308
+ static #resolveFootprintStorageName(streams, footprintName, sectionKeys) {
309
+ const candidates = [
310
+ footprintName,
311
+ PcbLibStreamExtractor.#sanitizeStorageName(footprintName),
312
+ sectionKeys[footprintName],
313
+ PcbLibStreamExtractor.#sanitizeStorageName(
314
+ footprintName.slice(0, 31)
315
+ )
316
+ ].filter(Boolean)
317
+
318
+ return (
319
+ candidates.find((candidate) => streams.has(candidate + '/Data')) ||
320
+ ''
321
+ )
322
+ }
323
+
324
+ /**
325
+ * Extracts one footprint storage into normalized primitive lists.
326
+ * @param {Map<string, Uint8Array>} streams
327
+ * @param {string} name
328
+ * @param {string} storageName
329
+ * @param {object} componentParams
330
+ * @returns {object}
331
+ */
332
+ static #extractFootprint(streams, name, storageName, componentParams) {
333
+ const declaredPrimitiveCount = PcbLibStreamExtractor.#readCountHeader(
334
+ streams.get(storageName + '/Header')
335
+ )
336
+ const parameters = PcbLibStreamExtractor.#parsePropertyStream(
337
+ streams.get(storageName + '/Parameters')
338
+ )
339
+ const wideStrings = PcbLibStreamExtractor.#parseWideStrings(
340
+ streams.get(storageName + '/WideStrings')
341
+ )
342
+ const parsedData = PcbLibStreamExtractor.#parseFootprintData(
343
+ streams.get(storageName + '/Data') || new Uint8Array(),
344
+ declaredPrimitiveCount,
345
+ wideStrings,
346
+ storageName
347
+ )
348
+
349
+ return {
350
+ name,
351
+ dataName: parsedData.dataName || name,
352
+ sourceStorage: storageName,
353
+ declaredPrimitiveCount,
354
+ parameters,
355
+ componentParams,
356
+ wideStrings,
357
+ primitiveCount: parsedData.primitiveOrder.length,
358
+ primitiveOrder: parsedData.primitiveOrder,
359
+ unknownRecords: parsedData.unknownRecords,
360
+ rawRecords: parsedData.rawRecords,
361
+ pads: parsedData.pads,
362
+ tracks: parsedData.tracks,
363
+ arcs: parsedData.arcs,
364
+ vias: parsedData.vias,
365
+ fills: parsedData.fills,
366
+ texts: parsedData.texts,
367
+ regions: parsedData.regions
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Parses one footprint Data stream after its leading name block.
373
+ * @param {Uint8Array} bytes
374
+ * @param {number} declaredPrimitiveCount
375
+ * @param {Record<number, string>} wideStrings
376
+ * @param {string} sourceStorage
377
+ * @returns {{ dataName: string, primitiveOrder: object[], unknownRecords: object[], rawRecords: object[], pads: object[], tracks: object[], arcs: object[], vias: object[], fills: object[], texts: object[], regions: object[] }}
378
+ */
379
+ static #parseFootprintData(
380
+ bytes,
381
+ declaredPrimitiveCount,
382
+ wideStrings,
383
+ sourceStorage
384
+ ) {
385
+ const collections = PcbLibStreamExtractor.#createPrimitiveCollections()
386
+ const dataName = PcbLibStreamExtractor.#readStringBlockAt(bytes, 0)
387
+ let offset = dataName ? dataName.nextOffset : 0
388
+ let parsedCount = 0
389
+
390
+ while (
391
+ offset < bytes.byteLength &&
392
+ (!declaredPrimitiveCount || parsedCount < declaredPrimitiveCount)
393
+ ) {
394
+ if (bytes[offset] === 0) {
395
+ break
396
+ }
397
+
398
+ const remainingCount = declaredPrimitiveCount
399
+ ? declaredPrimitiveCount - parsedCount - 1
400
+ : null
401
+ const record = PcbLibStreamExtractor.#readFootprintRecordAt(
402
+ bytes,
403
+ offset,
404
+ remainingCount
405
+ )
406
+
407
+ if (!record) {
408
+ break
409
+ }
410
+
411
+ PcbLibStreamExtractor.#appendFootprintRecord(
412
+ collections,
413
+ record,
414
+ wideStrings,
415
+ sourceStorage,
416
+ parsedCount
417
+ )
418
+ offset += record.byteLength
419
+ parsedCount += 1
420
+ }
421
+
422
+ return {
423
+ dataName: dataName?.text || '',
424
+ ...collections
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Creates the mutable primitive collection object used while parsing one
430
+ * footprint.
431
+ * @returns {{ primitiveOrder: object[], unknownRecords: object[], rawRecords: object[], pads: object[], tracks: object[], arcs: object[], vias: object[], fills: object[], texts: object[], regions: object[] }}
432
+ */
433
+ static #createPrimitiveCollections() {
434
+ return {
435
+ primitiveOrder: [],
436
+ unknownRecords: [],
437
+ rawRecords: [],
438
+ pads: [],
439
+ tracks: [],
440
+ arcs: [],
441
+ vias: [],
442
+ fills: [],
443
+ texts: [],
444
+ regions: []
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Reads one mixed-format PcbLib footprint record.
450
+ * @param {Uint8Array} bytes
451
+ * @param {number} offset
452
+ * @param {number | null} remainingCount
453
+ * @returns {{ typeId: number, descriptor: object | null, recordBytes: Uint8Array, byteLength: number, offset: number } | null}
454
+ */
455
+ static #readFootprintRecordAt(bytes, offset, remainingCount) {
456
+ if (offset >= bytes.byteLength) {
457
+ return null
458
+ }
459
+
460
+ const typeId = bytes[offset]
461
+ const descriptor =
462
+ PcbLibStreamExtractor.#FOOTPRINT_RECORD_TYPES[typeId] || null
463
+
464
+ if (!descriptor) {
465
+ return {
466
+ typeId,
467
+ descriptor: null,
468
+ recordBytes: bytes.slice(offset, offset + 1),
469
+ byteLength: 1,
470
+ offset
471
+ }
472
+ }
473
+
474
+ const minimumEnd = PcbLibStreamExtractor.#readMinimumRecordEnd(
475
+ bytes,
476
+ offset,
477
+ descriptor
478
+ )
479
+
480
+ if (!minimumEnd) {
481
+ return {
482
+ typeId,
483
+ descriptor: null,
484
+ recordBytes: bytes.slice(offset, offset + 1),
485
+ byteLength: 1,
486
+ offset
487
+ }
488
+ }
489
+
490
+ const nextRecordOffset =
491
+ remainingCount && remainingCount > 0
492
+ ? PcbLibStreamExtractor.#findNextKnownRecordOffset(
493
+ bytes,
494
+ minimumEnd
495
+ )
496
+ : null
497
+ const endOffset = nextRecordOffset || minimumEnd
498
+
499
+ return {
500
+ typeId,
501
+ descriptor,
502
+ recordBytes: bytes.slice(offset, endOffset),
503
+ byteLength: endOffset - offset,
504
+ offset
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Reads the minimum byte boundary for a known footprint record.
510
+ * @param {Uint8Array} bytes
511
+ * @param {number} offset
512
+ * @param {{ minimumSubrecordCount: number, minimumPayloadByteLength: number, validatedSubrecordIndex?: number }} descriptor
513
+ * @returns {number | null}
514
+ */
515
+ static #readMinimumRecordEnd(bytes, offset, descriptor) {
516
+ let cursor = offset + 1
517
+
518
+ for (
519
+ let subrecordIndex = 0;
520
+ subrecordIndex < descriptor.minimumSubrecordCount;
521
+ subrecordIndex += 1
522
+ ) {
523
+ const subrecord = PcbLibStreamExtractor.#readSubrecordAt(
524
+ bytes,
525
+ cursor
526
+ )
527
+
528
+ if (!subrecord) {
529
+ return null
530
+ }
531
+
532
+ const shouldValidate =
533
+ descriptor.validatedSubrecordIndex === undefined ||
534
+ descriptor.validatedSubrecordIndex === subrecordIndex
535
+ if (
536
+ shouldValidate &&
537
+ subrecord.payloadByteLength <
538
+ descriptor.minimumPayloadByteLength
539
+ ) {
540
+ return null
541
+ }
542
+
543
+ cursor = subrecord.nextOffset
544
+ }
545
+
546
+ return cursor
547
+ }
548
+
549
+ /**
550
+ * Finds the next plausible known primitive record after optional extra
551
+ * subrecords attached to the current primitive.
552
+ * @param {Uint8Array} bytes
553
+ * @param {number} offset
554
+ * @returns {number | null}
555
+ */
556
+ static #findNextKnownRecordOffset(bytes, offset) {
557
+ let cursor = offset
558
+
559
+ while (cursor < bytes.byteLength) {
560
+ if (PcbLibStreamExtractor.#isKnownRecordStart(bytes, cursor)) {
561
+ return cursor
562
+ }
563
+
564
+ const unknownSubrecord = PcbLibStreamExtractor.#readSubrecordAt(
565
+ bytes,
566
+ cursor
567
+ )
568
+ if (unknownSubrecord && unknownSubrecord.nextOffset > cursor) {
569
+ cursor = unknownSubrecord.nextOffset
570
+ continue
571
+ }
572
+
573
+ cursor += 1
574
+ }
575
+
576
+ return null
577
+ }
578
+
579
+ /**
580
+ * Returns true when an offset can start a known footprint primitive record.
581
+ * @param {Uint8Array} bytes
582
+ * @param {number} offset
583
+ * @returns {boolean}
584
+ */
585
+ static #isKnownRecordStart(bytes, offset) {
586
+ const descriptor =
587
+ PcbLibStreamExtractor.#FOOTPRINT_RECORD_TYPES[bytes[offset]]
588
+
589
+ return Boolean(
590
+ descriptor &&
591
+ PcbLibStreamExtractor.#readMinimumRecordEnd(
592
+ bytes,
593
+ offset,
594
+ descriptor
595
+ )
596
+ )
597
+ }
598
+
599
+ /**
600
+ * Appends one parsed record to the appropriate footprint primitive list.
601
+ * @param {object} collections
602
+ * @param {{ typeId: number, descriptor: object | null, recordBytes: Uint8Array, offset: number, byteLength: number }} record
603
+ * @param {Record<number, string>} wideStrings
604
+ * @param {string} sourceStorage
605
+ * @param {number} recordIndex
606
+ */
607
+ static #appendFootprintRecord(
608
+ collections,
609
+ record,
610
+ wideStrings,
611
+ sourceStorage,
612
+ recordIndex
613
+ ) {
614
+ if (!record.descriptor) {
615
+ collections.rawRecords.push(
616
+ PcbRawRecordRegistry.createPcbLibRecord({
617
+ sourceStorage,
618
+ record,
619
+ recordIndex,
620
+ parsed: false
621
+ })
622
+ )
623
+ collections.unknownRecords.push({
624
+ typeId: record.typeId,
625
+ offset: record.offset,
626
+ byteLength: record.byteLength
627
+ })
628
+ return
629
+ }
630
+
631
+ const primitives = PcbLibStreamExtractor.#parsePrimitiveRecord(
632
+ record,
633
+ wideStrings
634
+ )
635
+
636
+ if (!primitives.length) {
637
+ collections.rawRecords.push(
638
+ PcbRawRecordRegistry.createPcbLibRecord({
639
+ sourceStorage,
640
+ record,
641
+ recordIndex,
642
+ parsed: false
643
+ })
644
+ )
645
+ collections.unknownRecords.push({
646
+ typeId: record.typeId,
647
+ offset: record.offset,
648
+ byteLength: record.byteLength
649
+ })
650
+ return
651
+ }
652
+
653
+ collections.rawRecords.push(
654
+ PcbRawRecordRegistry.createPcbLibRecord({
655
+ sourceStorage,
656
+ record,
657
+ recordIndex,
658
+ parsed: true
659
+ })
660
+ )
661
+
662
+ for (const primitive of primitives) {
663
+ const collection = collections[record.descriptor.collection]
664
+ const index = collection.length
665
+ collection.push(primitive)
666
+ collections.primitiveOrder.push({
667
+ type: record.descriptor.type,
668
+ collection: record.descriptor.collection,
669
+ index
670
+ })
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Parses one primitive record using the existing PcbDoc binary primitive
676
+ * parsers with an artificial one-record header.
677
+ * @param {{ descriptor: { parser: string }, recordBytes: Uint8Array }} record
678
+ * @param {Record<number, string>} wideStrings
679
+ * @returns {object[]}
680
+ */
681
+ static #parsePrimitiveRecord(record, wideStrings) {
682
+ const headerBytes = PcbLibStreamExtractor.#createCountHeader(1)
683
+ const parser = PcbBinaryPrimitiveParser[record.descriptor.parser]
684
+ const primitives = parser.call(
685
+ PcbBinaryPrimitiveParser,
686
+ headerBytes,
687
+ record.recordBytes
688
+ )
689
+
690
+ if (record.descriptor.type === 'text') {
691
+ return primitives.map((primitive) =>
692
+ PcbLibStreamExtractor.#resolveTextWideString(
693
+ primitive,
694
+ wideStrings
695
+ )
696
+ )
697
+ }
698
+
699
+ return primitives
700
+ }
701
+
702
+ /**
703
+ * Resolves text through the footprint WideStrings table when the parser
704
+ * exposes a numeric text index.
705
+ * @param {object} primitive
706
+ * @param {Record<number, string>} wideStrings
707
+ * @returns {object}
708
+ */
709
+ static #resolveTextWideString(primitive, wideStrings) {
710
+ const wideText = wideStrings[primitive.wideStringIndex]
711
+
712
+ return wideText ? { ...primitive, text: wideText } : primitive
713
+ }
714
+
715
+ /**
716
+ * Parses one length-prefixed property stream.
717
+ * @param {Uint8Array | undefined} bytes
718
+ * @returns {Record<string, string>}
719
+ */
720
+ static #parsePropertyStream(bytes) {
721
+ if (!bytes) {
722
+ return {}
723
+ }
724
+
725
+ const record = PcbLibStreamExtractor.#readLengthPrefixedTextAt(bytes, 0)
726
+
727
+ return record
728
+ ? PcbLibStreamExtractor.#parsePipeProperties(record.text)
729
+ : {}
730
+ }
731
+
732
+ /**
733
+ * Parses one PcbLib WideStrings stream.
734
+ * @param {Uint8Array | undefined} bytes
735
+ * @returns {Record<number, string>}
736
+ */
737
+ static #parseWideStrings(bytes) {
738
+ const properties = PcbLibStreamExtractor.#parsePropertyStream(bytes)
739
+ const wideStrings = {}
740
+
741
+ for (const [key, value] of Object.entries(properties)) {
742
+ const match = key.match(/^ENCODEDTEXT(\d+)$/u)
743
+ if (!match) {
744
+ continue
745
+ }
746
+ wideStrings[Number(match[1])] =
747
+ PcbLibStreamExtractor.#decodeCsvCharCodes(value)
748
+ }
749
+
750
+ return wideStrings
751
+ }
752
+
753
+ /**
754
+ * Parses pipe-delimited Altium properties.
755
+ * @param {string} text
756
+ * @returns {Record<string, string>}
757
+ */
758
+ static #parsePipeProperties(text) {
759
+ const properties = {}
760
+
761
+ for (const part of String(text || '')
762
+ .replace(/\u0000+$/u, '')
763
+ .split('|')) {
764
+ const separator = part.indexOf('=')
765
+ if (separator <= 0) {
766
+ continue
767
+ }
768
+ properties[part.slice(0, separator)] = part
769
+ .slice(separator + 1)
770
+ .replace(/\r?\n$/u, '')
771
+ }
772
+
773
+ return properties
774
+ }
775
+
776
+ /**
777
+ * Decodes comma-separated character codes into text.
778
+ * @param {string} value
779
+ * @returns {string}
780
+ */
781
+ static #decodeCsvCharCodes(value) {
782
+ return String(value || '')
783
+ .split(',')
784
+ .filter(Boolean)
785
+ .map((part) => String.fromCharCode(Number(part)))
786
+ .join('')
787
+ }
788
+
789
+ /**
790
+ * Reads one length-prefixed UTF-8/ASCII text block.
791
+ * @param {Uint8Array} bytes
792
+ * @param {number} offset
793
+ * @returns {{ text: string, nextOffset: number } | null}
794
+ */
795
+ static #readLengthPrefixedTextAt(bytes, offset) {
796
+ if (!bytes || offset + 4 > bytes.byteLength) {
797
+ return null
798
+ }
799
+
800
+ const byteLength = PcbLibStreamExtractor.#readUint32(bytes, offset)
801
+ const payloadOffset = offset + 4
802
+ const nextOffset = payloadOffset + byteLength
803
+
804
+ if (nextOffset > bytes.byteLength) {
805
+ return null
806
+ }
807
+
808
+ return {
809
+ text: new TextDecoder().decode(
810
+ bytes.slice(payloadOffset, nextOffset)
811
+ ),
812
+ nextOffset
813
+ }
814
+ }
815
+
816
+ /**
817
+ * Reads one PCB string block with a four-byte block length and Pascal text
818
+ * payload.
819
+ * @param {Uint8Array} bytes
820
+ * @param {number} offset
821
+ * @returns {{ text: string, nextOffset: number } | null}
822
+ */
823
+ static #readStringBlockAt(bytes, offset) {
824
+ if (!bytes || offset + 5 > bytes.byteLength) {
825
+ return null
826
+ }
827
+
828
+ const blockByteLength = PcbLibStreamExtractor.#readUint32(bytes, offset)
829
+ const payloadOffset = offset + 4
830
+ const nextOffset = payloadOffset + blockByteLength
831
+
832
+ if (blockByteLength < 1 || nextOffset > bytes.byteLength) {
833
+ return null
834
+ }
835
+
836
+ const textByteLength = Math.min(
837
+ bytes[payloadOffset],
838
+ blockByteLength - 1
839
+ )
840
+
841
+ return {
842
+ text: new TextDecoder().decode(
843
+ bytes.slice(
844
+ payloadOffset + 1,
845
+ payloadOffset + 1 + textByteLength
846
+ )
847
+ ),
848
+ nextOffset
849
+ }
850
+ }
851
+
852
+ /**
853
+ * Reads one length-prefixed primitive subrecord at an offset.
854
+ * @param {Uint8Array} bytes
855
+ * @param {number} offset
856
+ * @returns {{ payloadByteLength: number, payloadOffset: number, nextOffset: number } | null}
857
+ */
858
+ static #readSubrecordAt(bytes, offset) {
859
+ if (offset + 4 > bytes.byteLength) {
860
+ return null
861
+ }
862
+
863
+ const payloadByteLength = PcbLibStreamExtractor.#readUint32(
864
+ bytes,
865
+ offset
866
+ )
867
+ const payloadOffset = offset + 4
868
+ const nextOffset = payloadOffset + payloadByteLength
869
+
870
+ if (nextOffset > bytes.byteLength) {
871
+ return null
872
+ }
873
+
874
+ return { payloadByteLength, payloadOffset, nextOffset }
875
+ }
876
+
877
+ /**
878
+ * Reads one count from a four-byte Header stream.
879
+ * @param {Uint8Array | undefined} bytes
880
+ * @returns {number}
881
+ */
882
+ static #readCountHeader(bytes) {
883
+ if (!bytes || bytes.byteLength < 4) {
884
+ return 0
885
+ }
886
+
887
+ return PcbLibStreamExtractor.#readUint32(bytes, 0)
888
+ }
889
+
890
+ /**
891
+ * Creates one little-endian four-byte count header.
892
+ * @param {number} count
893
+ * @returns {Uint8Array}
894
+ */
895
+ static #createCountHeader(count) {
896
+ const bytes = new Uint8Array(4)
897
+ new DataView(bytes.buffer).setUint32(0, count, true)
898
+ return bytes
899
+ }
900
+
901
+ /**
902
+ * Returns all stream names that contributed to the extraction.
903
+ * @param {object[]} footprints
904
+ * @param {Map<string, Uint8Array>} streams
905
+ * @param {{ fonts?: { sourceStream: string }[] }} embeddedFonts
906
+ * @returns {string[]}
907
+ */
908
+ static #collectUsedStreamNames(footprints, streams, embeddedFonts) {
909
+ const names = new Set()
910
+
911
+ for (const baseName of [
912
+ 'Library/Data',
913
+ 'Library/ComponentParamsTOC/Data',
914
+ 'SectionKeys'
915
+ ]) {
916
+ if (streams.has(baseName)) {
917
+ names.add(baseName)
918
+ }
919
+ }
920
+
921
+ for (const footprint of footprints) {
922
+ for (const suffix of [
923
+ 'Header',
924
+ 'Data',
925
+ 'Parameters',
926
+ 'WideStrings'
927
+ ]) {
928
+ const name = footprint.sourceStorage + '/' + suffix
929
+ if (streams.has(name)) {
930
+ names.add(name)
931
+ }
932
+ }
933
+ }
934
+
935
+ for (const font of embeddedFonts.fonts || []) {
936
+ if (streams.has(font.sourceStream)) {
937
+ names.add(font.sourceStream)
938
+ }
939
+ }
940
+
941
+ return [...names].sort((left, right) => left.localeCompare(right))
942
+ }
943
+
944
+ /**
945
+ * Sanitizes one footprint name for legacy OLE storage lookup.
946
+ * @param {string} name
947
+ * @returns {string}
948
+ */
949
+ static #sanitizeStorageName(name) {
950
+ return String(name || '')
951
+ .replace(/[/:\\]/gu, '_')
952
+ .slice(0, 31)
953
+ }
954
+
955
+ /**
956
+ * Reads a little-endian unsigned 32-bit value from one byte array.
957
+ * @param {Uint8Array} bytes
958
+ * @param {number} offset
959
+ * @returns {number}
960
+ */
961
+ static #readUint32(bytes, offset) {
962
+ return new DataView(
963
+ bytes.buffer,
964
+ bytes.byteOffset + offset,
965
+ 4
966
+ ).getUint32(0, true)
967
+ }
968
+ }