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,548 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Decodes optional Altium via stack and mask-expansion fields.
7
+ */
8
+ export class PcbViaStackParser {
9
+ static #FLAGS_1_OFFSET = 6
10
+
11
+ static #FLAGS_2_OFFSET = 7
12
+
13
+ static #PLANE_CONNECTION_STYLE_OFFSET = 36
14
+
15
+ static #THERMAL_RELIEF_AIR_GAP_OFFSET = 37
16
+
17
+ static #THERMAL_RELIEF_CONDUCTOR_COUNT_OFFSET = 41
18
+
19
+ static #THERMAL_RELIEF_CONDUCTOR_WIDTH_OFFSET = 43
20
+
21
+ static #POWER_PLANE_RELIEF_EXPANSION_OFFSET = 47
22
+
23
+ static #POWER_PLANE_CLEARANCE_OFFSET = 51
24
+
25
+ static #PASTE_MASK_EXPANSION_OFFSET = 55
26
+
27
+ static #SOLDER_MASK_EXPANSION_OFFSET = 59
28
+
29
+ static #PASTE_MASK_MODE_OFFSET = 64
30
+
31
+ static #SOLDER_MASK_MODE_OFFSET = 71
32
+
33
+ static #DIAMETER_STACK_MODE_OFFSET = 79
34
+
35
+ static #DIAMETER_BY_LAYER_OFFSET = 80
36
+
37
+ static #REMOVED_PAD_FLAGS_OFFSET = 214
38
+
39
+ static #SOLDER_MASK_LINKED_OFFSET = 246
40
+
41
+ static #SOLDER_MASK_EXPANSION_BACK_OFFSET = 247
42
+
43
+ static #EXTERNAL_STACK_TABLE_OFFSET = 251
44
+
45
+ static #EXTERNAL_STACK_ENTRY_HEADER_BYTE_LENGTH = 9
46
+
47
+ static #SOLDER_MASK_FROM_HOLE_EDGE_OFFSET = 263
48
+
49
+ static #UNIQUE_ID_OFFSET = 264
50
+
51
+ static #TAIL_SIGNATURE_OFFSET = 280
52
+
53
+ static #POSITIVE_TOLERANCE_OFFSET = 296
54
+
55
+ static #NEGATIVE_TOLERANCE_OFFSET = 300
56
+
57
+ static #DRILL_LAYER_PAIR_TYPE_OFFSET = 317
58
+
59
+ static #PHYSICAL_LAYER_COUNT = 32
60
+
61
+ /**
62
+ * Decodes optional via stack metadata from one via record view.
63
+ * @param {DataView} view
64
+ * @returns {Record<string, unknown>}
65
+ */
66
+ static parse(view) {
67
+ const result = {}
68
+ const externalStack = PcbViaStackParser.#parseExternalStack(view)
69
+ const externalStackShift = PcbViaStackParser.#externalStackShift(
70
+ view,
71
+ externalStack.externalStackEntryCount,
72
+ externalStack.externalStackEntryStride
73
+ )
74
+ const pasteMaskExpansion = PcbViaStackParser.#readMilIfAvailable(
75
+ view,
76
+ PcbViaStackParser.#PASTE_MASK_EXPANSION_OFFSET
77
+ )
78
+ const solderMaskExpansion = PcbViaStackParser.#readMilIfAvailable(
79
+ view,
80
+ PcbViaStackParser.#SOLDER_MASK_EXPANSION_OFFSET
81
+ )
82
+ const pasteMaskExpansionMode = PcbViaStackParser.#readByteIfAvailable(
83
+ view,
84
+ PcbViaStackParser.#PASTE_MASK_MODE_OFFSET
85
+ )
86
+ const solderMaskExpansionMode = PcbViaStackParser.#readByteIfAvailable(
87
+ view,
88
+ PcbViaStackParser.#SOLDER_MASK_MODE_OFFSET
89
+ )
90
+ const diameterStackMode = PcbViaStackParser.#readByteIfAvailable(
91
+ view,
92
+ PcbViaStackParser.#DIAMETER_STACK_MODE_OFFSET
93
+ )
94
+ const diameterByLayer = PcbViaStackParser.#parseDiameterByLayer(view)
95
+
96
+ if (pasteMaskExpansion) {
97
+ result.pasteMaskExpansion = pasteMaskExpansion
98
+ }
99
+ if (solderMaskExpansion) {
100
+ result.solderMaskExpansion = solderMaskExpansion
101
+ }
102
+ if (pasteMaskExpansionMode) {
103
+ result.pasteMaskExpansionMode = pasteMaskExpansionMode
104
+ }
105
+ if (solderMaskExpansionMode) {
106
+ result.solderMaskExpansionMode = solderMaskExpansionMode
107
+ }
108
+ if (diameterStackMode || diameterByLayer.length) {
109
+ result.diameterStackMode = diameterStackMode || 0
110
+ result.diameterByLayer = diameterByLayer
111
+ }
112
+
113
+ return {
114
+ ...PcbViaStackParser.#parseFlags(view),
115
+ ...PcbViaStackParser.#parsePlaneReliefFields(view),
116
+ ...result,
117
+ ...PcbViaStackParser.#parseRemovedPads(view),
118
+ ...PcbViaStackParser.#parseBackSolderMask(view),
119
+ ...externalStack,
120
+ ...PcbViaStackParser.#parseTail(view, externalStackShift)
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Decodes via state flags shared by Altium readers.
126
+ * @param {DataView} view
127
+ * @returns {Record<string, boolean>}
128
+ */
129
+ static #parseFlags(view) {
130
+ const flags1 = PcbViaStackParser.#readByteIfAvailable(
131
+ view,
132
+ PcbViaStackParser.#FLAGS_1_OFFSET
133
+ )
134
+ const flags2 = PcbViaStackParser.#readByteIfAvailable(
135
+ view,
136
+ PcbViaStackParser.#FLAGS_2_OFFSET
137
+ )
138
+
139
+ if (!flags1 && !flags2) {
140
+ return {}
141
+ }
142
+
143
+ return {
144
+ isSelected: (flags1 & 0x01) !== 0,
145
+ isPolygonOutline: (flags1 & 0x02) !== 0,
146
+ isLocked: (flags1 & 0x04) === 0,
147
+ isTentingTop: (flags1 & 0x20) !== 0,
148
+ isTentingBottom: (flags1 & 0x40) !== 0,
149
+ isTestFabTop: (flags1 & 0x80) !== 0,
150
+ isTestFabBottom: (flags2 & 0x01) !== 0,
151
+ isKeepout: (flags2 & 0x02) !== 0
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Decodes optional plane-connection and thermal-relief fields.
157
+ * @param {DataView} view
158
+ * @returns {Record<string, number>}
159
+ */
160
+ static #parsePlaneReliefFields(view) {
161
+ const result = {}
162
+ const planeConnectionStyle = PcbViaStackParser.#readByteIfAvailable(
163
+ view,
164
+ PcbViaStackParser.#PLANE_CONNECTION_STYLE_OFFSET
165
+ )
166
+ const thermalReliefAirGap = PcbViaStackParser.#readMilIfAvailable(
167
+ view,
168
+ PcbViaStackParser.#THERMAL_RELIEF_AIR_GAP_OFFSET
169
+ )
170
+ const thermalReliefConductorCount =
171
+ PcbViaStackParser.#readUint16IfAvailable(
172
+ view,
173
+ PcbViaStackParser.#THERMAL_RELIEF_CONDUCTOR_COUNT_OFFSET
174
+ )
175
+ const thermalReliefConductorWidth =
176
+ PcbViaStackParser.#readMilIfAvailable(
177
+ view,
178
+ PcbViaStackParser.#THERMAL_RELIEF_CONDUCTOR_WIDTH_OFFSET
179
+ )
180
+ const powerPlaneReliefExpansion = PcbViaStackParser.#readMilIfAvailable(
181
+ view,
182
+ PcbViaStackParser.#POWER_PLANE_RELIEF_EXPANSION_OFFSET
183
+ )
184
+ const powerPlaneClearance = PcbViaStackParser.#readMilIfAvailable(
185
+ view,
186
+ PcbViaStackParser.#POWER_PLANE_CLEARANCE_OFFSET
187
+ )
188
+
189
+ if (planeConnectionStyle) {
190
+ result.planeConnectionStyle = planeConnectionStyle
191
+ }
192
+ if (thermalReliefAirGap) {
193
+ result.thermalReliefAirGap = thermalReliefAirGap
194
+ }
195
+ if (thermalReliefConductorCount) {
196
+ result.thermalReliefConductorCount = thermalReliefConductorCount
197
+ }
198
+ if (thermalReliefConductorWidth) {
199
+ result.thermalReliefConductorWidth = thermalReliefConductorWidth
200
+ }
201
+ if (powerPlaneReliefExpansion) {
202
+ result.powerPlaneReliefExpansion = powerPlaneReliefExpansion
203
+ }
204
+ if (powerPlaneClearance) {
205
+ result.powerPlaneClearance = powerPlaneClearance
206
+ }
207
+
208
+ return result
209
+ }
210
+
211
+ /**
212
+ * Decodes the per-layer removed-pad bitmap.
213
+ * @param {DataView} view
214
+ * @returns {Record<string, { layerNumber: number }[]>}
215
+ */
216
+ static #parseRemovedPads(view) {
217
+ if (
218
+ !view ||
219
+ PcbViaStackParser.#REMOVED_PAD_FLAGS_OFFSET +
220
+ PcbViaStackParser.#PHYSICAL_LAYER_COUNT >
221
+ view.byteLength
222
+ ) {
223
+ return {}
224
+ }
225
+
226
+ const removedPadsByLayer = []
227
+
228
+ for (
229
+ let index = 0;
230
+ index < PcbViaStackParser.#PHYSICAL_LAYER_COUNT;
231
+ index += 1
232
+ ) {
233
+ const removed = view.getUint8(
234
+ PcbViaStackParser.#REMOVED_PAD_FLAGS_OFFSET + index
235
+ )
236
+
237
+ if (removed) {
238
+ removedPadsByLayer.push({ layerNumber: index + 1 })
239
+ }
240
+ }
241
+
242
+ return removedPadsByLayer.length ? { removedPadsByLayer } : {}
243
+ }
244
+
245
+ /**
246
+ * Decodes linked/back solder-mask expansion fields.
247
+ * @param {DataView} view
248
+ * @returns {Record<string, boolean | number>}
249
+ */
250
+ static #parseBackSolderMask(view) {
251
+ const result = {}
252
+ const linked = PcbViaStackParser.#readByteIfAvailable(
253
+ view,
254
+ PcbViaStackParser.#SOLDER_MASK_LINKED_OFFSET
255
+ )
256
+ const backExpansion = PcbViaStackParser.#readMilIfAvailable(
257
+ view,
258
+ PcbViaStackParser.#SOLDER_MASK_EXPANSION_BACK_OFFSET
259
+ )
260
+
261
+ if (linked) {
262
+ result.solderMaskExpansionLinked = true
263
+ }
264
+ if (backExpansion) {
265
+ result.solderMaskExpansionBack = backExpansion
266
+ }
267
+
268
+ return result
269
+ }
270
+
271
+ /**
272
+ * Decodes optional external stack-table entries and the following marker.
273
+ * @param {DataView} view
274
+ * @returns {Record<string, unknown>}
275
+ */
276
+ static #parseExternalStack(view) {
277
+ if (
278
+ !view ||
279
+ PcbViaStackParser.#EXTERNAL_STACK_TABLE_OFFSET + 8 > view.byteLength
280
+ ) {
281
+ return {}
282
+ }
283
+
284
+ const count = view.getUint32(
285
+ PcbViaStackParser.#EXTERNAL_STACK_TABLE_OFFSET,
286
+ true
287
+ )
288
+ const stride = view.getUint32(
289
+ PcbViaStackParser.#EXTERNAL_STACK_TABLE_OFFSET + 4,
290
+ true
291
+ )
292
+ const result = {
293
+ externalStackEntryCount: count,
294
+ externalStackEntryStride: stride,
295
+ externalStackEntries: []
296
+ }
297
+ const entries = PcbViaStackParser.#parseExternalStackEntries(
298
+ view,
299
+ count,
300
+ stride
301
+ )
302
+ const markerOffset =
303
+ PcbViaStackParser.#EXTERNAL_STACK_TABLE_OFFSET +
304
+ 8 +
305
+ entries.byteLength
306
+ const marker = PcbViaStackParser.#readByteIfAvailable(
307
+ view,
308
+ markerOffset
309
+ )
310
+
311
+ result.externalStackEntries = entries.values
312
+ if (marker) {
313
+ result.externalStackMarker = marker
314
+ }
315
+
316
+ return PcbViaStackParser.#hasExternalStackData(result) ? result : {}
317
+ }
318
+
319
+ /**
320
+ * Decodes one sane external via stack table.
321
+ * @param {DataView} view
322
+ * @param {number} count
323
+ * @param {number} stride
324
+ * @returns {{ values: object[], byteLength: number }}
325
+ */
326
+ static #parseExternalStackEntries(view, count, stride) {
327
+ if (
328
+ count <= 0 ||
329
+ count > 64 ||
330
+ stride <
331
+ PcbViaStackParser.#EXTERNAL_STACK_ENTRY_HEADER_BYTE_LENGTH ||
332
+ stride > 64
333
+ ) {
334
+ return { values: [], byteLength: 0 }
335
+ }
336
+
337
+ const dataOffset = PcbViaStackParser.#EXTERNAL_STACK_TABLE_OFFSET + 8
338
+ const byteLength = count * stride
339
+
340
+ if (dataOffset + byteLength > view.byteLength) {
341
+ return { values: [], byteLength: 0 }
342
+ }
343
+
344
+ const values = []
345
+
346
+ for (let index = 0; index < count; index += 1) {
347
+ const offset = dataOffset + index * stride
348
+ values.push({
349
+ layerId: view.getUint32(offset, true),
350
+ sizeOnLayer: view.getInt32(offset + 4, true) / 10000,
351
+ entryState: view.getUint8(offset + 8)
352
+ })
353
+ }
354
+
355
+ return { values, byteLength }
356
+ }
357
+
358
+ /**
359
+ * Checks whether the external stack block carries non-default data.
360
+ * @param {Record<string, unknown>} result
361
+ * @returns {boolean}
362
+ */
363
+ static #hasExternalStackData(result) {
364
+ return (
365
+ result.externalStackEntryCount !== 0 ||
366
+ result.externalStackEntryStride !== 0 ||
367
+ result.externalStackEntries.length !== 0 ||
368
+ result.externalStackMarker !== undefined
369
+ )
370
+ }
371
+
372
+ /**
373
+ * Computes the tail offset shift introduced by external stack entries.
374
+ * @param {DataView} view
375
+ * @param {number | undefined} count
376
+ * @param {number | undefined} stride
377
+ * @returns {number}
378
+ */
379
+ static #externalStackShift(view, count, stride) {
380
+ if (!view || !count || !stride || count > 64 || stride > 64) {
381
+ return 0
382
+ }
383
+
384
+ const shift = count * stride
385
+
386
+ if (
387
+ PcbViaStackParser.#POSITIVE_TOLERANCE_OFFSET + shift + 4 >
388
+ view.byteLength ||
389
+ PcbViaStackParser.#DRILL_LAYER_PAIR_TYPE_OFFSET + shift >=
390
+ view.byteLength
391
+ ) {
392
+ return 0
393
+ }
394
+
395
+ return shift
396
+ }
397
+
398
+ /**
399
+ * Decodes tail metadata after any optional external stack table.
400
+ * @param {DataView} view
401
+ * @param {number} offsetShift
402
+ * @returns {Record<string, boolean | number | string>}
403
+ */
404
+ static #parseTail(view, offsetShift) {
405
+ const result = {}
406
+ const fromHoleEdge = PcbViaStackParser.#readByteIfAvailable(
407
+ view,
408
+ PcbViaStackParser.#SOLDER_MASK_FROM_HOLE_EDGE_OFFSET + offsetShift
409
+ )
410
+ const uniqueId = PcbViaStackParser.#readHexBytesIfAvailable(
411
+ view,
412
+ PcbViaStackParser.#UNIQUE_ID_OFFSET + offsetShift,
413
+ 16
414
+ )
415
+ const tailSignature = PcbViaStackParser.#readHexBytesIfAvailable(
416
+ view,
417
+ PcbViaStackParser.#TAIL_SIGNATURE_OFFSET + offsetShift,
418
+ 16
419
+ )
420
+ const positiveTolerance = PcbViaStackParser.#readMilIfAvailable(
421
+ view,
422
+ PcbViaStackParser.#POSITIVE_TOLERANCE_OFFSET + offsetShift
423
+ )
424
+ const negativeTolerance = PcbViaStackParser.#readMilIfAvailable(
425
+ view,
426
+ PcbViaStackParser.#NEGATIVE_TOLERANCE_OFFSET + offsetShift
427
+ )
428
+ const drillLayerPairType = PcbViaStackParser.#readByteIfAvailable(
429
+ view,
430
+ PcbViaStackParser.#DRILL_LAYER_PAIR_TYPE_OFFSET + offsetShift
431
+ )
432
+
433
+ if (fromHoleEdge) {
434
+ result.solderMaskExpansionFromHoleEdge = true
435
+ }
436
+ if (uniqueId) {
437
+ result.uniqueId = uniqueId
438
+ }
439
+ if (tailSignature) {
440
+ result.tailSignature = tailSignature
441
+ }
442
+ if (positiveTolerance) {
443
+ result.positiveTolerance = positiveTolerance
444
+ }
445
+ if (negativeTolerance) {
446
+ result.negativeTolerance = negativeTolerance
447
+ }
448
+ if (drillLayerPairType) {
449
+ result.drillLayerPairType = drillLayerPairType
450
+ }
451
+
452
+ return result
453
+ }
454
+
455
+ /**
456
+ * Decodes non-empty via diameters by layer.
457
+ * @param {DataView} view
458
+ * @returns {{ layerNumber: number, diameter: number }[]}
459
+ */
460
+ static #parseDiameterByLayer(view) {
461
+ const entries = []
462
+
463
+ for (
464
+ let index = 0;
465
+ index < PcbViaStackParser.#PHYSICAL_LAYER_COUNT;
466
+ index += 1
467
+ ) {
468
+ const offset =
469
+ PcbViaStackParser.#DIAMETER_BY_LAYER_OFFSET + index * 4
470
+ const diameter = PcbViaStackParser.#readMilIfAvailable(view, offset)
471
+
472
+ if (diameter) {
473
+ entries.push({
474
+ layerNumber: index + 1,
475
+ diameter
476
+ })
477
+ }
478
+ }
479
+
480
+ return entries
481
+ }
482
+
483
+ /**
484
+ * Reads one signed fixed-point mil value when fully available.
485
+ * @param {DataView} view
486
+ * @param {number} offset
487
+ * @returns {number | null}
488
+ */
489
+ static #readMilIfAvailable(view, offset) {
490
+ if (!view || offset + 4 > view.byteLength) {
491
+ return null
492
+ }
493
+
494
+ return view.getInt32(offset, true) / 10000
495
+ }
496
+
497
+ /**
498
+ * Reads one byte when available.
499
+ * @param {DataView} view
500
+ * @param {number} offset
501
+ * @returns {number | null}
502
+ */
503
+ static #readByteIfAvailable(view, offset) {
504
+ if (!view || offset >= view.byteLength) {
505
+ return null
506
+ }
507
+
508
+ return view.getUint8(offset)
509
+ }
510
+
511
+ /**
512
+ * Reads one unsigned 16-bit value when available.
513
+ * @param {DataView} view
514
+ * @param {number} offset
515
+ * @returns {number | null}
516
+ */
517
+ static #readUint16IfAvailable(view, offset) {
518
+ if (!view || offset + 2 > view.byteLength) {
519
+ return null
520
+ }
521
+
522
+ return view.getUint16(offset, true)
523
+ }
524
+
525
+ /**
526
+ * Reads non-zero bytes as a lowercase hexadecimal string.
527
+ * @param {DataView} view
528
+ * @param {number} offset
529
+ * @param {number} byteLength
530
+ * @returns {string | null}
531
+ */
532
+ static #readHexBytesIfAvailable(view, offset, byteLength) {
533
+ if (!view || offset + byteLength > view.byteLength) {
534
+ return null
535
+ }
536
+
537
+ const bytes = []
538
+ let hasData = false
539
+
540
+ for (let index = 0; index < byteLength; index += 1) {
541
+ const value = view.getUint8(offset + index)
542
+ bytes.push(value.toString(16).padStart(2, '0'))
543
+ hasData ||= value !== 0
544
+ }
545
+
546
+ return hasData ? bytes.join('') : null
547
+ }
548
+ }
@@ -0,0 +1,108 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Decodes Altium WideStrings6/Data text-table streams.
7
+ */
8
+ export class PcbWideStringTableParser {
9
+ /**
10
+ * Parses indexed UTF-16LE string-table records.
11
+ * @param {Uint8Array | ArrayBuffer | undefined} dataBytes
12
+ * @returns {{ entries: { index: number, text: string }[], byIndex: Record<string, string> }}
13
+ */
14
+ static parse(dataBytes) {
15
+ const bytes = PcbWideStringTableParser.#toUint8Array(dataBytes)
16
+ const entries = []
17
+ let offset = 0
18
+
19
+ while (offset + 8 <= bytes.byteLength) {
20
+ const index = PcbWideStringTableParser.#readUint32(bytes, offset)
21
+ const byteLength = PcbWideStringTableParser.#readUint32(
22
+ bytes,
23
+ offset + 4
24
+ )
25
+ offset += 8
26
+
27
+ if (offset + byteLength > bytes.byteLength) {
28
+ break
29
+ }
30
+
31
+ const stringBytes = bytes.subarray(offset, offset + byteLength)
32
+ offset += byteLength
33
+
34
+ entries.push({
35
+ index,
36
+ text: PcbWideStringTableParser.#decodeWideString(stringBytes)
37
+ })
38
+ }
39
+
40
+ return {
41
+ entries,
42
+ byIndex: PcbWideStringTableParser.#buildWideStringLookup(entries)
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Builds a JSON-friendly string lookup keyed by numeric string index.
48
+ * @param {{ index: number, text: string }[]} entries
49
+ * @returns {Record<string, string>}
50
+ */
51
+ static #buildWideStringLookup(entries) {
52
+ const byIndex = {}
53
+
54
+ for (const entry of entries) {
55
+ byIndex[entry.index] = entry.text
56
+ }
57
+
58
+ return byIndex
59
+ }
60
+
61
+ /**
62
+ * Decodes and normalizes one UTF-16LE string-table entry.
63
+ * @param {Uint8Array} bytes
64
+ * @returns {string}
65
+ */
66
+ static #decodeWideString(bytes) {
67
+ if (!bytes.byteLength) {
68
+ return ''
69
+ }
70
+
71
+ return new TextDecoder('utf-16le')
72
+ .decode(bytes)
73
+ .replace(/\u0000+$/gu, '')
74
+ .replace(/^[\u0000-\u001f\u007f-\u009f]+/gu, '')
75
+ .trim()
76
+ }
77
+
78
+ /**
79
+ * Reads one little-endian unsigned integer from a byte view.
80
+ * @param {Uint8Array} bytes
81
+ * @param {number} offset
82
+ * @returns {number}
83
+ */
84
+ static #readUint32(bytes, offset) {
85
+ return new DataView(
86
+ bytes.buffer,
87
+ bytes.byteOffset + offset,
88
+ 4
89
+ ).getUint32(0, true)
90
+ }
91
+
92
+ /**
93
+ * Normalizes one byte-like input into a Uint8Array view.
94
+ * @param {Uint8Array | ArrayBuffer | undefined} bytes
95
+ * @returns {Uint8Array}
96
+ */
97
+ static #toUint8Array(bytes) {
98
+ if (!bytes) {
99
+ return new Uint8Array(0)
100
+ }
101
+
102
+ if (bytes instanceof Uint8Array) {
103
+ return bytes
104
+ }
105
+
106
+ return new Uint8Array(bytes)
107
+ }
108
+ }