altium-toolkit 1.1.30 → 1.1.32

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.
@@ -0,0 +1,433 @@
1
+ import { unzlibSync } from 'fflate'
2
+ import { OleCompoundDocument } from '../ole/OleCompoundDocument.mjs'
3
+
4
+ const MISSING_IMAGE_MESSAGE =
5
+ 'Embedded schematic image payload could not be resolved for '
6
+
7
+ /**
8
+ * Recovers Altium schematic image payloads from packed OLE Storage entries.
9
+ */
10
+ export class AltiumSchematicPackedImageResolver {
11
+ /**
12
+ * Hydrates missing embedded schematic images from the source SchDoc buffer.
13
+ * @param {object} documentModel Parsed Altium document model.
14
+ * @param {ArrayBuffer} arrayBuffer Source SchDoc buffer.
15
+ * @returns {object}
16
+ */
17
+ static hydrate(documentModel, arrayBuffer) {
18
+ const images = Array.isArray(documentModel?.schematic?.images)
19
+ ? documentModel.schematic.images
20
+ : []
21
+ const missingImages = images.filter((image) =>
22
+ AltiumSchematicPackedImageResolver.#shouldHydrateImage(image)
23
+ )
24
+ if (!missingImages.length) {
25
+ return documentModel
26
+ }
27
+
28
+ const storageBytes =
29
+ AltiumSchematicPackedImageResolver.#readStorageBytes(arrayBuffer)
30
+ if (!storageBytes) {
31
+ return documentModel
32
+ }
33
+
34
+ const resolvedFileNames = []
35
+ for (const image of missingImages) {
36
+ const imageBytes =
37
+ AltiumSchematicPackedImageResolver.#resolvePackedImageBytes(
38
+ storageBytes,
39
+ image.fileName
40
+ )
41
+ if (!imageBytes) {
42
+ continue
43
+ }
44
+
45
+ image.mimeType = AltiumSchematicPackedImageResolver.#detectMimeType(
46
+ imageBytes,
47
+ image.fileName
48
+ )
49
+ image.dataBase64 =
50
+ AltiumSchematicPackedImageResolver.#encodeBase64(imageBytes)
51
+ image.diagnosticState = 'embedded'
52
+ resolvedFileNames.push(String(image.fileName || ''))
53
+ }
54
+
55
+ if (resolvedFileNames.length) {
56
+ AltiumSchematicPackedImageResolver.#removeResolvedDiagnostics(
57
+ documentModel,
58
+ resolvedFileNames
59
+ )
60
+ }
61
+
62
+ return documentModel
63
+ }
64
+
65
+ /**
66
+ * Returns whether one image needs packed-storage recovery.
67
+ * @param {object} image Normalized schematic image placement.
68
+ * @returns {boolean}
69
+ */
70
+ static #shouldHydrateImage(image) {
71
+ return (
72
+ image?.embedded === true &&
73
+ !image?.dataBase64 &&
74
+ !image?.mimeType &&
75
+ String(image?.fileName || '').trim() &&
76
+ String(image?.diagnosticState || '') === 'missing-embedded-payload'
77
+ )
78
+ }
79
+
80
+ /**
81
+ * Reads the Altium packed image Storage stream from a SchDoc container.
82
+ * @param {ArrayBuffer} arrayBuffer Source SchDoc buffer.
83
+ * @returns {Uint8Array | null}
84
+ */
85
+ static #readStorageBytes(arrayBuffer) {
86
+ try {
87
+ return OleCompoundDocument.fromArrayBuffer(arrayBuffer).getStream(
88
+ 'Storage'
89
+ )
90
+ } catch (_error) {
91
+ return null
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Resolves decoded image bytes by exact path, normalized path, or basename.
97
+ * @param {Uint8Array} storageBytes Packed Storage stream bytes.
98
+ * @param {string} fileName Image file name from the schematic record.
99
+ * @returns {Uint8Array | null}
100
+ */
101
+ static #resolvePackedImageBytes(storageBytes, fileName) {
102
+ const candidates =
103
+ AltiumSchematicPackedImageResolver.#buildPathCandidates(fileName)
104
+
105
+ for (const candidate of candidates.exact) {
106
+ const bytes =
107
+ AltiumSchematicPackedImageResolver.#findPackedImageBytes(
108
+ storageBytes,
109
+ candidate,
110
+ false
111
+ )
112
+ if (bytes) {
113
+ return bytes
114
+ }
115
+ }
116
+
117
+ return AltiumSchematicPackedImageResolver.#findPackedImageBytes(
118
+ storageBytes,
119
+ candidates.basename,
120
+ true
121
+ )
122
+ }
123
+
124
+ /**
125
+ * Builds direct and basename lookup candidates for one image path.
126
+ * @param {string} fileName Image file name from the schematic record.
127
+ * @returns {{ exact: string[], basename: string }}
128
+ */
129
+ static #buildPathCandidates(fileName) {
130
+ const sourcePath = String(fileName || '').trim()
131
+ const slashPath = sourcePath.replace(/\\+/gu, '/')
132
+ const backslashPath = sourcePath.replace(/\/+/gu, '\\')
133
+ const exact = [
134
+ ...new Set([sourcePath, slashPath, backslashPath])
135
+ ].filter(Boolean)
136
+
137
+ return {
138
+ exact,
139
+ basename: slashPath.split('/').filter(Boolean).at(-1) || sourcePath
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Finds a valid packed image payload after one path occurrence.
145
+ * @param {Uint8Array} storageBytes Packed Storage stream bytes.
146
+ * @param {string} needle Path text to search for.
147
+ * @param {boolean} requireUnique Whether multiple valid matches are unsafe.
148
+ * @returns {Uint8Array | null}
149
+ */
150
+ static #findPackedImageBytes(storageBytes, needle, requireUnique) {
151
+ const normalizedNeedle = String(needle || '').toLowerCase()
152
+ if (!normalizedNeedle) {
153
+ return null
154
+ }
155
+
156
+ const storageText =
157
+ AltiumSchematicPackedImageResolver.#decodeStorageText(storageBytes)
158
+ const normalizedStorageText = storageText.toLowerCase()
159
+ const matches = []
160
+ let searchIndex = 0
161
+
162
+ while (searchIndex < normalizedStorageText.length) {
163
+ const matchIndex = normalizedStorageText.indexOf(
164
+ normalizedNeedle,
165
+ searchIndex
166
+ )
167
+ if (matchIndex < 0) {
168
+ break
169
+ }
170
+
171
+ const imageBytes =
172
+ AltiumSchematicPackedImageResolver.#decodePayloadAfterPath(
173
+ storageBytes,
174
+ matchIndex + normalizedNeedle.length
175
+ )
176
+ if (imageBytes) {
177
+ matches.push(imageBytes)
178
+ }
179
+ searchIndex = matchIndex + Math.max(normalizedNeedle.length, 1)
180
+ }
181
+
182
+ if (requireUnique) {
183
+ return matches.length === 1 ? matches[0] : null
184
+ }
185
+
186
+ return matches[0] || null
187
+ }
188
+
189
+ /**
190
+ * Decodes a single-byte Storage stream as text for path lookup.
191
+ * @param {Uint8Array} storageBytes Packed Storage stream bytes.
192
+ * @returns {string}
193
+ */
194
+ static #decodeStorageText(storageBytes) {
195
+ try {
196
+ return new TextDecoder('windows-1252').decode(storageBytes)
197
+ } catch (_error) {
198
+ return new TextDecoder().decode(storageBytes)
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Decodes a zlib image payload whose length follows the stored path.
204
+ * @param {Uint8Array} storageBytes Packed Storage stream bytes.
205
+ * @param {number} pathEndOffset Offset immediately after the path text.
206
+ * @returns {Uint8Array | null}
207
+ */
208
+ static #decodePayloadAfterPath(storageBytes, pathEndOffset) {
209
+ if (pathEndOffset + 6 > storageBytes.byteLength) {
210
+ return null
211
+ }
212
+
213
+ const view = new DataView(
214
+ storageBytes.buffer,
215
+ storageBytes.byteOffset,
216
+ storageBytes.byteLength
217
+ )
218
+ const compressedLength = view.getUint32(pathEndOffset, true)
219
+ const compressedOffset = pathEndOffset + 4
220
+ const compressedEnd = compressedOffset + compressedLength
221
+ if (
222
+ compressedLength <= 0 ||
223
+ compressedEnd > storageBytes.byteLength ||
224
+ !AltiumSchematicPackedImageResolver.#looksLikeZlibStream(
225
+ storageBytes,
226
+ compressedOffset
227
+ )
228
+ ) {
229
+ return null
230
+ }
231
+
232
+ try {
233
+ return AltiumSchematicPackedImageResolver.#normalizeImageBytes(
234
+ unzlibSync(
235
+ storageBytes.subarray(compressedOffset, compressedEnd)
236
+ )
237
+ )
238
+ } catch (_error) {
239
+ return null
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Checks whether bytes at one offset look like a zlib stream.
245
+ * @param {Uint8Array} bytes Source bytes.
246
+ * @param {number} offset Candidate stream offset.
247
+ * @returns {boolean}
248
+ */
249
+ static #looksLikeZlibStream(bytes, offset) {
250
+ if (offset + 2 > bytes.byteLength || bytes[offset] !== 0x78) {
251
+ return false
252
+ }
253
+
254
+ return ((bytes[offset] << 8) + bytes[offset + 1]) % 31 === 0
255
+ }
256
+
257
+ /**
258
+ * Trims decoded BMP payloads to their declared file size.
259
+ * @param {Uint8Array} bytes Decoded image bytes.
260
+ * @returns {Uint8Array}
261
+ */
262
+ static #normalizeImageBytes(bytes) {
263
+ if (bytes.byteLength >= 6 && bytes[0] === 0x42 && bytes[1] === 0x4d) {
264
+ const declaredSize = new DataView(
265
+ bytes.buffer,
266
+ bytes.byteOffset,
267
+ bytes.byteLength
268
+ ).getUint32(2, true)
269
+ if (declaredSize > 0 && declaredSize <= bytes.byteLength) {
270
+ return bytes.subarray(0, declaredSize)
271
+ }
272
+ }
273
+
274
+ return bytes
275
+ }
276
+
277
+ /**
278
+ * Detects a browser-facing MIME type from image bytes.
279
+ * @param {Uint8Array} bytes Image bytes.
280
+ * @param {string} fileName Image file name.
281
+ * @returns {string}
282
+ */
283
+ static #detectMimeType(bytes, fileName) {
284
+ if (
285
+ AltiumSchematicPackedImageResolver.#hasPrefix(bytes, [0x42, 0x4d])
286
+ ) {
287
+ return 'image/bmp'
288
+ }
289
+ if (
290
+ AltiumSchematicPackedImageResolver.#hasPrefix(
291
+ bytes,
292
+ [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
293
+ )
294
+ ) {
295
+ return 'image/png'
296
+ }
297
+ if (
298
+ AltiumSchematicPackedImageResolver.#hasPrefix(
299
+ bytes,
300
+ [0xff, 0xd8, 0xff]
301
+ )
302
+ ) {
303
+ return 'image/jpeg'
304
+ }
305
+ if (
306
+ AltiumSchematicPackedImageResolver.#hasAsciiPrefix(
307
+ bytes,
308
+ 'GIF87a'
309
+ ) ||
310
+ AltiumSchematicPackedImageResolver.#hasAsciiPrefix(bytes, 'GIF89a')
311
+ ) {
312
+ return 'image/gif'
313
+ }
314
+ if (
315
+ AltiumSchematicPackedImageResolver.#hasAsciiPrefix(bytes, 'RIFF') &&
316
+ AltiumSchematicPackedImageResolver.#hasAsciiAt(bytes, 'WEBP', 8)
317
+ ) {
318
+ return 'image/webp'
319
+ }
320
+
321
+ return AltiumSchematicPackedImageResolver.#inferMimeType(fileName)
322
+ }
323
+
324
+ /**
325
+ * Checks whether bytes start with one numeric prefix.
326
+ * @param {Uint8Array} bytes Source bytes.
327
+ * @param {number[]} prefix Expected prefix bytes.
328
+ * @returns {boolean}
329
+ */
330
+ static #hasPrefix(bytes, prefix) {
331
+ return prefix.every((value, index) => bytes[index] === value)
332
+ }
333
+
334
+ /**
335
+ * Checks whether bytes start with one ASCII token.
336
+ * @param {Uint8Array} bytes Source bytes.
337
+ * @param {string} text Expected ASCII text.
338
+ * @returns {boolean}
339
+ */
340
+ static #hasAsciiPrefix(bytes, text) {
341
+ return AltiumSchematicPackedImageResolver.#hasAsciiAt(bytes, text, 0)
342
+ }
343
+
344
+ /**
345
+ * Checks whether bytes contain one ASCII token at an offset.
346
+ * @param {Uint8Array} bytes Source bytes.
347
+ * @param {string} text Expected ASCII text.
348
+ * @param {number} offset Start offset.
349
+ * @returns {boolean}
350
+ */
351
+ static #hasAsciiAt(bytes, text, offset) {
352
+ return [...String(text || '')].every(
353
+ (character, index) =>
354
+ bytes[offset + index] === character.charCodeAt(0)
355
+ )
356
+ }
357
+
358
+ /**
359
+ * Infers a MIME type from a file extension as a last resort.
360
+ * @param {string} fileName Source file name.
361
+ * @returns {string}
362
+ */
363
+ static #inferMimeType(fileName) {
364
+ const normalized = String(fileName || '').toLowerCase()
365
+ if (normalized.endsWith('.png')) return 'image/png'
366
+ if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) {
367
+ return 'image/jpeg'
368
+ }
369
+ if (normalized.endsWith('.gif')) return 'image/gif'
370
+ if (normalized.endsWith('.bmp')) return 'image/bmp'
371
+ if (normalized.endsWith('.webp')) return 'image/webp'
372
+ if (normalized.endsWith('.svg')) return 'image/svg+xml'
373
+ return 'application/octet-stream'
374
+ }
375
+
376
+ /**
377
+ * Encodes bytes into base64 in browser and Node runtimes.
378
+ * @param {Uint8Array} bytes Source bytes.
379
+ * @returns {string}
380
+ */
381
+ static #encodeBase64(bytes) {
382
+ if (typeof Buffer !== 'undefined') {
383
+ return Buffer.from(bytes).toString('base64')
384
+ }
385
+
386
+ let binary = ''
387
+ const chunkSize = 0x8000
388
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
389
+ binary += String.fromCharCode(
390
+ ...bytes.subarray(offset, offset + chunkSize)
391
+ )
392
+ }
393
+
394
+ return btoa(binary)
395
+ }
396
+
397
+ /**
398
+ * Removes stale missing-image diagnostics after successful recovery.
399
+ * @param {object} documentModel Parsed Altium document model.
400
+ * @param {string[]} resolvedFileNames Image file names that were resolved.
401
+ * @returns {void}
402
+ */
403
+ static #removeResolvedDiagnostics(documentModel, resolvedFileNames) {
404
+ if (!Array.isArray(documentModel?.diagnostics)) {
405
+ return
406
+ }
407
+
408
+ documentModel.diagnostics = documentModel.diagnostics.filter(
409
+ (diagnostic) =>
410
+ !AltiumSchematicPackedImageResolver.#matchesResolvedDiagnostic(
411
+ diagnostic,
412
+ resolvedFileNames
413
+ )
414
+ )
415
+ }
416
+
417
+ /**
418
+ * Returns whether one diagnostic belongs to a recovered image.
419
+ * @param {object} diagnostic Parser diagnostic.
420
+ * @param {string[]} resolvedFileNames Image file names that were resolved.
421
+ * @returns {boolean}
422
+ */
423
+ static #matchesResolvedDiagnostic(diagnostic, resolvedFileNames) {
424
+ const message = String(diagnostic?.message || '')
425
+ if (!message.startsWith(MISSING_IMAGE_MESSAGE)) {
426
+ return false
427
+ }
428
+
429
+ return resolvedFileNames.some((fileName) =>
430
+ message.includes(String(fileName || ''))
431
+ )
432
+ }
433
+ }