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,8 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
+ import { NaturalStringComparator } from './NaturalStringComparator.mjs'
6
+
5
7
  /**
6
8
  * Builds route-highlight profile rows for PCB review metadata.
7
9
  */
@@ -12,33 +14,45 @@ export class PcbReviewRouteHighlightProfileBuilder {
12
14
  * @returns {object[]}
13
15
  */
14
16
  static build(routeAnalysis = {}) {
17
+ const routeRowsByName =
18
+ PcbReviewRouteHighlightProfileBuilder.#routeRowsByName(
19
+ routeAnalysis
20
+ )
15
21
  return [
16
22
  ...PcbReviewRouteHighlightProfileBuilder.#differentialPairProfiles(
17
- routeAnalysis
23
+ routeAnalysis,
24
+ routeRowsByName
18
25
  ),
19
26
  ...PcbReviewRouteHighlightProfileBuilder.#differentialPairClassProfiles(
20
- routeAnalysis
27
+ routeAnalysis,
28
+ routeRowsByName
21
29
  ),
22
30
  ...PcbReviewRouteHighlightProfileBuilder.#netClassProfiles(
23
- routeAnalysis
31
+ routeAnalysis,
32
+ routeRowsByName
24
33
  ),
25
- ...PcbReviewRouteHighlightProfileBuilder.#netProfiles(routeAnalysis)
34
+ ...PcbReviewRouteHighlightProfileBuilder.#netProfiles(
35
+ routeAnalysis,
36
+ routeRowsByName
37
+ )
26
38
  ]
27
39
  }
28
40
 
29
41
  /**
30
42
  * Builds net-class highlight profiles.
31
43
  * @param {object} routeAnalysis Route analysis model.
44
+ * @param {Map<string, object[]>} routeRowsByName Route rows by net name.
32
45
  * @returns {object[]}
33
46
  */
34
- static #netClassProfiles(routeAnalysis) {
47
+ static #netClassProfiles(routeAnalysis, routeRowsByName) {
35
48
  return (routeAnalysis.classes || []).map((classRow) =>
36
49
  PcbReviewRouteHighlightProfileBuilder.#highlightProfile({
37
50
  selectorKind: 'net-class',
38
51
  keyPrefix: 'highlight-net-class-',
39
52
  name: classRow.name,
40
53
  netNames: classRow.netNames || [],
41
- routeAnalysis
54
+ routeAnalysis,
55
+ routeRowsByName
42
56
  })
43
57
  )
44
58
  }
@@ -46,9 +60,10 @@ export class PcbReviewRouteHighlightProfileBuilder {
46
60
  /**
47
61
  * Builds differential-pair highlight profiles.
48
62
  * @param {object} routeAnalysis Route analysis model.
63
+ * @param {Map<string, object[]>} routeRowsByName Route rows by net name.
49
64
  * @returns {object[]}
50
65
  */
51
- static #differentialPairProfiles(routeAnalysis) {
66
+ static #differentialPairProfiles(routeAnalysis, routeRowsByName) {
52
67
  return (routeAnalysis.differentialPairs || []).map((pair) =>
53
68
  PcbReviewRouteHighlightProfileBuilder.#highlightProfile({
54
69
  selectorKind: 'differential-pair',
@@ -57,7 +72,8 @@ export class PcbReviewRouteHighlightProfileBuilder {
57
72
  netNames: [pair.positiveNetName, pair.negativeNetName].filter(
58
73
  Boolean
59
74
  ),
60
- routeAnalysis
75
+ routeAnalysis,
76
+ routeRowsByName
61
77
  })
62
78
  )
63
79
  }
@@ -65,9 +81,10 @@ export class PcbReviewRouteHighlightProfileBuilder {
65
81
  /**
66
82
  * Builds differential-pair class highlight profiles.
67
83
  * @param {object} routeAnalysis Route analysis model.
84
+ * @param {Map<string, object[]>} routeRowsByName Route rows by net name.
68
85
  * @returns {object[]}
69
86
  */
70
- static #differentialPairClassProfiles(routeAnalysis) {
87
+ static #differentialPairClassProfiles(routeAnalysis, routeRowsByName) {
71
88
  const classNames = new Map()
72
89
  for (const pair of routeAnalysis.differentialPairs || []) {
73
90
  for (const className of pair.classes || []) {
@@ -88,7 +105,7 @@ export class PcbReviewRouteHighlightProfileBuilder {
88
105
 
89
106
  return [...classNames.entries()]
90
107
  .sort(([left], [right]) =>
91
- left.localeCompare(right, undefined, { numeric: true })
108
+ NaturalStringComparator.compare(left, right)
92
109
  )
93
110
  .map(([className, netNames]) =>
94
111
  PcbReviewRouteHighlightProfileBuilder.#highlightProfile({
@@ -96,7 +113,8 @@ export class PcbReviewRouteHighlightProfileBuilder {
96
113
  keyPrefix: 'highlight-diff-pair-class-',
97
114
  name: className,
98
115
  netNames: [...netNames],
99
- routeAnalysis
116
+ routeAnalysis,
117
+ routeRowsByName
100
118
  })
101
119
  )
102
120
  }
@@ -104,23 +122,25 @@ export class PcbReviewRouteHighlightProfileBuilder {
104
122
  /**
105
123
  * Builds scalar net highlight profiles.
106
124
  * @param {object} routeAnalysis Route analysis model.
125
+ * @param {Map<string, object[]>} routeRowsByName Route rows by net name.
107
126
  * @returns {object[]}
108
127
  */
109
- static #netProfiles(routeAnalysis) {
128
+ static #netProfiles(routeAnalysis, routeRowsByName) {
110
129
  return (routeAnalysis.byNet || []).map((net) =>
111
130
  PcbReviewRouteHighlightProfileBuilder.#highlightProfile({
112
131
  selectorKind: 'net',
113
132
  keyPrefix: 'highlight-net-',
114
133
  name: net.netName,
115
134
  netNames: [net.netName],
116
- routeAnalysis
135
+ routeAnalysis,
136
+ routeRowsByName
117
137
  })
118
138
  )
119
139
  }
120
140
 
121
141
  /**
122
142
  * Builds one route-highlight profile.
123
- * @param {{ selectorKind: string, keyPrefix: string, name: string, netNames: string[], routeAnalysis: object }} options Profile options.
143
+ * @param {{ selectorKind: string, keyPrefix: string, name: string, netNames: string[], routeAnalysis: object, routeRowsByName: Map<string, object[]> }} options Profile options.
124
144
  * @returns {object}
125
145
  */
126
146
  static #highlightProfile(options) {
@@ -129,7 +149,8 @@ export class PcbReviewRouteHighlightProfileBuilder {
129
149
  )
130
150
  const layerGroups = PcbReviewRouteHighlightProfileBuilder.#layerGroups(
131
151
  options.routeAnalysis,
132
- netNames
152
+ netNames,
153
+ options.routeRowsByName
133
154
  )
134
155
 
135
156
  return PcbReviewRouteHighlightProfileBuilder.#stripEmpty({
@@ -158,12 +179,13 @@ export class PcbReviewRouteHighlightProfileBuilder {
158
179
  * Builds per-layer route-highlight groups.
159
180
  * @param {object} routeAnalysis Route analysis model.
160
181
  * @param {string[]} netNames Net names.
182
+ * @param {Map<string, object[]>} routeRowsByName Route rows by net name.
161
183
  * @returns {object[]}
162
184
  */
163
- static #layerGroups(routeAnalysis, netNames) {
185
+ static #layerGroups(routeAnalysis, netNames, routeRowsByName) {
164
186
  const groupsByLayer = new Map()
165
187
  for (const net of PcbReviewRouteHighlightProfileBuilder.#netsByName(
166
- routeAnalysis,
188
+ routeRowsByName,
167
189
  netNames
168
190
  )) {
169
191
  for (const participation of net.layerParticipation || []) {
@@ -205,22 +227,37 @@ export class PcbReviewRouteHighlightProfileBuilder {
205
227
  )
206
228
  }))
207
229
  .sort((left, right) =>
208
- left.layerKey.localeCompare(right.layerKey, undefined, {
209
- numeric: true
210
- })
230
+ NaturalStringComparator.compare(left.layerKey, right.layerKey)
211
231
  )
212
232
  }
213
233
 
214
234
  /**
215
- * Resolves net route rows by name.
235
+ * Builds route rows indexed by net name.
216
236
  * @param {object} routeAnalysis Route analysis model.
237
+ * @returns {Map<string, object[]>}
238
+ */
239
+ static #routeRowsByName(routeAnalysis) {
240
+ const rowsByName = new Map()
241
+ for (const net of routeAnalysis.byNet || []) {
242
+ const netName = String(net?.netName || '')
243
+ if (!netName) continue
244
+ if (!rowsByName.has(netName)) {
245
+ rowsByName.set(netName, [])
246
+ }
247
+ rowsByName.get(netName).push(net)
248
+ }
249
+ return rowsByName
250
+ }
251
+
252
+ /**
253
+ * Resolves net route rows by name.
254
+ * @param {Map<string, object[]>} routeRowsByName Route rows by net name.
217
255
  * @param {string[]} netNames Net names.
218
256
  * @returns {object[]}
219
257
  */
220
- static #netsByName(routeAnalysis, netNames) {
221
- const wanted = new Set(netNames || [])
222
- return (routeAnalysis.byNet || []).filter((net) =>
223
- wanted.has(net.netName)
258
+ static #netsByName(routeRowsByName, netNames) {
259
+ return (netNames || []).flatMap(
260
+ (netName) => routeRowsByName.get(netName) || []
224
261
  )
225
262
  }
226
263
 
@@ -248,10 +285,10 @@ export class PcbReviewRouteHighlightProfileBuilder {
248
285
  * @returns {string[]}
249
286
  */
250
287
  static #sortedStrings(values) {
251
- return [...new Set((values || []).filter(Boolean))].sort(
252
- (left, right) =>
253
- left.localeCompare(right, undefined, { numeric: true })
254
- )
288
+ const sortedValues = [...new Set((values || []).filter(Boolean))]
289
+ return sortedValues.length < 2
290
+ ? sortedValues
291
+ : sortedValues.sort(NaturalStringComparator.compare)
255
292
  }
256
293
 
257
294
  /**
@@ -183,23 +183,30 @@ export class PcbRouteAnalysisBuilder {
183
183
  * @returns {object[]}
184
184
  */
185
185
  static #netRows(pcb, routePrimitives, viaRows) {
186
+ const primitivesByNet =
187
+ PcbRouteAnalysisBuilder.#rowsByNet(routePrimitives)
188
+ const viasByNet = PcbRouteAnalysisBuilder.#rowsByNet(viaRows)
186
189
  const netNames = new Set([
187
190
  ...(pcb?.nets || []).map((net) => net.name).filter(Boolean),
188
- ...routePrimitives.map((primitive) => primitive.netName),
189
- ...viaRows.map((via) => via.netName)
191
+ ...primitivesByNet.keys(),
192
+ ...viasByNet.keys()
190
193
  ])
191
194
 
192
195
  return [...netNames]
193
- .map((netName) =>
194
- PcbRouteAnalysisBuilder.#netRow(
196
+ .map((netName) => {
197
+ const primitives = primitivesByNet.get(netName) || []
198
+ const vias = viasByNet.get(netName) || []
199
+ if (!primitives.length && !vias.length) {
200
+ return null
201
+ }
202
+
203
+ return PcbRouteAnalysisBuilder.#netRow(
195
204
  netName,
196
- routePrimitives.filter(
197
- (primitive) => primitive.netName === netName
198
- ),
199
- viaRows.filter((via) => via.netName === netName)
205
+ primitives,
206
+ vias
200
207
  )
201
- )
202
- .filter((net) => net.totalLengthMil > 0 || net.viaCount > 0)
208
+ })
209
+ .filter(Boolean)
203
210
  .sort((left, right) =>
204
211
  left.netName.localeCompare(right.netName, undefined, {
205
212
  numeric: true
@@ -215,12 +222,16 @@ export class PcbRouteAnalysisBuilder {
215
222
  * @returns {object}
216
223
  */
217
224
  static #netRow(netName, primitives, vias) {
218
- const trackRows = primitives.filter(
219
- (primitive) => primitive.kind === 'track'
220
- )
221
- const arcRows = primitives.filter(
222
- (primitive) => primitive.kind === 'arc'
223
- )
225
+ const trackRows = []
226
+ const arcRows = []
227
+
228
+ for (const primitive of primitives || []) {
229
+ if (primitive.kind === 'track') {
230
+ trackRows.push(primitive)
231
+ } else if (primitive.kind === 'arc') {
232
+ arcRows.push(primitive)
233
+ }
234
+ }
224
235
 
225
236
  return {
226
237
  netName,
@@ -240,6 +251,29 @@ export class PcbRouteAnalysisBuilder {
240
251
  }
241
252
  }
242
253
 
254
+ /**
255
+ * Groups route rows by net name.
256
+ * @param {object[]} rows Route or via rows.
257
+ * @returns {Map<string, object[]>}
258
+ */
259
+ static #rowsByNet(rows) {
260
+ const rowsByNet = new Map()
261
+
262
+ for (const row of rows || []) {
263
+ const netName = String(row?.netName || '').trim()
264
+ if (!netName) {
265
+ continue
266
+ }
267
+
268
+ if (!rowsByNet.has(netName)) {
269
+ rowsByNet.set(netName, [])
270
+ }
271
+ rowsByNet.get(netName).push(row)
272
+ }
273
+
274
+ return rowsByNet
275
+ }
276
+
243
277
  /**
244
278
  * Builds per-net-class length summaries.
245
279
  * @param {object} pcb Normalized PCB model.
@@ -6,6 +6,9 @@
6
6
  * Extracts long printable runs from binary Altium documents.
7
7
  */
8
8
  export class PrintableTextDecoder {
9
+ static #decoderCache = new Map()
10
+ static #decoderConstructor = null
11
+
9
12
  static #WINDOWS_1252_PRINTABLE_CONTROL_BYTES = new Set([
10
13
  0x80, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c,
11
14
  0x8e, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b,
@@ -115,7 +118,7 @@ export class PrintableTextDecoder {
115
118
  if (preferredEncoding === 'utf-8') {
116
119
  return (
117
120
  PrintableTextDecoder.#tryDecode(bytes, 'utf-8') ||
118
- new TextDecoder('utf-8').decode(bytes)
121
+ PrintableTextDecoder.#decode(bytes, 'utf-8')
119
122
  )
120
123
  }
121
124
  if (
@@ -124,7 +127,7 @@ export class PrintableTextDecoder {
124
127
  ) {
125
128
  return (
126
129
  PrintableTextDecoder.#tryDecodeWindows1252(bytes) ||
127
- new TextDecoder('utf-8').decode(bytes)
130
+ PrintableTextDecoder.#decode(bytes, 'utf-8')
128
131
  )
129
132
  }
130
133
 
@@ -144,7 +147,7 @@ export class PrintableTextDecoder {
144
147
  return (
145
148
  PrintableTextDecoder.#tryDecode(bytes, 'gb18030') ||
146
149
  PrintableTextDecoder.#tryDecodeWindows1252(bytes) ||
147
- new TextDecoder('utf-8').decode(bytes)
150
+ PrintableTextDecoder.#decode(bytes, 'utf-8')
148
151
  )
149
152
  }
150
153
 
@@ -157,18 +160,25 @@ export class PrintableTextDecoder {
157
160
  * @param {number} minLength
158
161
  */
159
162
  static #pushRunBytes(runs, bytes, start, end, minLength) {
160
- const length = end - start
161
- if (length < minLength) return
162
-
163
- const slice = bytes.slice(start, end)
164
- const normalized = PrintableTextDecoder.#normalizeRun(
165
- PrintableTextDecoder.decodeBytes(slice)
163
+ const bounds = PrintableTextDecoder.#trimAsciiByteRange(
164
+ bytes,
165
+ start,
166
+ end
166
167
  )
168
+ const length = bounds.end - bounds.start
169
+ if (length < minLength) return
167
170
 
168
- if (normalized.length < minLength) return
169
- if (!normalized.includes('|') || !normalized.includes('=')) return
171
+ if (
172
+ !PrintableTextDecoder.#containsRecordDelimiterBytes(
173
+ bytes,
174
+ bounds.start,
175
+ bounds.end
176
+ )
177
+ ) {
178
+ return
179
+ }
170
180
 
171
- runs.push(slice)
181
+ runs.push(bytes.slice(start, end))
172
182
  }
173
183
 
174
184
  /**
@@ -218,6 +228,72 @@ export class PrintableTextDecoder {
218
228
  return false
219
229
  }
220
230
 
231
+ /**
232
+ * Trims ASCII whitespace from one byte range.
233
+ * @param {Uint8Array} bytes
234
+ * @param {number} start
235
+ * @param {number} end
236
+ * @returns {{ start: number, end: number }}
237
+ */
238
+ static #trimAsciiByteRange(bytes, start, end) {
239
+ let trimmedStart = start
240
+ let trimmedEnd = end
241
+
242
+ while (
243
+ trimmedStart < trimmedEnd &&
244
+ PrintableTextDecoder.#isAsciiWhitespaceByte(bytes[trimmedStart])
245
+ ) {
246
+ trimmedStart += 1
247
+ }
248
+
249
+ while (
250
+ trimmedEnd > trimmedStart &&
251
+ PrintableTextDecoder.#isAsciiWhitespaceByte(bytes[trimmedEnd - 1])
252
+ ) {
253
+ trimmedEnd -= 1
254
+ }
255
+
256
+ return {
257
+ start: trimmedStart,
258
+ end: trimmedEnd
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Returns true when one byte range contains Altium record delimiters.
264
+ * @param {Uint8Array} bytes
265
+ * @param {number} start
266
+ * @param {number} end
267
+ * @returns {boolean}
268
+ */
269
+ static #containsRecordDelimiterBytes(bytes, start, end) {
270
+ let hasPipe = false
271
+ let hasEquals = false
272
+
273
+ for (let index = start; index < end; index += 1) {
274
+ if (bytes[index] === 0x7c) {
275
+ hasPipe = true
276
+ } else if (bytes[index] === 0x3d) {
277
+ hasEquals = true
278
+ }
279
+
280
+ if (hasPipe && hasEquals) {
281
+ return true
282
+ }
283
+ }
284
+
285
+ return false
286
+ }
287
+
288
+ /**
289
+ * Returns true when a byte is ASCII whitespace normalized around runs.
290
+ * @param {number} byte
291
+ * @returns {boolean}
292
+ */
293
+ static #isAsciiWhitespaceByte(byte) {
294
+ return byte === 9 || byte === 10 || byte === 13 || byte === 32
295
+ }
296
+
221
297
  /**
222
298
  * Tries one strict decode and returns null when bytes are invalid for it.
223
299
  * @param {Uint8Array} bytes
@@ -226,12 +302,56 @@ export class PrintableTextDecoder {
226
302
  */
227
303
  static #tryDecode(bytes, encoding) {
228
304
  try {
229
- return new TextDecoder(encoding, { fatal: true }).decode(bytes)
305
+ return PrintableTextDecoder.#decode(bytes, encoding, {
306
+ fatal: true
307
+ })
230
308
  } catch {
231
309
  return null
232
310
  }
233
311
  }
234
312
 
313
+ /**
314
+ * Decodes one byte slice with a cached runtime decoder.
315
+ * @param {Uint8Array} bytes
316
+ * @param {string} encoding
317
+ * @param {{ fatal?: boolean }} [options]
318
+ * @returns {string}
319
+ */
320
+ static #decode(bytes, encoding, options = {}) {
321
+ return PrintableTextDecoder.#getTextDecoder(encoding, options).decode(
322
+ bytes
323
+ )
324
+ }
325
+
326
+ /**
327
+ * Resolves a cached TextDecoder for one encoding and fatal mode.
328
+ * @param {string} encoding
329
+ * @param {{ fatal?: boolean }} options
330
+ * @returns {TextDecoder}
331
+ */
332
+ static #getTextDecoder(encoding, options) {
333
+ const Decoder = globalThis.TextDecoder
334
+ if (PrintableTextDecoder.#decoderConstructor !== Decoder) {
335
+ PrintableTextDecoder.#decoderCache = new Map()
336
+ PrintableTextDecoder.#decoderConstructor = Decoder
337
+ }
338
+
339
+ const normalizedEncoding = String(encoding || 'utf-8').toLowerCase()
340
+ const fatal = Boolean(options?.fatal)
341
+ const cacheKey = `${normalizedEncoding}:${fatal ? 'fatal' : 'replace'}`
342
+ const cached = PrintableTextDecoder.#decoderCache.get(cacheKey)
343
+ if (cached) {
344
+ return cached
345
+ }
346
+
347
+ const decoder = new Decoder(
348
+ normalizedEncoding,
349
+ fatal ? { fatal: true } : {}
350
+ )
351
+ PrintableTextDecoder.#decoderCache.set(cacheKey, decoder)
352
+ return decoder
353
+ }
354
+
235
355
  /**
236
356
  * Tries a Windows-1252 decode and normalizes runtimes that expose C1 bytes
237
357
  * as control characters instead of punctuation.
@@ -66,6 +66,19 @@ export class SchematicComponentOwnerTextResolver {
66
66
  )
67
67
  }
68
68
 
69
+ /**
70
+ * Resolves candidate owner indexes for one schematic component record.
71
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }} componentRecord Component placement record.
72
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }[]} records Indexed schematic records.
73
+ * @returns {string[]}
74
+ */
75
+ static resolveOwnerIndexes(componentRecord, records) {
76
+ return SchematicComponentOwnerTextResolver.#resolveOwnerIndexes(
77
+ componentRecord,
78
+ records
79
+ )
80
+ }
81
+
69
82
  /**
70
83
  * Resolves candidate owner indexes for one schematic component record.
71
84
  * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }} componentRecord Component placement record.
@@ -56,7 +56,7 @@ export class SchematicComponentTextResolver {
56
56
  */
57
57
  static resolveValue(ownerTexts, texts, component) {
58
58
  const ownerValue =
59
- SchematicComponentTextResolver.#findFirstRelatedTextRecord(
59
+ SchematicComponentTextResolver.#findFirstUsableRelatedTextRecord(
60
60
  ownerTexts,
61
61
  ['Comment', 'VALUE']
62
62
  )
@@ -128,6 +128,45 @@ export class SchematicComponentTextResolver {
128
128
  return { found: false, text: '' }
129
129
  }
130
130
 
131
+ /**
132
+ * Finds the first owner text that is resolved or explicitly empty, while
133
+ * allowing unresolved templates to fall through to later owner parameters.
134
+ * @param {{ fields: Record<string, string | string[]> }[]} records
135
+ * @param {string[]} logicalNames Logical text names.
136
+ * @returns {{ found: boolean, text: string }}
137
+ */
138
+ static #findFirstUsableRelatedTextRecord(records, logicalNames) {
139
+ let unresolvedMatch = { found: false, text: '' }
140
+
141
+ for (const logicalName of logicalNames) {
142
+ const match = SchematicComponentTextResolver.#findRelatedTextRecord(
143
+ records,
144
+ logicalName
145
+ )
146
+
147
+ if (!match.found) {
148
+ continue
149
+ }
150
+
151
+ if (
152
+ SchematicComponentTextResolver.#isExplicitEmptyText(
153
+ match.text
154
+ ) ||
155
+ SchematicComponentTextResolver.#isResolvedComponentText(
156
+ match.text
157
+ )
158
+ ) {
159
+ return match
160
+ }
161
+
162
+ if (!unresolvedMatch.found) {
163
+ unresolvedMatch = match
164
+ }
165
+ }
166
+
167
+ return unresolvedMatch
168
+ }
169
+
131
170
  /**
132
171
  * Finds the closest nearby designator text for one component.
133
172
  * @param {{ x: number, y: number, text: string, name: string }[]} texts
@@ -18,12 +18,14 @@ export class SchematicDisplayModeCatalogParser {
18
18
  * @returns {object | null}
19
19
  */
20
20
  static parse(records) {
21
+ const childrenByOwner =
22
+ SchematicDisplayModeCatalogParser.#ownerChildrenByOwner(records)
21
23
  const components = (records || [])
22
24
  .filter((record) => getField(record.fields, 'RECORD') === '1')
23
25
  .map((record) =>
24
26
  SchematicDisplayModeCatalogParser.#componentCatalog(
25
27
  record,
26
- records
28
+ childrenByOwner
27
29
  )
28
30
  )
29
31
  .filter(Boolean)
@@ -41,17 +43,17 @@ export class SchematicDisplayModeCatalogParser {
41
43
  /**
42
44
  * Builds one component display-mode catalog row.
43
45
  * @param {object} componentRecord Component record.
44
- * @param {object[]} records All schematic records.
46
+ * @param {Map<string, object[]>} childrenByOwner Child records by owner index.
45
47
  * @returns {object}
46
48
  */
47
- static #componentCatalog(componentRecord, records) {
49
+ static #componentCatalog(componentRecord, childrenByOwner) {
48
50
  const indexInSheet = parseNumericField(
49
51
  componentRecord.fields,
50
52
  'IndexInSheet'
51
53
  )
52
54
  const ownerIndex = String(indexInSheet ?? componentRecord.recordIndex)
53
55
  const children = SchematicDisplayModeCatalogParser.#ownerChildren(
54
- records,
56
+ childrenByOwner,
55
57
  ownerIndex
56
58
  )
57
59
  const declaredPartCount =
@@ -95,16 +97,39 @@ export class SchematicDisplayModeCatalogParser {
95
97
 
96
98
  /**
97
99
  * Returns child records with owner part/display metadata.
98
- * @param {object[]} records All schematic records.
100
+ * @param {Map<string, object[]>} childrenByOwner Child records by owner index.
99
101
  * @param {string} ownerIndex Component owner index.
100
102
  * @returns {object[]}
101
103
  */
102
- static #ownerChildren(records, ownerIndex) {
103
- return (records || []).filter(
104
- (record) =>
105
- getField(record.fields, 'OwnerIndex') === ownerIndex &&
106
- parseNumericField(record.fields, 'OwnerPartID') !== null
107
- )
104
+ static #ownerChildren(childrenByOwner, ownerIndex) {
105
+ return childrenByOwner.get(ownerIndex) || []
106
+ }
107
+
108
+ /**
109
+ * Indexes schematic owner child records once for display-mode cataloguing.
110
+ * @param {object[]} records Schematic records.
111
+ * @returns {Map<string, object[]>}
112
+ */
113
+ static #ownerChildrenByOwner(records) {
114
+ const childrenByOwner = new Map()
115
+
116
+ for (const record of records || []) {
117
+ if (parseNumericField(record.fields, 'OwnerPartID') === null) {
118
+ continue
119
+ }
120
+
121
+ const ownerIndex = getField(record.fields, 'OwnerIndex')
122
+ if (!ownerIndex) {
123
+ continue
124
+ }
125
+
126
+ if (!childrenByOwner.has(ownerIndex)) {
127
+ childrenByOwner.set(ownerIndex, [])
128
+ }
129
+ childrenByOwner.get(ownerIndex).push(record)
130
+ }
131
+
132
+ return childrenByOwner
108
133
  }
109
134
 
110
135
  /**