altium-toolkit 1.1.38 → 1.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.1.38",
3
+ "version": "1.1.40",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -12,7 +12,7 @@ export class SchematicDirectiveParser {
12
12
  /**
13
13
  * Normalizes schematic directive records into drawable directive metadata.
14
14
  * @param {{ fields: Record<string, string | string[]> }[]} records
15
- * @returns {{ x: number, y: number, color: string, name: string, orientation: number }[]}
15
+ * @returns {{ x: number, y: number, color: string, name: string, orientation: number, style?: number }[]}
16
16
  */
17
17
  static parseSchematicDirectives(records) {
18
18
  return records
@@ -31,7 +31,11 @@ export class SchematicDirectiveParser {
31
31
  return null
32
32
  }
33
33
 
34
- return {
34
+ const style = ParserUtils.parseNumericField(
35
+ record.fields,
36
+ 'Style'
37
+ )
38
+ const directive = {
35
39
  x,
36
40
  y,
37
41
  color: ParserUtils.toColor(record.fields.Color, '#ff0000'),
@@ -42,6 +46,12 @@ export class SchematicDirectiveParser {
42
46
  'Orientation'
43
47
  ) || 0
44
48
  }
49
+
50
+ if (style !== null) {
51
+ directive.style = style
52
+ }
53
+
54
+ return directive
45
55
  })
46
56
  .filter(Boolean)
47
57
  }
@@ -210,6 +220,8 @@ export class SchematicDirectiveParser {
210
220
  orientation:
211
221
  ParserUtils.parseNumericField(record.fields, 'Orientation') ||
212
222
  0,
223
+ style:
224
+ ParserUtils.parseNumericField(record.fields, 'Style') ?? null,
213
225
  parameters,
214
226
  parameterMap,
215
227
  isDifferentialPair,
@@ -873,11 +873,18 @@ export class SchematicPinParser {
873
873
  ownerDrawnInternalPinOwners.has(ownerIndex) &&
874
874
  SchematicPinParser.#isCompactNumberedFetTerminalGroup(
875
875
  normalizedPins,
876
- semanticNames,
877
876
  orientationCount
878
877
  )
879
878
  ) {
880
879
  labelMode = 'number-only'
880
+ } else if (
881
+ ownerDrawnInternalPinOwners.has(ownerIndex) &&
882
+ SchematicPinParser.#isOwnerDrawnAmplifierTerminalGroup(
883
+ normalizedPins,
884
+ semanticNames
885
+ )
886
+ ) {
887
+ labelMode = 'number-only'
881
888
  } else if (
882
889
  SchematicPinParser.#isOwnerDrawnTerminalGlyphGroup(
883
890
  normalizedPins,
@@ -904,6 +911,15 @@ export class SchematicPinParser {
904
911
  )
905
912
  ) {
906
913
  labelMode = 'number-only'
914
+ } else if (
915
+ ownerDrawnInternalPinOwners.has(ownerIndex) &&
916
+ SchematicPinParser.#isCompactCommonTerminalDiodeGroup(
917
+ normalizedPins,
918
+ names,
919
+ orientationCount
920
+ )
921
+ ) {
922
+ labelMode = 'number-only'
907
923
  } else if (!semanticNames.length && orientationCount <= 2) {
908
924
  labelMode = 'number-only'
909
925
  } else if (
@@ -948,32 +964,94 @@ export class SchematicPinParser {
948
964
  }
949
965
 
950
966
  /**
951
- * Returns true when a compact owner-drawn FET body uses semantic terminal
952
- * names internally but still exposes external numeric contact labels.
967
+ * Returns true when a compact owner-drawn FET body or pin array uses
968
+ * semantic terminal names internally but still exposes external numeric
969
+ * contact labels.
953
970
  * @param {{ designator: string, name: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
954
- * @param {string[]} semanticNames
955
971
  * @param {number} orientationCount
956
972
  * @returns {boolean}
957
973
  */
958
- static #isCompactNumberedFetTerminalGroup(
959
- pins,
960
- semanticNames,
961
- orientationCount
962
- ) {
974
+ static #isCompactNumberedFetTerminalGroup(pins, orientationCount) {
963
975
  if (
964
- pins.length !== 4 ||
965
- orientationCount < 3 ||
976
+ pins.length < 4 ||
977
+ pins.length > 12 ||
978
+ orientationCount < 2 ||
979
+ !pins.every((pin) => /^\d+$/.test(String(pin.designator || '')))
980
+ ) {
981
+ return false
982
+ }
983
+
984
+ const normalizedNames = pins.map((pin) =>
985
+ String(pin.name || '')
986
+ .trim()
987
+ .toUpperCase()
988
+ )
989
+
990
+ return (
991
+ new Set(normalizedNames).size >= 2 &&
992
+ normalizedNames.every((name) =>
993
+ SchematicPinParser.#isFetTerminalName(name)
994
+ )
995
+ )
996
+ }
997
+
998
+ /**
999
+ * Returns true when an owner-drawn amplifier body already represents its
1000
+ * input, output, and supply terminal names in the drawn symbol artwork.
1001
+ * @param {{ designator: string, name: string }[]} pins
1002
+ * @param {string[]} semanticNames
1003
+ * @returns {boolean}
1004
+ */
1005
+ static #isOwnerDrawnAmplifierTerminalGroup(pins, semanticNames) {
1006
+ if (
1007
+ pins.length < 3 ||
1008
+ pins.length > 8 ||
966
1009
  semanticNames.length !== pins.length ||
967
1010
  !pins.every((pin) => /^\d+$/.test(String(pin.designator || '')))
968
1011
  ) {
969
1012
  return false
970
1013
  }
971
1014
 
972
- return semanticNames.every((name) =>
973
- SchematicPinParser.#isFetTerminalName(name)
1015
+ const normalizedNames = semanticNames.map((name) =>
1016
+ SchematicPinParser.#normalizeAmplifierTerminalName(name)
1017
+ )
1018
+
1019
+ return (
1020
+ normalizedNames.every(Boolean) &&
1021
+ normalizedNames.some((name) => name === 'IN+' || name === 'IN-') &&
1022
+ normalizedNames.includes('OUT')
974
1023
  )
975
1024
  }
976
1025
 
1026
+ /**
1027
+ * Normalizes amplifier terminal names that are normally drawn inside the
1028
+ * owner-authored symbol body.
1029
+ * @param {string} name Raw pin name.
1030
+ * @returns {string}
1031
+ */
1032
+ static #normalizeAmplifierTerminalName(name) {
1033
+ const normalized = String(name || '')
1034
+ .trim()
1035
+ .toUpperCase()
1036
+
1037
+ switch (normalized) {
1038
+ case 'IN+':
1039
+ case '+IN':
1040
+ case '+':
1041
+ return 'IN+'
1042
+ case 'IN-':
1043
+ case '-IN':
1044
+ case '-':
1045
+ return 'IN-'
1046
+ case 'OUT':
1047
+ case 'V+':
1048
+ case 'V-':
1049
+ return normalized
1050
+ default:
1051
+ return ''
1052
+ }
1053
+ }
1054
+
977
1055
  /**
978
1056
  * Returns true when a compact multi-side owner has transistor-like terminal
979
1057
  * letters that are part of the drawn symbol body, not external pin labels.
@@ -1059,6 +1137,48 @@ export class SchematicPinParser {
1059
1137
  )
1060
1138
  }
1061
1139
 
1140
+ /**
1141
+ * Returns true when a compact owner-drawn diode-like section exposes one
1142
+ * ordinary anode/cathode terminal and one common terminal name internally.
1143
+ * @param {{ designator: string, name: string, length: number, electrical?: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
1144
+ * @param {string[]} names
1145
+ * @param {number} orientationCount
1146
+ * @returns {boolean}
1147
+ */
1148
+ static #isCompactCommonTerminalDiodeGroup(pins, names, orientationCount) {
1149
+ if (
1150
+ pins.length !== 2 ||
1151
+ orientationCount !== 2 ||
1152
+ names.length !== pins.length ||
1153
+ !SchematicPinParser.#hasOptionalNumericPinDesignators(pins)
1154
+ ) {
1155
+ return false
1156
+ }
1157
+
1158
+ if (
1159
+ pins.some((pin) => {
1160
+ const length = Math.abs(Number(pin.length || 0))
1161
+ const electrical = Number(pin.electrical)
1162
+ return (
1163
+ length <= 0 ||
1164
+ length > 15 ||
1165
+ (Number.isFinite(electrical) && electrical !== 4)
1166
+ )
1167
+ })
1168
+ ) {
1169
+ return false
1170
+ }
1171
+
1172
+ const hasDiodeTerminal = names.some((name) =>
1173
+ SchematicPinParser.#isDiodeTerminalName(name)
1174
+ )
1175
+ const hasCommonTerminal = names.some((name) =>
1176
+ SchematicPinParser.#isCommonDiodeTerminalName(name)
1177
+ )
1178
+
1179
+ return hasDiodeTerminal && hasCommonTerminal
1180
+ }
1181
+
1062
1182
  /**
1063
1183
  * Returns true when compact owner-drawn terminal glyph pins have either no
1064
1184
  * external designators or ordinary numeric pin numbers.
@@ -1104,6 +1224,24 @@ export class SchematicPinParser {
1104
1224
  )
1105
1225
  }
1106
1226
 
1227
+ /**
1228
+ * Returns true for one-letter terminals used inside diode-style symbols.
1229
+ * @param {string} name
1230
+ * @returns {boolean}
1231
+ */
1232
+ static #isDiodeTerminalName(name) {
1233
+ return /^[AKC]$/i.test(String(name || '').trim())
1234
+ }
1235
+
1236
+ /**
1237
+ * Returns true for common-terminal names used by multipart diode symbols.
1238
+ * @param {string} name
1239
+ * @returns {boolean}
1240
+ */
1241
+ static #isCommonDiodeTerminalName(name) {
1242
+ return /^COM[AC]$/i.test(String(name || '').trim())
1243
+ }
1244
+
1107
1245
  /**
1108
1246
  * Returns true when one passive two-pin symbol uses the ordinary 1/2 pin
1109
1247
  * numbering that should stay hidden for simple resistor-like parts.
@@ -29,12 +29,19 @@ export class SchematicTextOrientationResolver {
29
29
  }
30
30
 
31
31
  /**
32
- * Resolves component text orientation bit 1 to top source anchoring.
32
+ * Resolves text vertical anchoring from Altium's justification grid and
33
+ * component text orientation bits.
33
34
  * @param {Record<string, string | string[]>} fields Text record fields.
34
35
  * @param {string} recordType Native text record type.
35
- * @returns {'top' | null}
36
+ * @returns {'middle' | 'top' | null}
36
37
  */
37
38
  static resolveVerticalAnchor(fields, recordType) {
39
+ const justificationAnchor =
40
+ SchematicTextOrientationResolver.#resolveJustificationVerticalAnchor(
41
+ fields
42
+ )
43
+ if (justificationAnchor) return justificationAnchor
44
+
38
45
  return SchematicTextOrientationResolver.resolveHorizontalAnchor(
39
46
  fields,
40
47
  recordType
@@ -65,6 +72,28 @@ export class SchematicTextOrientationResolver {
65
72
  return orientation === 1
66
73
  }
67
74
 
75
+ /**
76
+ * Resolves the vertical row from Altium's 3x3 justification grid.
77
+ * @param {Record<string, string | string[]>} fields Text record fields.
78
+ * @returns {'middle' | 'top' | null}
79
+ */
80
+ static #resolveJustificationVerticalAnchor(fields) {
81
+ const justification =
82
+ ParserUtils.parseNumericField(fields, 'Justification') ??
83
+ ParserUtils.parseNumericField(fields, 'Alignment')
84
+
85
+ if (!Number.isInteger(justification)) {
86
+ return null
87
+ }
88
+
89
+ const normalizedJustification = ((justification % 9) + 9) % 9
90
+ const verticalRow = Math.floor(normalizedJustification / 3)
91
+
92
+ if (verticalRow === 1) return 'middle'
93
+ if (verticalRow === 2) return 'top'
94
+ return null
95
+ }
96
+
68
97
  /**
69
98
  * Returns true for component designator and parameter text records.
70
99
  * @param {string} recordType Native text record type.
@@ -137,7 +137,7 @@ export class SchematicTextParser {
137
137
  * @param {{ width: number, marginWidth: number, titleBlockOn?: boolean }} sheet
138
138
  * @param {Record<string, { size: number, family: string, bold: boolean, italic?: boolean, rotation: number }>} fonts
139
139
  * @param {Map<string, Record<string, string>>} [ownerMetadata]
140
- * @returns {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, ownerIndex?: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string, rotation: number, sourceOrientation?: number, isMirrored?: boolean, anchor: 'start' | 'middle' | 'end', verticalAnchor?: 'top', powerPortDirection?: 'up' | 'down' | 'left' | 'right', cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] } | null}
140
+ * @returns {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, ownerIndex?: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string, rotation: number, sourceOrientation?: number, isMirrored?: boolean, anchor: 'start' | 'middle' | 'end', verticalAnchor?: 'middle' | 'top', powerPortDirection?: 'up' | 'down' | 'left' | 'right', cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] } | null}
141
141
  */
142
142
  static normalizeSchematicTextRecord(
143
143
  fields,
@@ -49,6 +49,12 @@ export class SchematicColorResolver {
49
49
  return normalized
50
50
  }
51
51
 
52
+ if (SchematicColorResolver.#isDefaultInkSourceColor(normalized)) {
53
+ return SchematicColorResolver.#toVariable(
54
+ '--schematic-default-ink-color'
55
+ )
56
+ }
57
+
52
58
  const token = COLOR_TOKEN_BY_VALUE.get(normalized)
53
59
 
54
60
  if (token) {
@@ -316,6 +322,30 @@ export class SchematicColorResolver {
316
322
  return Math.max(rgb.r, rgb.g, rgb.b) <= 32
317
323
  }
318
324
 
325
+ /**
326
+ * Returns true for dark saturated blue source colors that represent
327
+ * Altium's default schematic ink family rather than custom artwork color.
328
+ * @param {string} color Normalized color.
329
+ * @returns {boolean}
330
+ */
331
+ static #isDefaultInkSourceColor(color) {
332
+ const rgb = SchematicColorResolver.#parseHexColor(color)
333
+ if (!rgb) {
334
+ return false
335
+ }
336
+
337
+ const hsl = SchematicColorResolver.#rgbToHsl(rgb)
338
+ const hueDegrees = hsl.h * 360
339
+
340
+ return (
341
+ hueDegrees >= 220 &&
342
+ hueDegrees <= 270 &&
343
+ hsl.s >= 45 &&
344
+ hsl.l >= 12 &&
345
+ hsl.l <= 38
346
+ )
347
+ }
348
+
319
349
  /**
320
350
  * Converts RGB channels to HSL.
321
351
  * @param {{ r: number, g: number, b: number }} color
@@ -15,7 +15,7 @@ const { createSvgText, escapeHtml, formatNumber, projectSchematicY } =
15
15
  export class SchematicDirectiveRenderer {
16
16
  /**
17
17
  * Builds directive markup for supported schematic directive primitives.
18
- * @param {{ x: number, y: number, color: string, name: string, orientation?: number }[]} directives
18
+ * @param {{ x: number, y: number, color: string, name: string, orientation?: number, style?: number }[]} directives
19
19
  * @param {number} sheetHeight
20
20
  * @param {{ fonts?: Record<string, { size: number, family: string, bold: boolean }> }} sheet
21
21
  * @returns {string}
@@ -34,7 +34,7 @@ export class SchematicDirectiveRenderer {
34
34
 
35
35
  /**
36
36
  * Builds one supported directive glyph.
37
- * @param {{ x: number, y: number, color: string, name: string, orientation?: number }} directive
37
+ * @param {{ x: number, y: number, color: string, name: string, orientation?: number, style?: number }} directive
38
38
  * @param {number} sheetHeight
39
39
  * @param {{ fonts?: Record<string, { size: number, family: string, bold: boolean }> }} sheet
40
40
  * @returns {string}
@@ -67,13 +67,24 @@ export class SchematicDirectiveRenderer {
67
67
 
68
68
  /**
69
69
  * Builds the labeled info-callout marker for one parameter-set directive.
70
- * @param {{ x: number, y: number, color: string, name: string, orientation?: number }} directive
70
+ * @param {{ x: number, y: number, color: string, name: string, orientation?: number, style?: number }} directive
71
71
  * @param {number} sheetHeight
72
72
  * @param {{ fonts?: Record<string, { size: number, family: string, bold: boolean }> }} sheet
73
73
  * @param {string} classModifier
74
74
  * @returns {string}
75
75
  */
76
76
  static #buildRouteMarkup(directive, sheetHeight, sheet, classModifier) {
77
+ if (
78
+ classModifier === 'parameter-set' &&
79
+ Number(directive?.style) === 1
80
+ ) {
81
+ return SchematicDirectiveRenderer.#buildCompactParameterSetMarkup(
82
+ directive,
83
+ sheetHeight,
84
+ classModifier
85
+ )
86
+ }
87
+
77
88
  const color = SchematicColorResolver.resolveColor(
78
89
  directive.color,
79
90
  '--schematic-alert-color'
@@ -154,6 +165,61 @@ export class SchematicDirectiveRenderer {
154
165
  )
155
166
  }
156
167
 
168
+ /**
169
+ * Builds the compact style-1 parameter-set marker used for connection
170
+ * adornments whose class name is carried as metadata rather than text.
171
+ * @param {{ x: number, y: number, color: string, orientation?: number }} directive
172
+ * @param {number} sheetHeight
173
+ * @param {string} classModifier
174
+ * @returns {string}
175
+ */
176
+ static #buildCompactParameterSetMarkup(
177
+ directive,
178
+ sheetHeight,
179
+ classModifier
180
+ ) {
181
+ const color = SchematicColorResolver.resolveColor(
182
+ directive.color,
183
+ '--schematic-alert-color'
184
+ )
185
+ const projectedY = projectSchematicY(sheetHeight, directive.y)
186
+ const direction = SchematicDirectiveRenderer.#resolveCalloutDirection(
187
+ directive.orientation
188
+ )
189
+ const circleRadius = 3
190
+ const circleCenterX = directive.x + direction.x * 6
191
+ const circleCenterY = projectedY + direction.y * 6
192
+ const leaderEndX = circleCenterX - direction.x * circleRadius
193
+ const leaderEndY = circleCenterY - direction.y * circleRadius
194
+
195
+ return (
196
+ '<g class="schematic-directive schematic-directive--' +
197
+ escapeHtml(classModifier) +
198
+ '">' +
199
+ '<line x1="' +
200
+ formatNumber(directive.x) +
201
+ '" y1="' +
202
+ formatNumber(projectedY) +
203
+ '" x2="' +
204
+ formatNumber(leaderEndX) +
205
+ '" y2="' +
206
+ formatNumber(leaderEndY) +
207
+ '" stroke="' +
208
+ escapeHtml(color) +
209
+ '" stroke-width="1" />' +
210
+ '<circle cx="' +
211
+ formatNumber(circleCenterX) +
212
+ '" cy="' +
213
+ formatNumber(circleCenterY) +
214
+ '" r="' +
215
+ formatNumber(circleRadius) +
216
+ '" fill="none" stroke="' +
217
+ escapeHtml(color) +
218
+ '" stroke-width="1" />' +
219
+ '</g>'
220
+ )
221
+ }
222
+
157
223
  /**
158
224
  * Resolves Altium's four-way callout orientation into an outward vector.
159
225
  * @param {number | undefined} orientation
@@ -17,7 +17,8 @@ export class SchematicLineColorResolver {
17
17
  if (SchematicLineColorResolver.isElectricalLine(line)) {
18
18
  return SchematicColorResolver.resolveNonTextColor(
19
19
  line?.color,
20
- '--schematic-default-ink-color'
20
+ '--schematic-default-ink-color',
21
+ true
21
22
  )
22
23
  }
23
24
 
@@ -37,7 +38,8 @@ export class SchematicLineColorResolver {
37
38
 
38
39
  return SchematicColorResolver.resolveNonTextColor(
39
40
  line?.color,
40
- '--schematic-default-ink-color'
41
+ '--schematic-default-ink-color',
42
+ true
41
43
  )
42
44
  }
43
45
 
@@ -143,6 +143,56 @@ export class SchematicOwnerPinLabelLayout {
143
143
  return offsets
144
144
  }
145
145
 
146
+ /**
147
+ * Collects pins whose owner already draws a visible text label for the pin
148
+ * designator, avoiding a second synthetic pin-number label at the contact.
149
+ * @param {{ ownerIndex?: string, text?: string, x?: number, y?: number, recordType?: string, hidden?: boolean }[]} texts
150
+ * @param {{ ownerIndex?: string, designator?: string, x: number, y: number, length?: number, orientation: 'left' | 'right' | 'top' | 'bottom', labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }[]} pins
151
+ * @returns {Set<string>}
152
+ */
153
+ static collectExplicitOwnerPinNumberLabelKeys(texts, pins) {
154
+ const ownerTexts = SchematicOwnerPinLabelLayout.#groupByOwnerIndex(
155
+ (texts || []).filter(
156
+ (text) => text.recordType === '4' && !text.hidden
157
+ )
158
+ )
159
+ const keys = new Set()
160
+
161
+ for (const pin of pins || []) {
162
+ const ownerIndex = String(pin.ownerIndex || '').trim()
163
+ const designator = String(pin.designator || '').trim()
164
+
165
+ if (
166
+ !ownerIndex ||
167
+ !designator ||
168
+ (pin.labelMode || 'name-and-number') === 'hidden' ||
169
+ (pin.labelMode || 'name-and-number') === 'name-only'
170
+ ) {
171
+ continue
172
+ }
173
+
174
+ const nativeNumberLabels = ownerTexts.get(ownerIndex) || []
175
+ const hasNativeNumberLabel = nativeNumberLabels.some((text) =>
176
+ SchematicOwnerPinLabelLayout.#isExplicitOwnerPinNumberLabel(
177
+ text,
178
+ pin,
179
+ designator
180
+ )
181
+ )
182
+
183
+ if (hasNativeNumberLabel) {
184
+ keys.add(
185
+ SchematicOwnerPinLabelLayout.buildOwnerPinLabelKey(
186
+ ownerIndex,
187
+ designator
188
+ )
189
+ )
190
+ }
191
+ }
192
+
193
+ return keys
194
+ }
195
+
146
196
  /**
147
197
  * Collects compact FET-like owner groups whose numeric contact labels need
148
198
  * to stay outside the owner-drawn device body.
@@ -481,6 +531,53 @@ export class SchematicOwnerPinLabelLayout {
481
531
  )
482
532
  }
483
533
 
534
+ /**
535
+ * Returns true when an owner text is a native pin-number label for a pin.
536
+ * @param {{ text?: string, x?: number, y?: number }} text
537
+ * @param {{ x: number, y: number, length?: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
538
+ * @param {string} designator
539
+ * @returns {boolean}
540
+ */
541
+ static #isExplicitOwnerPinNumberLabel(text, pin, designator) {
542
+ if (String(text?.text || '').trim() !== designator) {
543
+ return false
544
+ }
545
+
546
+ const textX = Number(text?.x)
547
+ const textY = Number(text?.y)
548
+ const pinX = Number(pin?.x)
549
+ const pinY = Number(pin?.y)
550
+
551
+ if (
552
+ !Number.isFinite(textX) ||
553
+ !Number.isFinite(textY) ||
554
+ !Number.isFinite(pinX) ||
555
+ !Number.isFinite(pinY)
556
+ ) {
557
+ return false
558
+ }
559
+
560
+ const laneTolerance = 6
561
+ const axisTolerance =
562
+ Math.max(Math.abs(Number(pin.length || 0)), 10) + 6
563
+
564
+ if (pin.orientation === 'left' || pin.orientation === 'right') {
565
+ return (
566
+ Math.abs(textY - pinY) <= laneTolerance &&
567
+ Math.abs(textX - pinX) <= axisTolerance
568
+ )
569
+ }
570
+
571
+ if (pin.orientation === 'top' || pin.orientation === 'bottom') {
572
+ return (
573
+ Math.abs(textX - pinX) <= laneTolerance &&
574
+ Math.abs(textY - pinY) <= axisTolerance
575
+ )
576
+ }
577
+
578
+ return false
579
+ }
580
+
484
581
  /**
485
582
  * Groups schematic owner-local primitives by owner index.
486
583
  * @template T
@@ -24,6 +24,7 @@ export class SchematicPinSvgRenderer {
24
24
  * @param {Map<string, number>} explicitOwnerPinLabelOffsets
25
25
  * @param {Map<string, 'left' | 'right'>} compactExternalNumberLabelSides
26
26
  * @param {Map<string, { left: number, right: number }>} internalNumberLabelBoxes
27
+ * @param {Set<string>} explicitOwnerPinNumberLabelKeys
27
28
  * @param {Set<string>} overlappingExternalNumberLabelKeys
28
29
  * @returns {string}
29
30
  */
@@ -36,6 +37,7 @@ export class SchematicPinSvgRenderer {
36
37
  explicitOwnerPinLabelOffsets,
37
38
  compactExternalNumberLabelSides = new Map(),
38
39
  internalNumberLabelBoxes = new Map(),
40
+ explicitOwnerPinNumberLabelKeys = new Set(),
39
41
  overlappingExternalNumberLabelKeys = new Set()
40
42
  ) {
41
43
  const geometry =
@@ -76,13 +78,14 @@ export class SchematicPinSvgRenderer {
76
78
  null
77
79
  const internalNumberLabelBox =
78
80
  internalNumberLabelBoxes.get(String(pin.ownerIndex || '')) || null
79
- const shouldRenderExternalNumber =
80
- !overlappingExternalNumberLabelKeys.has(
81
- SchematicOwnerPinLabelLayout.buildOwnerPinLabelKey(
82
- pin.ownerIndex,
83
- pin.designator
84
- )
81
+ const ownerPinNumberLabelKey =
82
+ SchematicOwnerPinLabelLayout.buildOwnerPinLabelKey(
83
+ pin.ownerIndex,
84
+ pin.designator
85
85
  )
86
+ const shouldRenderExternalNumber =
87
+ !explicitOwnerPinNumberLabelKeys.has(ownerPinNumberLabelKey) &&
88
+ !overlappingExternalNumberLabelKeys.has(ownerPinNumberLabelKey)
86
89
 
87
90
  if (pin.orientation === 'left') {
88
91
  if (labelMode !== 'hidden' && labelMode !== 'name-only') {
@@ -247,6 +250,7 @@ export class SchematicPinSvgRenderer {
247
250
  if (
248
251
  labelMode !== 'hidden' &&
249
252
  labelMode !== 'name-only' &&
253
+ shouldRenderExternalNumber &&
250
254
  (pin.orientation === 'top' || pin.orientation === 'bottom')
251
255
  ) {
252
256
  texts.push(
@@ -34,6 +34,7 @@ const SECTION_HEADING_LINE_X_PADDING = 15
34
34
  const BODY_TEXT_ASCENT_RATIO = 0.72
35
35
  const BODY_TEXT_DESCENT_RATIO = 0.14
36
36
  const BODY_TEXT_FLAT_DESCENT_RATIO = 0
37
+ const TEXT_VERTICAL_MIDDLE_BASELINE_RATIO = 0.36
37
38
  const BODY_TEXT_MIN_SEPARATOR_SPAN_RATIO = 0.3
38
39
  const BODY_TEXT_DESCENDER_PATTERN = /[gjpqyQ_,;]/
39
40
 
@@ -45,7 +46,7 @@ export class SchematicSvgRenderer {
45
46
 
46
47
  /**
47
48
  * Renders a normalized schematic model into SVG markup.
48
- * @param {{ fileName?: string, summary: { title?: string }, schematic?: { sheet: { width: number, height: number, sourceWidth?: number, sourceHeight?: number, paperSize?: string, borderOn?: boolean, titleBlockOn?: boolean, marginWidth?: number, xZones?: number, yZones?: number, titleBlock?: { title?: string, revision?: string, documentNumber?: string, sheetNumber?: string, sheetTotal?: string, date?: string, drawnBy?: string } }, lines: { x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, isBus?: boolean, ownerIndex?: string, renderOrder?: number, recordType?: string }[], polygons?: { points: { x: number, y: number }[], color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string, renderOrder?: number }[], rectangles?: { x: number, y: number, width: number, height: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string, renderOrder?: number }[], regions?: { x: number, y: number, width: number, height: number, color: string, fill: string, renderOrder?: number }[], ellipses?: { x: number, y: number, radiusX: number, radiusY: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string, renderOrder?: number }[], arcs?: { x: number, y: number, radius: number, startAngle: number, endAngle: number, color: string, width: number, ownerIndex?: string, renderOrder?: number }[], directives?: { x: number, y: number, color: string, name: string, orientation?: number }[], texts: { x: number, y: number, text: string, textSegments?: { text: string, overline: boolean }[], color: string, recordType?: string, style?: number, fontSize?: number, fontFamily?: string, fontWeight?: number, fontStyle?: string, rotation?: number, sourceOrientation?: number, isMirrored?: boolean, anchor?: 'start' | 'middle' | 'end', powerPortDirection?: 'up' | 'down' | 'left' | 'right', cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] }[], components: { x: number, y: number, designator: string }[], pins?: { x: number, y: number, length: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, color: string, labelColor?: string, labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number', ownerIndex?: string }[], ports?: { x: number, y: number, width: number, height: number, name: string, fill: string, color: string, direction?: 'left' | 'right' | 'up' | 'down', shape?: 'single' | 'double' | 'plain' }[], crosses?: { x: number, y: number, size: number, color: string }[] } }} documentModel
49
+ * @param {{ fileName?: string, summary: { title?: string }, schematic?: { sheet: { width: number, height: number, sourceWidth?: number, sourceHeight?: number, paperSize?: string, borderOn?: boolean, titleBlockOn?: boolean, marginWidth?: number, xZones?: number, yZones?: number, titleBlock?: { title?: string, revision?: string, documentNumber?: string, sheetNumber?: string, sheetTotal?: string, date?: string, drawnBy?: string } }, lines: { x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, isBus?: boolean, ownerIndex?: string, renderOrder?: number, recordType?: string }[], polygons?: { points: { x: number, y: number }[], color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string, renderOrder?: number }[], rectangles?: { x: number, y: number, width: number, height: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string, renderOrder?: number }[], regions?: { x: number, y: number, width: number, height: number, color: string, fill: string, renderOrder?: number }[], ellipses?: { x: number, y: number, radiusX: number, radiusY: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string, renderOrder?: number }[], arcs?: { x: number, y: number, radius: number, startAngle: number, endAngle: number, color: string, width: number, ownerIndex?: string, renderOrder?: number }[], directives?: { x: number, y: number, color: string, name: string, orientation?: number, style?: number }[], texts: { x: number, y: number, text: string, textSegments?: { text: string, overline: boolean }[], color: string, recordType?: string, style?: number, fontSize?: number, fontFamily?: string, fontWeight?: number, fontStyle?: string, rotation?: number, sourceOrientation?: number, isMirrored?: boolean, anchor?: 'start' | 'middle' | 'end', powerPortDirection?: 'up' | 'down' | 'left' | 'right', cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] }[], components: { x: number, y: number, designator: string }[], pins?: { x: number, y: number, length: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, color: string, labelColor?: string, labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number', ownerIndex?: string }[], ports?: { x: number, y: number, width: number, height: number, name: string, fill: string, color: string, direction?: 'left' | 'right' | 'up' | 'down', shape?: 'single' | 'double' | 'plain' }[], crosses?: { x: number, y: number, size: number, color: string }[] } }} documentModel
49
50
  * @param {{ projectParameters?: Record<string, string | number | boolean | null | undefined>, colorizeImages?: boolean, colorize_images?: boolean }} options Render options.
50
51
  * @returns {string}
51
52
  */
@@ -634,6 +635,11 @@ export class SchematicSvgRenderer {
634
635
  pins,
635
636
  contentRectangles
636
637
  )
638
+ const explicitOwnerPinNumberLabelKeys =
639
+ SchematicOwnerPinLabelLayout.collectExplicitOwnerPinNumberLabelKeys(
640
+ texts,
641
+ pins
642
+ )
637
643
  const overlappingExternalNumberLabelKeys =
638
644
  SchematicOwnerPinLabelLayout.collectOverlappingExternalNumberLabelKeys(
639
645
  pins,
@@ -652,6 +658,7 @@ export class SchematicSvgRenderer {
652
658
  explicitOwnerPinLabelOffsets,
653
659
  compactExternalNumberLabelSides,
654
660
  internalNumberLabelBoxes,
661
+ explicitOwnerPinNumberLabelKeys,
655
662
  overlappingExternalNumberLabelKeys
656
663
  ),
657
664
  SchematicSvgRenderer.#semanticAttributes(
@@ -2702,7 +2709,7 @@ export class SchematicSvgRenderer {
2702
2709
 
2703
2710
  /**
2704
2711
  * Resolves final text placement for schematic free-text annotations.
2705
- * @param {{ x: number, y: number, text: string, ownerIndex?: string, recordType?: string, fontSize?: number, rotation?: number, anchor?: 'start' | 'middle' | 'end', verticalAnchor?: 'top' }} text
2712
+ * @param {{ x: number, y: number, text: string, ownerIndex?: string, recordType?: string, fontSize?: number, rotation?: number, anchor?: 'start' | 'middle' | 'end', verticalAnchor?: 'middle' | 'top' }} text
2706
2713
  * @param {number} sheetHeight
2707
2714
  * @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number }[]} lines
2708
2715
  * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin
@@ -2822,17 +2829,26 @@ export class SchematicSvgRenderer {
2822
2829
  }
2823
2830
 
2824
2831
  /**
2825
- * Converts top-anchored text coordinates into SVG baseline coordinates.
2826
- * @param {{ verticalAnchor?: 'top' }} text Text primitive.
2832
+ * Converts vertically anchored text coordinates into SVG baseline
2833
+ * coordinates.
2834
+ * @param {{ verticalAnchor?: 'middle' | 'top' }} text Text primitive.
2827
2835
  * @param {number} fontSize Viewer font size.
2828
2836
  * @returns {number}
2829
2837
  */
2830
2838
  static #resolveVerticalAnchorBaselineOffset(text, fontSize) {
2831
- if (text?.verticalAnchor !== 'top') {
2839
+ if (!Number.isFinite(fontSize) || fontSize <= 0) {
2832
2840
  return 0
2833
2841
  }
2834
2842
 
2835
- return Number.isFinite(fontSize) && fontSize > 0 ? fontSize : 0
2843
+ if (text?.verticalAnchor === 'middle') {
2844
+ return fontSize * TEXT_VERTICAL_MIDDLE_BASELINE_RATIO
2845
+ }
2846
+
2847
+ if (text?.verticalAnchor === 'top') {
2848
+ return fontSize
2849
+ }
2850
+
2851
+ return 0
2836
2852
  }
2837
2853
 
2838
2854
  /**