altium-toolkit 1.1.2 → 1.1.3

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 (27) hide show
  1. package/package.json +1 -1
  2. package/src/core/altium/AltiumLayoutParser.mjs +275 -13
  3. package/src/core/altium/AltiumParser.mjs +240 -8
  4. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
  5. package/src/core/altium/PrintableTextDecoder.mjs +133 -13
  6. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
  7. package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
  8. package/src/core/altium/SchematicImageParser.mjs +291 -6
  9. package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
  10. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
  11. package/src/core/altium/SchematicPinParser.mjs +175 -4
  12. package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
  13. package/src/core/altium/SchematicTextParser.mjs +125 -11
  14. package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
  15. package/src/ui/SchematicColorResolver.mjs +78 -0
  16. package/src/ui/SchematicContentLayout.mjs +58 -1
  17. package/src/ui/SchematicImageRenderer.mjs +125 -10
  18. package/src/ui/SchematicJunctionRenderer.mjs +1 -1
  19. package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
  20. package/src/ui/SchematicNoteRenderer.mjs +82 -6
  21. package/src/ui/SchematicOwnerPinLabelLayout.mjs +292 -3
  22. package/src/ui/SchematicPinSvgRenderer.mjs +197 -15
  23. package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
  24. package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
  25. package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
  26. package/src/ui/SchematicShapeRenderer.mjs +82 -23
  27. package/src/ui/SchematicSvgRenderer.mjs +293 -47
@@ -2,6 +2,7 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
+ import { unzlibSync } from 'fflate'
5
6
  import { OleCompoundDocument } from '../ole/OleCompoundDocument.mjs'
6
7
  import { ParserUtils } from './ParserUtils.mjs'
7
8
 
@@ -53,11 +54,15 @@ export class SchematicImageParser {
53
54
  }
54
55
  }
55
56
 
57
+ const packedStorageImages = oleDocument
58
+ ? SchematicImageParser.#indexPackedStorageImages(oleDocument)
59
+ : SchematicImageParser.#createPackedStorageImageIndex()
56
60
  const images = imageRecords
57
61
  .map((record) =>
58
62
  SchematicImageParser.#parseSchematicImageRecord(
59
63
  record,
60
64
  oleDocument,
65
+ packedStorageImages,
61
66
  diagnostics
62
67
  )
63
68
  )
@@ -79,10 +84,16 @@ export class SchematicImageParser {
79
84
  * Normalizes one image placement record.
80
85
  * @param {{ fields: Record<string, string | string[]>, recordIndex: number }} record
81
86
  * @param {OleCompoundDocument | null} oleDocument
87
+ * @param {{ byPath: Map<string, Uint8Array>, byBaseName: Map<string, Uint8Array | null> }} packedStorageImages
82
88
  * @param {{ severity: 'info' | 'warning', message: string }[]} diagnostics
83
- * @returns {{ x: number, y: number, cornerX: number, cornerY: number, fileName: string, embedded: boolean, keepAspect: boolean, mimeType: string, dataBase64: string, renderOrder: number, diagnosticState: string } | null}
89
+ * @returns {{ x: number, y: number, cornerX: number, cornerY: number, fileName: string, embedded: boolean, keepAspect: boolean, mimeType: string, dataBase64: string, renderOrder: number, diagnosticState: string, ownerIndex?: string } | null}
84
90
  */
85
- static #parseSchematicImageRecord(record, oleDocument, diagnostics) {
91
+ static #parseSchematicImageRecord(
92
+ record,
93
+ oleDocument,
94
+ packedStorageImages,
95
+ diagnostics
96
+ ) {
86
97
  const x = parseNumericField(record.fields, 'Location.X')
87
98
  const y = parseNumericField(record.fields, 'Location.Y')
88
99
  const cornerX = parseNumericField(record.fields, 'Corner.X')
@@ -110,8 +121,13 @@ export class SchematicImageParser {
110
121
  let diagnosticState = embedded ? 'missing-embedded-payload' : 'external'
111
122
 
112
123
  if (embedded && fileName && oleDocument) {
113
- try {
114
- const streamBytes = oleDocument.getStream(fileName)
124
+ const streamBytes = SchematicImageParser.#resolveEmbeddedImageBytes(
125
+ fileName,
126
+ oleDocument,
127
+ packedStorageImages
128
+ )
129
+
130
+ if (streamBytes) {
115
131
  const decoded =
116
132
  SchematicImageParser.#decodeEmbeddedImagePayload(
117
133
  streamBytes,
@@ -123,7 +139,7 @@ export class SchematicImageParser {
123
139
  hasAlpha = decoded.hasAlpha
124
140
  dataBase64 = SchematicImageParser.#encodeBase64(decoded.bytes)
125
141
  diagnosticState = 'embedded'
126
- } catch {
142
+ } else {
127
143
  diagnostics.push({
128
144
  severity: 'warning',
129
145
  message:
@@ -153,7 +169,10 @@ export class SchematicImageParser {
153
169
  mimeType,
154
170
  dataBase64,
155
171
  renderOrder,
156
- diagnosticState
172
+ diagnosticState,
173
+ ...(getField(record.fields, 'OwnerIndex')
174
+ ? { ownerIndex: getField(record.fields, 'OwnerIndex') }
175
+ : {})
157
176
  }
158
177
 
159
178
  if (sourceMimeType && sourceMimeType !== mimeType) {
@@ -169,6 +188,272 @@ export class SchematicImageParser {
169
188
  return image
170
189
  }
171
190
 
191
+ /**
192
+ * Resolves an embedded image payload from direct OLE streams or packed
193
+ * Altium icon storage.
194
+ * @param {string} fileName Image file name from the schematic record.
195
+ * @param {OleCompoundDocument} oleDocument Parsed OLE container.
196
+ * @param {{ byPath: Map<string, Uint8Array>, byBaseName: Map<string, Uint8Array | null> }} packedStorageImages
197
+ * @returns {Uint8Array | null}
198
+ */
199
+ static #resolveEmbeddedImageBytes(
200
+ fileName,
201
+ oleDocument,
202
+ packedStorageImages
203
+ ) {
204
+ try {
205
+ return oleDocument.getStream(fileName)
206
+ } catch {
207
+ return SchematicImageParser.#findPackedStorageImage(
208
+ fileName,
209
+ packedStorageImages
210
+ )
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Creates an empty packed-storage image index.
216
+ * @returns {{ byPath: Map<string, Uint8Array>, byBaseName: Map<string, Uint8Array | null> }}
217
+ */
218
+ static #createPackedStorageImageIndex() {
219
+ return {
220
+ byPath: new Map(),
221
+ byBaseName: new Map()
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Reads Altium's packed schematic image storage stream.
227
+ * @param {OleCompoundDocument} oleDocument Parsed OLE container.
228
+ * @returns {{ byPath: Map<string, Uint8Array>, byBaseName: Map<string, Uint8Array | null> }}
229
+ */
230
+ static #indexPackedStorageImages(oleDocument) {
231
+ try {
232
+ return SchematicImageParser.#parsePackedStorageImages(
233
+ oleDocument.getStream('Storage')
234
+ )
235
+ } catch {
236
+ return SchematicImageParser.#createPackedStorageImageIndex()
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Parses Altium's compact icon-storage records into decoded image bytes.
242
+ * @param {Uint8Array} bytes Packed storage stream bytes.
243
+ * @returns {{ byPath: Map<string, Uint8Array>, byBaseName: Map<string, Uint8Array | null> }}
244
+ */
245
+ static #parsePackedStorageImages(bytes) {
246
+ const index = SchematicImageParser.#createPackedStorageImageIndex()
247
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength < 16) {
248
+ return index
249
+ }
250
+
251
+ const view = new DataView(
252
+ bytes.buffer,
253
+ bytes.byteOffset,
254
+ bytes.byteLength
255
+ )
256
+ const headerLength = view.getUint32(0, true)
257
+ if (headerLength <= 0 || 4 + headerLength > bytes.byteLength) {
258
+ return index
259
+ }
260
+
261
+ const headerText = new TextDecoder('windows-1252').decode(
262
+ bytes.subarray(4, 4 + headerLength)
263
+ )
264
+ if (!headerText.includes('Icon storage')) {
265
+ return index
266
+ }
267
+
268
+ let offset = 4 + headerLength
269
+ if (bytes[offset] === 0) {
270
+ offset += 1
271
+ }
272
+
273
+ while (offset + 10 <= bytes.byteLength) {
274
+ while (offset < bytes.byteLength && bytes[offset] === 0) {
275
+ offset += 1
276
+ }
277
+ if (offset + 10 > bytes.byteLength) {
278
+ break
279
+ }
280
+
281
+ const entryLength =
282
+ bytes[offset] |
283
+ (bytes[offset + 1] << 8) |
284
+ (bytes[offset + 2] << 16)
285
+ const parsedEntry =
286
+ SchematicImageParser.#parsePackedStorageImageEntry(
287
+ bytes,
288
+ view,
289
+ offset
290
+ )
291
+
292
+ if (parsedEntry) {
293
+ SchematicImageParser.#addPackedStorageImage(
294
+ index,
295
+ parsedEntry.fileName,
296
+ parsedEntry.bytes
297
+ )
298
+ offset = parsedEntry.nextOffset
299
+ continue
300
+ }
301
+
302
+ offset += Math.max(entryLength + 3, 1)
303
+ }
304
+
305
+ return index
306
+ }
307
+
308
+ /**
309
+ * Parses one packed icon-storage entry.
310
+ * @param {Uint8Array} bytes Storage stream bytes.
311
+ * @param {DataView} view Storage stream view.
312
+ * @param {number} offset Entry offset.
313
+ * @returns {{ fileName: string, bytes: Uint8Array, nextOffset: number } | null}
314
+ */
315
+ static #parsePackedStorageImageEntry(bytes, view, offset) {
316
+ const entryStart = offset + 3
317
+ if (entryStart + 3 > bytes.byteLength) {
318
+ return null
319
+ }
320
+
321
+ const pathLength = bytes[entryStart + 2]
322
+ const pathOffset = entryStart + 3
323
+ const compressedLengthOffset = pathOffset + pathLength
324
+ const compressedOffset = compressedLengthOffset + 4
325
+
326
+ if (pathLength <= 0 || compressedLengthOffset + 4 > bytes.byteLength) {
327
+ return null
328
+ }
329
+
330
+ const compressedLength = view.getUint32(compressedLengthOffset, true)
331
+ const compressedEnd = compressedOffset + compressedLength
332
+ if (
333
+ compressedLength <= 0 ||
334
+ compressedEnd > bytes.byteLength ||
335
+ !SchematicImageParser.#looksLikeZlibStream(bytes, compressedOffset)
336
+ ) {
337
+ return null
338
+ }
339
+
340
+ try {
341
+ return {
342
+ fileName: SchematicImageParser.#decodePackedImagePath(
343
+ bytes.subarray(pathOffset, compressedLengthOffset)
344
+ ),
345
+ bytes: unzlibSync(
346
+ bytes.subarray(compressedOffset, compressedEnd)
347
+ ),
348
+ nextOffset: compressedEnd
349
+ }
350
+ } catch {
351
+ return null
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Adds one decoded image to path and unique-basename lookup indexes.
357
+ * @param {{ byPath: Map<string, Uint8Array>, byBaseName: Map<string, Uint8Array | null> }} index Packed-storage index.
358
+ * @param {string} fileName Image path.
359
+ * @param {Uint8Array} bytes Image bytes.
360
+ */
361
+ static #addPackedStorageImage(index, fileName, bytes) {
362
+ const normalizedPath =
363
+ SchematicImageParser.#normalizeImageLookupPath(fileName)
364
+ if (!normalizedPath) {
365
+ return
366
+ }
367
+
368
+ index.byPath.set(normalizedPath, bytes)
369
+
370
+ const baseName =
371
+ SchematicImageParser.#imageLookupBaseName(normalizedPath)
372
+ if (!baseName) {
373
+ return
374
+ }
375
+
376
+ if (
377
+ index.byBaseName.has(baseName) &&
378
+ index.byBaseName.get(baseName) !== bytes
379
+ ) {
380
+ index.byBaseName.set(baseName, null)
381
+ return
382
+ }
383
+
384
+ index.byBaseName.set(baseName, bytes)
385
+ }
386
+
387
+ /**
388
+ * Finds one packed-storage image by full path or unique basename.
389
+ * @param {string} fileName Requested image path.
390
+ * @param {{ byPath: Map<string, Uint8Array>, byBaseName: Map<string, Uint8Array | null> }} index Packed-storage image index.
391
+ * @returns {Uint8Array | null}
392
+ */
393
+ static #findPackedStorageImage(fileName, index) {
394
+ const normalizedPath =
395
+ SchematicImageParser.#normalizeImageLookupPath(fileName)
396
+ if (!normalizedPath) {
397
+ return null
398
+ }
399
+
400
+ const directMatch = index.byPath.get(normalizedPath)
401
+ if (directMatch) {
402
+ return directMatch
403
+ }
404
+
405
+ const baseName =
406
+ SchematicImageParser.#imageLookupBaseName(normalizedPath)
407
+ return index.byBaseName.get(baseName) || null
408
+ }
409
+
410
+ /**
411
+ * Decodes a packed-storage path string.
412
+ * @param {Uint8Array} bytes Path bytes.
413
+ * @returns {string}
414
+ */
415
+ static #decodePackedImagePath(bytes) {
416
+ return new TextDecoder('windows-1252')
417
+ .decode(bytes)
418
+ .replace(/\0+$/u, '')
419
+ }
420
+
421
+ /**
422
+ * Checks whether bytes at an offset resemble a zlib stream.
423
+ * @param {Uint8Array} bytes Source bytes.
424
+ * @param {number} offset Candidate offset.
425
+ * @returns {boolean}
426
+ */
427
+ static #looksLikeZlibStream(bytes, offset) {
428
+ if (offset + 2 > bytes.byteLength || bytes[offset] !== 0x78) {
429
+ return false
430
+ }
431
+
432
+ return ((bytes[offset] << 8) + bytes[offset + 1]) % 31 === 0
433
+ }
434
+
435
+ /**
436
+ * Normalizes image paths for cross-platform lookup.
437
+ * @param {string} fileName Image path.
438
+ * @returns {string}
439
+ */
440
+ static #normalizeImageLookupPath(fileName) {
441
+ return String(fileName || '')
442
+ .replace(/\\+/gu, '/')
443
+ .replace(/\/+/gu, '/')
444
+ .toLowerCase()
445
+ }
446
+
447
+ /**
448
+ * Returns the final path segment from a normalized image lookup path.
449
+ * @param {string} normalizedPath Normalized path.
450
+ * @returns {string}
451
+ */
452
+ static #imageLookupBaseName(normalizedPath) {
453
+ const parts = normalizedPath.split('/')
454
+ return parts[parts.length - 1] || ''
455
+ }
456
+
172
457
  /**
173
458
  * Chooses the browser-facing image payload from one embedded stream.
174
459
  * @param {Uint8Array} bytes Embedded image stream bytes.
@@ -0,0 +1,164 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Normalizes visible designator labels for repeated multipart schematic units.
7
+ */
8
+ export class SchematicMultipartDesignatorNormalizer {
9
+ /**
10
+ * Rewrites multipart designator labels from the active Altium part id.
11
+ * @param {{ text: string, name?: string, ownerIndex?: string, recordType?: string }[]} texts
12
+ * @param {Map<string, string>} activeMultipartOwnerParts
13
+ * @returns {{ text: string, name?: string, ownerIndex?: string, recordType?: string }[]}
14
+ */
15
+ static normalize(texts, activeMultipartOwnerParts) {
16
+ const repeatedStems =
17
+ SchematicMultipartDesignatorNormalizer.#collectRepeatedStems(
18
+ texts,
19
+ activeMultipartOwnerParts
20
+ )
21
+
22
+ return texts.map((text) =>
23
+ SchematicMultipartDesignatorNormalizer.#normalizeText(
24
+ text,
25
+ activeMultipartOwnerParts,
26
+ repeatedStems
27
+ )
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Collects designator stems used by multiple active multipart owners.
33
+ * @param {{ text: string, name?: string, ownerIndex?: string, recordType?: string }[]} texts
34
+ * @param {Map<string, string>} activeMultipartOwnerParts
35
+ * @returns {Set<string>}
36
+ */
37
+ static #collectRepeatedStems(texts, activeMultipartOwnerParts) {
38
+ const counts = new Map()
39
+
40
+ for (const text of texts) {
41
+ if (
42
+ !SchematicMultipartDesignatorNormalizer.#isActiveDesignator(
43
+ text,
44
+ activeMultipartOwnerParts
45
+ )
46
+ ) {
47
+ continue
48
+ }
49
+
50
+ const parsed =
51
+ SchematicMultipartDesignatorNormalizer.#parseDesignatorStem(
52
+ text.text
53
+ )
54
+ if (!parsed) {
55
+ continue
56
+ }
57
+
58
+ counts.set(parsed.stem, (counts.get(parsed.stem) || 0) + 1)
59
+ }
60
+
61
+ return new Set(
62
+ [...counts.entries()]
63
+ .filter(([, count]) => count > 1)
64
+ .map(([stem]) => stem)
65
+ )
66
+ }
67
+
68
+ /**
69
+ * Normalizes one designator text row.
70
+ * @param {{ text: string, name?: string, ownerIndex?: string, recordType?: string }} text
71
+ * @param {Map<string, string>} activeMultipartOwnerParts
72
+ * @param {Set<string>} repeatedStems
73
+ * @returns {{ text: string, name?: string, ownerIndex?: string, recordType?: string }}
74
+ */
75
+ static #normalizeText(text, activeMultipartOwnerParts, repeatedStems) {
76
+ if (
77
+ !SchematicMultipartDesignatorNormalizer.#isActiveDesignator(
78
+ text,
79
+ activeMultipartOwnerParts
80
+ )
81
+ ) {
82
+ return text
83
+ }
84
+
85
+ const parsed =
86
+ SchematicMultipartDesignatorNormalizer.#parseDesignatorStem(
87
+ text.text
88
+ )
89
+ if (!parsed || !repeatedStems.has(parsed.stem)) {
90
+ return text
91
+ }
92
+
93
+ const suffix = SchematicMultipartDesignatorNormalizer.#formatPartSuffix(
94
+ activeMultipartOwnerParts.get(String(text.ownerIndex || ''))
95
+ )
96
+ const normalizedText = parsed.stem + suffix
97
+
98
+ return normalizedText === text.text
99
+ ? text
100
+ : {
101
+ ...text,
102
+ text: normalizedText
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Returns true when a text row is an active multipart designator.
108
+ * @param {{ name?: string, ownerIndex?: string, recordType?: string }} text
109
+ * @param {Map<string, string>} activeMultipartOwnerParts
110
+ * @returns {boolean}
111
+ */
112
+ static #isActiveDesignator(text, activeMultipartOwnerParts) {
113
+ const ownerIndex = String(text?.ownerIndex || '')
114
+
115
+ return (
116
+ activeMultipartOwnerParts.has(ownerIndex) &&
117
+ text?.recordType === '34' &&
118
+ String(text?.name || '')
119
+ .trim()
120
+ .toLowerCase() === 'designator'
121
+ )
122
+ }
123
+
124
+ /**
125
+ * Parses a component stem before an optional multipart suffix.
126
+ * @param {string} text Designator text.
127
+ * @returns {{ stem: string, suffix: string } | null}
128
+ */
129
+ static #parseDesignatorStem(text) {
130
+ const match = /^(?<stem>.*\d)(?<suffix>[A-Z]+)?$/u.exec(
131
+ String(text || '').trim()
132
+ )
133
+
134
+ return match?.groups
135
+ ? {
136
+ stem: match.groups.stem,
137
+ suffix: match.groups.suffix || ''
138
+ }
139
+ : null
140
+ }
141
+
142
+ /**
143
+ * Converts one numeric multipart part id into an alphabetic suffix.
144
+ * @param {string | undefined} partId
145
+ * @returns {string}
146
+ */
147
+ static #formatPartSuffix(partId) {
148
+ const numericPartId = Number.parseInt(String(partId || ''), 10)
149
+ if (!Number.isInteger(numericPartId) || numericPartId <= 0) {
150
+ return ''
151
+ }
152
+
153
+ let suffix = ''
154
+ let remaining = numericPartId
155
+
156
+ while (remaining > 0) {
157
+ remaining -= 1
158
+ suffix = String.fromCharCode(65 + (remaining % 26)) + suffix
159
+ remaining = Math.floor(remaining / 26)
160
+ }
161
+
162
+ return suffix
163
+ }
164
+ }
@@ -542,10 +542,12 @@ export class SchematicMultipartOwnerMatcher {
542
542
  static #inferSchematicPinOrientation(conglomerate) {
543
543
  switch (conglomerate) {
544
544
  case 34:
545
+ case 42:
545
546
  case 50:
546
547
  case 58:
547
548
  return 'left'
548
549
  case 32:
550
+ case 40:
549
551
  case 48:
550
552
  case 56:
551
553
  return 'right'