altium-toolkit 1.1.2 → 1.1.22

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 (57) hide show
  1. package/docs/api.md +37 -0
  2. package/docs/model-format.md +18 -0
  3. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +2 -2
  4. package/docs/testing.md +5 -0
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +5 -0
  7. package/src/core/altium/AltiumLayoutParser.mjs +275 -13
  8. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  9. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  10. package/src/core/altium/AltiumParser.mjs +245 -10
  11. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  12. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  13. package/src/core/altium/AsciiRecordParser.mjs +43 -11
  14. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  15. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
  16. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  17. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  18. package/src/core/altium/PrintableTextDecoder.mjs +133 -13
  19. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
  20. package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
  21. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  22. package/src/core/altium/SchematicImageParser.mjs +291 -6
  23. package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
  24. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
  25. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  26. package/src/core/altium/SchematicPinParser.mjs +262 -24
  27. package/src/core/altium/SchematicPrimitiveParser.mjs +116 -8
  28. package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
  29. package/src/core/altium/SchematicStreamExtractor.mjs +62 -15
  30. package/src/core/altium/SchematicTextParser.mjs +125 -11
  31. package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
  32. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  33. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  34. package/src/core/altium/SourceComponentClient.mjs +239 -0
  35. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  36. package/src/parser.mjs +8 -0
  37. package/src/styles/altium-renderers.css +6 -6
  38. package/src/ui/PcbArcUtils.mjs +19 -2
  39. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  40. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  41. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  42. package/src/ui/SchematicColorResolver.mjs +263 -0
  43. package/src/ui/SchematicContentLayout.mjs +58 -1
  44. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  45. package/src/ui/SchematicImageRenderer.mjs +125 -10
  46. package/src/ui/SchematicJunctionRenderer.mjs +1 -1
  47. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  48. package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
  49. package/src/ui/SchematicNoteRenderer.mjs +87 -7
  50. package/src/ui/SchematicOwnerPinLabelLayout.mjs +560 -10
  51. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  52. package/src/ui/SchematicPinSvgRenderer.mjs +397 -48
  53. package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
  54. package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
  55. package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
  56. package/src/ui/SchematicShapeRenderer.mjs +109 -24
  57. package/src/ui/SchematicSvgRenderer.mjs +1210 -71
@@ -0,0 +1,101 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { OleCompoundDocumentWriter } from '../ole/OleCompoundDocumentWriter.mjs'
6
+ import { AltiumLibraryRecordBuilder } from './AltiumLibraryRecordBuilder.mjs'
7
+ import { SourceComponentBundleNormalizer } from './SourceComponentBundleNormalizer.mjs'
8
+
9
+ /**
10
+ * Exports normalized bundles into a compact OLE-backed footprint library.
11
+ */
12
+ export class AltiumPcbLibExporter {
13
+ /**
14
+ * Exports one or more component bundles as `.PcbLib` bytes.
15
+ * @param {object[] | object} bundles Component bundles.
16
+ * @returns {Uint8Array}
17
+ */
18
+ static export(bundles) {
19
+ const normalizedBundles =
20
+ AltiumPcbLibExporter.#normalizeBundles(bundles)
21
+ const streams = new Map()
22
+ const modelRows = AltiumPcbLibExporter.#collectModels(normalizedBundles)
23
+
24
+ streams.set(
25
+ 'Library/Data',
26
+ AltiumLibraryRecordBuilder.buildPcbLibraryData(normalizedBundles)
27
+ )
28
+ streams.set(
29
+ 'Library/ComponentParamsTOC/Data',
30
+ AltiumLibraryRecordBuilder.buildComponentParamsToc(
31
+ normalizedBundles
32
+ )
33
+ )
34
+ streams.set(
35
+ 'SectionKeys',
36
+ AltiumLibraryRecordBuilder.buildSectionKeys(normalizedBundles)
37
+ )
38
+
39
+ for (const bundle of normalizedBundles) {
40
+ const storageName = AltiumLibraryRecordBuilder.sanitizeStorageName(
41
+ bundle.footprint.name
42
+ )
43
+ streams.set(
44
+ storageName + '/Header',
45
+ AltiumLibraryRecordBuilder.createCountHeader(0)
46
+ )
47
+ streams.set(
48
+ storageName + '/Parameters',
49
+ AltiumLibraryRecordBuilder.buildFootprintParameters(bundle)
50
+ )
51
+ streams.set(
52
+ storageName + '/Data',
53
+ AltiumLibraryRecordBuilder.buildFootprintData(bundle)
54
+ )
55
+ streams.set(
56
+ storageName + '/SourceRecord',
57
+ new TextEncoder().encode(
58
+ AltiumLibraryRecordBuilder.buildPcbFootprintRecord(bundle)
59
+ )
60
+ )
61
+ }
62
+
63
+ if (modelRows.length) {
64
+ streams.set(
65
+ 'Models/Data',
66
+ AltiumLibraryRecordBuilder.buildModelsData(modelRows)
67
+ )
68
+ modelRows.forEach((row, index) => {
69
+ streams.set('Models/' + index, row.model.bytes)
70
+ })
71
+ }
72
+
73
+ return OleCompoundDocumentWriter.write({ streams })
74
+ }
75
+
76
+ /**
77
+ * Collects model rows with deterministic generated ids.
78
+ * @param {object[]} bundles Normalized bundles.
79
+ * @returns {{ model: object, id: string, checksum: number }[]}
80
+ */
81
+ static #collectModels(bundles) {
82
+ return bundles.flatMap((bundle, bundleIndex) =>
83
+ bundle.models.map((model, modelIndex) => ({
84
+ model,
85
+ id: 'model-' + bundleIndex + '-' + modelIndex,
86
+ checksum: AltiumLibraryRecordBuilder.checksumBytes(model.bytes)
87
+ }))
88
+ )
89
+ }
90
+
91
+ /**
92
+ * Normalizes one or more bundles.
93
+ * @param {object[] | object} bundles Bundle input.
94
+ * @returns {object[]}
95
+ */
96
+ static #normalizeBundles(bundles) {
97
+ return (Array.isArray(bundles) ? bundles : [bundles]).map((bundle) =>
98
+ SourceComponentBundleNormalizer.normalize(bundle)
99
+ )
100
+ }
101
+ }
@@ -0,0 +1,57 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { OleCompoundDocumentWriter } from '../ole/OleCompoundDocumentWriter.mjs'
6
+ import { AltiumLibraryRecordBuilder } from './AltiumLibraryRecordBuilder.mjs'
7
+ import { SourceComponentBundleNormalizer } from './SourceComponentBundleNormalizer.mjs'
8
+
9
+ /**
10
+ * Exports normalized bundles into a compact OLE-backed schematic library.
11
+ */
12
+ export class AltiumSchLibExporter {
13
+ /**
14
+ * Exports one or more component bundles as `.SchLib` bytes.
15
+ * @param {object[] | object} bundles Component bundles.
16
+ * @returns {Uint8Array}
17
+ */
18
+ static export(bundles) {
19
+ const normalizedBundles =
20
+ AltiumSchLibExporter.#normalizeBundles(bundles)
21
+ const streams = new Map()
22
+ const libraryRecord = normalizedBundles
23
+ .map((bundle) =>
24
+ AltiumLibraryRecordBuilder.buildSchematicComponentRecord(bundle)
25
+ )
26
+ .join('\n')
27
+
28
+ streams.set('Library/Data', new TextEncoder().encode(libraryRecord))
29
+ for (const bundle of normalizedBundles) {
30
+ streams.set(
31
+ 'Components/' +
32
+ AltiumLibraryRecordBuilder.sanitizeStorageName(
33
+ bundle.symbol.name
34
+ ) +
35
+ '/Data',
36
+ new TextEncoder().encode(
37
+ AltiumLibraryRecordBuilder.buildSchematicComponentRecord(
38
+ bundle
39
+ )
40
+ )
41
+ )
42
+ }
43
+
44
+ return OleCompoundDocumentWriter.write({ streams })
45
+ }
46
+
47
+ /**
48
+ * Normalizes one or more bundles.
49
+ * @param {object[] | object} bundles Bundle input.
50
+ * @returns {object[]}
51
+ */
52
+ static #normalizeBundles(bundles) {
53
+ return (Array.isArray(bundles) ? bundles : [bundles]).map((bundle) =>
54
+ SourceComponentBundleNormalizer.normalize(bundle)
55
+ )
56
+ }
57
+ }
@@ -146,17 +146,11 @@ export class AsciiRecordParser {
146
146
  const rawKey = AsciiRecordParser.#trimAscii(
147
147
  segment.slice(0, separatorIndex)
148
148
  )
149
- const value = PrintableTextDecoder.decodeBytes(
150
- AsciiRecordParser.#binaryStringToBytes(
151
- AsciiRecordParser.#trimAscii(
152
- segment.slice(separatorIndex + 1)
153
- )
154
- ),
155
- {
156
- encoding: rawKey.startsWith('%UTF8%') ? 'utf-8' : undefined
157
- }
158
- )
159
149
  const isUtf8Field = rawKey.startsWith('%UTF8%')
150
+ const value = AsciiRecordParser.#decodeFieldValue(
151
+ AsciiRecordParser.#trimAscii(segment.slice(separatorIndex + 1)),
152
+ isUtf8Field ? 'utf-8' : ''
153
+ )
160
154
  const key = rawKey.replace(/^%UTF8%/, '')
161
155
  if (!key) continue
162
156
 
@@ -228,13 +222,51 @@ export class AsciiRecordParser {
228
222
  return normalizedKeyIndex
229
223
  }
230
224
 
225
+ /**
226
+ * Decodes one pipe-delimited field value from the byte-preserving run
227
+ * string. Plain ASCII is already decoded by construction and can avoid
228
+ * byte-array allocation plus TextDecoder fallback probing.
229
+ * @param {string} value Byte-preserving field value.
230
+ * @param {string} preferredEncoding Optional preferred decoder encoding.
231
+ * @returns {string}
232
+ */
233
+ static #decodeFieldValue(value, preferredEncoding) {
234
+ if (!AsciiRecordParser.#hasExtendedByte(value)) {
235
+ return value
236
+ }
237
+
238
+ return PrintableTextDecoder.decodeBytes(
239
+ AsciiRecordParser.#binaryStringToBytes(value),
240
+ { encoding: preferredEncoding || undefined }
241
+ )
242
+ }
243
+
231
244
  /**
232
245
  * Converts one binary string into bytes without altering byte values.
233
246
  * @param {string} value
234
247
  * @returns {Uint8Array}
235
248
  */
236
249
  static #binaryStringToBytes(value) {
237
- return Uint8Array.from(value, (character) => character.charCodeAt(0))
250
+ const bytes = new Uint8Array(value.length)
251
+
252
+ for (let index = 0; index < value.length; index += 1) {
253
+ bytes[index] = value.charCodeAt(index) & 0xff
254
+ }
255
+
256
+ return bytes
257
+ }
258
+
259
+ /**
260
+ * Returns true when the byte-preserving string contains non-ASCII bytes.
261
+ * @param {string} value Field value.
262
+ * @returns {boolean}
263
+ */
264
+ static #hasExtendedByte(value) {
265
+ for (let index = 0; index < value.length; index += 1) {
266
+ if (value.charCodeAt(index) > 0x7f) return true
267
+ }
268
+
269
+ return false
238
270
  }
239
271
 
240
272
  /**
@@ -12,49 +12,49 @@ const KIND_BY_VALUE = {
12
12
  displayName: 'Standard',
13
13
  includeInBom: true,
14
14
  includeInNetlist: true,
15
- includeInPnp: true
15
+ includeInPlacement: true
16
16
  },
17
17
  1: {
18
18
  name: 'mechanical',
19
19
  displayName: 'Mechanical',
20
20
  includeInBom: true,
21
21
  includeInNetlist: true,
22
- includeInPnp: true
22
+ includeInPlacement: true
23
23
  },
24
24
  2: {
25
25
  name: 'graphical',
26
26
  displayName: 'Graphical',
27
27
  includeInBom: false,
28
28
  includeInNetlist: false,
29
- includeInPnp: false
29
+ includeInPlacement: false
30
30
  },
31
31
  3: {
32
32
  name: 'net-tie-bom',
33
33
  displayName: 'Net Tie BOM',
34
34
  includeInBom: true,
35
35
  includeInNetlist: true,
36
- includeInPnp: true
36
+ includeInPlacement: true
37
37
  },
38
38
  4: {
39
39
  name: 'net-tie-no-bom',
40
40
  displayName: 'Net Tie No BOM',
41
41
  includeInBom: false,
42
42
  includeInNetlist: true,
43
- includeInPnp: true
43
+ includeInPlacement: true
44
44
  },
45
45
  5: {
46
46
  name: 'standard-no-bom',
47
47
  displayName: 'Standard No BOM',
48
48
  includeInBom: false,
49
49
  includeInNetlist: true,
50
- includeInPnp: true
50
+ includeInPlacement: true
51
51
  },
52
52
  6: {
53
53
  name: 'jumper',
54
54
  displayName: 'Jumper',
55
55
  includeInBom: true,
56
56
  includeInNetlist: true,
57
- includeInPnp: true
57
+ includeInPlacement: true
58
58
  }
59
59
  }
60
60
 
@@ -65,7 +65,7 @@ export class PcbComponentKindPolicy {
65
65
  /**
66
66
  * Parses native versioned component-kind fields.
67
67
  * @param {Record<string, string | string[]>} fields Native component row.
68
- * @returns {{ value: number, name: string, displayName: string, includeInBom: boolean, includeInNetlist: boolean, includeInPnp: boolean } | undefined}
68
+ * @returns {{ value: number, name: string, displayName: string, includeInBom: boolean, includeInNetlist: boolean, includeInPlacement: boolean } | undefined}
69
69
  */
70
70
  static parse(fields) {
71
71
  if (!PcbComponentKindPolicy.#hasKindField(fields)) return undefined
@@ -76,7 +76,7 @@ export class PcbComponentKindPolicy {
76
76
  displayName: 'Unknown',
77
77
  includeInBom: true,
78
78
  includeInNetlist: true,
79
- includeInPnp: true
79
+ includeInPlacement: true
80
80
  }
81
81
 
82
82
  return {
@@ -2,13 +2,16 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
- import { unzlibSync } from 'fflate'
5
+ import { Unzlib } from 'fflate'
6
6
  import { PcbFontMetricsParser } from './PcbFontMetricsParser.mjs'
7
7
 
8
8
  /**
9
9
  * Extracts zlib-compressed embedded font payloads from PCB compound streams.
10
10
  */
11
11
  export class PcbEmbeddedFontExtractor {
12
+ static #BASE64_ALPHABET =
13
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
14
+
12
15
  static #CANDIDATE_STREAM_NAMES = [
13
16
  'EmbeddedFonts6/Data',
14
17
  'EmbeddedFonts/Data',
@@ -114,16 +117,19 @@ export class PcbEmbeddedFontExtractor {
114
117
  return null
115
118
  }
116
119
 
117
- const compressedEnd = PcbEmbeddedFontExtractor.#findCompressedEnd(
120
+ const payload = PcbEmbeddedFontExtractor.#inflateZlibPayloadAt(
118
121
  bytes,
119
122
  zlibOffset
120
123
  )
121
- if (compressedEnd <= zlibOffset) {
124
+ if (!payload) {
122
125
  return null
123
126
  }
124
127
 
125
- const compressedBytes = bytes.subarray(zlibOffset, compressedEnd)
126
- const payloadBytes = unzlibSync(compressedBytes)
128
+ const compressedBytes = bytes.subarray(
129
+ zlibOffset,
130
+ payload.compressedEnd
131
+ )
132
+ const payloadBytes = payload.bytes
127
133
  const metadata = PcbEmbeddedFontExtractor.#normalizeFontMetadata(
128
134
  familyField.text,
129
135
  alternateField.text,
@@ -151,7 +157,7 @@ export class PcbEmbeddedFontExtractor {
151
157
  PcbEmbeddedFontExtractor.#bytesToBase64(payloadBytes),
152
158
  metrics
153
159
  },
154
- nextOffset: compressedEnd
160
+ nextOffset: payload.compressedEnd
155
161
  }
156
162
  }
157
163
 
@@ -224,48 +230,146 @@ export class PcbEmbeddedFontExtractor {
224
230
  }
225
231
 
226
232
  /**
227
- * Finds the smallest trailing offset that fully contains a zlib payload.
233
+ * Inflates one zlib payload and returns its exact stream boundary.
228
234
  * @param {Uint8Array} bytes
229
235
  * @param {number} zlibOffset
236
+ * @returns {{ bytes: Uint8Array, compressedEnd: number } | null}
237
+ */
238
+ static #inflateZlibPayloadAt(bytes, zlibOffset) {
239
+ const input = bytes.subarray(zlibOffset)
240
+ const chunks = []
241
+ let inflater
242
+
243
+ try {
244
+ inflater = new Unzlib((chunk) => {
245
+ chunks.push(chunk)
246
+ })
247
+ inflater.push(input, false)
248
+ } catch {
249
+ return null
250
+ }
251
+
252
+ if (!Number(inflater?.s?.f || 0)) {
253
+ return null
254
+ }
255
+
256
+ const payloadBytes = PcbEmbeddedFontExtractor.#concatBytes(chunks)
257
+ const compressedByteCount =
258
+ PcbEmbeddedFontExtractor.#resolveCompressedByteCount(
259
+ input,
260
+ inflater,
261
+ payloadBytes
262
+ )
263
+
264
+ if (compressedByteCount <= 2) {
265
+ return null
266
+ }
267
+
268
+ return {
269
+ bytes: payloadBytes,
270
+ compressedEnd: zlibOffset + compressedByteCount
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Resolves the zlib stream length from fflate's remaining input buffer.
276
+ * @param {Uint8Array} input
277
+ * @param {Unzlib} inflater
278
+ * @param {Uint8Array} payloadBytes
230
279
  * @returns {number}
231
280
  */
232
- static #findCompressedEnd(bytes, zlibOffset) {
233
- let low = zlibOffset + 2
234
- let high = bytes.byteLength
281
+ static #resolveCompressedByteCount(input, inflater, payloadBytes) {
282
+ const remainingByteCount = Number(inflater?.p?.byteLength || 0)
283
+ if (remainingByteCount < 4) {
284
+ return -1
285
+ }
286
+
287
+ const baseByteCount = input.byteLength - remainingByteCount + 4
288
+ const checksum = PcbEmbeddedFontExtractor.#adler32(payloadBytes)
235
289
 
236
- while (low < high) {
237
- const midpoint = Math.floor((low + high) / 2)
290
+ // fflate can leave the final consumed deflate byte in `p` when the
291
+ // stream ends mid-byte, so validate both adjacent boundary candidates.
292
+ for (const compressedByteCount of [baseByteCount, baseByteCount + 1]) {
238
293
  if (
239
- PcbEmbeddedFontExtractor.#canInflate(
240
- bytes.subarray(zlibOffset, midpoint)
294
+ PcbEmbeddedFontExtractor.#hasZlibChecksumAt(
295
+ input,
296
+ compressedByteCount,
297
+ checksum
241
298
  )
242
299
  ) {
243
- high = midpoint
244
- } else {
245
- low = midpoint + 1
300
+ return compressedByteCount
246
301
  }
247
302
  }
248
303
 
249
- return PcbEmbeddedFontExtractor.#canInflate(
250
- bytes.subarray(zlibOffset, low)
251
- )
252
- ? low
253
- : -1
304
+ return -1
254
305
  }
255
306
 
256
307
  /**
257
- * Returns true when one byte slice can be inflated as a complete zlib
258
- * stream.
259
- * @param {Uint8Array} bytes
308
+ * Returns true when a candidate zlib boundary ends with the checksum.
309
+ * @param {Uint8Array} input
310
+ * @param {number} compressedByteCount
311
+ * @param {number} checksum
260
312
  * @returns {boolean}
261
313
  */
262
- static #canInflate(bytes) {
263
- try {
264
- unzlibSync(bytes)
265
- return true
266
- } catch {
314
+ static #hasZlibChecksumAt(input, compressedByteCount, checksum) {
315
+ if (compressedByteCount < 6 || compressedByteCount > input.byteLength) {
267
316
  return false
268
317
  }
318
+
319
+ const checksumOffset = compressedByteCount - 4
320
+ const actualChecksum = new DataView(
321
+ input.buffer,
322
+ input.byteOffset + checksumOffset,
323
+ 4
324
+ ).getUint32(0, false)
325
+
326
+ return actualChecksum === checksum
327
+ }
328
+
329
+ /**
330
+ * Computes the Adler-32 checksum used by zlib trailers.
331
+ * @param {Uint8Array} bytes
332
+ * @returns {number}
333
+ */
334
+ static #adler32(bytes) {
335
+ const modulo = 65521
336
+ let low = 1
337
+ let high = 0
338
+
339
+ for (let offset = 0; offset < bytes.byteLength; offset += 5552) {
340
+ const end = Math.min(offset + 5552, bytes.byteLength)
341
+ for (let index = offset; index < end; index += 1) {
342
+ low += bytes[index]
343
+ high += low
344
+ }
345
+ low %= modulo
346
+ high %= modulo
347
+ }
348
+
349
+ return ((high << 16) | low) >>> 0
350
+ }
351
+
352
+ /**
353
+ * Concatenates inflated output chunks.
354
+ * @param {Uint8Array[]} chunks
355
+ * @returns {Uint8Array}
356
+ */
357
+ static #concatBytes(chunks) {
358
+ if (chunks.length === 1) {
359
+ return chunks[0]
360
+ }
361
+
362
+ const bytes = new Uint8Array(
363
+ chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
364
+ )
365
+ let offset = 0
366
+
367
+ for (const chunk of chunks) {
368
+ bytes.set(chunk, offset)
369
+ offset += chunk.byteLength
370
+ }
371
+
372
+ return bytes
269
373
  }
270
374
 
271
375
  /**
@@ -451,22 +555,61 @@ export class PcbEmbeddedFontExtractor {
451
555
  * @returns {string}
452
556
  */
453
557
  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
- )
558
+ if (typeof Buffer === 'function' && typeof Buffer.from === 'function') {
559
+ return Buffer.from(bytes).toString('base64')
560
+ }
561
+
562
+ return PcbEmbeddedFontExtractor.#bytesToBase64Portable(bytes)
563
+ }
564
+
565
+ /**
566
+ * Encodes bytes as base64 without relying on Node APIs.
567
+ * @param {Uint8Array} bytes
568
+ * @returns {string}
569
+ */
570
+ static #bytesToBase64Portable(bytes) {
571
+ const alphabet = PcbEmbeddedFontExtractor.#BASE64_ALPHABET
572
+ const groupBuffer = new Array(4096)
573
+ const outputChunks = []
574
+ let groupIndex = 0
575
+ let byteIndex = 0
576
+
577
+ for (; byteIndex + 2 < bytes.byteLength; byteIndex += 3) {
578
+ const value =
579
+ (bytes[byteIndex] << 16) |
580
+ (bytes[byteIndex + 1] << 8) |
581
+ bytes[byteIndex + 2]
582
+ groupBuffer[groupIndex] =
583
+ alphabet[(value >> 18) & 63] +
584
+ alphabet[(value >> 12) & 63] +
585
+ alphabet[(value >> 6) & 63] +
586
+ alphabet[value & 63]
587
+ groupIndex += 1
588
+
589
+ if (groupIndex === groupBuffer.length) {
590
+ outputChunks.push(groupBuffer.join(''))
591
+ groupIndex = 0
465
592
  }
466
- return btoa(binary)
467
593
  }
468
594
 
469
- return Buffer.from(bytes).toString('base64')
595
+ if (byteIndex < bytes.byteLength) {
596
+ const hasSecondByte = byteIndex + 1 < bytes.byteLength
597
+ const value =
598
+ (bytes[byteIndex] << 16) |
599
+ ((hasSecondByte ? bytes[byteIndex + 1] : 0) << 8)
600
+ groupBuffer[groupIndex] =
601
+ alphabet[(value >> 18) & 63] +
602
+ alphabet[(value >> 12) & 63] +
603
+ (hasSecondByte ? alphabet[(value >> 6) & 63] : '=') +
604
+ '='
605
+ groupIndex += 1
606
+ }
607
+
608
+ if (groupIndex > 0) {
609
+ outputChunks.push(groupBuffer.slice(0, groupIndex).join(''))
610
+ }
611
+
612
+ return outputChunks.join('')
470
613
  }
471
614
 
472
615
  /**
@@ -20,14 +20,16 @@ export class PcbEmbeddedModelExtractor {
20
20
  * @returns {{ models: { id: string, checksum: number, name: string, format: string, payloadText: string, sourceStream: string, transform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number } }[], componentBodies: { sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[] }}
21
21
  */
22
22
  static extractFromStreams(streams) {
23
+ const modelStreamPrefix =
24
+ PcbEmbeddedModelExtractor.#resolveModelStreamPrefix(streams)
23
25
  const modelMetadataRecords =
24
26
  PcbEmbeddedModelExtractor.#parseModelMetadataStream(
25
- streams.get('Models/Data')
27
+ streams.get(modelStreamPrefix + '/Data')
26
28
  )
27
29
  const modelMetadataRows = modelMetadataRecords.map((fields, index) => ({
28
30
  fields,
29
31
  index,
30
- sourceStream: 'Models/' + index,
32
+ sourceStream: modelStreamPrefix + '/' + index,
31
33
  id: PcbEmbeddedModelExtractor.#getField(fields, 'ID'),
32
34
  name: PcbEmbeddedModelExtractor.#getField(fields, 'NAME'),
33
35
  checksum: PcbEmbeddedModelExtractor.#normalizeChecksum(
@@ -70,7 +72,24 @@ export class PcbEmbeddedModelExtractor {
70
72
  }
71
73
 
72
74
  /**
73
- * Parses the length-prefixed `Models/Data` metadata stream.
75
+ * Resolves the embedded-model stream folder used by the compound document.
76
+ * @param {Map<string, Uint8Array>} streams
77
+ * @returns {string}
78
+ */
79
+ static #resolveModelStreamPrefix(streams) {
80
+ if (streams.has('Models/Data')) {
81
+ return 'Models'
82
+ }
83
+
84
+ if (streams.has('Library/Models/Data')) {
85
+ return 'Library/Models'
86
+ }
87
+
88
+ return 'Models'
89
+ }
90
+
91
+ /**
92
+ * Parses the length-prefixed model metadata stream.
74
93
  * @param {Uint8Array | undefined} bytes
75
94
  * @returns {Record<string, string | string[]>[]}
76
95
  */