altium-toolkit 0.1.0 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +24 -6
  2. package/docs/api.md +42 -4
  3. package/docs/model-format.md +95 -5
  4. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
  5. package/docs/testing.md +7 -2
  6. package/package.json +21 -2
  7. package/spec/library-scope.md +7 -1
  8. package/src/core/altium/AltiumParser.mjs +22 -325
  9. package/src/core/altium/NormalizedModelSchema.mjs +28 -0
  10. package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
  11. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
  12. package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
  13. package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
  14. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
  15. package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
  16. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
  17. package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
  18. package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
  19. package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
  20. package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
  21. package/src/core/altium/PcbLibModelParser.mjs +202 -0
  22. package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
  23. package/src/core/altium/PcbModelParser.mjs +618 -66
  24. package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
  25. package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
  26. package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
  27. package/src/core/altium/PcbPadStackParser.mjs +903 -0
  28. package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
  29. package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
  30. package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
  31. package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
  32. package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
  33. package/src/core/altium/PcbRuleParser.mjs +587 -0
  34. package/src/core/altium/PcbStreamExtractor.mjs +127 -4
  35. package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
  36. package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
  37. package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
  38. package/src/core/altium/PcbViaStackParser.mjs +548 -0
  39. package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
  40. package/src/core/altium/PrjPcbModelParser.mjs +797 -0
  41. package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
  42. package/src/parser.mjs +13 -0
  43. package/src/renderers.mjs +5 -0
  44. package/src/styles/altium-renderers.css +11 -6
  45. package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
  46. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
  47. package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
  48. package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
  49. package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
  50. package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
  51. package/src/ui/PcbSvgRenderer.mjs +101 -109
  52. package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
  53. package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
  54. package/src/ui/SchematicSheetZoneRenderer.mjs +104 -0
@@ -0,0 +1,831 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbPrimitiveRecordSlicer } from './PcbPrimitiveRecordSlicer.mjs'
6
+
7
+ /**
8
+ * Provides a read-only PCB primitive record registry and raw record
9
+ * preservation helpers.
10
+ */
11
+ export class PcbRawRecordRegistry {
12
+ static #PCB_DOC_DESCRIPTORS = Object.freeze(
13
+ [
14
+ {
15
+ sourceStream: 'Arcs6/Data',
16
+ headerStream: 'Arcs6/Header',
17
+ family: 'arcs',
18
+ collection: 'arcs',
19
+ type: 'arc',
20
+ typeId: 1,
21
+ fixedRecordByteLength: 60,
22
+ minimumPayloadByteLength: 45,
23
+ lengthPrefixedView: 'payload',
24
+ parser: 'PcbArcPrimitiveParser',
25
+ strategy: 'slicer'
26
+ },
27
+ {
28
+ sourceStream: 'Tracks6/Data',
29
+ headerStream: 'Tracks6/Header',
30
+ family: 'tracks',
31
+ collection: 'tracks',
32
+ type: 'track',
33
+ typeId: 4,
34
+ fixedRecordByteLength: 49,
35
+ minimumPayloadByteLength: 33,
36
+ lengthPrefixedView: 'payload',
37
+ parser: 'PcbTrackPrimitiveParser',
38
+ strategy: 'slicer'
39
+ },
40
+ {
41
+ sourceStream: 'Vias6/Data',
42
+ headerStream: 'Vias6/Header',
43
+ family: 'vias',
44
+ collection: 'vias',
45
+ type: 'via',
46
+ typeId: 3,
47
+ fixedRecordByteLength: 326,
48
+ minimumPayloadByteLength: 321,
49
+ lengthPrefixedView: 'record',
50
+ parser: 'PcbViaPrimitiveParser',
51
+ strategy: 'slicer'
52
+ },
53
+ {
54
+ sourceStream: 'Fills6/Data',
55
+ headerStream: 'Fills6/Header',
56
+ family: 'fills',
57
+ collection: 'fills',
58
+ type: 'fill',
59
+ typeId: 6,
60
+ fixedRecordByteLength: 55,
61
+ minimumPayloadByteLength: 50,
62
+ lengthPrefixedView: 'record',
63
+ parser: 'PcbFillPrimitiveParser',
64
+ strategy: 'slicer'
65
+ },
66
+ {
67
+ sourceStream: 'Pads6/Data',
68
+ headerStream: 'Pads6/Header',
69
+ family: 'pads',
70
+ collection: 'pads',
71
+ type: 'pad',
72
+ typeId: 2,
73
+ minimumSubrecordCount: 6,
74
+ validatedSubrecordIndex: 4,
75
+ minimumPayloadByteLength: 61,
76
+ parser: 'PcbPadPrimitiveParser',
77
+ strategy: 'subrecord-list'
78
+ },
79
+ {
80
+ sourceStream: 'Texts6/Data',
81
+ headerStream: 'Texts6/Header',
82
+ family: 'texts',
83
+ collection: 'texts',
84
+ type: 'text',
85
+ typeId: 5,
86
+ minimumPayloadByteLength: 64,
87
+ maximumPayloadByteLength: 2048,
88
+ parser: 'PcbTextPrimitiveParser',
89
+ strategy: 'text-tail'
90
+ },
91
+ {
92
+ sourceStream: 'Texts/Data',
93
+ headerStream: 'Texts/Header',
94
+ family: 'texts',
95
+ collection: 'texts',
96
+ type: 'text',
97
+ typeId: 5,
98
+ minimumPayloadByteLength: 64,
99
+ maximumPayloadByteLength: 2048,
100
+ parser: 'PcbTextPrimitiveParser',
101
+ strategy: 'text-tail'
102
+ },
103
+ {
104
+ sourceStream: 'Regions6/Data',
105
+ headerStream: 'Regions6/Header',
106
+ family: 'regions',
107
+ collection: 'regions',
108
+ type: 'region',
109
+ typeId: 11,
110
+ minimumPayloadByteLength: 18,
111
+ parser: 'PcbRegionPrimitiveParser',
112
+ strategy: 'length-prefixed'
113
+ },
114
+ {
115
+ sourceStream: 'ShapeBasedRegions6/Data',
116
+ headerStream: 'ShapeBasedRegions6/Header',
117
+ family: 'shapeBasedRegions',
118
+ collection: 'shapeBasedRegions',
119
+ type: 'region',
120
+ typeId: 11,
121
+ minimumPayloadByteLength: 18,
122
+ parser: 'PcbRegionPrimitiveParser',
123
+ strategy: 'length-prefixed'
124
+ },
125
+ {
126
+ sourceStream: 'BoardRegions/Data',
127
+ headerStream: 'BoardRegions/Header',
128
+ family: 'boardRegions',
129
+ collection: 'boardRegions',
130
+ type: 'region',
131
+ typeId: 11,
132
+ minimumPayloadByteLength: 18,
133
+ parser: 'PcbRegionPrimitiveParser',
134
+ strategy: 'length-prefixed'
135
+ }
136
+ ].map((descriptor) => Object.freeze(descriptor))
137
+ )
138
+
139
+ /**
140
+ * Returns immutable copies of the registered PcbDoc primitive descriptors.
141
+ * @returns {object[]}
142
+ */
143
+ static pcbDocDescriptors() {
144
+ return PcbRawRecordRegistry.#PCB_DOC_DESCRIPTORS.map((descriptor) =>
145
+ Object.freeze({ ...descriptor })
146
+ )
147
+ }
148
+
149
+ /**
150
+ * Returns the descriptor registered for one PcbDoc data stream.
151
+ * @param {string} sourceStream
152
+ * @returns {object | null}
153
+ */
154
+ static descriptorForPcbDocStream(sourceStream) {
155
+ const descriptor = PcbRawRecordRegistry.#PCB_DOC_DESCRIPTORS.find(
156
+ (candidate) => candidate.sourceStream === sourceStream
157
+ )
158
+
159
+ return descriptor ? Object.freeze({ ...descriptor }) : null
160
+ }
161
+
162
+ /**
163
+ * Collects raw records from registered PcbDoc primitive streams.
164
+ * @param {Map<string, Uint8Array>} streams
165
+ * @param {Record<string, object[]>} [binaryPrimitives]
166
+ * @returns {object[]}
167
+ */
168
+ static collectPcbDocRecords(streams, binaryPrimitives = {}) {
169
+ const rawRecords = []
170
+
171
+ for (const descriptor of PcbRawRecordRegistry.#PCB_DOC_DESCRIPTORS) {
172
+ const headerBytes = streams.get(descriptor.headerStream)
173
+ const dataBytes = streams.get(descriptor.sourceStream)
174
+
175
+ if (!headerBytes || !dataBytes) {
176
+ continue
177
+ }
178
+
179
+ const slices = PcbRawRecordRegistry.#slicePcbDocRecords(
180
+ descriptor,
181
+ headerBytes,
182
+ dataBytes
183
+ )
184
+ const parsedCount =
185
+ binaryPrimitives?.[descriptor.collection]?.length || 0
186
+
187
+ if (!slices.length) {
188
+ if (
189
+ PcbRawRecordRegistry.#readRecordCount(headerBytes) > 0 &&
190
+ PcbRawRecordRegistry.#toUint8Array(dataBytes).byteLength > 0
191
+ ) {
192
+ rawRecords.push(
193
+ PcbRawRecordRegistry.#createUnparsedPcbDocRecord(
194
+ descriptor,
195
+ dataBytes
196
+ )
197
+ )
198
+ }
199
+ continue
200
+ }
201
+
202
+ for (const slice of slices) {
203
+ rawRecords.push(
204
+ PcbRawRecordRegistry.#normalizePcbDocRecord(
205
+ descriptor,
206
+ slice,
207
+ parsedCount
208
+ )
209
+ )
210
+ }
211
+ }
212
+
213
+ return rawRecords
214
+ }
215
+
216
+ /**
217
+ * Creates one raw PcbLib footprint record descriptor.
218
+ * @param {{ sourceStorage: string, record: { typeId: number, descriptor: object | null, recordBytes: Uint8Array, offset: number, byteLength: number }, recordIndex: number, parsed: boolean }} options
219
+ * @returns {object}
220
+ */
221
+ static createPcbLibRecord(options) {
222
+ const descriptor = options.record.descriptor
223
+ const sourceStream = options.sourceStorage + '/Data'
224
+
225
+ return {
226
+ registryId: 'pcblib:' + sourceStream + ':' + options.recordIndex,
227
+ source: 'pcblib',
228
+ sourceStorage: options.sourceStorage,
229
+ sourceStream,
230
+ family: descriptor?.collection || 'unknown',
231
+ type: descriptor?.type || 'unknown',
232
+ typeId: options.record.typeId,
233
+ recordIndex: options.recordIndex,
234
+ offset: options.record.offset,
235
+ byteLength: options.record.byteLength,
236
+ payloadByteLength: null,
237
+ encoding: 'mixed-footprint',
238
+ supported: Boolean(descriptor),
239
+ parsed: Boolean(options.parsed),
240
+ rawBase64: PcbRawRecordRegistry.#toBase64(
241
+ options.record.recordBytes
242
+ )
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Selects the slicing strategy for one registered PcbDoc stream.
248
+ * @param {object} descriptor
249
+ * @param {Uint8Array | ArrayBuffer} headerBytes
250
+ * @param {Uint8Array | ArrayBuffer} dataBytes
251
+ * @returns {object[]}
252
+ */
253
+ static #slicePcbDocRecords(descriptor, headerBytes, dataBytes) {
254
+ if (descriptor.strategy === 'slicer') {
255
+ return PcbPrimitiveRecordSlicer.slicePrimitiveRecords({
256
+ headerBytes,
257
+ dataBytes,
258
+ objectId: descriptor.typeId,
259
+ fixedRecordByteLength: descriptor.fixedRecordByteLength,
260
+ minimumPayloadByteLength: descriptor.minimumPayloadByteLength,
261
+ lengthPrefixedView: descriptor.lengthPrefixedView
262
+ })
263
+ }
264
+
265
+ if (descriptor.strategy === 'subrecord-list') {
266
+ return PcbRawRecordRegistry.#sliceSubrecordListRecords(
267
+ descriptor,
268
+ headerBytes,
269
+ dataBytes
270
+ )
271
+ }
272
+
273
+ if (descriptor.strategy === 'text-tail') {
274
+ return PcbRawRecordRegistry.#sliceTextTailRecords(
275
+ descriptor,
276
+ headerBytes,
277
+ dataBytes
278
+ )
279
+ }
280
+
281
+ return PcbRawRecordRegistry.#sliceLengthPrefixedRecords(
282
+ descriptor,
283
+ headerBytes,
284
+ dataBytes
285
+ )
286
+ }
287
+
288
+ /**
289
+ * Slices exact object-id/payload-length records.
290
+ * @param {object} descriptor
291
+ * @param {Uint8Array | ArrayBuffer} headerBytes
292
+ * @param {Uint8Array | ArrayBuffer} dataBytes
293
+ * @returns {object[]}
294
+ */
295
+ static #sliceLengthPrefixedRecords(descriptor, headerBytes, dataBytes) {
296
+ const count = PcbRawRecordRegistry.#readRecordCount(headerBytes)
297
+ const bytes = PcbRawRecordRegistry.#toUint8Array(dataBytes)
298
+ const records = []
299
+ let offset = 0
300
+
301
+ for (let index = 0; index < count; index += 1) {
302
+ const record = PcbRawRecordRegistry.#readLengthPrefixedRecordAt(
303
+ bytes,
304
+ offset,
305
+ descriptor
306
+ )
307
+
308
+ if (!record) {
309
+ return []
310
+ }
311
+
312
+ records.push({ ...record, recordIndex: index })
313
+ offset += record.byteLength
314
+ }
315
+
316
+ return offset === bytes.byteLength ? records : []
317
+ }
318
+
319
+ /**
320
+ * Reads one object-id/payload-length record at an offset.
321
+ * @param {Uint8Array} bytes
322
+ * @param {number} offset
323
+ * @param {object} descriptor
324
+ * @returns {object | null}
325
+ */
326
+ static #readLengthPrefixedRecordAt(bytes, offset, descriptor) {
327
+ if (
328
+ offset + 5 > bytes.byteLength ||
329
+ bytes[offset] !== descriptor.typeId
330
+ ) {
331
+ return null
332
+ }
333
+
334
+ const payloadByteLength = PcbRawRecordRegistry.#readUint32(
335
+ bytes,
336
+ offset + 1
337
+ )
338
+ const byteLength = 5 + payloadByteLength
339
+
340
+ if (
341
+ payloadByteLength < descriptor.minimumPayloadByteLength ||
342
+ offset + byteLength > bytes.byteLength
343
+ ) {
344
+ return null
345
+ }
346
+
347
+ return {
348
+ recordBytes: bytes.slice(offset, offset + byteLength),
349
+ offset,
350
+ byteLength,
351
+ payloadByteLength,
352
+ encoding: 'length-prefixed',
353
+ objectId: descriptor.typeId,
354
+ recordIndex: 0
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Slices subrecord-list primitives such as PcbDoc pads.
360
+ * @param {object} descriptor
361
+ * @param {Uint8Array | ArrayBuffer} headerBytes
362
+ * @param {Uint8Array | ArrayBuffer} dataBytes
363
+ * @returns {object[]}
364
+ */
365
+ static #sliceSubrecordListRecords(descriptor, headerBytes, dataBytes) {
366
+ const count = PcbRawRecordRegistry.#readRecordCount(headerBytes)
367
+ const bytes = PcbRawRecordRegistry.#toUint8Array(dataBytes)
368
+ const records = []
369
+ let offset = 0
370
+
371
+ for (let index = 0; index < count; index += 1) {
372
+ const remainingCount = count - index - 1
373
+ const record = PcbRawRecordRegistry.#readSubrecordListRecordAt(
374
+ bytes,
375
+ offset,
376
+ descriptor,
377
+ remainingCount
378
+ )
379
+
380
+ if (!record) {
381
+ return []
382
+ }
383
+
384
+ records.push({ ...record, recordIndex: index })
385
+ offset += record.byteLength
386
+ }
387
+
388
+ return records
389
+ }
390
+
391
+ /**
392
+ * Reads one subrecord-list primitive record at an offset.
393
+ * @param {Uint8Array} bytes
394
+ * @param {number} offset
395
+ * @param {object} descriptor
396
+ * @param {number} remainingCount
397
+ * @returns {object | null}
398
+ */
399
+ static #readSubrecordListRecordAt(
400
+ bytes,
401
+ offset,
402
+ descriptor,
403
+ remainingCount
404
+ ) {
405
+ if (
406
+ offset + 1 > bytes.byteLength ||
407
+ bytes[offset] !== descriptor.typeId
408
+ ) {
409
+ return null
410
+ }
411
+
412
+ const minimumEnd = PcbRawRecordRegistry.#readMinimumSubrecordListEnd(
413
+ bytes,
414
+ offset,
415
+ descriptor
416
+ )
417
+
418
+ if (!minimumEnd) {
419
+ return null
420
+ }
421
+
422
+ const endOffset = remainingCount
423
+ ? PcbRawRecordRegistry.#findNextSubrecordListRecordOffset(
424
+ bytes,
425
+ minimumEnd,
426
+ descriptor,
427
+ remainingCount
428
+ )
429
+ : bytes.byteLength
430
+
431
+ if (!endOffset) {
432
+ return null
433
+ }
434
+
435
+ return {
436
+ recordBytes: bytes.slice(offset, endOffset),
437
+ offset,
438
+ byteLength: endOffset - offset,
439
+ payloadByteLength: null,
440
+ encoding: 'subrecord-list',
441
+ objectId: descriptor.typeId,
442
+ recordIndex: 0
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Reads the minimum byte boundary for one subrecord-list record.
448
+ * @param {Uint8Array} bytes
449
+ * @param {number} offset
450
+ * @param {object} descriptor
451
+ * @returns {number | null}
452
+ */
453
+ static #readMinimumSubrecordListEnd(bytes, offset, descriptor) {
454
+ let cursor = offset + 1
455
+
456
+ for (
457
+ let subrecordIndex = 0;
458
+ subrecordIndex < descriptor.minimumSubrecordCount;
459
+ subrecordIndex += 1
460
+ ) {
461
+ const subrecord = PcbRawRecordRegistry.#readSubrecordAt(
462
+ bytes,
463
+ cursor
464
+ )
465
+
466
+ if (!subrecord) {
467
+ return null
468
+ }
469
+
470
+ const shouldValidate =
471
+ descriptor.validatedSubrecordIndex === undefined ||
472
+ descriptor.validatedSubrecordIndex === subrecordIndex
473
+ if (
474
+ shouldValidate &&
475
+ subrecord.payloadByteLength <
476
+ descriptor.minimumPayloadByteLength
477
+ ) {
478
+ return null
479
+ }
480
+
481
+ cursor = subrecord.nextOffset
482
+ }
483
+
484
+ return cursor
485
+ }
486
+
487
+ /**
488
+ * Finds the next known subrecord-list primitive boundary.
489
+ * @param {Uint8Array} bytes
490
+ * @param {number} offset
491
+ * @param {object} descriptor
492
+ * @param {number} remainingCount
493
+ * @returns {number | null}
494
+ */
495
+ static #findNextSubrecordListRecordOffset(
496
+ bytes,
497
+ offset,
498
+ descriptor,
499
+ remainingCount
500
+ ) {
501
+ let cursor = offset
502
+
503
+ while (cursor < bytes.byteLength) {
504
+ if (
505
+ PcbRawRecordRegistry.#canReadSubrecordListSequence(
506
+ bytes,
507
+ cursor,
508
+ descriptor,
509
+ remainingCount
510
+ )
511
+ ) {
512
+ return cursor
513
+ }
514
+
515
+ const unknownSubrecord = PcbRawRecordRegistry.#readSubrecordAt(
516
+ bytes,
517
+ cursor
518
+ )
519
+ cursor = unknownSubrecord ? unknownSubrecord.nextOffset : cursor + 1
520
+ }
521
+
522
+ return null
523
+ }
524
+
525
+ /**
526
+ * Checks whether the remaining subrecord-list records are readable.
527
+ * @param {Uint8Array} bytes
528
+ * @param {number} offset
529
+ * @param {object} descriptor
530
+ * @param {number} remainingCount
531
+ * @returns {boolean}
532
+ */
533
+ static #canReadSubrecordListSequence(
534
+ bytes,
535
+ offset,
536
+ descriptor,
537
+ remainingCount
538
+ ) {
539
+ const minimumEnd = PcbRawRecordRegistry.#readMinimumSubrecordListEnd(
540
+ bytes,
541
+ offset,
542
+ descriptor
543
+ )
544
+
545
+ if (!minimumEnd) {
546
+ return false
547
+ }
548
+
549
+ if (remainingCount <= 1) {
550
+ return true
551
+ }
552
+
553
+ return (
554
+ PcbRawRecordRegistry.#findNextSubrecordListRecordOffset(
555
+ bytes,
556
+ minimumEnd,
557
+ descriptor,
558
+ remainingCount - 1
559
+ ) !== null
560
+ )
561
+ }
562
+
563
+ /**
564
+ * Slices PCB text records including their variable string tails.
565
+ * @param {object} descriptor
566
+ * @param {Uint8Array | ArrayBuffer} headerBytes
567
+ * @param {Uint8Array | ArrayBuffer} dataBytes
568
+ * @returns {object[]}
569
+ */
570
+ static #sliceTextTailRecords(descriptor, headerBytes, dataBytes) {
571
+ const count = PcbRawRecordRegistry.#readRecordCount(headerBytes)
572
+ const bytes = PcbRawRecordRegistry.#toUint8Array(dataBytes)
573
+ const records = []
574
+ let offset = 0
575
+
576
+ for (let index = 0; index < count; index += 1) {
577
+ const record = PcbRawRecordRegistry.#readTextTailRecordAt(
578
+ bytes,
579
+ offset,
580
+ descriptor,
581
+ index === count - 1
582
+ )
583
+
584
+ if (!record) {
585
+ return []
586
+ }
587
+
588
+ records.push({ ...record, recordIndex: index })
589
+ offset += record.byteLength
590
+ }
591
+
592
+ return records
593
+ }
594
+
595
+ /**
596
+ * Reads one PCB text-tail record.
597
+ * @param {Uint8Array} bytes
598
+ * @param {number} offset
599
+ * @param {object} descriptor
600
+ * @param {boolean} isLastRecord
601
+ * @returns {object | null}
602
+ */
603
+ static #readTextTailRecordAt(bytes, offset, descriptor, isLastRecord) {
604
+ if (
605
+ !PcbRawRecordRegistry.#isTextTailRecordStart(
606
+ bytes,
607
+ offset,
608
+ descriptor
609
+ )
610
+ ) {
611
+ return null
612
+ }
613
+
614
+ const payloadByteLength = PcbRawRecordRegistry.#readUint32(
615
+ bytes,
616
+ offset + 1
617
+ )
618
+ const payloadEnd = offset + 5 + payloadByteLength
619
+ const nextOffset = isLastRecord
620
+ ? bytes.byteLength
621
+ : PcbRawRecordRegistry.#findNextTextTailRecordOffset(
622
+ bytes,
623
+ payloadEnd,
624
+ descriptor
625
+ )
626
+
627
+ if (!nextOffset) {
628
+ return null
629
+ }
630
+
631
+ return {
632
+ recordBytes: bytes.slice(offset, nextOffset),
633
+ offset,
634
+ byteLength: nextOffset - offset,
635
+ payloadByteLength,
636
+ encoding: 'text-tail',
637
+ objectId: descriptor.typeId,
638
+ recordIndex: 0
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Finds the next plausible PCB text-tail record start.
644
+ * @param {Uint8Array} bytes
645
+ * @param {number} offset
646
+ * @param {object} descriptor
647
+ * @returns {number | null}
648
+ */
649
+ static #findNextTextTailRecordOffset(bytes, offset, descriptor) {
650
+ for (let cursor = offset; cursor < bytes.byteLength - 5; cursor += 1) {
651
+ if (
652
+ PcbRawRecordRegistry.#isTextTailRecordStart(
653
+ bytes,
654
+ cursor,
655
+ descriptor
656
+ )
657
+ ) {
658
+ return cursor
659
+ }
660
+ }
661
+
662
+ return null
663
+ }
664
+
665
+ /**
666
+ * Returns true when an offset looks like a PCB text-tail record start.
667
+ * @param {Uint8Array} bytes
668
+ * @param {number} offset
669
+ * @param {object} descriptor
670
+ * @returns {boolean}
671
+ */
672
+ static #isTextTailRecordStart(bytes, offset, descriptor) {
673
+ if (
674
+ offset + 5 > bytes.byteLength ||
675
+ bytes[offset] !== descriptor.typeId
676
+ ) {
677
+ return false
678
+ }
679
+
680
+ const payloadByteLength = PcbRawRecordRegistry.#readUint32(
681
+ bytes,
682
+ offset + 1
683
+ )
684
+ const payloadEnd = offset + 5 + payloadByteLength
685
+
686
+ return (
687
+ payloadByteLength >= descriptor.minimumPayloadByteLength &&
688
+ payloadByteLength <= descriptor.maximumPayloadByteLength &&
689
+ payloadEnd <= bytes.byteLength
690
+ )
691
+ }
692
+
693
+ /**
694
+ * Reads one length-prefixed subrecord.
695
+ * @param {Uint8Array} bytes
696
+ * @param {number} offset
697
+ * @returns {{ payloadByteLength: number, nextOffset: number } | null}
698
+ */
699
+ static #readSubrecordAt(bytes, offset) {
700
+ if (offset + 4 > bytes.byteLength) {
701
+ return null
702
+ }
703
+
704
+ const payloadByteLength = PcbRawRecordRegistry.#readUint32(
705
+ bytes,
706
+ offset
707
+ )
708
+ const nextOffset = offset + 4 + payloadByteLength
709
+
710
+ if (nextOffset > bytes.byteLength) {
711
+ return null
712
+ }
713
+
714
+ return { payloadByteLength, nextOffset }
715
+ }
716
+
717
+ /**
718
+ * Creates one normalized PcbDoc raw record.
719
+ * @param {object} descriptor
720
+ * @param {object} slice
721
+ * @param {number} parsedCount
722
+ * @returns {object}
723
+ */
724
+ static #normalizePcbDocRecord(descriptor, slice, parsedCount) {
725
+ return {
726
+ registryId:
727
+ 'pcbdoc:' + descriptor.sourceStream + ':' + slice.recordIndex,
728
+ source: 'pcbdoc',
729
+ sourceStream: descriptor.sourceStream,
730
+ headerStream: descriptor.headerStream,
731
+ family: descriptor.family,
732
+ type: descriptor.type,
733
+ typeId: descriptor.typeId,
734
+ recordIndex: slice.recordIndex,
735
+ offset: slice.offset,
736
+ byteLength: slice.byteLength,
737
+ payloadByteLength: slice.payloadByteLength,
738
+ encoding: slice.encoding,
739
+ supported: true,
740
+ parsed: slice.recordIndex < parsedCount,
741
+ rawBase64: PcbRawRecordRegistry.#toBase64(slice.recordBytes)
742
+ }
743
+ }
744
+
745
+ /**
746
+ * Creates one fallback raw record for a registered but unparsed stream.
747
+ * @param {object} descriptor
748
+ * @param {Uint8Array | ArrayBuffer} dataBytes
749
+ * @returns {object}
750
+ */
751
+ static #createUnparsedPcbDocRecord(descriptor, dataBytes) {
752
+ const bytes = PcbRawRecordRegistry.#toUint8Array(dataBytes)
753
+
754
+ return {
755
+ registryId: 'pcbdoc:' + descriptor.sourceStream + ':0',
756
+ source: 'pcbdoc',
757
+ sourceStream: descriptor.sourceStream,
758
+ headerStream: descriptor.headerStream,
759
+ family: descriptor.family,
760
+ type: descriptor.type,
761
+ typeId: descriptor.typeId,
762
+ recordIndex: 0,
763
+ offset: 0,
764
+ byteLength: bytes.byteLength,
765
+ payloadByteLength: null,
766
+ encoding: 'unparsed-stream',
767
+ supported: true,
768
+ parsed: false,
769
+ rawBase64: PcbRawRecordRegistry.#toBase64(bytes)
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Reads one little-endian record count.
775
+ * @param {Uint8Array | ArrayBuffer} headerBytes
776
+ * @returns {number}
777
+ */
778
+ static #readRecordCount(headerBytes) {
779
+ const bytes = PcbRawRecordRegistry.#toUint8Array(headerBytes)
780
+
781
+ if (bytes.byteLength < 4) {
782
+ return 0
783
+ }
784
+
785
+ return new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(
786
+ 0,
787
+ true
788
+ )
789
+ }
790
+
791
+ /**
792
+ * Reads one little-endian unsigned 32-bit value.
793
+ * @param {Uint8Array} bytes
794
+ * @param {number} offset
795
+ * @returns {number}
796
+ */
797
+ static #readUint32(bytes, offset) {
798
+ return new DataView(
799
+ bytes.buffer,
800
+ bytes.byteOffset + offset,
801
+ 4
802
+ ).getUint32(0, true)
803
+ }
804
+
805
+ /**
806
+ * Normalizes one byte-like input into a Uint8Array view.
807
+ * @param {Uint8Array | ArrayBuffer} bytes
808
+ * @returns {Uint8Array}
809
+ */
810
+ static #toUint8Array(bytes) {
811
+ return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
812
+ }
813
+
814
+ /**
815
+ * Encodes raw bytes as base64 without assuming a Node-only runtime.
816
+ * @param {Uint8Array} bytes
817
+ * @returns {string}
818
+ */
819
+ static #toBase64(bytes) {
820
+ if (typeof Buffer !== 'undefined') {
821
+ return Buffer.from(bytes).toString('base64')
822
+ }
823
+
824
+ let binary = ''
825
+ for (const byte of bytes) {
826
+ binary += String.fromCharCode(byte)
827
+ }
828
+
829
+ return btoa(binary)
830
+ }
831
+ }