altium-toolkit 1.1.1 → 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 (40) 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/NaturalStringComparator.mjs +175 -0
  5. package/src/core/altium/ParserUtils.mjs +83 -14
  6. package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +104 -33
  7. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
  8. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +26 -4
  9. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +28 -5
  10. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +1 -3
  11. package/src/core/altium/PcbReviewMetadataBuilder.mjs +59 -28
  12. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +66 -29
  13. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +50 -16
  14. package/src/core/altium/PrintableTextDecoder.mjs +133 -13
  15. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
  16. package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
  17. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +36 -11
  18. package/src/core/altium/SchematicImageParser.mjs +291 -6
  19. package/src/core/altium/SchematicImplementationParser.mjs +75 -36
  20. package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
  21. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
  22. package/src/core/altium/SchematicPinParser.mjs +175 -4
  23. package/src/core/altium/SchematicQaReportBuilder.mjs +115 -38
  24. package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
  25. package/src/core/altium/SchematicTextParser.mjs +125 -11
  26. package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
  27. package/src/ui/PcbInteractionIndex.mjs +16 -1
  28. package/src/ui/SchematicColorResolver.mjs +78 -0
  29. package/src/ui/SchematicContentLayout.mjs +58 -1
  30. package/src/ui/SchematicImageRenderer.mjs +125 -10
  31. package/src/ui/SchematicJunctionRenderer.mjs +1 -1
  32. package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
  33. package/src/ui/SchematicNoteRenderer.mjs +82 -6
  34. package/src/ui/SchematicOwnerPinLabelLayout.mjs +292 -3
  35. package/src/ui/SchematicPinSvgRenderer.mjs +197 -15
  36. package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
  37. package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
  38. package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
  39. package/src/ui/SchematicShapeRenderer.mjs +82 -23
  40. 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.
@@ -21,6 +21,10 @@ export class SchematicImplementationParser {
21
21
  const components = SchematicImplementationParser.#componentRows(records)
22
22
  const listsByIndex =
23
23
  SchematicImplementationParser.#implementationLists(records)
24
+ const childrenByOwner =
25
+ SchematicImplementationParser.#implementationChildrenByOwner(
26
+ records
27
+ )
24
28
  const implementations = (records || [])
25
29
  .filter(
26
30
  (record) =>
@@ -32,9 +36,9 @@ export class SchematicImplementationParser {
32
36
  .map((record) =>
33
37
  SchematicImplementationParser.#implementation(
34
38
  record,
35
- records,
36
39
  components,
37
- listsByIndex
40
+ listsByIndex,
41
+ childrenByOwner
38
42
  )
39
43
  )
40
44
  .filter(Boolean)
@@ -137,12 +141,12 @@ export class SchematicImplementationParser {
137
141
  /**
138
142
  * Parses one implementation record.
139
143
  * @param {object} record Implementation record.
140
- * @param {object[]} records All schematic records.
141
144
  * @param {object[]} components Component rows.
142
145
  * @param {Map<string, object>} listsByIndex Implementation lists.
146
+ * @param {Map<string, { recordType: string, record: object }[]>} childrenByOwner Implementation child rows by owner index.
143
147
  * @returns {object | null}
144
148
  */
145
- static #implementation(record, records, components, listsByIndex) {
149
+ static #implementation(record, components, listsByIndex, childrenByOwner) {
146
150
  const indexInSheet =
147
151
  parseNumericField(record.fields, 'IndexInSheet') ??
148
152
  record.recordIndex ??
@@ -184,11 +188,11 @@ export class SchematicImplementationParser {
184
188
  record.fields
185
189
  ),
186
190
  mapDefiners: SchematicImplementationParser.#mapDefiners(
187
- records,
191
+ childrenByOwner,
188
192
  indexInSheet
189
193
  ),
190
194
  parameters: SchematicImplementationParser.#parameters(
191
- records,
195
+ childrenByOwner,
192
196
  indexInSheet
193
197
  )
194
198
  })
@@ -326,23 +330,17 @@ export class SchematicImplementationParser {
326
330
 
327
331
  /**
328
332
  * Parses map-definer child records for one implementation.
329
- * @param {object[]} records All schematic records.
333
+ * @param {Map<string, { recordType: string, record: object }[]>} childrenByOwner Implementation child rows by owner index.
330
334
  * @param {number} implementationIndex Implementation index.
331
335
  * @returns {object[]}
332
336
  */
333
- static #mapDefiners(records, implementationIndex) {
334
- return (records || [])
335
- .filter(
336
- (record) =>
337
- SchematicImplementationParser.#field(
338
- record.fields,
339
- 'RECORD'
340
- ) === '47' &&
341
- SchematicImplementationParser.#field(
342
- record.fields,
343
- 'OwnerIndex'
344
- ) === String(implementationIndex)
345
- )
337
+ static #mapDefiners(childrenByOwner, implementationIndex) {
338
+ return SchematicImplementationParser.#ownedImplementationChildren(
339
+ childrenByOwner,
340
+ implementationIndex
341
+ )
342
+ .filter((row) => row.recordType === '47')
343
+ .map((row) => row.record)
346
344
  .map((record) =>
347
345
  SchematicImplementationParser.#stripEmpty({
348
346
  recordKey: SchematicImplementationParser.#recordKey(record),
@@ -360,25 +358,17 @@ export class SchematicImplementationParser {
360
358
 
361
359
  /**
362
360
  * Parses implementation parameter child records.
363
- * @param {object[]} records All schematic records.
361
+ * @param {Map<string, { recordType: string, record: object }[]>} childrenByOwner Implementation child rows by owner index.
364
362
  * @param {number} implementationIndex Implementation index.
365
363
  * @returns {object[]}
366
364
  */
367
- static #parameters(records, implementationIndex) {
368
- return (records || [])
369
- .filter((record) => {
370
- const recordType = SchematicImplementationParser.#field(
371
- record.fields,
372
- 'RECORD'
373
- )
374
- return (
375
- (recordType === '48' || recordType === '41') &&
376
- SchematicImplementationParser.#field(
377
- record.fields,
378
- 'OwnerIndex'
379
- ) === String(implementationIndex)
380
- )
381
- })
365
+ static #parameters(childrenByOwner, implementationIndex) {
366
+ return SchematicImplementationParser.#ownedImplementationChildren(
367
+ childrenByOwner,
368
+ implementationIndex
369
+ )
370
+ .filter((row) => row.recordType === '48' || row.recordType === '41')
371
+ .map((row) => row.record)
382
372
  .map((record) =>
383
373
  SchematicImplementationParser.#stripEmpty({
384
374
  recordKey: SchematicImplementationParser.#recordKey(record),
@@ -396,6 +386,55 @@ export class SchematicImplementationParser {
396
386
  )
397
387
  }
398
388
 
389
+ /**
390
+ * Indexes implementation child rows by owner index once per parse.
391
+ * @param {object[]} records Schematic records.
392
+ * @returns {Map<string, { recordType: string, record: object }[]>}
393
+ */
394
+ static #implementationChildrenByOwner(records) {
395
+ const rowsByOwner = new Map()
396
+
397
+ for (const record of records || []) {
398
+ const recordType = SchematicImplementationParser.#field(
399
+ record.fields,
400
+ 'RECORD'
401
+ )
402
+ if (
403
+ recordType !== '47' &&
404
+ recordType !== '48' &&
405
+ recordType !== '41'
406
+ ) {
407
+ continue
408
+ }
409
+
410
+ const ownerIndex = SchematicImplementationParser.#field(
411
+ record.fields,
412
+ 'OwnerIndex'
413
+ )
414
+ if (!ownerIndex) {
415
+ continue
416
+ }
417
+
418
+ if (!rowsByOwner.has(ownerIndex)) {
419
+ rowsByOwner.set(ownerIndex, [])
420
+ }
421
+
422
+ rowsByOwner.get(ownerIndex).push({ recordType, record })
423
+ }
424
+
425
+ return rowsByOwner
426
+ }
427
+
428
+ /**
429
+ * Returns pre-indexed child rows for one implementation.
430
+ * @param {Map<string, { recordType: string, record: object }[]>} childrenByOwner Implementation child rows by owner index.
431
+ * @param {number} implementationIndex Implementation index.
432
+ * @returns {{ recordType: string, record: object }[]}
433
+ */
434
+ static #ownedImplementationChildren(childrenByOwner, implementationIndex) {
435
+ return childrenByOwner.get(String(implementationIndex)) || []
436
+ }
437
+
399
438
  /**
400
439
  * Parses indexed implementation designator fields from a map definer.
401
440
  * @param {Record<string, string | string[]>} fields Record fields.
@@ -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'