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