altium-toolkit 1.4.13 → 1.4.14

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.4.13",
3
+ "version": "1.4.14",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -7,6 +7,7 @@ import { AltiumParser } from '../core/altium/AltiumParser.mjs'
7
7
  import { CircuitJsonModelAdapter } from '../core/circuit-json/CircuitJsonModelAdapter.mjs'
8
8
  import { CircuitJsonSchematicImageProjection } from '../core/circuit-json/CircuitJsonSchematicImageProjection.mjs'
9
9
  import { AltiumCircuitJsonProjection } from './AltiumCircuitJsonProjection.mjs'
10
+ import { AltiumOleInputTailNormalizer } from './AltiumOleInputTailNormalizer.mjs'
10
11
  import { AltiumSchematicImageNormalizer } from './AltiumSchematicImageNormalizer.mjs'
11
12
  import { ParserInput } from './ParserInput.mjs'
12
13
 
@@ -20,7 +21,9 @@ export class AltiumDocumentBuilder {
20
21
  * @returns {{ native: Record<string, any>, model: object[], nativeSidecarCount: number }} Decoded source data.
21
22
  */
22
23
  static decode(normalized) {
23
- const buffer = ParserInput.arrayBuffer(normalized.input.data)
24
+ const buffer = AltiumOleInputTailNormalizer.normalize(
25
+ ParserInput.arrayBuffer(normalized.input.data)
26
+ )
24
27
  const native = AltiumSchematicImageNormalizer.normalize(
25
28
  AltiumParser.parseArrayBufferToRendererModel(
26
29
  normalized.input.fileName,
@@ -0,0 +1,541 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ const HEADER_BYTE_LENGTH = 512
5
+ const DIRECTORY_ENTRY_BYTE_LENGTH = 128
6
+ const END_OF_CHAIN = -2
7
+ const HEADER_SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]
8
+ const VALID_SECTOR_SHIFTS = new Set([9, 12])
9
+
10
+ /**
11
+ * Restores omitted physical padding only after proving every logical OLE byte
12
+ * and structural sector is present.
13
+ */
14
+ export class AltiumOleInputTailNormalizer {
15
+ /**
16
+ * Returns an aligned owned buffer when only unused final-sector padding is
17
+ * absent, otherwise preserves the original input for strict native errors.
18
+ * @param {ArrayBuffer} arrayBuffer OLE candidate bytes.
19
+ * @returns {ArrayBuffer} Original or safely padded bytes.
20
+ */
21
+ static normalize(arrayBuffer) {
22
+ if (!(arrayBuffer instanceof ArrayBuffer)) return arrayBuffer
23
+ const bytes = new Uint8Array(arrayBuffer)
24
+ if (!AltiumOleInputTailNormalizer.#hasOleSignature(bytes)) {
25
+ return arrayBuffer
26
+ }
27
+ if (bytes.byteLength < HEADER_BYTE_LENGTH) return arrayBuffer
28
+
29
+ const sourceView = new DataView(arrayBuffer)
30
+ const sectorShift = sourceView.getUint16(30, true)
31
+ if (!VALID_SECTOR_SHIFTS.has(sectorShift)) return arrayBuffer
32
+ const miniSectorShift = sourceView.getUint16(32, true)
33
+ if (miniSectorShift !== 6) return arrayBuffer
34
+ const sectorByteLength = 2 ** sectorShift
35
+ const miniSectorByteLength = 2 ** miniSectorShift
36
+ const payloadByteLength = bytes.byteLength - HEADER_BYTE_LENGTH
37
+ if (payloadByteLength % sectorByteLength === 0) return arrayBuffer
38
+
39
+ const alignedPayloadByteLength =
40
+ Math.ceil(payloadByteLength / sectorByteLength) * sectorByteLength
41
+ const normalizedBytes = new Uint8Array(
42
+ HEADER_BYTE_LENGTH + alignedPayloadByteLength
43
+ )
44
+ normalizedBytes.set(bytes)
45
+
46
+ try {
47
+ const isComplete =
48
+ AltiumOleInputTailNormalizer.#isLogicallyComplete(
49
+ new DataView(normalizedBytes.buffer),
50
+ bytes.byteLength,
51
+ sectorByteLength,
52
+ miniSectorByteLength
53
+ )
54
+ return isComplete ? normalizedBytes.buffer : arrayBuffer
55
+ } catch (_error) {
56
+ return arrayBuffer
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Checks the OLE header signature without parsing the document.
62
+ * @param {Uint8Array} bytes Source bytes.
63
+ * @returns {boolean} Whether the signature matches.
64
+ */
65
+ static #hasOleSignature(bytes) {
66
+ return (
67
+ bytes.byteLength >= HEADER_SIGNATURE.length &&
68
+ HEADER_SIGNATURE.every((value, index) => bytes[index] === value)
69
+ )
70
+ }
71
+
72
+ /**
73
+ * Verifies structural sectors and every regular logical stream byte.
74
+ * @param {DataView} view Zero-padded aligned candidate view.
75
+ * @param {number} sourceByteLength Original physical byte length.
76
+ * @param {number} sectorByteLength OLE sector size.
77
+ * @param {number} miniSectorByteLength OLE mini-sector size.
78
+ * @returns {boolean} Whether padding cannot synthesize declared data.
79
+ */
80
+ static #isLogicallyComplete(
81
+ view,
82
+ sourceByteLength,
83
+ sectorByteLength,
84
+ miniSectorByteLength
85
+ ) {
86
+ const fatSectorIds = AltiumOleInputTailNormalizer.#collectFatSectorIds(
87
+ view,
88
+ sourceByteLength,
89
+ sectorByteLength
90
+ )
91
+ const numberOfFatSectors = view.getUint32(44, true)
92
+ if (!numberOfFatSectors || fatSectorIds.length < numberOfFatSectors) {
93
+ return false
94
+ }
95
+ const fatEntries = AltiumOleInputTailNormalizer.#readFatEntries(
96
+ view,
97
+ fatSectorIds.slice(0, numberOfFatSectors),
98
+ sourceByteLength,
99
+ sectorByteLength
100
+ )
101
+ if (fatEntries.length !== (numberOfFatSectors * sectorByteLength) / 4) {
102
+ return false
103
+ }
104
+ const directorySectorIds =
105
+ AltiumOleInputTailNormalizer.#readSectorChain(
106
+ view.getInt32(48, true),
107
+ fatEntries
108
+ )
109
+ if (
110
+ !directorySectorIds.length ||
111
+ !AltiumOleInputTailNormalizer.#hasFullSectors(
112
+ directorySectorIds,
113
+ sourceByteLength,
114
+ sectorByteLength
115
+ )
116
+ ) {
117
+ return false
118
+ }
119
+
120
+ const numberOfMiniFatSectors = view.getUint32(64, true)
121
+ let miniFatEntries = []
122
+ if (numberOfMiniFatSectors) {
123
+ const miniFatSectorIds =
124
+ AltiumOleInputTailNormalizer.#readSectorChain(
125
+ view.getInt32(60, true),
126
+ fatEntries
127
+ )
128
+ if (
129
+ miniFatSectorIds.length !== numberOfMiniFatSectors ||
130
+ !AltiumOleInputTailNormalizer.#hasFullSectors(
131
+ miniFatSectorIds.slice(0, numberOfMiniFatSectors),
132
+ sourceByteLength,
133
+ sectorByteLength
134
+ )
135
+ ) {
136
+ return false
137
+ }
138
+ const miniFatBytes = AltiumOleInputTailNormalizer.#readFullSectors(
139
+ view,
140
+ miniFatSectorIds.slice(0, numberOfMiniFatSectors),
141
+ sectorByteLength
142
+ )
143
+ miniFatEntries =
144
+ AltiumOleInputTailNormalizer.#readInt32Entries(miniFatBytes)
145
+ }
146
+
147
+ const directoryBytes = AltiumOleInputTailNormalizer.#readFullSectors(
148
+ view,
149
+ directorySectorIds,
150
+ sectorByteLength
151
+ )
152
+ return AltiumOleInputTailNormalizer.#areStreamsComplete(
153
+ directoryBytes,
154
+ fatEntries,
155
+ miniFatEntries,
156
+ view.getUint32(56, true),
157
+ sourceByteLength,
158
+ sectorByteLength,
159
+ miniSectorByteLength,
160
+ Math.floor(
161
+ (sourceByteLength - HEADER_BYTE_LENGTH) / sectorByteLength
162
+ )
163
+ )
164
+ }
165
+
166
+ /**
167
+ * Decodes little-endian signed integers from structural table bytes.
168
+ * @param {Uint8Array} bytes Structural sector bytes.
169
+ * @returns {number[]} Decoded table entries.
170
+ */
171
+ static #readInt32Entries(bytes) {
172
+ const view = new DataView(
173
+ bytes.buffer,
174
+ bytes.byteOffset,
175
+ bytes.byteLength
176
+ )
177
+ const entries = []
178
+ for (let offset = 0; offset < bytes.byteLength; offset += 4) {
179
+ entries.push(view.getInt32(offset, true))
180
+ }
181
+ return entries
182
+ }
183
+
184
+ /**
185
+ * Collects FAT sector ids from the header and DIFAT chain.
186
+ * @param {DataView} view Aligned candidate view.
187
+ * @param {number} sourceByteLength Original physical byte length.
188
+ * @param {number} sectorByteLength OLE sector size.
189
+ * @returns {number[]} FAT sector ids.
190
+ */
191
+ static #collectFatSectorIds(view, sourceByteLength, sectorByteLength) {
192
+ const sectorIds = []
193
+ for (let index = 0; index < 109; index += 1) {
194
+ const sectorId = view.getInt32(76 + index * 4, true)
195
+ if (sectorId >= 0) sectorIds.push(sectorId)
196
+ }
197
+
198
+ const numberOfDifatSectors = view.getUint32(72, true)
199
+ let currentSectorId = view.getInt32(68, true)
200
+ const visited = new Set()
201
+ const entriesPerSector = sectorByteLength / 4
202
+ for (
203
+ let index = 0;
204
+ index < numberOfDifatSectors && currentSectorId >= 0;
205
+ index += 1
206
+ ) {
207
+ if (visited.has(currentSectorId)) return []
208
+ visited.add(currentSectorId)
209
+ if (
210
+ !AltiumOleInputTailNormalizer.#hasFullSector(
211
+ currentSectorId,
212
+ sourceByteLength,
213
+ sectorByteLength
214
+ )
215
+ ) {
216
+ return []
217
+ }
218
+ const offset =
219
+ HEADER_BYTE_LENGTH + currentSectorId * sectorByteLength
220
+ for (let entry = 0; entry < entriesPerSector - 1; entry += 1) {
221
+ const sectorId = view.getInt32(offset + entry * 4, true)
222
+ if (sectorId >= 0) sectorIds.push(sectorId)
223
+ }
224
+ currentSectorId = view.getInt32(
225
+ offset + (entriesPerSector - 1) * 4,
226
+ true
227
+ )
228
+ }
229
+ if (numberOfDifatSectors && visited.size !== numberOfDifatSectors) {
230
+ return []
231
+ }
232
+ return sectorIds
233
+ }
234
+
235
+ /**
236
+ * Reads every FAT entry from complete FAT sectors.
237
+ * @param {DataView} view Aligned candidate view.
238
+ * @param {number[]} sectorIds FAT sector ids.
239
+ * @param {number} sourceByteLength Original physical byte length.
240
+ * @param {number} sectorByteLength OLE sector size.
241
+ * @returns {number[]} FAT entries.
242
+ */
243
+ static #readFatEntries(
244
+ view,
245
+ sectorIds,
246
+ sourceByteLength,
247
+ sectorByteLength
248
+ ) {
249
+ if (
250
+ !AltiumOleInputTailNormalizer.#hasFullSectors(
251
+ sectorIds,
252
+ sourceByteLength,
253
+ sectorByteLength
254
+ )
255
+ ) {
256
+ return []
257
+ }
258
+ const entries = []
259
+ for (const sectorId of sectorIds) {
260
+ const offset = HEADER_BYTE_LENGTH + sectorId * sectorByteLength
261
+ for (let index = 0; index < sectorByteLength / 4; index += 1) {
262
+ entries.push(view.getInt32(offset + index * 4, true))
263
+ }
264
+ }
265
+ return entries
266
+ }
267
+
268
+ /**
269
+ * Reads one FAT chain with loop and bounds protection.
270
+ * @param {number} startSectorId First sector id.
271
+ * @param {number[]} fatEntries FAT entries.
272
+ * @returns {number[]} Ordered sector ids, or an empty invalid chain.
273
+ */
274
+ static #readSectorChain(startSectorId, fatEntries) {
275
+ if (startSectorId < 0) return []
276
+ const sectorIds = []
277
+ const visited = new Set()
278
+ let currentSectorId = startSectorId
279
+ while (currentSectorId >= 0) {
280
+ if (
281
+ currentSectorId >= fatEntries.length ||
282
+ visited.has(currentSectorId)
283
+ ) {
284
+ return []
285
+ }
286
+ visited.add(currentSectorId)
287
+ sectorIds.push(currentSectorId)
288
+ const nextSectorId = fatEntries[currentSectorId]
289
+ if (nextSectorId === END_OF_CHAIN) return sectorIds
290
+ if (!Number.isInteger(nextSectorId) || nextSectorId < 0) return []
291
+ currentSectorId = nextSectorId
292
+ }
293
+ return []
294
+ }
295
+
296
+ /**
297
+ * Verifies regular, root, and mini-stream entries against source bytes.
298
+ * @param {Uint8Array} directoryBytes Decoded directory sectors.
299
+ * @param {number[]} fatEntries FAT entries.
300
+ * @param {number[]} miniFatEntries Mini-FAT entries.
301
+ * @param {number} miniStreamCutoff OLE mini-stream cutoff.
302
+ * @param {number} sourceByteLength Original physical byte length.
303
+ * @param {number} sectorByteLength OLE sector size.
304
+ * @param {number} miniSectorByteLength OLE mini-sector size.
305
+ * @param {number} partialSectorId Physically partial final sector id.
306
+ * @returns {boolean} Whether every declared stream is complete.
307
+ */
308
+ static #areStreamsComplete(
309
+ directoryBytes,
310
+ fatEntries,
311
+ miniFatEntries,
312
+ miniStreamCutoff,
313
+ sourceByteLength,
314
+ sectorByteLength,
315
+ miniSectorByteLength,
316
+ partialSectorId
317
+ ) {
318
+ const view = new DataView(
319
+ directoryBytes.buffer,
320
+ directoryBytes.byteOffset,
321
+ directoryBytes.byteLength
322
+ )
323
+ const entryCount =
324
+ directoryBytes.byteLength / DIRECTORY_ENTRY_BYTE_LENGTH
325
+ const entries = []
326
+ for (let index = 0; index < entryCount; index += 1) {
327
+ const offset = index * DIRECTORY_ENTRY_BYTE_LENGTH
328
+ const type = view.getUint8(offset + 66)
329
+ const streamSize = Number(view.getBigUint64(offset + 120, true))
330
+ if (!Number.isSafeInteger(streamSize)) return false
331
+ entries.push({
332
+ startSectorId: view.getInt32(offset + 116, true),
333
+ streamSize,
334
+ type
335
+ })
336
+ }
337
+
338
+ const rootEntry = entries.find((entry) => entry.type === 5)
339
+ const rootStreamByteLength = rootEntry?.streamSize ?? 0
340
+ let containsDeclaredPartialSector = false
341
+ for (const entry of entries) {
342
+ const { startSectorId, streamSize, type } = entry
343
+ const isRootStream = type === 5 && streamSize > 0
344
+ const isRegularStream = type === 2 && streamSize >= miniStreamCutoff
345
+ if (!isRootStream && !isRegularStream) continue
346
+ const sectorIds = AltiumOleInputTailNormalizer.#readSectorChain(
347
+ startSectorId,
348
+ fatEntries
349
+ )
350
+ if (
351
+ !AltiumOleInputTailNormalizer.#isStreamComplete(
352
+ sectorIds,
353
+ streamSize,
354
+ sourceByteLength,
355
+ sectorByteLength
356
+ )
357
+ ) {
358
+ return false
359
+ }
360
+ if (sectorIds.includes(partialSectorId)) {
361
+ containsDeclaredPartialSector = true
362
+ }
363
+ }
364
+
365
+ for (const entry of entries) {
366
+ const { startSectorId, streamSize, type } = entry
367
+ const isMiniStream =
368
+ type === 2 && streamSize > 0 && streamSize < miniStreamCutoff
369
+ if (!isMiniStream) continue
370
+ if (!rootEntry || !miniFatEntries.length) return false
371
+ const miniSectorIds = AltiumOleInputTailNormalizer.#readSectorChain(
372
+ startSectorId,
373
+ miniFatEntries
374
+ )
375
+ if (
376
+ !AltiumOleInputTailNormalizer.#isMiniStreamComplete(
377
+ miniSectorIds,
378
+ streamSize,
379
+ rootStreamByteLength,
380
+ miniSectorByteLength
381
+ )
382
+ ) {
383
+ return false
384
+ }
385
+ }
386
+ return containsDeclaredPartialSector
387
+ }
388
+
389
+ /**
390
+ * Checks one mini-stream chain against its declared root stream container.
391
+ * @param {number[]} miniSectorIds Ordered mini-sector ids.
392
+ * @param {number} streamByteLength Declared mini-stream length.
393
+ * @param {number} rootStreamByteLength Declared root stream length.
394
+ * @param {number} miniSectorByteLength OLE mini-sector size.
395
+ * @returns {boolean} Whether every mini-stream byte is contained.
396
+ */
397
+ static #isMiniStreamComplete(
398
+ miniSectorIds,
399
+ streamByteLength,
400
+ rootStreamByteLength,
401
+ miniSectorByteLength
402
+ ) {
403
+ const requiredSectorCount = Math.ceil(
404
+ streamByteLength / miniSectorByteLength
405
+ )
406
+ if (miniSectorIds.length !== requiredSectorCount) {
407
+ return false
408
+ }
409
+ for (let index = 0; index < miniSectorIds.length; index += 1) {
410
+ const miniSectorId = miniSectorIds[index]
411
+ const miniSectorOffset = miniSectorId * miniSectorByteLength
412
+ if (!Number.isSafeInteger(miniSectorOffset)) return false
413
+ const remaining = streamByteLength - index * miniSectorByteLength
414
+ const required = Math.min(
415
+ miniSectorByteLength,
416
+ Math.max(0, remaining)
417
+ )
418
+ if (!required) continue
419
+ const available = Math.max(
420
+ 0,
421
+ Math.min(
422
+ miniSectorByteLength,
423
+ rootStreamByteLength - miniSectorOffset
424
+ )
425
+ )
426
+ if (available < required) return false
427
+ }
428
+ return true
429
+ }
430
+
431
+ /**
432
+ * Checks every declared byte of one regular stream chain.
433
+ * @param {number[]} sectorIds Stream sector ids.
434
+ * @param {number} streamByteLength Declared logical stream length.
435
+ * @param {number} sourceByteLength Original physical byte length.
436
+ * @param {number} sectorByteLength OLE sector size.
437
+ * @returns {boolean} Whether the logical stream is physically complete.
438
+ */
439
+ static #isStreamComplete(
440
+ sectorIds,
441
+ streamByteLength,
442
+ sourceByteLength,
443
+ sectorByteLength
444
+ ) {
445
+ const requiredSectorCount = Math.ceil(
446
+ streamByteLength / sectorByteLength
447
+ )
448
+ if (sectorIds.length !== requiredSectorCount) return false
449
+ for (let index = 0; index < sectorIds.length; index += 1) {
450
+ const remaining = streamByteLength - index * sectorByteLength
451
+ const required = Math.min(sectorByteLength, Math.max(0, remaining))
452
+ if (!required) continue
453
+ const available =
454
+ AltiumOleInputTailNormalizer.#availableSectorByteLength(
455
+ sectorIds[index],
456
+ sourceByteLength,
457
+ sectorByteLength
458
+ )
459
+ if (available < required) return false
460
+ }
461
+ return true
462
+ }
463
+
464
+ /**
465
+ * Returns whether every sector is fully present in the original source.
466
+ * @param {number[]} sectorIds Sector ids.
467
+ * @param {number} sourceByteLength Original physical byte length.
468
+ * @param {number} sectorByteLength OLE sector size.
469
+ * @returns {boolean} Whether all sectors are complete.
470
+ */
471
+ static #hasFullSectors(sectorIds, sourceByteLength, sectorByteLength) {
472
+ return sectorIds.every((sectorId) =>
473
+ AltiumOleInputTailNormalizer.#hasFullSector(
474
+ sectorId,
475
+ sourceByteLength,
476
+ sectorByteLength
477
+ )
478
+ )
479
+ }
480
+
481
+ /**
482
+ * Returns whether one full sector is physically present.
483
+ * @param {number} sectorId Sector id.
484
+ * @param {number} sourceByteLength Original physical byte length.
485
+ * @param {number} sectorByteLength OLE sector size.
486
+ * @returns {boolean} Whether the sector is complete.
487
+ */
488
+ static #hasFullSector(sectorId, sourceByteLength, sectorByteLength) {
489
+ return (
490
+ Number.isInteger(sectorId) &&
491
+ sectorId >= 0 &&
492
+ AltiumOleInputTailNormalizer.#availableSectorByteLength(
493
+ sectorId,
494
+ sourceByteLength,
495
+ sectorByteLength
496
+ ) === sectorByteLength
497
+ )
498
+ }
499
+
500
+ /**
501
+ * Resolves physical source bytes available for one sector.
502
+ * @param {number} sectorId Sector id.
503
+ * @param {number} sourceByteLength Original physical byte length.
504
+ * @param {number} sectorByteLength OLE sector size.
505
+ * @returns {number} Available bytes from zero through one full sector.
506
+ */
507
+ static #availableSectorByteLength(
508
+ sectorId,
509
+ sourceByteLength,
510
+ sectorByteLength
511
+ ) {
512
+ const offset = HEADER_BYTE_LENGTH + sectorId * sectorByteLength
513
+ return Math.max(
514
+ 0,
515
+ Math.min(sectorByteLength, sourceByteLength - offset)
516
+ )
517
+ }
518
+
519
+ /**
520
+ * Concatenates complete structural sectors from the aligned view.
521
+ * @param {DataView} view Aligned candidate view.
522
+ * @param {number[]} sectorIds Sector ids.
523
+ * @param {number} sectorByteLength OLE sector size.
524
+ * @returns {Uint8Array} Concatenated bytes.
525
+ */
526
+ static #readFullSectors(view, sectorIds, sectorByteLength) {
527
+ const bytes = new Uint8Array(sectorIds.length * sectorByteLength)
528
+ const source = new Uint8Array(view.buffer)
529
+ sectorIds.forEach((sectorId, index) => {
530
+ const offset = HEADER_BYTE_LENGTH + sectorId * sectorByteLength
531
+ bytes.set(
532
+ source.slice(offset, offset + sectorByteLength),
533
+ index * sectorByteLength
534
+ )
535
+ })
536
+ return bytes
537
+ }
538
+ }
539
+
540
+ Object.freeze(AltiumOleInputTailNormalizer.prototype)
541
+ Object.freeze(AltiumOleInputTailNormalizer)