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.
- package/package.json +1 -1
- package/src/core/altium/AltiumParser.mjs +34 -1
- package/src/core/altium/AltiumSchematicArcAngleNormalizer.mjs +82 -0
- package/src/core/altium/AltiumSchematicFreeGraphicStrokeNormalizer.mjs +105 -0
- package/src/core/altium/AltiumSchematicHiddenDesignatorResolver.mjs +191 -0
- package/src/core/altium/AltiumSchematicPackedImageResolver.mjs +433 -0
- package/src/core/altium/AltiumSchematicSheetBoundsNormalizer.mjs +287 -0
- package/src/parser.mjs +5 -0
- package/src/renderers.mjs +2 -0
- package/src/scene3d.mjs +1 -0
- package/src/ui/AltiumPcbBottomViewMirror.mjs +394 -0
- package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +300 -0
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +364 -22
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +568 -4
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +149 -23
- package/src/ui/PcbFootprintPadAxisNormalizer.mjs +609 -0
- package/src/ui/PcbScene3dBuilder.mjs +313 -15
- package/src/ui/PcbScene3dPlacementSideResolver.mjs +34 -2
|
@@ -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
|
+
}
|