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,537 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Decodes Altium PCB text primitive streams.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbTextPrimitiveParser {
|
|
9
|
+
static #TEXT_OBJECT_ID = 5
|
|
10
|
+
|
|
11
|
+
static #TEXT_RECORD_MIN_BYTE_LENGTH = 64
|
|
12
|
+
|
|
13
|
+
static #TEXT_RECORD_MAX_BYTE_LENGTH = 2048
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Decodes one variable-length PCB text stream.
|
|
17
|
+
* @param {Uint8Array | ArrayBuffer} headerBytes
|
|
18
|
+
* @param {Uint8Array | ArrayBuffer} dataBytes
|
|
19
|
+
* @param {{ wideStrings?: Map<number | string, string> | Record<string, string> | { byIndex?: Record<string, string> } }} [options]
|
|
20
|
+
* @returns {{ text: string, x: number, y: number, height: number, layerId: number, ownerIndex: number | null, kind: number, visibilityFlags: number, rotation: number, strokeFontType?: number, strokeWidth?: number, fontType?: number, fontTypeName?: string, fontName?: string, fontFamily?: string, isBold?: boolean, isItalic?: boolean, fontWeight?: number, fontStyle?: string, wideStringIndex?: number, textSource?: string, role?: string, isDesignator?: boolean, isComment?: boolean, isPlaceholder?: boolean, componentIndex?: number }[]}
|
|
21
|
+
*/
|
|
22
|
+
static parseTextStream(headerBytes, dataBytes, options = {}) {
|
|
23
|
+
const count = PcbTextPrimitiveParser.#readRecordCount(headerBytes)
|
|
24
|
+
const normalizedData = PcbTextPrimitiveParser.#toUint8Array(dataBytes)
|
|
25
|
+
const wideStrings = PcbTextPrimitiveParser.#normalizeWideStrings(
|
|
26
|
+
options.wideStrings
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if (!count) {
|
|
30
|
+
return []
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let offset = 0
|
|
34
|
+
const texts = []
|
|
35
|
+
|
|
36
|
+
for (let index = 0; index < count; index += 1) {
|
|
37
|
+
const record = PcbTextPrimitiveParser.#readTextRecordAt(
|
|
38
|
+
normalizedData,
|
|
39
|
+
offset,
|
|
40
|
+
index === count - 1,
|
|
41
|
+
wideStrings
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if (!record) {
|
|
45
|
+
return texts
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (record.text) {
|
|
49
|
+
texts.push(record.text)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
offset = record.nextOffset
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return texts
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reads one text record and its trailing string bytes at an offset.
|
|
60
|
+
* @param {Uint8Array} bytes
|
|
61
|
+
* @param {number} offset
|
|
62
|
+
* @param {boolean} isLastRecord
|
|
63
|
+
* @param {Map<string, string>} wideStrings
|
|
64
|
+
* @returns {{ text: { text: string, x: number, y: number, height: number, layerId: number, ownerIndex: number | null, kind: number, visibilityFlags: number, rotation: number } | null, nextOffset: number } | null}
|
|
65
|
+
*/
|
|
66
|
+
static #readTextRecordAt(bytes, offset, isLastRecord, wideStrings) {
|
|
67
|
+
if (!PcbTextPrimitiveParser.#isTextRecordStart(bytes, offset)) {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const payloadLength = PcbTextPrimitiveParser.#readUint32FromBytes(
|
|
72
|
+
bytes,
|
|
73
|
+
offset + 1
|
|
74
|
+
)
|
|
75
|
+
const payloadOffset = offset + 5
|
|
76
|
+
const payloadEnd = payloadOffset + payloadLength
|
|
77
|
+
|
|
78
|
+
if (payloadEnd > bytes.byteLength) {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const nextOffset = isLastRecord
|
|
83
|
+
? bytes.byteLength
|
|
84
|
+
: PcbTextPrimitiveParser.#findNextTextRecordOffset(
|
|
85
|
+
bytes,
|
|
86
|
+
payloadEnd
|
|
87
|
+
)
|
|
88
|
+
const text = PcbTextPrimitiveParser.#parseTextRecord(
|
|
89
|
+
new DataView(
|
|
90
|
+
bytes.buffer,
|
|
91
|
+
bytes.byteOffset + payloadOffset,
|
|
92
|
+
payloadLength
|
|
93
|
+
),
|
|
94
|
+
bytes.slice(payloadEnd, nextOffset),
|
|
95
|
+
wideStrings
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
text,
|
|
100
|
+
nextOffset
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parses one fixed PCB text payload and its variable string tail.
|
|
106
|
+
* @param {DataView} payload
|
|
107
|
+
* @param {Uint8Array} textBytes
|
|
108
|
+
* @param {Map<string, string>} wideStrings
|
|
109
|
+
* @returns {{ text: string, x: number, y: number, height: number, layerId: number, ownerIndex: number | null, kind: number, visibilityFlags: number, rotation: number, strokeFontType?: number, strokeWidth?: number, fontType?: number, fontTypeName?: string, fontName?: string, fontFamily?: string, isBold?: boolean, isItalic?: boolean, fontWeight?: number, fontStyle?: string, wideStringIndex?: number, textSource?: string, role?: string, isDesignator?: boolean, isComment?: boolean, isPlaceholder?: boolean, componentIndex?: number } | null}
|
|
110
|
+
*/
|
|
111
|
+
static #parseTextRecord(payload, textBytes, wideStrings) {
|
|
112
|
+
if (
|
|
113
|
+
payload.byteLength <
|
|
114
|
+
PcbTextPrimitiveParser.#TEXT_RECORD_MIN_BYTE_LENGTH
|
|
115
|
+
) {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const extendedText =
|
|
120
|
+
PcbTextPrimitiveParser.#parseExtendedTextFields(payload)
|
|
121
|
+
const resolvedText = PcbTextPrimitiveParser.#resolveTextContent(
|
|
122
|
+
PcbTextPrimitiveParser.#decodeTextBytes(textBytes),
|
|
123
|
+
extendedText.wideStringIndex,
|
|
124
|
+
wideStrings
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if (!resolvedText.text) {
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ownerIndex = payload.getInt16(7, true)
|
|
132
|
+
const normalizedOwnerIndex = ownerIndex === -1 ? null : ownerIndex
|
|
133
|
+
const hasExtendedFontFields = payload.byteLength >= 110
|
|
134
|
+
const visibilityFlags = hasExtendedFontFields
|
|
135
|
+
? 0
|
|
136
|
+
: payload.getUint32(41, true)
|
|
137
|
+
|
|
138
|
+
const role = PcbTextPrimitiveParser.#parseTextRole(
|
|
139
|
+
payload,
|
|
140
|
+
normalizedOwnerIndex,
|
|
141
|
+
hasExtendedFontFields
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
text: resolvedText.text,
|
|
146
|
+
layerId: payload.getUint8(0),
|
|
147
|
+
ownerIndex: normalizedOwnerIndex,
|
|
148
|
+
x: PcbTextPrimitiveParser.#readMil(payload, 13),
|
|
149
|
+
y: PcbTextPrimitiveParser.#readMil(payload, 17),
|
|
150
|
+
height: PcbTextPrimitiveParser.#readMil(payload, 21),
|
|
151
|
+
kind: hasExtendedFontFields
|
|
152
|
+
? payload.getUint16(25, true)
|
|
153
|
+
: payload.getUint32(25, true),
|
|
154
|
+
visibilityFlags,
|
|
155
|
+
rotation: PcbTextPrimitiveParser.#resolveTextRotation(
|
|
156
|
+
payload,
|
|
157
|
+
visibilityFlags,
|
|
158
|
+
hasExtendedFontFields
|
|
159
|
+
),
|
|
160
|
+
...extendedText,
|
|
161
|
+
...resolvedText.metadata,
|
|
162
|
+
...role,
|
|
163
|
+
...PcbTextPrimitiveParser.#parsePlaceholderMetadata(
|
|
164
|
+
resolvedText.text,
|
|
165
|
+
role
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Parses extended TrueType/barcode font metadata when the payload carries
|
|
172
|
+
* the modern PCB text field block.
|
|
173
|
+
* @param {DataView} payload
|
|
174
|
+
* @returns {{ strokeFontType?: number, strokeWidth?: number, fontType?: number, fontTypeName?: string, fontName?: string, fontFamily?: string, isBold?: boolean, isItalic?: boolean, fontWeight?: number, fontStyle?: string, wideStringIndex?: number }}
|
|
175
|
+
*/
|
|
176
|
+
static #parseExtendedTextFields(payload) {
|
|
177
|
+
if (payload.byteLength < 110) {
|
|
178
|
+
return {}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const fontType = payload.getUint8(43)
|
|
182
|
+
const isBold = payload.getUint8(44) !== 0
|
|
183
|
+
const isItalic = payload.getUint8(45) !== 0
|
|
184
|
+
const fontName = PcbTextPrimitiveParser.#decodeFixedUtf16(
|
|
185
|
+
payload,
|
|
186
|
+
46,
|
|
187
|
+
64
|
|
188
|
+
)
|
|
189
|
+
const extendedFields = {
|
|
190
|
+
strokeFontType: payload.getUint16(25, true),
|
|
191
|
+
strokeWidth: PcbTextPrimitiveParser.#readMil(payload, 36),
|
|
192
|
+
fontType,
|
|
193
|
+
fontTypeName: PcbTextPrimitiveParser.#fontTypeName(fontType),
|
|
194
|
+
fontName,
|
|
195
|
+
fontFamily:
|
|
196
|
+
fontName ||
|
|
197
|
+
PcbTextPrimitiveParser.#fallbackFontFamily(fontType),
|
|
198
|
+
isBold,
|
|
199
|
+
isItalic,
|
|
200
|
+
fontWeight: isBold ? 700 : 400,
|
|
201
|
+
fontStyle: isItalic ? 'italic' : 'normal'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (payload.byteLength >= 119) {
|
|
205
|
+
extendedFields.wideStringIndex = payload.getUint32(115, true)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return extendedFields
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Decodes one fixed-length UTF-16LE field from a payload view.
|
|
213
|
+
* @param {DataView} payload
|
|
214
|
+
* @param {number} offset
|
|
215
|
+
* @param {number} byteLength
|
|
216
|
+
* @returns {string}
|
|
217
|
+
*/
|
|
218
|
+
static #decodeFixedUtf16(payload, offset, byteLength) {
|
|
219
|
+
if (offset + byteLength > payload.byteLength) {
|
|
220
|
+
return ''
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return new TextDecoder('utf-16le')
|
|
224
|
+
.decode(
|
|
225
|
+
new Uint8Array(
|
|
226
|
+
payload.buffer,
|
|
227
|
+
payload.byteOffset + offset,
|
|
228
|
+
byteLength
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
.replace(/\u0000+$/gu, '')
|
|
232
|
+
.trim()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Resolves inline text first, then WideStrings6 text-table references.
|
|
237
|
+
* @param {string} inlineText
|
|
238
|
+
* @param {number | undefined} wideStringIndex
|
|
239
|
+
* @param {Map<string, string>} wideStrings
|
|
240
|
+
* @returns {{ text: string, metadata: { textSource?: string } }}
|
|
241
|
+
*/
|
|
242
|
+
static #resolveTextContent(inlineText, wideStringIndex, wideStrings) {
|
|
243
|
+
if (inlineText) {
|
|
244
|
+
return { text: inlineText, metadata: {} }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!Number.isInteger(wideStringIndex)) {
|
|
248
|
+
return { text: '', metadata: {} }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const wideText = wideStrings.get(String(wideStringIndex)) || ''
|
|
252
|
+
|
|
253
|
+
return wideText
|
|
254
|
+
? {
|
|
255
|
+
text: wideText,
|
|
256
|
+
metadata: { textSource: 'WideStrings6/Data' }
|
|
257
|
+
}
|
|
258
|
+
: { text: '', metadata: {} }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Parses explicit modern Texts6 role metadata.
|
|
263
|
+
* @param {DataView} payload
|
|
264
|
+
* @param {number | null} ownerIndex
|
|
265
|
+
* @param {boolean} hasExtendedFontFields
|
|
266
|
+
* @returns {{ role?: string, isDesignator?: boolean, isComment?: boolean, componentIndex?: number }}
|
|
267
|
+
*/
|
|
268
|
+
static #parseTextRole(payload, ownerIndex, hasExtendedFontFields) {
|
|
269
|
+
if (
|
|
270
|
+
!hasExtendedFontFields ||
|
|
271
|
+
payload.byteLength < 42 ||
|
|
272
|
+
!Number.isInteger(ownerIndex)
|
|
273
|
+
) {
|
|
274
|
+
return {}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (payload.getUint8(41) !== 0) {
|
|
278
|
+
return {
|
|
279
|
+
role: 'designator',
|
|
280
|
+
isDesignator: true,
|
|
281
|
+
componentIndex: ownerIndex
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const kind = payload.getUint16(25, true)
|
|
286
|
+
if (kind === 1) {
|
|
287
|
+
return {
|
|
288
|
+
role: 'comment',
|
|
289
|
+
isComment: true,
|
|
290
|
+
componentIndex: ownerIndex
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
componentIndex: ownerIndex
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Marks unresolved component annotation placeholders once during parsing.
|
|
301
|
+
* @param {string} text
|
|
302
|
+
* @param {{ role?: string, isDesignator?: boolean, isComment?: boolean, componentIndex?: number }} role
|
|
303
|
+
* @returns {{ role?: string, isDesignator?: boolean, isComment?: boolean, isPlaceholder?: boolean }}
|
|
304
|
+
*/
|
|
305
|
+
static #parsePlaceholderMetadata(text, role) {
|
|
306
|
+
const value = String(text || '').trim()
|
|
307
|
+
const hasComponentOwner = Number.isInteger(role?.componentIndex)
|
|
308
|
+
|
|
309
|
+
if (role?.isComment === true || role?.role === 'comment') {
|
|
310
|
+
return value === 'Comment' ? { isPlaceholder: true } : {}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (role?.isDesignator === true || role?.role === 'designator') {
|
|
314
|
+
return /^Designator\d*$/u.test(value) ? { isPlaceholder: true } : {}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (hasComponentOwner && value === 'Comment') {
|
|
318
|
+
return {
|
|
319
|
+
role: 'comment',
|
|
320
|
+
isComment: true,
|
|
321
|
+
isPlaceholder: true
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (hasComponentOwner && /^Designator\d*$/u.test(value)) {
|
|
326
|
+
return {
|
|
327
|
+
role: 'designator',
|
|
328
|
+
isDesignator: true,
|
|
329
|
+
isPlaceholder: true
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return {}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resolves a public label for one PCB text font type.
|
|
338
|
+
* @param {number} fontType
|
|
339
|
+
* @returns {'Stroke' | 'TrueType' | 'BarCode' | 'Unknown'}
|
|
340
|
+
*/
|
|
341
|
+
static #fontTypeName(fontType) {
|
|
342
|
+
return (
|
|
343
|
+
{
|
|
344
|
+
0: 'Stroke',
|
|
345
|
+
1: 'TrueType',
|
|
346
|
+
2: 'BarCode'
|
|
347
|
+
}[Number(fontType)] || 'Unknown'
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Returns a generic fallback family for text records without a font name.
|
|
353
|
+
* @param {number} fontType
|
|
354
|
+
* @returns {string}
|
|
355
|
+
*/
|
|
356
|
+
static #fallbackFontFamily(fontType) {
|
|
357
|
+
return Number(fontType) === 0 ? 'Stroke' : 'Arial'
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Decodes the printable text payload that trails a fixed text record.
|
|
362
|
+
* @param {Uint8Array} bytes
|
|
363
|
+
* @returns {string}
|
|
364
|
+
*/
|
|
365
|
+
static #decodeTextBytes(bytes) {
|
|
366
|
+
const start = bytes.findIndex((byte) => byte >= 0x20 && byte <= 0x7e)
|
|
367
|
+
|
|
368
|
+
if (start < 0) {
|
|
369
|
+
return ''
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return new TextDecoder()
|
|
373
|
+
.decode(bytes.slice(start))
|
|
374
|
+
.replace(/\u0000/gu, '')
|
|
375
|
+
.replace(/\r\n?/gu, '\n')
|
|
376
|
+
.replace(/^[\u0000-\u001f\u007f-\u009f]+/gu, '')
|
|
377
|
+
.trim()
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Finds the next length-prefixed PCB text record after one string tail.
|
|
382
|
+
* @param {Uint8Array} bytes
|
|
383
|
+
* @param {number} offset
|
|
384
|
+
* @returns {number}
|
|
385
|
+
*/
|
|
386
|
+
static #findNextTextRecordOffset(bytes, offset) {
|
|
387
|
+
for (let cursor = offset; cursor < bytes.byteLength - 5; cursor += 1) {
|
|
388
|
+
if (PcbTextPrimitiveParser.#isTextRecordStart(bytes, cursor)) {
|
|
389
|
+
return cursor
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return bytes.byteLength
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Returns true when a byte offset looks like a text record boundary.
|
|
398
|
+
* @param {Uint8Array} bytes
|
|
399
|
+
* @param {number} offset
|
|
400
|
+
* @returns {boolean}
|
|
401
|
+
*/
|
|
402
|
+
static #isTextRecordStart(bytes, offset) {
|
|
403
|
+
if (offset + 5 > bytes.byteLength) {
|
|
404
|
+
return false
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (bytes[offset] !== PcbTextPrimitiveParser.#TEXT_OBJECT_ID) {
|
|
408
|
+
return false
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const payloadLength = PcbTextPrimitiveParser.#readUint32FromBytes(
|
|
412
|
+
bytes,
|
|
413
|
+
offset + 1
|
|
414
|
+
)
|
|
415
|
+
const payloadEnd = offset + 5 + payloadLength
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
payloadLength >=
|
|
419
|
+
PcbTextPrimitiveParser.#TEXT_RECORD_MIN_BYTE_LENGTH &&
|
|
420
|
+
payloadLength <=
|
|
421
|
+
PcbTextPrimitiveParser.#TEXT_RECORD_MAX_BYTE_LENGTH &&
|
|
422
|
+
payloadEnd <= bytes.byteLength
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Resolves the text rotation encoded in the visibility/options bit field.
|
|
428
|
+
* @param {number} visibilityFlags
|
|
429
|
+
* @returns {number}
|
|
430
|
+
*/
|
|
431
|
+
static #textRotationFromFlags(visibilityFlags) {
|
|
432
|
+
return (Number(visibilityFlags) & 0x00010000) !== 0 ? 90 : 0
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Resolves text rotation from modern double fields with legacy flag
|
|
437
|
+
* fallback.
|
|
438
|
+
* @param {DataView} payload
|
|
439
|
+
* @param {number} visibilityFlags
|
|
440
|
+
* @param {boolean} hasExtendedFontFields
|
|
441
|
+
* @returns {number}
|
|
442
|
+
*/
|
|
443
|
+
static #resolveTextRotation(
|
|
444
|
+
payload,
|
|
445
|
+
visibilityFlags,
|
|
446
|
+
hasExtendedFontFields
|
|
447
|
+
) {
|
|
448
|
+
if (hasExtendedFontFields && payload.byteLength >= 35) {
|
|
449
|
+
const rotation = payload.getFloat64(27, true)
|
|
450
|
+
if (Number.isFinite(rotation) && Math.abs(rotation) > 0.000001) {
|
|
451
|
+
return rotation
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return PcbTextPrimitiveParser.#textRotationFromFlags(visibilityFlags)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Normalizes supported WideStrings6 lookup shapes into a string-keyed map.
|
|
460
|
+
* @param {Map<number | string, string> | Record<string, string> | { byIndex?: Record<string, string> } | undefined} wideStrings
|
|
461
|
+
* @returns {Map<string, string>}
|
|
462
|
+
*/
|
|
463
|
+
static #normalizeWideStrings(wideStrings) {
|
|
464
|
+
const normalized = new Map()
|
|
465
|
+
const lookup = wideStrings?.byIndex || wideStrings || {}
|
|
466
|
+
|
|
467
|
+
if (lookup instanceof Map) {
|
|
468
|
+
for (const [index, text] of lookup.entries()) {
|
|
469
|
+
normalized.set(String(index), String(text || ''))
|
|
470
|
+
}
|
|
471
|
+
return normalized
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
for (const [index, text] of Object.entries(lookup)) {
|
|
475
|
+
normalized.set(String(index), String(text || ''))
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return normalized
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Reads one little-endian record count from a binary stream header.
|
|
483
|
+
* @param {Uint8Array | ArrayBuffer} headerBytes
|
|
484
|
+
* @returns {number}
|
|
485
|
+
*/
|
|
486
|
+
static #readRecordCount(headerBytes) {
|
|
487
|
+
const normalizedHeader =
|
|
488
|
+
PcbTextPrimitiveParser.#toUint8Array(headerBytes)
|
|
489
|
+
|
|
490
|
+
if (normalizedHeader.byteLength < 4) {
|
|
491
|
+
return 0
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return new DataView(
|
|
495
|
+
normalizedHeader.buffer,
|
|
496
|
+
normalizedHeader.byteOffset,
|
|
497
|
+
4
|
|
498
|
+
).getUint32(0, true)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Reads one little-endian unsigned integer from a byte view.
|
|
503
|
+
* @param {Uint8Array} bytes
|
|
504
|
+
* @param {number} offset
|
|
505
|
+
* @returns {number}
|
|
506
|
+
*/
|
|
507
|
+
static #readUint32FromBytes(bytes, offset) {
|
|
508
|
+
return new DataView(
|
|
509
|
+
bytes.buffer,
|
|
510
|
+
bytes.byteOffset + offset,
|
|
511
|
+
4
|
|
512
|
+
).getUint32(0, true)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Reads one signed fixed-point mil coordinate.
|
|
517
|
+
* @param {DataView} view
|
|
518
|
+
* @param {number} offset
|
|
519
|
+
* @returns {number}
|
|
520
|
+
*/
|
|
521
|
+
static #readMil(view, offset) {
|
|
522
|
+
return view.getInt32(offset, true) / 10000
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Normalizes one byte-like input into a Uint8Array view.
|
|
527
|
+
* @param {Uint8Array | ArrayBuffer} bytes
|
|
528
|
+
* @returns {Uint8Array}
|
|
529
|
+
*/
|
|
530
|
+
static #toUint8Array(bytes) {
|
|
531
|
+
if (bytes instanceof Uint8Array) {
|
|
532
|
+
return bytes
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return new Uint8Array(bytes)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { PcbPrimitiveRecordSlicer } from './PcbPrimitiveRecordSlicer.mjs'
|
|
6
|
+
import { PcbPrimitiveOwnershipIndexParser } from './PcbPrimitiveOwnershipIndexParser.mjs'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Decodes Altium track primitive streams.
|
|
10
|
+
*/
|
|
11
|
+
export class PcbTrackPrimitiveParser {
|
|
12
|
+
static #TRACK_OBJECT_ID = 4
|
|
13
|
+
|
|
14
|
+
static #TRACK_RECORD_BYTE_LENGTH = 49
|
|
15
|
+
|
|
16
|
+
static #TRACK_PAYLOAD_MIN_BYTE_LENGTH = 33
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decodes one track stream.
|
|
20
|
+
* @param {Uint8Array | ArrayBuffer} headerBytes
|
|
21
|
+
* @param {Uint8Array | ArrayBuffer} dataBytes
|
|
22
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, width: number, componentIndex: number | null, netIndex: number | null, polygonIndex: number | null, layerCode: number, layerId: number }[]}
|
|
23
|
+
*/
|
|
24
|
+
static parseTrackStream(headerBytes, dataBytes) {
|
|
25
|
+
return PcbTrackPrimitiveParser.#sliceTrackRecords(
|
|
26
|
+
headerBytes,
|
|
27
|
+
dataBytes
|
|
28
|
+
).map((view) => PcbTrackPrimitiveParser.#parseTrackRecord(view))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Decodes one track record view into a normalized primitive.
|
|
33
|
+
* @param {DataView} view
|
|
34
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, width: number, componentIndex: number | null, netIndex: number | null, polygonIndex: number | null, layerCode: number, layerId: number }}
|
|
35
|
+
*/
|
|
36
|
+
static #parseTrackRecord(view) {
|
|
37
|
+
const layerId = view.getUint8(0)
|
|
38
|
+
const ownershipIndexes =
|
|
39
|
+
PcbPrimitiveOwnershipIndexParser.readOwnershipIndexes(view, {
|
|
40
|
+
component: 7,
|
|
41
|
+
net: 3,
|
|
42
|
+
polygon: 5
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
x1: PcbTrackPrimitiveParser.#readMil(view, 13),
|
|
47
|
+
y1: PcbTrackPrimitiveParser.#readMil(view, 17),
|
|
48
|
+
x2: PcbTrackPrimitiveParser.#readMil(view, 21),
|
|
49
|
+
y2: PcbTrackPrimitiveParser.#readMil(view, 25),
|
|
50
|
+
width: PcbTrackPrimitiveParser.#readMil(view, 29),
|
|
51
|
+
...ownershipIndexes,
|
|
52
|
+
layerCode: layerId,
|
|
53
|
+
layerId
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Splits a track stream into record views, preserving variable payload
|
|
59
|
+
* lengths when tracks are stored with object-id and payload-length
|
|
60
|
+
* prefixes.
|
|
61
|
+
* @param {Uint8Array | ArrayBuffer} headerBytes
|
|
62
|
+
* @param {Uint8Array | ArrayBuffer} dataBytes
|
|
63
|
+
* @returns {DataView[]}
|
|
64
|
+
*/
|
|
65
|
+
static #sliceTrackRecords(headerBytes, dataBytes) {
|
|
66
|
+
return PcbPrimitiveRecordSlicer.slicePrimitiveRecords({
|
|
67
|
+
headerBytes,
|
|
68
|
+
dataBytes,
|
|
69
|
+
objectId: PcbTrackPrimitiveParser.#TRACK_OBJECT_ID,
|
|
70
|
+
fixedRecordByteLength:
|
|
71
|
+
PcbTrackPrimitiveParser.#TRACK_RECORD_BYTE_LENGTH,
|
|
72
|
+
minimumPayloadByteLength:
|
|
73
|
+
PcbTrackPrimitiveParser.#TRACK_PAYLOAD_MIN_BYTE_LENGTH,
|
|
74
|
+
lengthPrefixedView: 'payload'
|
|
75
|
+
}).map((record) => record.view)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reads one standard fixed-point mil value.
|
|
80
|
+
* @param {DataView} view
|
|
81
|
+
* @param {number} offset
|
|
82
|
+
* @returns {number}
|
|
83
|
+
*/
|
|
84
|
+
static #readMil(view, offset) {
|
|
85
|
+
return view.getInt32(offset, true) / 10000
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { PcbPrimitiveRecordSlicer } from './PcbPrimitiveRecordSlicer.mjs'
|
|
6
|
+
import { PcbPrimitiveOwnershipIndexParser } from './PcbPrimitiveOwnershipIndexParser.mjs'
|
|
7
|
+
import { PcbViaStackParser } from './PcbViaStackParser.mjs'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Decodes Altium via primitive streams.
|
|
11
|
+
*/
|
|
12
|
+
export class PcbViaPrimitiveParser {
|
|
13
|
+
static #VIA_OBJECT_ID = 3
|
|
14
|
+
|
|
15
|
+
static #VIA_RECORD_BYTE_LENGTH = 326
|
|
16
|
+
|
|
17
|
+
static #VIA_PAYLOAD_MIN_BYTE_LENGTH = 321
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decodes one via stream.
|
|
21
|
+
* @param {Uint8Array | ArrayBuffer} headerBytes
|
|
22
|
+
* @param {Uint8Array | ArrayBuffer} dataBytes
|
|
23
|
+
* @returns {{ x: number, y: number, diameter: number, holeDiameter: number, componentIndex: number | null, netIndex: number | null, polygonIndex: number | null, layerCode: number | null, layerId: number | null, layerStartId: number | null, layerEndId: number | null, [key: string]: unknown }[]}
|
|
24
|
+
*/
|
|
25
|
+
static parseViaStream(headerBytes, dataBytes) {
|
|
26
|
+
return PcbViaPrimitiveParser.#sliceViaRecords(
|
|
27
|
+
headerBytes,
|
|
28
|
+
dataBytes
|
|
29
|
+
).map((view) => PcbViaPrimitiveParser.#parseViaRecord(view))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Decodes one via record view into a normalized primitive.
|
|
34
|
+
* @param {DataView} view
|
|
35
|
+
* @returns {{ x: number, y: number, diameter: number, holeDiameter: number, componentIndex: number | null, netIndex: number | null, polygonIndex: number | null, layerCode: number | null, layerId: number | null, layerStartId: number | null, layerEndId: number | null, [key: string]: unknown }}
|
|
36
|
+
*/
|
|
37
|
+
static #parseViaRecord(view) {
|
|
38
|
+
const ownershipIndexes =
|
|
39
|
+
PcbPrimitiveOwnershipIndexParser.readOwnershipIndexes(view, {
|
|
40
|
+
component: 12,
|
|
41
|
+
net: 8,
|
|
42
|
+
polygon: 10
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
x: PcbViaPrimitiveParser.#readMil(view, 18),
|
|
47
|
+
y: PcbViaPrimitiveParser.#readMil(view, 22),
|
|
48
|
+
diameter: PcbViaPrimitiveParser.#readMil(view, 26),
|
|
49
|
+
holeDiameter: PcbViaPrimitiveParser.#readMil(view, 30),
|
|
50
|
+
...ownershipIndexes,
|
|
51
|
+
layerCode: view.getUint8(5) || null,
|
|
52
|
+
layerId: view.getUint8(5) || null,
|
|
53
|
+
layerStartId: view.getUint8(34) || null,
|
|
54
|
+
layerEndId: view.getUint8(35) || null,
|
|
55
|
+
...PcbViaStackParser.parse(view)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Splits a via stream into record views, preserving variable tail lengths
|
|
61
|
+
* when Altium stores object-id and payload-length prefixes.
|
|
62
|
+
* @param {Uint8Array | ArrayBuffer} headerBytes
|
|
63
|
+
* @param {Uint8Array | ArrayBuffer} dataBytes
|
|
64
|
+
* @returns {DataView[]}
|
|
65
|
+
*/
|
|
66
|
+
static #sliceViaRecords(headerBytes, dataBytes) {
|
|
67
|
+
return PcbPrimitiveRecordSlicer.slicePrimitiveRecords({
|
|
68
|
+
headerBytes,
|
|
69
|
+
dataBytes,
|
|
70
|
+
objectId: PcbViaPrimitiveParser.#VIA_OBJECT_ID,
|
|
71
|
+
fixedRecordByteLength:
|
|
72
|
+
PcbViaPrimitiveParser.#VIA_RECORD_BYTE_LENGTH,
|
|
73
|
+
minimumPayloadByteLength:
|
|
74
|
+
PcbViaPrimitiveParser.#VIA_PAYLOAD_MIN_BYTE_LENGTH,
|
|
75
|
+
lengthPrefixedView: 'record'
|
|
76
|
+
}).map((record) => record.view)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reads one standard fixed-point mil value.
|
|
81
|
+
* @param {DataView} view
|
|
82
|
+
* @param {number} offset
|
|
83
|
+
* @returns {number}
|
|
84
|
+
*/
|
|
85
|
+
static #readMil(view, offset) {
|
|
86
|
+
return view.getInt32(offset, true) / 10000
|
|
87
|
+
}
|
|
88
|
+
}
|