altium-toolkit 1.1.3 → 1.1.23

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 (125) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +171 -23
  3. package/docs/model-format.md +192 -20
  4. package/docs/schemas/altium_toolkit/embedded_assets_a1.schema.json +56 -0
  5. package/docs/schemas/altium_toolkit/fixture_coverage_matrix_a1.schema.json +89 -0
  6. package/docs/schemas/altium_toolkit/geometry_bounds_a1.schema.json +86 -0
  7. package/docs/schemas/altium_toolkit/library_catalog_a1.schema.json +65 -0
  8. package/docs/schemas/altium_toolkit/library_diff_a1.schema.json +54 -0
  9. package/docs/schemas/altium_toolkit/library_inspection_a1.schema.json +94 -0
  10. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +4 -0
  11. package/docs/schemas/altium_toolkit/native_stream_inventory_a1.schema.json +66 -0
  12. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +513 -3
  13. package/docs/schemas/altium_toolkit/parameter_record_inventory_a1.schema.json +84 -0
  14. package/docs/schemas/altium_toolkit/parser_diagnostics_a1.schema.json +63 -0
  15. package/docs/schemas/altium_toolkit/parser_value_verification_a1.schema.json +74 -0
  16. package/docs/schemas/altium_toolkit/pcb_class_report_a1.schema.json +79 -0
  17. package/docs/schemas/altium_toolkit/pcb_inspection_a1.schema.json +65 -0
  18. package/docs/schemas/altium_toolkit/pcb_net_membership_a1.schema.json +98 -0
  19. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +3 -0
  20. package/docs/schemas/altium_toolkit/project_hierarchy_a1.schema.json +79 -0
  21. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +212 -0
  22. package/docs/testing.md +7 -0
  23. package/examples/README.md +21 -0
  24. package/examples/cli-utils.mjs +148 -0
  25. package/examples/corpus-smoke.mjs +523 -0
  26. package/examples/extract-bom.mjs +47 -0
  27. package/examples/generate-pnp.mjs +59 -0
  28. package/examples/inspect-board.mjs +70 -0
  29. package/examples/inspect-schematic.mjs +406 -0
  30. package/examples/library-catalog.mjs +115 -0
  31. package/examples/net-report.mjs +61 -0
  32. package/examples/validate-library.mjs +59 -0
  33. package/package.json +1 -1
  34. package/spec/library-scope.md +5 -0
  35. package/src/core/BinaryReader.mjs +213 -2
  36. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  37. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  38. package/src/core/altium/AltiumParser.mjs +357 -16
  39. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  40. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  41. package/src/core/altium/AltiumUnits.mjs +205 -0
  42. package/src/core/altium/AsciiRecordParser.mjs +52 -11
  43. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  44. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  45. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  46. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  47. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  48. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  49. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  50. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  51. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  52. package/src/core/altium/ParameterCollection.mjs +431 -0
  53. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  54. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  55. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  56. package/src/core/altium/ParserErrors.mjs +90 -0
  57. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  58. package/src/core/altium/ParserUtils.mjs +24 -0
  59. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  60. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  61. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  62. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  63. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  64. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  65. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  66. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  67. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  68. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  69. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  70. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  71. package/src/core/altium/PcbModelParser.mjs +223 -4
  72. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  73. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  74. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  75. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  76. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  77. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  78. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  79. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  80. package/src/core/altium/SchLibModelParser.mjs +840 -0
  81. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  82. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  83. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  84. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  85. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  86. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  87. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  88. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  89. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  90. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  91. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  92. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  93. package/src/core/altium/SchematicPinParser.mjs +99 -65
  94. package/src/core/altium/SchematicPrimitiveParser.mjs +125 -22
  95. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  96. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  97. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  98. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  99. package/src/core/altium/SchematicStreamExtractor.mjs +107 -21
  100. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  101. package/src/core/altium/SchematicTextParser.mjs +28 -12
  102. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  103. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  104. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  105. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  106. package/src/core/altium/SourceComponentClient.mjs +239 -0
  107. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  108. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  109. package/src/parser.mjs +43 -1
  110. package/src/renderers.mjs +1 -0
  111. package/src/styles/altium-renderers.css +6 -6
  112. package/src/ui/PcbArcUtils.mjs +19 -2
  113. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  114. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  115. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  116. package/src/ui/SchematicColorResolver.mjs +185 -0
  117. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  118. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  119. package/src/ui/SchematicNoteRenderer.mjs +5 -1
  120. package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
  121. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  122. package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
  123. package/src/ui/SchematicShapeRenderer.mjs +86 -17
  124. package/src/ui/SchematicSvgRenderer.mjs +980 -58
  125. package/src/ui/SchematicTypography.mjs +4 -3
@@ -0,0 +1,425 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { unzlibSync } from 'fflate'
6
+ import { OleCompoundDocument } from '../ole/OleCompoundDocument.mjs'
7
+
8
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
9
+
10
+ /**
11
+ * Extracts schematic preview thumbnail payloads from OLE-backed schematics.
12
+ */
13
+ export class SchematicThumbnailParser {
14
+ /**
15
+ * Parses thumbnail metadata from a schematic document buffer.
16
+ * @param {ArrayBuffer} arrayBuffer Source file bytes.
17
+ * @returns {{ thumbnails: object[], diagnostics: object[] }}
18
+ */
19
+ static parse(arrayBuffer) {
20
+ let oleDocument
21
+
22
+ try {
23
+ oleDocument = OleCompoundDocument.fromArrayBuffer(arrayBuffer)
24
+ } catch {
25
+ return { thumbnails: [], diagnostics: [] }
26
+ }
27
+
28
+ return SchematicThumbnailParser.parseOleDocument(oleDocument)
29
+ }
30
+
31
+ /**
32
+ * Parses thumbnail metadata from an OLE compound document.
33
+ * @param {OleCompoundDocument} oleDocument Parsed OLE document.
34
+ * @returns {{ thumbnails: object[], diagnostics: object[] }}
35
+ */
36
+ static parseOleDocument(oleDocument) {
37
+ const diagnostics = []
38
+ const thumbnails = []
39
+
40
+ for (const streamName of oleDocument.listStreams()) {
41
+ if (!SchematicThumbnailParser.#isPreviewStream(streamName)) {
42
+ continue
43
+ }
44
+
45
+ const thumbnail = SchematicThumbnailParser.#parsePreviewStream(
46
+ streamName,
47
+ oleDocument.getStream(streamName),
48
+ diagnostics
49
+ )
50
+ if (thumbnail) {
51
+ thumbnails.push(thumbnail)
52
+ }
53
+ }
54
+
55
+ return { thumbnails, diagnostics }
56
+ }
57
+
58
+ /**
59
+ * Returns true when one stream name can contain schematic preview data.
60
+ * @param {string} streamName OLE stream path.
61
+ * @returns {boolean}
62
+ */
63
+ static #isPreviewStream(streamName) {
64
+ return (
65
+ String(streamName || '')
66
+ .split('/')
67
+ .at(-1)
68
+ ?.toLowerCase() === 'preview'
69
+ )
70
+ }
71
+
72
+ /**
73
+ * Parses one preview stream.
74
+ * @param {string} sourceStream OLE stream path.
75
+ * @param {Uint8Array} bytes Stream bytes.
76
+ * @param {object[]} diagnostics Diagnostic sink.
77
+ * @returns {object | null}
78
+ */
79
+ static #parsePreviewStream(sourceStream, bytes, diagnostics) {
80
+ const fields = SchematicThumbnailParser.#parsePreviewFields(bytes)
81
+ const width = SchematicThumbnailParser.#positiveInteger(
82
+ fields.LargeImageWidth
83
+ )
84
+ const height = SchematicThumbnailParser.#positiveInteger(
85
+ fields.LargeImageHeight
86
+ )
87
+ const compressedBytes = SchematicThumbnailParser.#hexToBytes(
88
+ fields.LargeImage
89
+ )
90
+
91
+ if (!width || !height || !compressedBytes) {
92
+ diagnostics.push(
93
+ SchematicThumbnailParser.#diagnostic(
94
+ sourceStream,
95
+ 'schematic.thumbnail.preview-metadata',
96
+ 'Schematic preview metadata was incomplete.'
97
+ )
98
+ )
99
+ return null
100
+ }
101
+
102
+ let pixelBytes
103
+ try {
104
+ pixelBytes = unzlibSync(compressedBytes)
105
+ } catch {
106
+ diagnostics.push(
107
+ SchematicThumbnailParser.#diagnostic(
108
+ sourceStream,
109
+ 'schematic.thumbnail.preview-zlib',
110
+ 'Schematic preview thumbnail payload could not be decompressed.'
111
+ )
112
+ )
113
+ return null
114
+ }
115
+
116
+ if (pixelBytes.length < width * height * 4) {
117
+ diagnostics.push(
118
+ SchematicThumbnailParser.#diagnostic(
119
+ sourceStream,
120
+ 'schematic.thumbnail.preview-size',
121
+ 'Schematic preview thumbnail payload was shorter than its declared dimensions.'
122
+ )
123
+ )
124
+ return null
125
+ }
126
+
127
+ return {
128
+ kind: 'large-preview',
129
+ width,
130
+ height,
131
+ mimeType: 'image/png',
132
+ dataBase64: SchematicThumbnailParser.#encodeBase64(
133
+ SchematicThumbnailParser.#encodePreviewPng(
134
+ width,
135
+ height,
136
+ pixelBytes
137
+ )
138
+ ),
139
+ sourceStream,
140
+ pixelFormat: 'bgra32'
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Parses key-value fields from the `[Preview]` section.
146
+ * @param {Uint8Array} bytes Stream bytes.
147
+ * @returns {Record<string, string>}
148
+ */
149
+ static #parsePreviewFields(bytes) {
150
+ const text = new TextDecoder('windows-1252').decode(bytes)
151
+ const fields = {}
152
+ let inPreviewSection = false
153
+
154
+ for (const rawLine of text.split(/\r?\n/u)) {
155
+ const line = rawLine.trim()
156
+ if (!line || line.startsWith(';') || line.startsWith('#')) {
157
+ continue
158
+ }
159
+
160
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/u)
161
+ if (sectionMatch) {
162
+ inPreviewSection =
163
+ sectionMatch[1].trim().toLowerCase() === 'preview'
164
+ continue
165
+ }
166
+
167
+ if (!inPreviewSection) {
168
+ continue
169
+ }
170
+
171
+ const separatorIndex = line.indexOf('=')
172
+ if (separatorIndex <= 0) {
173
+ continue
174
+ }
175
+
176
+ fields[line.slice(0, separatorIndex).trim()] = line
177
+ .slice(separatorIndex + 1)
178
+ .trim()
179
+ }
180
+
181
+ return fields
182
+ }
183
+
184
+ /**
185
+ * Parses a positive integer field.
186
+ * @param {string | undefined} value Raw value.
187
+ * @returns {number | null}
188
+ */
189
+ static #positiveInteger(value) {
190
+ const parsed = Number.parseInt(String(value || ''), 10)
191
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null
192
+ }
193
+
194
+ /**
195
+ * Decodes a hexadecimal byte string.
196
+ * @param {string | undefined} value Hexadecimal payload.
197
+ * @returns {Uint8Array | null}
198
+ */
199
+ static #hexToBytes(value) {
200
+ const hex = String(value || '').replace(/\s+/gu, '')
201
+ if (!hex || hex.length % 2 || /[^0-9a-f]/iu.test(hex)) {
202
+ return null
203
+ }
204
+
205
+ const bytes = new Uint8Array(hex.length / 2)
206
+ for (let index = 0; index < bytes.length; index += 1) {
207
+ bytes[index] = Number.parseInt(
208
+ hex.slice(index * 2, index * 2 + 2),
209
+ 16
210
+ )
211
+ }
212
+
213
+ return bytes
214
+ }
215
+
216
+ /**
217
+ * Encodes BGRX/BGRA preview pixels into a PNG payload.
218
+ * @param {number} width Pixel width.
219
+ * @param {number} height Pixel height.
220
+ * @param {Uint8Array} pixels Source pixels.
221
+ * @returns {Uint8Array}
222
+ */
223
+ static #encodePreviewPng(width, height, pixels) {
224
+ const rowStride = width * 4
225
+ const raw = new Uint8Array((rowStride + 1) * height)
226
+
227
+ for (let y = 0; y < height; y += 1) {
228
+ const sourceY = height - y - 1
229
+ const rowOffset = y * (rowStride + 1)
230
+ raw[rowOffset] = 0
231
+
232
+ for (let x = 0; x < width; x += 1) {
233
+ const sourceOffset = (sourceY * width + x) * 4
234
+ const targetOffset = rowOffset + 1 + x * 4
235
+ raw[targetOffset] = pixels[sourceOffset + 2]
236
+ raw[targetOffset + 1] = pixels[sourceOffset + 1]
237
+ raw[targetOffset + 2] = pixels[sourceOffset]
238
+ raw[targetOffset + 3] = pixels[sourceOffset + 3] || 255
239
+ }
240
+ }
241
+
242
+ return SchematicThumbnailParser.#concatByteArrays([
243
+ Uint8Array.from(PNG_SIGNATURE),
244
+ SchematicThumbnailParser.#pngChunk(
245
+ 'IHDR',
246
+ SchematicThumbnailParser.#pngHeader(width, height)
247
+ ),
248
+ SchematicThumbnailParser.#pngChunk(
249
+ 'IDAT',
250
+ SchematicThumbnailParser.#zlibStore(raw)
251
+ ),
252
+ SchematicThumbnailParser.#pngChunk('IEND', new Uint8Array())
253
+ ])
254
+ }
255
+
256
+ /**
257
+ * Builds PNG IHDR chunk data.
258
+ * @param {number} width Pixel width.
259
+ * @param {number} height Pixel height.
260
+ * @returns {Uint8Array}
261
+ */
262
+ static #pngHeader(width, height) {
263
+ const header = new Uint8Array(13)
264
+ const view = new DataView(header.buffer)
265
+
266
+ view.setUint32(0, width, false)
267
+ view.setUint32(4, height, false)
268
+ header[8] = 8
269
+ header[9] = 6
270
+
271
+ return header
272
+ }
273
+
274
+ /**
275
+ * Builds one PNG chunk.
276
+ * @param {string} type Four-byte chunk type.
277
+ * @param {Uint8Array} data Chunk data.
278
+ * @returns {Uint8Array}
279
+ */
280
+ static #pngChunk(type, data) {
281
+ const typeBytes = new TextEncoder().encode(type)
282
+ const chunk = new Uint8Array(12 + data.length)
283
+ const view = new DataView(chunk.buffer)
284
+
285
+ view.setUint32(0, data.length, false)
286
+ chunk.set(typeBytes, 4)
287
+ chunk.set(data, 8)
288
+ view.setUint32(
289
+ 8 + data.length,
290
+ SchematicThumbnailParser.#crc32(
291
+ SchematicThumbnailParser.#concatByteArrays([typeBytes, data])
292
+ ),
293
+ false
294
+ )
295
+
296
+ return chunk
297
+ }
298
+
299
+ /**
300
+ * Encodes raw scanlines as a stored zlib stream.
301
+ * @param {Uint8Array} bytes Raw scanline bytes.
302
+ * @returns {Uint8Array}
303
+ */
304
+ static #zlibStore(bytes) {
305
+ const blockCount = Math.max(1, Math.ceil(bytes.length / 65535))
306
+ const output = new Uint8Array(2 + blockCount * 5 + bytes.length + 4)
307
+ const view = new DataView(output.buffer)
308
+ let outputOffset = 0
309
+ let inputOffset = 0
310
+
311
+ output[outputOffset] = 0x78
312
+ output[outputOffset + 1] = 0x01
313
+ outputOffset += 2
314
+
315
+ for (let blockIndex = 0; blockIndex < blockCount; blockIndex += 1) {
316
+ const remaining = bytes.length - inputOffset
317
+ const blockLength = Math.min(remaining, 65535)
318
+ output[outputOffset] = blockIndex === blockCount - 1 ? 0x01 : 0x00
319
+ view.setUint16(outputOffset + 1, blockLength, true)
320
+ view.setUint16(outputOffset + 3, 0xffff ^ blockLength, true)
321
+ outputOffset += 5
322
+ output.set(
323
+ bytes.slice(inputOffset, inputOffset + blockLength),
324
+ outputOffset
325
+ )
326
+ outputOffset += blockLength
327
+ inputOffset += blockLength
328
+ }
329
+
330
+ view.setUint32(
331
+ outputOffset,
332
+ SchematicThumbnailParser.#adler32(bytes),
333
+ false
334
+ )
335
+
336
+ return output
337
+ }
338
+
339
+ /**
340
+ * Computes an Adler-32 checksum.
341
+ * @param {Uint8Array} bytes Bytes to checksum.
342
+ * @returns {number}
343
+ */
344
+ static #adler32(bytes) {
345
+ let a = 1
346
+ let b = 0
347
+
348
+ for (const byte of bytes) {
349
+ a = (a + byte) % 65521
350
+ b = (b + a) % 65521
351
+ }
352
+
353
+ return ((b << 16) | a) >>> 0
354
+ }
355
+
356
+ /**
357
+ * Computes a PNG-compatible CRC-32 checksum.
358
+ * @param {Uint8Array} bytes Bytes to checksum.
359
+ * @returns {number}
360
+ */
361
+ static #crc32(bytes) {
362
+ let crc = 0xffffffff
363
+
364
+ for (const byte of bytes) {
365
+ crc ^= byte
366
+ for (let bit = 0; bit < 8; bit += 1) {
367
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0)
368
+ }
369
+ }
370
+
371
+ return (crc ^ 0xffffffff) >>> 0
372
+ }
373
+
374
+ /**
375
+ * Concatenates byte arrays.
376
+ * @param {Uint8Array[]} chunks Byte arrays.
377
+ * @returns {Uint8Array}
378
+ */
379
+ static #concatByteArrays(chunks) {
380
+ const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
381
+ const output = new Uint8Array(totalLength)
382
+ let offset = 0
383
+
384
+ for (const chunk of chunks) {
385
+ output.set(chunk, offset)
386
+ offset += chunk.length
387
+ }
388
+
389
+ return output
390
+ }
391
+
392
+ /**
393
+ * Encodes one byte array as base64 in browser and Node runtimes.
394
+ * @param {Uint8Array} bytes Source bytes.
395
+ * @returns {string}
396
+ */
397
+ static #encodeBase64(bytes) {
398
+ if (typeof Buffer !== 'undefined') {
399
+ return Buffer.from(bytes).toString('base64')
400
+ }
401
+
402
+ let binary = ''
403
+ for (const byte of bytes) {
404
+ binary += String.fromCharCode(byte)
405
+ }
406
+
407
+ return btoa(binary)
408
+ }
409
+
410
+ /**
411
+ * Builds one parser diagnostic.
412
+ * @param {string} sourceStream Source stream name.
413
+ * @param {string} code Stable diagnostic code.
414
+ * @param {string} message User-facing diagnostic message.
415
+ * @returns {{ code: string, severity: string, sourceStream: string, message: string }}
416
+ */
417
+ static #diagnostic(sourceStream, code, message) {
418
+ return {
419
+ code,
420
+ severity: 'warning',
421
+ sourceStream,
422
+ message
423
+ }
424
+ }
425
+ }
@@ -0,0 +1,156 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SourceComponentBundleNormalizer } from './SourceComponentBundleNormalizer.mjs'
6
+
7
+ /**
8
+ * Exports normalized source component bundles as deterministic file entries.
9
+ */
10
+ export class SourceBundleExporter {
11
+ /**
12
+ * Exports one source bundle.
13
+ * @param {object} componentBundle Component bundle or raw response.
14
+ * @param {{ includeModels?: boolean }} [options] Export options.
15
+ * @returns {{ manifest: object, entries: { path: string, bytes: Uint8Array, contentType: string }[] }}
16
+ */
17
+ static export(componentBundle, options = {}) {
18
+ const bundle =
19
+ SourceComponentBundleNormalizer.normalize(componentBundle)
20
+ const includeModels = options.includeModels !== false
21
+ const manifest = SourceBundleExporter.#buildManifest(
22
+ bundle,
23
+ includeModels
24
+ )
25
+ const entries = [
26
+ SourceBundleExporter.#jsonEntry('manifest.json', manifest),
27
+ SourceBundleExporter.#jsonEntry(
28
+ 'source/source.json',
29
+ bundle.sourceJson
30
+ )
31
+ ]
32
+
33
+ if (includeModels) {
34
+ entries.push(
35
+ ...bundle.models.map((model) => ({
36
+ path:
37
+ 'models/' +
38
+ SourceBundleExporter.#safeFileName(model.name),
39
+ bytes: model.bytes,
40
+ contentType: SourceBundleExporter.#modelContentType(model)
41
+ }))
42
+ )
43
+ }
44
+
45
+ return {
46
+ manifest,
47
+ entries: entries.sort((left, right) =>
48
+ left.path.localeCompare(right.path)
49
+ )
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Builds a deterministic manifest.
55
+ * @param {object} bundle Normalized bundle.
56
+ * @param {boolean} includeModels Whether model assets are included.
57
+ * @returns {object}
58
+ */
59
+ static #buildManifest(bundle, includeModels) {
60
+ return {
61
+ schema: 'ecad-source-bundle-v1',
62
+ component: {
63
+ id: bundle.id,
64
+ name: bundle.name,
65
+ symbolName: bundle.symbol.name,
66
+ footprintName: bundle.footprint.name
67
+ },
68
+ assets: includeModels
69
+ ? bundle.models.map((model) => ({
70
+ name: model.name,
71
+ format: model.format,
72
+ path:
73
+ 'models/' +
74
+ SourceBundleExporter.#safeFileName(model.name),
75
+ byteLength: model.bytes.byteLength
76
+ }))
77
+ : []
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Creates one JSON entry.
83
+ * @param {string} path Entry path.
84
+ * @param {object} value JSON value.
85
+ * @returns {{ path: string, bytes: Uint8Array, contentType: string }}
86
+ */
87
+ static #jsonEntry(path, value) {
88
+ return {
89
+ path,
90
+ bytes: new TextEncoder().encode(
91
+ SourceBundleExporter.#stableStringify(value) + '\n'
92
+ ),
93
+ contentType: 'application/json'
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Returns a stable JSON string with sorted object keys.
99
+ * @param {any} value JSON-compatible value.
100
+ * @returns {string}
101
+ */
102
+ static #stableStringify(value) {
103
+ return JSON.stringify(SourceBundleExporter.#sortJson(value), null, 2)
104
+ }
105
+
106
+ /**
107
+ * Recursively sorts JSON object keys.
108
+ * @param {any} value JSON-compatible value.
109
+ * @returns {any}
110
+ */
111
+ static #sortJson(value) {
112
+ if (Array.isArray(value)) {
113
+ return value.map((entry) => SourceBundleExporter.#sortJson(entry))
114
+ }
115
+
116
+ if (
117
+ value &&
118
+ typeof value === 'object' &&
119
+ !(value instanceof Uint8Array)
120
+ ) {
121
+ return Object.fromEntries(
122
+ Object.keys(value)
123
+ .sort()
124
+ .map((key) => [
125
+ key,
126
+ SourceBundleExporter.#sortJson(value[key])
127
+ ])
128
+ )
129
+ }
130
+
131
+ return value
132
+ }
133
+
134
+ /**
135
+ * Returns a safe bundle file name.
136
+ * @param {string} name Raw file name.
137
+ * @returns {string}
138
+ */
139
+ static #safeFileName(name) {
140
+ return String(name || 'model.step').replace(
141
+ /[\\/:\u0000-\u001f]/gu,
142
+ '_'
143
+ )
144
+ }
145
+
146
+ /**
147
+ * Resolves a model content type.
148
+ * @param {object} model Model descriptor.
149
+ * @returns {string}
150
+ */
151
+ static #modelContentType(model) {
152
+ if (model.format === 'obj') return 'model/obj'
153
+ if (model.format === 'mtl') return 'text/plain'
154
+ return 'model/step'
155
+ }
156
+ }