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.
- 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 +21 -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,484 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { unzlibSync } from 'fflate'
|
|
6
|
+
import { PcbFontMetricsParser } from './PcbFontMetricsParser.mjs'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extracts zlib-compressed embedded font payloads from PCB compound streams.
|
|
10
|
+
*/
|
|
11
|
+
export class PcbEmbeddedFontExtractor {
|
|
12
|
+
static #CANDIDATE_STREAM_NAMES = [
|
|
13
|
+
'EmbeddedFonts6/Data',
|
|
14
|
+
'EmbeddedFonts/Data',
|
|
15
|
+
'Library/EmbeddedFonts'
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extracts embedded fonts from known PcbDoc and PcbLib stream names.
|
|
20
|
+
* @param {Map<string, Uint8Array>} streams
|
|
21
|
+
* @returns {{ fonts: { index: number, name: string, style: string, fileName: string, sourceStream: string, format: string, mimeType: string, byteCount: number, compressedByteCount: number, payloadBase64: string, metrics: Record<string, number | string> }[] }}
|
|
22
|
+
*/
|
|
23
|
+
static extractFromStreams(streams) {
|
|
24
|
+
const fonts = []
|
|
25
|
+
|
|
26
|
+
for (const streamName of PcbEmbeddedFontExtractor
|
|
27
|
+
.#CANDIDATE_STREAM_NAMES) {
|
|
28
|
+
const bytes = streams.get(streamName)
|
|
29
|
+
if (!(bytes instanceof Uint8Array) || !bytes.byteLength) {
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fonts.push(
|
|
34
|
+
...PcbEmbeddedFontExtractor.extractFromBytes(bytes, streamName)
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
fonts: PcbEmbeddedFontExtractor.#dedupeFonts(fonts).map(
|
|
40
|
+
(font, index) => ({
|
|
41
|
+
...font,
|
|
42
|
+
index
|
|
43
|
+
})
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extracts embedded fonts from one raw EmbeddedFonts stream.
|
|
50
|
+
* @param {Uint8Array | ArrayBuffer} bytes
|
|
51
|
+
* @param {string} [sourceStream]
|
|
52
|
+
* @returns {{ index: number, name: string, style: string, fileName: string, sourceStream: string, format: string, mimeType: string, byteCount: number, compressedByteCount: number, payloadBase64: string, metrics: Record<string, number | string> }[]}
|
|
53
|
+
*/
|
|
54
|
+
static extractFromBytes(bytes, sourceStream = 'EmbeddedFonts6/Data') {
|
|
55
|
+
const normalizedBytes = PcbEmbeddedFontExtractor.#toUint8Array(bytes)
|
|
56
|
+
const fonts = []
|
|
57
|
+
let offset = 0
|
|
58
|
+
|
|
59
|
+
while (offset < normalizedBytes.byteLength) {
|
|
60
|
+
const record = PcbEmbeddedFontExtractor.#readFontRecordAt(
|
|
61
|
+
normalizedBytes,
|
|
62
|
+
offset,
|
|
63
|
+
sourceStream,
|
|
64
|
+
fonts.length
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if (!record) {
|
|
68
|
+
break
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fonts.push(record.font)
|
|
72
|
+
offset = record.nextOffset
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return fonts
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reads one embedded-font record at the current stream offset.
|
|
80
|
+
* @param {Uint8Array} bytes
|
|
81
|
+
* @param {number} offset
|
|
82
|
+
* @param {string} sourceStream
|
|
83
|
+
* @param {number} index
|
|
84
|
+
* @returns {{ font: { index: number, name: string, style: string, fileName: string, sourceStream: string, format: string, mimeType: string, byteCount: number, compressedByteCount: number, payloadBase64: string, metrics: Record<string, number | string> }, nextOffset: number } | null}
|
|
85
|
+
*/
|
|
86
|
+
static #readFontRecordAt(bytes, offset, sourceStream, index) {
|
|
87
|
+
const familyField = PcbEmbeddedFontExtractor.#readUtf16FieldAt(
|
|
88
|
+
bytes,
|
|
89
|
+
offset
|
|
90
|
+
)
|
|
91
|
+
if (!familyField) {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const alternateField = PcbEmbeddedFontExtractor.#readUtf16FieldAt(
|
|
96
|
+
bytes,
|
|
97
|
+
familyField.nextOffset
|
|
98
|
+
)
|
|
99
|
+
const styleField = alternateField
|
|
100
|
+
? PcbEmbeddedFontExtractor.#readUtf16FieldAt(
|
|
101
|
+
bytes,
|
|
102
|
+
alternateField.nextOffset
|
|
103
|
+
)
|
|
104
|
+
: null
|
|
105
|
+
if (!alternateField || !styleField) {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const zlibOffset = PcbEmbeddedFontExtractor.#findZlibHeaderOffset(
|
|
110
|
+
bytes,
|
|
111
|
+
styleField.nextOffset
|
|
112
|
+
)
|
|
113
|
+
if (zlibOffset < 0) {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const compressedEnd = PcbEmbeddedFontExtractor.#findCompressedEnd(
|
|
118
|
+
bytes,
|
|
119
|
+
zlibOffset
|
|
120
|
+
)
|
|
121
|
+
if (compressedEnd <= zlibOffset) {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const compressedBytes = bytes.subarray(zlibOffset, compressedEnd)
|
|
126
|
+
const payloadBytes = unzlibSync(compressedBytes)
|
|
127
|
+
const metadata = PcbEmbeddedFontExtractor.#normalizeFontMetadata(
|
|
128
|
+
familyField.text,
|
|
129
|
+
alternateField.text,
|
|
130
|
+
styleField.text
|
|
131
|
+
)
|
|
132
|
+
const metrics = PcbFontMetricsParser.parse(payloadBytes)
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
font: {
|
|
136
|
+
index,
|
|
137
|
+
name: metadata.name,
|
|
138
|
+
style: metadata.style,
|
|
139
|
+
fileName: PcbEmbeddedFontExtractor.#buildFileName(
|
|
140
|
+
metadata.name,
|
|
141
|
+
metadata.style
|
|
142
|
+
),
|
|
143
|
+
sourceStream,
|
|
144
|
+
format: metrics.format || 'unknown',
|
|
145
|
+
mimeType: PcbEmbeddedFontExtractor.#resolveMimeType(
|
|
146
|
+
metrics.format
|
|
147
|
+
),
|
|
148
|
+
byteCount: payloadBytes.byteLength,
|
|
149
|
+
compressedByteCount: compressedBytes.byteLength,
|
|
150
|
+
payloadBase64:
|
|
151
|
+
PcbEmbeddedFontExtractor.#bytesToBase64(payloadBytes),
|
|
152
|
+
metrics
|
|
153
|
+
},
|
|
154
|
+
nextOffset: compressedEnd
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Reads one little-endian length-prefixed UTF-16LE string field.
|
|
160
|
+
* @param {Uint8Array} bytes
|
|
161
|
+
* @param {number} offset
|
|
162
|
+
* @returns {{ text: string, nextOffset: number } | null}
|
|
163
|
+
*/
|
|
164
|
+
static #readUtf16FieldAt(bytes, offset) {
|
|
165
|
+
if (offset + 4 > bytes.byteLength) {
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const byteLength = new DataView(
|
|
170
|
+
bytes.buffer,
|
|
171
|
+
bytes.byteOffset + offset,
|
|
172
|
+
4
|
|
173
|
+
).getUint32(0, true)
|
|
174
|
+
const textOffset = offset + 4
|
|
175
|
+
const textEnd = textOffset + byteLength
|
|
176
|
+
|
|
177
|
+
if (byteLength < 0 || textEnd > bytes.byteLength) {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
text: new TextDecoder('utf-16le')
|
|
183
|
+
.decode(bytes.subarray(textOffset, textEnd))
|
|
184
|
+
.replace(/\u0000+$/gu, '')
|
|
185
|
+
.trim(),
|
|
186
|
+
nextOffset: textEnd
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Finds a zlib stream header shortly after the font metadata fields.
|
|
192
|
+
* @param {Uint8Array} bytes
|
|
193
|
+
* @param {number} offset
|
|
194
|
+
* @returns {number}
|
|
195
|
+
*/
|
|
196
|
+
static #findZlibHeaderOffset(bytes, offset) {
|
|
197
|
+
const end = Math.min(bytes.byteLength - 1, offset + 256)
|
|
198
|
+
|
|
199
|
+
for (let cursor = offset; cursor < end; cursor += 1) {
|
|
200
|
+
if (
|
|
201
|
+
PcbEmbeddedFontExtractor.#isLikelyZlibHeader(
|
|
202
|
+
bytes[cursor],
|
|
203
|
+
bytes[cursor + 1]
|
|
204
|
+
)
|
|
205
|
+
) {
|
|
206
|
+
return cursor
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return -1
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Returns true when two bytes look like a zlib header.
|
|
215
|
+
* @param {number} compressionMethod
|
|
216
|
+
* @param {number} flags
|
|
217
|
+
* @returns {boolean}
|
|
218
|
+
*/
|
|
219
|
+
static #isLikelyZlibHeader(compressionMethod, flags) {
|
|
220
|
+
return (
|
|
221
|
+
(Number(compressionMethod) & 0x0f) === 8 &&
|
|
222
|
+
((Number(compressionMethod) << 8) + Number(flags)) % 31 === 0
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Finds the smallest trailing offset that fully contains a zlib payload.
|
|
228
|
+
* @param {Uint8Array} bytes
|
|
229
|
+
* @param {number} zlibOffset
|
|
230
|
+
* @returns {number}
|
|
231
|
+
*/
|
|
232
|
+
static #findCompressedEnd(bytes, zlibOffset) {
|
|
233
|
+
let low = zlibOffset + 2
|
|
234
|
+
let high = bytes.byteLength
|
|
235
|
+
|
|
236
|
+
while (low < high) {
|
|
237
|
+
const midpoint = Math.floor((low + high) / 2)
|
|
238
|
+
if (
|
|
239
|
+
PcbEmbeddedFontExtractor.#canInflate(
|
|
240
|
+
bytes.subarray(zlibOffset, midpoint)
|
|
241
|
+
)
|
|
242
|
+
) {
|
|
243
|
+
high = midpoint
|
|
244
|
+
} else {
|
|
245
|
+
low = midpoint + 1
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return PcbEmbeddedFontExtractor.#canInflate(
|
|
250
|
+
bytes.subarray(zlibOffset, low)
|
|
251
|
+
)
|
|
252
|
+
? low
|
|
253
|
+
: -1
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Returns true when one byte slice can be inflated as a complete zlib
|
|
258
|
+
* stream.
|
|
259
|
+
* @param {Uint8Array} bytes
|
|
260
|
+
* @returns {boolean}
|
|
261
|
+
*/
|
|
262
|
+
static #canInflate(bytes) {
|
|
263
|
+
try {
|
|
264
|
+
unzlibSync(bytes)
|
|
265
|
+
return true
|
|
266
|
+
} catch {
|
|
267
|
+
return false
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Normalizes family/style metadata from the embedded-font record fields.
|
|
273
|
+
* @param {string} family
|
|
274
|
+
* @param {string} alternateFamily
|
|
275
|
+
* @param {string} explicitStyle
|
|
276
|
+
* @returns {{ name: string, style: string }}
|
|
277
|
+
*/
|
|
278
|
+
static #normalizeFontMetadata(family, alternateFamily, explicitStyle) {
|
|
279
|
+
const inferredStyle =
|
|
280
|
+
PcbEmbeddedFontExtractor.#normalizeStyle(explicitStyle) ||
|
|
281
|
+
PcbEmbeddedFontExtractor.#inferStyle([family, alternateFamily])
|
|
282
|
+
const alternate = String(alternateFamily || '').trim()
|
|
283
|
+
const baseFamily = String(family || alternate || 'Embedded Font').trim()
|
|
284
|
+
const name =
|
|
285
|
+
alternate &&
|
|
286
|
+
inferredStyle !== 'Regular' &&
|
|
287
|
+
PcbEmbeddedFontExtractor.#nameContainsStyle(
|
|
288
|
+
baseFamily,
|
|
289
|
+
inferredStyle
|
|
290
|
+
)
|
|
291
|
+
? alternate
|
|
292
|
+
: baseFamily
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
name: PcbEmbeddedFontExtractor.#trimStyleSuffix(
|
|
296
|
+
name,
|
|
297
|
+
inferredStyle
|
|
298
|
+
),
|
|
299
|
+
style: inferredStyle
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Normalizes a style field into one of the public style labels.
|
|
305
|
+
* @param {string} style
|
|
306
|
+
* @returns {'Regular' | 'Bold' | 'Italic' | 'Bold Italic' | ''}
|
|
307
|
+
*/
|
|
308
|
+
static #normalizeStyle(style) {
|
|
309
|
+
const normalized = String(style || '')
|
|
310
|
+
.trim()
|
|
311
|
+
.toLowerCase()
|
|
312
|
+
|
|
313
|
+
if (!normalized || normalized === 'regular') {
|
|
314
|
+
return ''
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const isBold = normalized.includes('bold')
|
|
318
|
+
const isItalic =
|
|
319
|
+
normalized.includes('italic') || normalized.includes('oblique')
|
|
320
|
+
|
|
321
|
+
if (isBold && isItalic) return 'Bold Italic'
|
|
322
|
+
if (isBold) return 'Bold'
|
|
323
|
+
if (isItalic) return 'Italic'
|
|
324
|
+
|
|
325
|
+
return 'Regular'
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Infers a font style from family-name hints when the style field is empty.
|
|
330
|
+
* @param {string[]} names
|
|
331
|
+
* @returns {'Regular' | 'Bold' | 'Italic' | 'Bold Italic'}
|
|
332
|
+
*/
|
|
333
|
+
static #inferStyle(names) {
|
|
334
|
+
const normalized = names.join(' ').toLowerCase()
|
|
335
|
+
const isBold = normalized.includes('bold')
|
|
336
|
+
const isItalic =
|
|
337
|
+
normalized.includes('italic') || normalized.includes('oblique')
|
|
338
|
+
|
|
339
|
+
if (isBold && isItalic) return 'Bold Italic'
|
|
340
|
+
if (isBold) return 'Bold'
|
|
341
|
+
if (isItalic) return 'Italic'
|
|
342
|
+
|
|
343
|
+
return 'Regular'
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Returns true when a family name already carries the style suffix.
|
|
348
|
+
* @param {string} name
|
|
349
|
+
* @param {string} style
|
|
350
|
+
* @returns {boolean}
|
|
351
|
+
*/
|
|
352
|
+
static #nameContainsStyle(name, style) {
|
|
353
|
+
const normalizedName = String(name || '').toLowerCase()
|
|
354
|
+
return String(style || '')
|
|
355
|
+
.toLowerCase()
|
|
356
|
+
.split(/\s+/u)
|
|
357
|
+
.every((part) => normalizedName.includes(part))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Removes an inferred style suffix from a family name.
|
|
362
|
+
* @param {string} name
|
|
363
|
+
* @param {string} style
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
static #trimStyleSuffix(name, style) {
|
|
367
|
+
if (style === 'Regular') {
|
|
368
|
+
return name
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const pattern = new RegExp(
|
|
372
|
+
'\\s+' +
|
|
373
|
+
String(style || '')
|
|
374
|
+
.trim()
|
|
375
|
+
.replace(/\s+/gu, '\\s+') +
|
|
376
|
+
'$',
|
|
377
|
+
'iu'
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
return String(name || 'Embedded Font')
|
|
381
|
+
.replace(pattern, '')
|
|
382
|
+
.trim()
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Creates a stable public filename for an embedded font payload.
|
|
387
|
+
* @param {string} name
|
|
388
|
+
* @param {string} style
|
|
389
|
+
* @returns {string}
|
|
390
|
+
*/
|
|
391
|
+
static #buildFileName(name, style) {
|
|
392
|
+
const suffix =
|
|
393
|
+
style && style !== 'Regular' ? '-' + style.replace(/\s+/gu, '') : ''
|
|
394
|
+
return (
|
|
395
|
+
String(name || 'Embedded Font')
|
|
396
|
+
.replace(/[\\/:*?"<>|]/gu, '_')
|
|
397
|
+
.trim() +
|
|
398
|
+
suffix +
|
|
399
|
+
'.ttf'
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Resolves a browser-safe font MIME type.
|
|
405
|
+
* @param {string | undefined} format
|
|
406
|
+
* @returns {string}
|
|
407
|
+
*/
|
|
408
|
+
static #resolveMimeType(format) {
|
|
409
|
+
if (format === 'opentype') {
|
|
410
|
+
return 'font/otf'
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (format === 'truetype') {
|
|
414
|
+
return 'font/ttf'
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return 'application/octet-stream'
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Deduplicates repeated font records.
|
|
422
|
+
* @param {{ name: string, style: string, compressedByteCount: number, payloadBase64: string }[]} fonts
|
|
423
|
+
* @returns {object[]}
|
|
424
|
+
*/
|
|
425
|
+
static #dedupeFonts(fonts) {
|
|
426
|
+
const seenKeys = new Set()
|
|
427
|
+
const deduped = []
|
|
428
|
+
|
|
429
|
+
for (const font of fonts) {
|
|
430
|
+
const key = [
|
|
431
|
+
font.name,
|
|
432
|
+
font.style,
|
|
433
|
+
font.compressedByteCount,
|
|
434
|
+
font.payloadBase64
|
|
435
|
+
].join('\u0000')
|
|
436
|
+
|
|
437
|
+
if (seenKeys.has(key)) {
|
|
438
|
+
continue
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
seenKeys.add(key)
|
|
442
|
+
deduped.push(font)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return deduped
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Encodes bytes as base64 in both browser and Node runtimes.
|
|
450
|
+
* @param {Uint8Array} bytes
|
|
451
|
+
* @returns {string}
|
|
452
|
+
*/
|
|
453
|
+
static #bytesToBase64(bytes) {
|
|
454
|
+
if (typeof btoa === 'function') {
|
|
455
|
+
let binary = ''
|
|
456
|
+
const chunkSize = 0x8000
|
|
457
|
+
for (
|
|
458
|
+
let offset = 0;
|
|
459
|
+
offset < bytes.byteLength;
|
|
460
|
+
offset += chunkSize
|
|
461
|
+
) {
|
|
462
|
+
binary += String.fromCharCode(
|
|
463
|
+
...bytes.subarray(offset, offset + chunkSize)
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
return btoa(binary)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return Buffer.from(bytes).toString('base64')
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Normalizes byte-like input into a Uint8Array view.
|
|
474
|
+
* @param {Uint8Array | ArrayBuffer} bytes
|
|
475
|
+
* @returns {Uint8Array}
|
|
476
|
+
*/
|
|
477
|
+
static #toUint8Array(bytes) {
|
|
478
|
+
if (bytes instanceof Uint8Array) {
|
|
479
|
+
return bytes
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return new Uint8Array(bytes || new ArrayBuffer(0))
|
|
483
|
+
}
|
|
484
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
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 fill primitive streams.
|
|
10
|
+
*/
|
|
11
|
+
export class PcbFillPrimitiveParser {
|
|
12
|
+
static #FILL_OBJECT_ID = 6
|
|
13
|
+
|
|
14
|
+
static #FILL_RECORD_BYTE_LENGTH = 55
|
|
15
|
+
|
|
16
|
+
static #FILL_PAYLOAD_MIN_BYTE_LENGTH = 50
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decodes one fill stream.
|
|
20
|
+
* @param {Uint8Array | ArrayBuffer} headerBytes
|
|
21
|
+
* @param {Uint8Array | ArrayBuffer} dataBytes
|
|
22
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, componentIndex: number | null, netIndex: number | null, polygonIndex: number | null, layerCode: number, layerId: number }[]}
|
|
23
|
+
*/
|
|
24
|
+
static parseFillStream(headerBytes, dataBytes) {
|
|
25
|
+
return PcbFillPrimitiveParser.#sliceFillRecords(
|
|
26
|
+
headerBytes,
|
|
27
|
+
dataBytes
|
|
28
|
+
).map((view) => PcbFillPrimitiveParser.#parseFillRecord(view))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Decodes one fill record view into a normalized primitive.
|
|
33
|
+
* @param {DataView} view
|
|
34
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, componentIndex: number | null, netIndex: number | null, polygonIndex: number | null, layerCode: number, layerId: number }}
|
|
35
|
+
*/
|
|
36
|
+
static #parseFillRecord(view) {
|
|
37
|
+
const ownershipIndexes =
|
|
38
|
+
PcbPrimitiveOwnershipIndexParser.readOwnershipIndexes(view, {
|
|
39
|
+
component: 12,
|
|
40
|
+
net: 8,
|
|
41
|
+
polygon: 10
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
x1: PcbFillPrimitiveParser.#readMil(view, 18),
|
|
46
|
+
y1: PcbFillPrimitiveParser.#readMil(view, 22),
|
|
47
|
+
x2: PcbFillPrimitiveParser.#readMil(view, 26),
|
|
48
|
+
y2: PcbFillPrimitiveParser.#readMil(view, 30),
|
|
49
|
+
...ownershipIndexes,
|
|
50
|
+
layerCode: view.getUint16(46, true),
|
|
51
|
+
layerId: view.getUint8(5)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Splits a fill stream into record views, preserving variable payload
|
|
57
|
+
* lengths when fills are stored with object-id and payload-length prefixes.
|
|
58
|
+
* @param {Uint8Array | ArrayBuffer} headerBytes
|
|
59
|
+
* @param {Uint8Array | ArrayBuffer} dataBytes
|
|
60
|
+
* @returns {DataView[]}
|
|
61
|
+
*/
|
|
62
|
+
static #sliceFillRecords(headerBytes, dataBytes) {
|
|
63
|
+
return PcbPrimitiveRecordSlicer.slicePrimitiveRecords({
|
|
64
|
+
headerBytes,
|
|
65
|
+
dataBytes,
|
|
66
|
+
objectId: PcbFillPrimitiveParser.#FILL_OBJECT_ID,
|
|
67
|
+
fixedRecordByteLength:
|
|
68
|
+
PcbFillPrimitiveParser.#FILL_RECORD_BYTE_LENGTH,
|
|
69
|
+
minimumPayloadByteLength:
|
|
70
|
+
PcbFillPrimitiveParser.#FILL_PAYLOAD_MIN_BYTE_LENGTH,
|
|
71
|
+
lengthPrefixedView: 'record'
|
|
72
|
+
}).map((record) => record.view)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reads one standard fixed-point mil value.
|
|
77
|
+
* @param {DataView} view
|
|
78
|
+
* @param {number} offset
|
|
79
|
+
* @returns {number}
|
|
80
|
+
*/
|
|
81
|
+
static #readMil(view, offset) {
|
|
82
|
+
return view.getInt32(offset, true) / 10000
|
|
83
|
+
}
|
|
84
|
+
}
|