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
@@ -143,6 +143,63 @@ export class SchematicOwnerPinLabelLayout {
143
143
  return offsets
144
144
  }
145
145
 
146
+ /**
147
+ * Collects compact FET-like owner groups whose numeric contact labels need
148
+ * to stay outside the owner-drawn device body.
149
+ * @param {{ ownerIndex?: string, name?: string, designator?: string, length?: number, orientation: 'left' | 'right' | 'top' | 'bottom', labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }[]} pins
150
+ * @returns {Map<string, 'left' | 'right'>}
151
+ */
152
+ static collectCompactExternalNumberLabelSides(pins) {
153
+ const ownerPins = new Map()
154
+
155
+ for (const pin of pins) {
156
+ const ownerIndex = String(pin.ownerIndex || '').trim()
157
+ if (!ownerIndex) continue
158
+ if (!ownerPins.has(ownerIndex)) ownerPins.set(ownerIndex, [])
159
+ ownerPins.get(ownerIndex).push(pin)
160
+ }
161
+
162
+ const sides = new Map()
163
+
164
+ for (const [ownerIndex, groupedPins] of ownerPins.entries()) {
165
+ const side =
166
+ SchematicOwnerPinLabelLayout.#resolveCompactExternalNumberLabelSide(
167
+ groupedPins
168
+ )
169
+ if (side) sides.set(ownerIndex, side)
170
+ }
171
+
172
+ return sides
173
+ }
174
+
175
+ /**
176
+ * Collects rectangular owner bodies whose numeric-only horizontal pin
177
+ * labels are drawn inside the body, close to the body edge.
178
+ * @param {{ ownerIndex?: string, name?: string, designator?: string, length?: number, x: number, y: number, orientation: 'left' | 'right' | 'top' | 'bottom', labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }[]} pins
179
+ * @param {{ ownerIndex?: string, x: number, y: number, width: number, height: number }[]} rectangles
180
+ * @returns {Map<string, { left: number, right: number }>}
181
+ */
182
+ static collectInternalNumberLabelBoxes(pins, rectangles) {
183
+ const ownerPins = SchematicOwnerPinLabelLayout.#groupByOwnerIndex(pins)
184
+ const ownerRectangles =
185
+ SchematicOwnerPinLabelLayout.#groupByOwnerIndex(rectangles)
186
+ const boxes = new Map()
187
+
188
+ for (const [ownerIndex, groupedPins] of ownerPins.entries()) {
189
+ const box =
190
+ SchematicOwnerPinLabelLayout.#resolveInternalNumberLabelBox(
191
+ groupedPins,
192
+ ownerRectangles.get(ownerIndex) || []
193
+ )
194
+
195
+ if (box) {
196
+ boxes.set(ownerIndex, box)
197
+ }
198
+ }
199
+
200
+ return boxes
201
+ }
202
+
146
203
  /**
147
204
  * Resolves the final SVG text anchor for one schematic free-text label.
148
205
  * Mirrored rotated owner pin-name labels need the opposite text edge so
@@ -170,6 +227,234 @@ export class SchematicOwnerPinLabelLayout {
170
227
  return Number(text.y) >= Number(matchedOwnerPin.y) ? 'end' : 'start'
171
228
  }
172
229
 
230
+ /**
231
+ * Resolves the side that should carry compact owner-drawn pin numbers.
232
+ * @param {{ name?: string, designator?: string, length?: number, orientation: 'left' | 'right' | 'top' | 'bottom', labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }[]} pins
233
+ * @returns {'left' | 'right' | null}
234
+ */
235
+ static #resolveCompactExternalNumberLabelSide(pins) {
236
+ if (
237
+ pins.length !== 4 ||
238
+ !pins.every(
239
+ (pin) =>
240
+ (pin.labelMode || 'name-and-number') === 'number-only' &&
241
+ /^\d+$/.test(String(pin.designator || '').trim()) &&
242
+ Math.abs(Number(pin.length || 0)) <= 20 &&
243
+ SchematicOwnerPinLabelLayout.#isFetTerminalName(pin.name)
244
+ )
245
+ ) {
246
+ return null
247
+ }
248
+
249
+ const hasTopPin = pins.some((pin) => pin.orientation === 'top')
250
+ const hasBottomPin = pins.some((pin) => pin.orientation === 'bottom')
251
+ const horizontalPins = pins.filter(
252
+ (pin) => pin.orientation === 'left' || pin.orientation === 'right'
253
+ )
254
+ const horizontalSides = new Set(
255
+ horizontalPins.map((pin) => pin.orientation)
256
+ )
257
+
258
+ if (
259
+ !hasTopPin ||
260
+ !hasBottomPin ||
261
+ horizontalPins.length === 0 ||
262
+ horizontalSides.size !== 1
263
+ ) {
264
+ return null
265
+ }
266
+
267
+ return horizontalPins[0].orientation
268
+ }
269
+
270
+ /**
271
+ * Resolves one rectangular body suitable for internal numeric pin labels.
272
+ * @param {{ name?: string, designator?: string, length?: number, x: number, y: number, orientation: 'left' | 'right' | 'top' | 'bottom', labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }[]} pins
273
+ * @param {{ x: number, y: number, width: number, height: number }[]} rectangles
274
+ * @returns {{ left: number, right: number } | null}
275
+ */
276
+ static #resolveInternalNumberLabelBox(pins, rectangles) {
277
+ if (
278
+ pins.length < 4 ||
279
+ !pins.every((pin) =>
280
+ SchematicOwnerPinLabelLayout.#isInternalNumberLabelPin(pin)
281
+ )
282
+ ) {
283
+ return null
284
+ }
285
+
286
+ const sides = new Set(pins.map((pin) => pin.orientation))
287
+
288
+ if (!sides.has('left') || !sides.has('right') || sides.size !== 2) {
289
+ return null
290
+ }
291
+
292
+ const rectangle =
293
+ SchematicOwnerPinLabelLayout.#findInternalNumberLabelRectangle(
294
+ pins,
295
+ rectangles
296
+ )
297
+
298
+ if (!rectangle) {
299
+ return null
300
+ }
301
+
302
+ return {
303
+ left: rectangle.left,
304
+ right: rectangle.right
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Returns the owner rectangle whose vertical edges carry every pin.
310
+ * @param {{ x: number, y: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
311
+ * @param {{ x: number, y: number, width: number, height: number }[]} rectangles
312
+ * @returns {{ left: number, right: number, top: number, bottom: number } | null}
313
+ */
314
+ static #findInternalNumberLabelRectangle(pins, rectangles) {
315
+ for (const rectangle of rectangles) {
316
+ const normalized =
317
+ SchematicOwnerPinLabelLayout.#normalizeRectangle(rectangle)
318
+
319
+ if (
320
+ normalized &&
321
+ SchematicOwnerPinLabelLayout.#pinsAlignWithRectangleEdges(
322
+ pins,
323
+ normalized
324
+ )
325
+ ) {
326
+ return normalized
327
+ }
328
+ }
329
+
330
+ return null
331
+ }
332
+
333
+ /**
334
+ * Returns true when every pin body endpoint lies on a vertical body edge.
335
+ * @param {{ x: number, y: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
336
+ * @param {{ left: number, right: number, top: number, bottom: number }} rectangle
337
+ * @returns {boolean}
338
+ */
339
+ static #pinsAlignWithRectangleEdges(pins, rectangle) {
340
+ const tolerance = 1.5
341
+
342
+ for (const pin of pins) {
343
+ const x = Number(pin.x)
344
+ const y = Number(pin.y)
345
+
346
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
347
+ return false
348
+ }
349
+
350
+ if (
351
+ y < rectangle.top - tolerance ||
352
+ y > rectangle.bottom + tolerance
353
+ ) {
354
+ return false
355
+ }
356
+
357
+ if (
358
+ pin.orientation === 'left' &&
359
+ Math.abs(x - rectangle.left) > tolerance
360
+ ) {
361
+ return false
362
+ }
363
+
364
+ if (
365
+ pin.orientation === 'right' &&
366
+ Math.abs(x - rectangle.right) > tolerance
367
+ ) {
368
+ return false
369
+ }
370
+ }
371
+
372
+ return true
373
+ }
374
+
375
+ /**
376
+ * Normalizes a rectangle to absolute edge coordinates.
377
+ * @param {{ x: number, y: number, width: number, height: number }} rectangle
378
+ * @returns {{ left: number, right: number, top: number, bottom: number } | null}
379
+ */
380
+ static #normalizeRectangle(rectangle) {
381
+ const x1 = Number(rectangle?.x)
382
+ const y1 = Number(rectangle?.y)
383
+ const x2 = x1 + Number(rectangle?.width)
384
+ const y2 = y1 + Number(rectangle?.height)
385
+
386
+ if (
387
+ !Number.isFinite(x1) ||
388
+ !Number.isFinite(y1) ||
389
+ !Number.isFinite(x2) ||
390
+ !Number.isFinite(y2) ||
391
+ x1 === x2 ||
392
+ y1 === y2
393
+ ) {
394
+ return null
395
+ }
396
+
397
+ return {
398
+ left: Math.min(x1, x2),
399
+ right: Math.max(x1, x2),
400
+ top: Math.min(y1, y2),
401
+ bottom: Math.max(y1, y2)
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Returns true for numeric-only horizontal pins on short owner stubs.
407
+ * @param {{ name?: string, designator?: string, length?: number, orientation: 'left' | 'right' | 'top' | 'bottom', labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }} pin
408
+ * @returns {boolean}
409
+ */
410
+ static #isInternalNumberLabelPin(pin) {
411
+ const name = String(pin.name || '').trim()
412
+ const designator = String(pin.designator || '').trim()
413
+
414
+ return (
415
+ (pin.orientation === 'left' || pin.orientation === 'right') &&
416
+ (pin.labelMode || 'name-and-number') === 'number-only' &&
417
+ /^\d+$/.test(designator) &&
418
+ (!name || /^\d+$/.test(name)) &&
419
+ Math.abs(Number(pin.length || 0)) <= 30
420
+ )
421
+ }
422
+
423
+ /**
424
+ * Groups schematic owner-local primitives by owner index.
425
+ * @template T
426
+ * @param {(T & { ownerIndex?: string })[]} items
427
+ * @returns {Map<string, T[]>}
428
+ */
429
+ static #groupByOwnerIndex(items) {
430
+ const groups = new Map()
431
+
432
+ for (const item of items) {
433
+ const ownerIndex = String(item.ownerIndex || '').trim()
434
+
435
+ if (!ownerIndex) {
436
+ continue
437
+ }
438
+
439
+ if (!groups.has(ownerIndex)) {
440
+ groups.set(ownerIndex, [])
441
+ }
442
+
443
+ groups.get(ownerIndex).push(item)
444
+ }
445
+
446
+ return groups
447
+ }
448
+
449
+ /**
450
+ * Returns true for FET terminal names, including numbered gate/source pins.
451
+ * @param {string | undefined} name
452
+ * @returns {boolean}
453
+ */
454
+ static #isFetTerminalName(name) {
455
+ return /^(?:[DS]|[GS]\d*)$/i.test(String(name || '').trim())
456
+ }
457
+
173
458
  /**
174
459
  * Moves left/right pin numbers outward by the same horizontal correction
175
460
  * already applied to their explicit owner pin-name labels.
@@ -206,7 +491,7 @@ export class SchematicOwnerPinLabelLayout {
206
491
  /**
207
492
  * Resolves schematic pin-number placement.
208
493
  * @param {{ x: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
209
- * @param {'single-in' | 'single-out' | 'double' | null} markerStyle
494
+ * @param {'single-in' | 'single-out' | 'double' | 'cross' | null} markerStyle
210
495
  * @param {{ rotateTopNumber?: boolean }} options
211
496
  * @returns {{ x: number, yOffset: number, anchor: 'start' | 'middle' | 'end', rotation: number } | null}
212
497
  */
@@ -308,7 +593,7 @@ export class SchematicOwnerPinLabelLayout {
308
593
 
309
594
  /**
310
595
  * Returns the horizontal pin-number clearance needed by the pin geometry.
311
- * @param {'single-in' | 'single-out' | 'double' | null} markerStyle
596
+ * @param {'single-in' | 'single-out' | 'double' | 'cross' | null} markerStyle
312
597
  * @param {{ length?: number }} pin
313
598
  * @returns {number}
314
599
  */
@@ -316,6 +601,8 @@ export class SchematicOwnerPinLabelLayout {
316
601
  switch (markerStyle) {
317
602
  case 'double':
318
603
  return 17
604
+ case 'cross':
605
+ return 12
319
606
  case 'single-in':
320
607
  case 'single-out':
321
608
  return 8
@@ -357,7 +644,7 @@ export class SchematicOwnerPinLabelLayout {
357
644
  /**
358
645
  * Resolves one authored outer pin marker style from the stored symbol flag.
359
646
  * @param {{ symbolOuter?: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
360
- * @returns {'single-in' | 'single-out' | 'double' | null}
647
+ * @returns {'single-in' | 'single-out' | 'double' | 'cross' | null}
361
648
  */
362
649
  static #resolveOuterPinMarkerStyle(pin) {
363
650
  if (pin.orientation !== 'left' && pin.orientation !== 'right') {
@@ -370,6 +657,8 @@ export class SchematicOwnerPinLabelLayout {
370
657
  return 'single-out'
371
658
  case 2:
372
659
  return 'single-in'
660
+ case 6:
661
+ return 'cross'
373
662
  case 34:
374
663
  return 'double'
375
664
  default:
@@ -22,6 +22,8 @@ export class SchematicPinSvgRenderer {
22
22
  * @param {Set<string>} rotatedVerticalNumberOwners
23
23
  * @param {Set<string>} explicitOwnerPinNameLabels
24
24
  * @param {Map<string, number>} explicitOwnerPinLabelOffsets
25
+ * @param {Map<string, 'left' | 'right'>} compactExternalNumberLabelSides
26
+ * @param {Map<string, { left: number, right: number }>} internalNumberLabelBoxes
25
27
  * @returns {string}
26
28
  */
27
29
  static buildMarkup(
@@ -30,7 +32,9 @@ export class SchematicPinSvgRenderer {
30
32
  sheet,
31
33
  rotatedVerticalNumberOwners,
32
34
  explicitOwnerPinNameLabels,
33
- explicitOwnerPinLabelOffsets
35
+ explicitOwnerPinLabelOffsets,
36
+ compactExternalNumberLabelSides = new Map(),
37
+ internalNumberLabelBoxes = new Map()
34
38
  ) {
35
39
  const geometry =
36
40
  SchematicPinSvgRenderer.#projectSchematicPinGeometry(pin)
@@ -60,15 +64,43 @@ export class SchematicPinSvgRenderer {
60
64
  const hasExplicitOwnerPinName =
61
65
  Boolean(pin.name) &&
62
66
  explicitOwnerPinNameLabels.has(ownerPinLabelKey)
67
+ const compactExternalNumberLabelSide =
68
+ compactExternalNumberLabelSides.get(String(pin.ownerIndex || '')) ||
69
+ null
70
+ const internalNumberLabelBox =
71
+ internalNumberLabelBoxes.get(String(pin.ownerIndex || '')) || null
63
72
 
64
73
  if (pin.orientation === 'left') {
65
74
  if (labelMode !== 'hidden' && labelMode !== 'name-only') {
66
- const defaultNumberX =
67
- geometry.bodyX -
68
- SchematicPinSvgRenderer.#resolveHorizontalPinNumberClearance(
69
- outerMarkerStyle,
70
- pin
75
+ const internalNumberPlacement =
76
+ SchematicPinSvgRenderer.#resolveInternalHorizontalNumberPlacement(
77
+ pin,
78
+ internalNumberLabelBox
79
+ )
80
+ if (internalNumberPlacement) {
81
+ texts.push(
82
+ SchematicPinSvgRenderer.#buildPinNameTextMarkup(
83
+ 'schematic-pin-name',
84
+ internalNumberPlacement.x,
85
+ projectedY - 1,
86
+ pin,
87
+ labelColor,
88
+ internalNumberPlacement.anchor,
89
+ textOptions
90
+ )
71
91
  )
92
+ }
93
+
94
+ const defaultNumberX = compactExternalNumberLabelSide
95
+ ? SchematicPinSvgRenderer.#resolveCompactExternalHorizontalNumberX(
96
+ pin,
97
+ geometry
98
+ )
99
+ : geometry.bodyX -
100
+ SchematicPinSvgRenderer.#resolveHorizontalPinNumberClearance(
101
+ outerMarkerStyle,
102
+ pin
103
+ )
72
104
  const numberX = hasExplicitOwnerPinName
73
105
  ? SchematicOwnerPinLabelLayout.resolveExplicitOwnerPinNumberX(
74
106
  pin,
@@ -116,12 +148,35 @@ export class SchematicPinSvgRenderer {
116
148
 
117
149
  if (pin.orientation === 'right') {
118
150
  if (labelMode !== 'hidden' && labelMode !== 'name-only') {
119
- const defaultNumberX =
120
- geometry.bodyX +
121
- SchematicPinSvgRenderer.#resolveHorizontalPinNumberClearance(
122
- outerMarkerStyle,
123
- pin
151
+ const internalNumberPlacement =
152
+ SchematicPinSvgRenderer.#resolveInternalHorizontalNumberPlacement(
153
+ pin,
154
+ internalNumberLabelBox
124
155
  )
156
+ if (internalNumberPlacement) {
157
+ texts.push(
158
+ SchematicPinSvgRenderer.#buildPinNameTextMarkup(
159
+ 'schematic-pin-name',
160
+ internalNumberPlacement.x,
161
+ projectedY - 1,
162
+ pin,
163
+ labelColor,
164
+ internalNumberPlacement.anchor,
165
+ textOptions
166
+ )
167
+ )
168
+ }
169
+
170
+ const defaultNumberX = compactExternalNumberLabelSide
171
+ ? SchematicPinSvgRenderer.#resolveCompactExternalHorizontalNumberX(
172
+ pin,
173
+ geometry
174
+ )
175
+ : geometry.bodyX +
176
+ SchematicPinSvgRenderer.#resolveHorizontalPinNumberClearance(
177
+ outerMarkerStyle,
178
+ pin
179
+ )
125
180
  const numberX = hasExplicitOwnerPinName
126
181
  ? SchematicOwnerPinLabelLayout.resolveExplicitOwnerPinNumberX(
127
182
  pin,
@@ -175,7 +230,12 @@ export class SchematicPinSvgRenderer {
175
230
  texts.push(
176
231
  createSvgText(
177
232
  'schematic-pin-number',
178
- geometry.bodyX - 2,
233
+ compactExternalNumberLabelSide
234
+ ? SchematicPinSvgRenderer.#resolveCompactExternalVerticalNumberX(
235
+ geometry,
236
+ compactExternalNumberLabelSide
237
+ )
238
+ : geometry.bodyX - 2,
179
239
  pin.orientation === 'top'
180
240
  ? projectedInnerY - 6
181
241
  : projectedInnerY + 7,
@@ -324,7 +384,7 @@ export class SchematicPinSvgRenderer {
324
384
  * @param {{ orientation: 'left' | 'right' | 'top' | 'bottom', labelColor?: string, color: string }} pin
325
385
  * @param {{ bodyX: number, bodyY: number }} geometry
326
386
  * @param {number} sheetHeight
327
- * @param {'single-in' | 'single-out' | 'double'} markerStyle
387
+ * @param {'single-in' | 'single-out' | 'double' | 'cross'} markerStyle
328
388
  * @returns {string}
329
389
  */
330
390
  static #buildOuterPinMarkerMarkup(pin, geometry, sheetHeight, markerStyle) {
@@ -339,6 +399,15 @@ export class SchematicPinSvgRenderer {
339
399
  '--schematic-text-color'
340
400
  )
341
401
 
402
+ if (markerStyle === 'cross') {
403
+ return SchematicPinSvgRenderer.#buildOuterPinCrossMarkerMarkup(
404
+ geometry.bodyX,
405
+ projectedY,
406
+ pin.orientation,
407
+ strokeColor
408
+ )
409
+ }
410
+
342
411
  const polygons = SchematicPinSvgRenderer.#buildOuterPinMarkerPolygons(
343
412
  geometry.bodyX,
344
413
  projectedY,
@@ -374,7 +443,7 @@ export class SchematicPinSvgRenderer {
374
443
 
375
444
  /**
376
445
  * Returns the horizontal pin-number clearance needed by the pin geometry.
377
- * @param {'single-in' | 'single-out' | 'double' | null} markerStyle
446
+ * @param {'single-in' | 'single-out' | 'double' | 'cross' | null} markerStyle
378
447
  * @param {{ length?: number }} pin
379
448
  * @returns {number}
380
449
  */
@@ -382,6 +451,8 @@ export class SchematicPinSvgRenderer {
382
451
  switch (markerStyle) {
383
452
  case 'double':
384
453
  return 17
454
+ case 'cross':
455
+ return 12
385
456
  case 'single-in':
386
457
  case 'single-out':
387
458
  return 8
@@ -420,6 +491,115 @@ export class SchematicPinSvgRenderer {
420
491
  return fallback === 2 ? 10 : 8
421
492
  }
422
493
 
494
+ /**
495
+ * Places numeric-only labels just inside a rectangular owner body.
496
+ * @param {{ name?: string, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
497
+ * @param {{ left: number, right: number } | null} box
498
+ * @returns {{ x: number, anchor: 'middle' } | null}
499
+ */
500
+ static #resolveInternalHorizontalNumberPlacement(pin, box) {
501
+ if (
502
+ !box ||
503
+ (pin.orientation !== 'left' && pin.orientation !== 'right') ||
504
+ !/^\d+$/u.test(String(pin.name || '').trim())
505
+ ) {
506
+ return null
507
+ }
508
+
509
+ const left = Number(box.left)
510
+ const right = Number(box.right)
511
+
512
+ if (
513
+ !Number.isFinite(left) ||
514
+ !Number.isFinite(right) ||
515
+ right <= left
516
+ ) {
517
+ return null
518
+ }
519
+
520
+ const inset = Math.min(6, Math.max(3, (right - left) / 10))
521
+
522
+ return {
523
+ x: pin.orientation === 'left' ? left + inset : right - inset,
524
+ anchor: 'middle'
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Places compact owner-drawn side pin numbers near the external pin stub.
530
+ * @param {{ length?: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
531
+ * @param {{ bodyX: number }} geometry
532
+ * @returns {number}
533
+ */
534
+ static #resolveCompactExternalHorizontalNumberX(pin, geometry) {
535
+ const length = Math.abs(Number(pin.length || 0))
536
+ const offset = Math.max(8, Math.min(length - 6, 12))
537
+
538
+ if (pin.orientation === 'left') {
539
+ return geometry.bodyX - offset
540
+ }
541
+
542
+ if (pin.orientation === 'right') {
543
+ return geometry.bodyX + offset
544
+ }
545
+
546
+ return geometry.bodyX
547
+ }
548
+
549
+ /**
550
+ * Places compact owner-drawn vertical pin numbers on the same side as the
551
+ * horizontal terminal contacts.
552
+ * @param {{ bodyX: number }} geometry
553
+ * @param {'left' | 'right'} side
554
+ * @returns {number}
555
+ */
556
+ static #resolveCompactExternalVerticalNumberX(geometry, side) {
557
+ return geometry.bodyX + (side === 'left' ? -10 : 10)
558
+ }
559
+
560
+ /**
561
+ * Builds an authored outer pin cross marker.
562
+ * @param {number} bodyX
563
+ * @param {number} projectedY
564
+ * @param {'left' | 'right' | 'top' | 'bottom'} orientation
565
+ * @param {string} strokeColor
566
+ * @returns {string}
567
+ */
568
+ static #buildOuterPinCrossMarkerMarkup(
569
+ bodyX,
570
+ projectedY,
571
+ orientation,
572
+ strokeColor
573
+ ) {
574
+ const direction = orientation === 'left' ? 1 : -1
575
+ const centerX = bodyX - direction * 8
576
+ const halfSize = 3
577
+
578
+ return (
579
+ '<g class="schematic-pin-marker"><line x1="' +
580
+ formatNumber(centerX - halfSize) +
581
+ '" y1="' +
582
+ formatNumber(projectedY - halfSize) +
583
+ '" x2="' +
584
+ formatNumber(centerX + halfSize) +
585
+ '" y2="' +
586
+ formatNumber(projectedY + halfSize) +
587
+ '" stroke="' +
588
+ escapeHtml(strokeColor) +
589
+ '" stroke-width="0.75" vector-effect="non-scaling-stroke" /><line x1="' +
590
+ formatNumber(centerX - halfSize) +
591
+ '" y1="' +
592
+ formatNumber(projectedY + halfSize) +
593
+ '" x2="' +
594
+ formatNumber(centerX + halfSize) +
595
+ '" y2="' +
596
+ formatNumber(projectedY - halfSize) +
597
+ '" stroke="' +
598
+ escapeHtml(strokeColor) +
599
+ '" stroke-width="0.75" vector-effect="non-scaling-stroke" /></g>'
600
+ )
601
+ }
602
+
423
603
  /**
424
604
  * Builds one or two authored outer-marker polygons for one horizontal pin.
425
605
  * @param {number} bodyX
@@ -469,7 +649,7 @@ export class SchematicPinSvgRenderer {
469
649
  /**
470
650
  * Resolves one authored outer pin marker style from the stored symbol flag.
471
651
  * @param {{ symbolOuter?: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
472
- * @returns {'single-in' | 'single-out' | 'double' | null}
652
+ * @returns {'single-in' | 'single-out' | 'double' | 'cross' | null}
473
653
  */
474
654
  static #resolveSchematicOuterPinMarkerStyle(pin) {
475
655
  if (pin.orientation !== 'left' && pin.orientation !== 'right') {
@@ -483,6 +663,8 @@ export class SchematicPinSvgRenderer {
483
663
  return 'single-out'
484
664
  case 2:
485
665
  return 'single-in'
666
+ case 6:
667
+ return 'cross'
486
668
  case 34:
487
669
  return 'double'
488
670
  default: