altium-toolkit 0.1.0

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 (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. package/src/workers/altium-parser.worker.mjs +29 -0
@@ -0,0 +1,427 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
6
+ import { SchematicColorResolver } from './SchematicColorResolver.mjs'
7
+ import { SchematicTypography } from './SchematicTypography.mjs'
8
+
9
+ const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
10
+ const MINIMUM_NOTE_TEXT_SIZE = 4
11
+
12
+ /**
13
+ * Renders boxed schematic notes recovered from Altium note records.
14
+ */
15
+ export class SchematicNoteRenderer {
16
+ /**
17
+ * Builds one boxed schematic note/callout with wrapped text rows.
18
+ * @param {{ x: number, y: number, color: string, fontSize?: number, fontFamily?: string, fontWeight?: number, cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] }} text
19
+ * @param {number} sheetHeight
20
+ * @returns {string}
21
+ */
22
+ static buildMarkup(text, sheetHeight) {
23
+ const left = Math.min(text.x, text.cornerX || text.x)
24
+ const right = Math.max(text.x, text.cornerX || text.x)
25
+ const top = Math.min(
26
+ projectSchematicY(sheetHeight, text.y),
27
+ projectSchematicY(sheetHeight, text.cornerY || text.y)
28
+ )
29
+ const bottom = Math.max(
30
+ projectSchematicY(sheetHeight, text.y),
31
+ projectSchematicY(sheetHeight, text.cornerY || text.y)
32
+ )
33
+ const width = Math.max(right - left, 1)
34
+ const height = Math.max(bottom - top, 1)
35
+ const textMargin = Math.max(Number(text.textMargin || 4), 3)
36
+ const requestedTextSize = Math.max(
37
+ Number(
38
+ SchematicTypography.resolveViewerFontSize(text.fontSize || 8) ||
39
+ MINIMUM_NOTE_TEXT_SIZE
40
+ ),
41
+ MINIMUM_NOTE_TEXT_SIZE
42
+ )
43
+ const noteFill = SchematicColorResolver.resolveFill(
44
+ text.isSolid === false
45
+ ? 'transparent'
46
+ : text.fill || 'var(--schematic-note-fill-color)',
47
+ '--schematic-note-fill-color'
48
+ )
49
+ const borderColor = SchematicColorResolver.resolveColor(
50
+ text.borderColor || 'var(--schematic-note-border-color)',
51
+ '--schematic-note-border-color'
52
+ )
53
+ const noteStroke = text.showBorder ? borderColor : 'none'
54
+ const layout = SchematicNoteRenderer.#resolveTextLayout(
55
+ text.noteLines || [],
56
+ Math.max(width - textMargin * 2, requestedTextSize),
57
+ Math.max(height - textMargin * 2, requestedTextSize),
58
+ requestedTextSize
59
+ )
60
+ const noteLines = layout.noteLines
61
+ const textSize = layout.textSize
62
+ const lineHeight = layout.lineHeight
63
+ const textMarkup = noteLines
64
+ .map((line, index) =>
65
+ SchematicNoteRenderer.#buildNoteLineMarkup(
66
+ line,
67
+ index,
68
+ left,
69
+ right,
70
+ top,
71
+ textMargin,
72
+ lineHeight,
73
+ textSize,
74
+ text
75
+ )
76
+ )
77
+ .join('')
78
+
79
+ return (
80
+ '<g class="schematic-note">' +
81
+ '<rect class="schematic-note-box" x="' +
82
+ formatNumber(left) +
83
+ '" y="' +
84
+ formatNumber(top) +
85
+ '" width="' +
86
+ formatNumber(width) +
87
+ '" height="' +
88
+ formatNumber(height) +
89
+ '" fill="' +
90
+ escapeHtml(noteFill) +
91
+ '" stroke="' +
92
+ escapeHtml(noteStroke) +
93
+ '" />' +
94
+ textMarkup +
95
+ '</g>'
96
+ )
97
+ }
98
+
99
+ /**
100
+ * Resolves wrapped note rows and a fitting text layout for one note box.
101
+ * @param {string[]} noteLines
102
+ * @param {number} maxWidth
103
+ * @param {number} maxHeight
104
+ * @param {number} requestedTextSize
105
+ * @returns {{ noteLines: string[], textSize: number, lineHeight: number }}
106
+ */
107
+ static #resolveTextLayout(
108
+ noteLines,
109
+ maxWidth,
110
+ maxHeight,
111
+ requestedTextSize
112
+ ) {
113
+ let textSize = requestedTextSize
114
+ let wrappedLines = []
115
+
116
+ for (let attempt = 0; attempt < 6; attempt += 1) {
117
+ wrappedLines = SchematicNoteRenderer.#wrapNoteLines(
118
+ noteLines,
119
+ maxWidth,
120
+ textSize
121
+ )
122
+
123
+ const lineHeight = SchematicNoteRenderer.#resolveLineHeight(
124
+ textSize,
125
+ maxHeight,
126
+ 0,
127
+ wrappedLines.length
128
+ )
129
+ const requiredHeight =
130
+ wrappedLines.length <= 0
131
+ ? 0
132
+ : textSize + lineHeight * (wrappedLines.length - 1)
133
+
134
+ if (
135
+ requiredHeight <= maxHeight ||
136
+ textSize <= MINIMUM_NOTE_TEXT_SIZE
137
+ ) {
138
+ return {
139
+ noteLines: wrappedLines,
140
+ textSize,
141
+ lineHeight
142
+ }
143
+ }
144
+
145
+ const nextSize = Math.max(
146
+ MINIMUM_NOTE_TEXT_SIZE,
147
+ Math.min(
148
+ textSize - 0.5,
149
+ textSize * (maxHeight / requiredHeight)
150
+ )
151
+ )
152
+
153
+ if (nextSize >= textSize) {
154
+ break
155
+ }
156
+
157
+ textSize = nextSize
158
+ }
159
+
160
+ return {
161
+ noteLines: wrappedLines,
162
+ textSize,
163
+ lineHeight: SchematicNoteRenderer.#resolveLineHeight(
164
+ textSize,
165
+ maxHeight,
166
+ 0,
167
+ wrappedLines.length
168
+ )
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Wraps recovered note rows to the available note-box width.
174
+ * @param {string[]} noteLines
175
+ * @param {number} maxWidth
176
+ * @param {number} textSize
177
+ * @returns {string[]}
178
+ */
179
+ static #wrapNoteLines(noteLines, maxWidth, textSize) {
180
+ return noteLines.flatMap((line) =>
181
+ SchematicNoteRenderer.#wrapSingleLine(line, maxWidth, textSize)
182
+ )
183
+ }
184
+
185
+ /**
186
+ * Wraps one visible note row to the available width.
187
+ * @param {string} line
188
+ * @param {number} maxWidth
189
+ * @param {number} textSize
190
+ * @returns {string[]}
191
+ */
192
+ static #wrapSingleLine(line, maxWidth, textSize) {
193
+ const normalizedLine = String(line || '').trim()
194
+ if (!normalizedLine) {
195
+ return []
196
+ }
197
+
198
+ if (/^_+$/.test(normalizedLine)) {
199
+ return [normalizedLine]
200
+ }
201
+
202
+ if (
203
+ SchematicNoteRenderer.#estimateTextWidth(
204
+ normalizedLine,
205
+ textSize
206
+ ) <= maxWidth
207
+ ) {
208
+ return [normalizedLine]
209
+ }
210
+
211
+ const wrappedLines = []
212
+ let currentLine = ''
213
+ const tokens = normalizedLine.match(/\S+\s*/g) || [normalizedLine]
214
+
215
+ for (const token of tokens) {
216
+ const trimmedToken = token.trim()
217
+ if (!trimmedToken) {
218
+ continue
219
+ }
220
+
221
+ const candidateLine = (currentLine + token).trimEnd()
222
+ if (
223
+ currentLine &&
224
+ SchematicNoteRenderer.#estimateTextWidth(
225
+ candidateLine,
226
+ textSize
227
+ ) > maxWidth
228
+ ) {
229
+ wrappedLines.push(currentLine.trimEnd())
230
+ currentLine = ''
231
+ }
232
+
233
+ if (
234
+ SchematicNoteRenderer.#estimateTextWidth(
235
+ trimmedToken,
236
+ textSize
237
+ ) > maxWidth
238
+ ) {
239
+ const tokenLines = SchematicNoteRenderer.#wrapLongToken(
240
+ trimmedToken,
241
+ maxWidth,
242
+ textSize
243
+ )
244
+
245
+ if (currentLine) {
246
+ wrappedLines.push(currentLine.trimEnd())
247
+ currentLine = ''
248
+ }
249
+
250
+ wrappedLines.push(...tokenLines.slice(0, -1))
251
+ currentLine = tokenLines[tokenLines.length - 1] || ''
252
+ continue
253
+ }
254
+
255
+ currentLine = (currentLine + token).trimStart()
256
+ }
257
+
258
+ if (currentLine) {
259
+ wrappedLines.push(currentLine.trimEnd())
260
+ }
261
+
262
+ return wrappedLines
263
+ }
264
+
265
+ /**
266
+ * Splits one oversized token into smaller width-safe fragments.
267
+ * @param {string} token
268
+ * @param {number} maxWidth
269
+ * @param {number} textSize
270
+ * @returns {string[]}
271
+ */
272
+ static #wrapLongToken(token, maxWidth, textSize) {
273
+ const fragments = []
274
+ let currentFragment = ''
275
+
276
+ for (const character of token) {
277
+ const candidateFragment = currentFragment + character
278
+ if (
279
+ currentFragment &&
280
+ SchematicNoteRenderer.#estimateTextWidth(
281
+ candidateFragment,
282
+ textSize
283
+ ) > maxWidth
284
+ ) {
285
+ fragments.push(currentFragment)
286
+ currentFragment = character
287
+ continue
288
+ }
289
+
290
+ currentFragment = candidateFragment
291
+ }
292
+
293
+ if (currentFragment) {
294
+ fragments.push(currentFragment)
295
+ }
296
+
297
+ return fragments
298
+ }
299
+
300
+ /**
301
+ * Approximates rendered note text width for line wrapping.
302
+ * @param {string} text
303
+ * @param {number} textSize
304
+ * @returns {number}
305
+ */
306
+ static #estimateTextWidth(text, textSize) {
307
+ let width = 0
308
+
309
+ for (const character of String(text || '')) {
310
+ width +=
311
+ SchematicNoteRenderer.#measureCharacterWidth(character) *
312
+ textSize
313
+ }
314
+
315
+ return width
316
+ }
317
+
318
+ /**
319
+ * Returns a rough Times New Roman width factor for one character.
320
+ * @param {string} character
321
+ * @returns {number}
322
+ */
323
+ static #measureCharacterWidth(character) {
324
+ if (/\s/.test(character)) return 0.32
325
+ if (/[.,;:!|]/.test(character)) return 0.24
326
+ if (/[()[\]{}]/.test(character)) return 0.32
327
+ if (/[-+/\\]/.test(character)) return 0.36
328
+ if (/[MW@#%&]/.test(character)) return 0.82
329
+ if (/[A-Z]/.test(character)) return 0.62
330
+ if (/[a-z0-9]/.test(character)) return 0.5
331
+ if (/[^ -~]/.test(character)) return 0.92
332
+
333
+ return 0.56
334
+ }
335
+
336
+ /**
337
+ * Picks a readable line height that still fits inside the note box.
338
+ * @param {number} textSize
339
+ * @param {number} noteHeight
340
+ * @param {number} textMargin
341
+ * @param {number} lineCount
342
+ * @returns {number}
343
+ */
344
+ static #resolveLineHeight(textSize, noteHeight, textMargin, lineCount) {
345
+ const defaultLineHeight = Math.max(textSize * 1.1, textSize + 1)
346
+ if (lineCount <= 1) {
347
+ return defaultLineHeight
348
+ }
349
+
350
+ const maxLineHeight =
351
+ (noteHeight - textMargin * 2 - textSize) / (lineCount - 1)
352
+
353
+ return Math.max(Math.min(defaultLineHeight, maxLineHeight), textSize)
354
+ }
355
+
356
+ /**
357
+ * Builds one rendered line inside a schematic note box.
358
+ * @param {string} line
359
+ * @param {number} index
360
+ * @param {number} left
361
+ * @param {number} right
362
+ * @param {number} top
363
+ * @param {number} textMargin
364
+ * @param {number} lineHeight
365
+ * @param {number} textSize
366
+ * @param {{ color: string, fontFamily?: string, fontWeight?: number }} text
367
+ * @returns {string}
368
+ */
369
+ static #buildNoteLineMarkup(
370
+ line,
371
+ index,
372
+ left,
373
+ right,
374
+ top,
375
+ textMargin,
376
+ lineHeight,
377
+ textSize,
378
+ text
379
+ ) {
380
+ const x = left + textMargin
381
+ const y = top + textMargin + textSize + index * lineHeight
382
+
383
+ if (/^_+$/.test(String(line || '').trim())) {
384
+ return (
385
+ '<line class="schematic-note-rule" x1="' +
386
+ formatNumber(x) +
387
+ '" y1="' +
388
+ formatNumber(y - textSize * 0.35) +
389
+ '" x2="' +
390
+ formatNumber(right - textMargin) +
391
+ '" y2="' +
392
+ formatNumber(y - textSize * 0.35) +
393
+ '" stroke="' +
394
+ escapeHtml(
395
+ SchematicColorResolver.resolveColor(
396
+ text.color,
397
+ '--schematic-text-color'
398
+ )
399
+ ) +
400
+ '" />'
401
+ )
402
+ }
403
+
404
+ return (
405
+ '<text class="schematic-note-text" x="' +
406
+ formatNumber(x) +
407
+ '" y="' +
408
+ formatNumber(y) +
409
+ '" fill="' +
410
+ escapeHtml(
411
+ SchematicColorResolver.resolveColor(
412
+ text.color,
413
+ '--schematic-text-color'
414
+ )
415
+ ) +
416
+ '" text-anchor="start" font-size="' +
417
+ formatNumber(textSize) +
418
+ '" font-family="' +
419
+ escapeHtml(text.fontFamily || 'Times New Roman') +
420
+ '" font-weight="' +
421
+ formatNumber(text.fontWeight || 400) +
422
+ '" xml:space="preserve">' +
423
+ escapeHtml(line) +
424
+ '</text>'
425
+ )
426
+ }
427
+ }
@@ -0,0 +1,173 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Shared layout helpers for explicit owner pin-name labels and their paired
7
+ * synthetic pin-number clearance.
8
+ */
9
+ export class SchematicOwnerPinLabelLayout {
10
+ /**
11
+ * Builds one owner/pin label key.
12
+ * @param {string | undefined} ownerIndex
13
+ * @param {string | undefined} name
14
+ * @returns {string}
15
+ */
16
+ static buildOwnerPinLabelKey(ownerIndex, name) {
17
+ const normalizedOwnerIndex = String(ownerIndex || '').trim()
18
+ const normalizedName = String(name || '').trim()
19
+
20
+ if (!normalizedOwnerIndex || !normalizedName) {
21
+ return ''
22
+ }
23
+
24
+ return normalizedOwnerIndex + '::' + normalizedName
25
+ }
26
+
27
+ /**
28
+ * Returns one matched owner pin when a free text primitive is explicitly
29
+ * reusing that pin name.
30
+ * @param {{ text?: string, ownerIndex?: string }} text
31
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
32
+ * @returns {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null}
33
+ */
34
+ static findExplicitOwnerPinLabelMatch(text, pins) {
35
+ const ownerIndex = String(text?.ownerIndex || '').trim()
36
+ const label = String(text?.text || '').trim()
37
+
38
+ if (!ownerIndex || !label) {
39
+ return null
40
+ }
41
+
42
+ return (
43
+ pins.find(
44
+ (pin) =>
45
+ String(pin.ownerIndex || '').trim() === ownerIndex &&
46
+ String(pin.name || '').trim() === label
47
+ ) || null
48
+ )
49
+ }
50
+
51
+ /**
52
+ * Reuses the matched pin axis for mirrored vertical owner pin-name labels
53
+ * while keeping their authored run distance along that axis.
54
+ * @param {{ x: number, y: number, recordType?: string, rotation?: number, isMirrored?: boolean }} text
55
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin
56
+ * @returns {{ x: number, y: number } | null}
57
+ */
58
+ static resolveMirroredOwnerPinLabelPlacement(text, matchedOwnerPin) {
59
+ if (
60
+ !matchedOwnerPin ||
61
+ !text?.isMirrored ||
62
+ !text?.rotation ||
63
+ text.recordType !== '4'
64
+ ) {
65
+ return null
66
+ }
67
+
68
+ return {
69
+ x: Number(matchedOwnerPin.x),
70
+ y: Number(text.y)
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Collects the horizontal correction applied to explicit owner pin-name
76
+ * labels so synthetic left/right pin numbers can keep their original gap.
77
+ * @param {{ ownerIndex?: string, text?: string, x?: number, y?: number, recordType?: string, rotation?: number, isMirrored?: boolean }[]} texts
78
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
79
+ * @returns {Map<string, number>}
80
+ */
81
+ static collectExplicitOwnerPinLabelOffsets(texts, pins) {
82
+ const offsets = new Map()
83
+
84
+ for (const text of texts) {
85
+ const matchedOwnerPin =
86
+ SchematicOwnerPinLabelLayout.findExplicitOwnerPinLabelMatch(
87
+ text,
88
+ pins
89
+ )
90
+ const placement =
91
+ SchematicOwnerPinLabelLayout.resolveMirroredOwnerPinLabelPlacement(
92
+ text,
93
+ matchedOwnerPin
94
+ )
95
+ const key = SchematicOwnerPinLabelLayout.buildOwnerPinLabelKey(
96
+ text?.ownerIndex,
97
+ text?.text
98
+ )
99
+
100
+ if (!placement || !key) {
101
+ continue
102
+ }
103
+
104
+ const delta = Number(placement.x) - Number(text.x)
105
+
106
+ if (delta) {
107
+ offsets.set(key, delta)
108
+ }
109
+ }
110
+
111
+ return offsets
112
+ }
113
+
114
+ /**
115
+ * Resolves the final SVG text anchor for one schematic free-text label.
116
+ * Mirrored rotated owner pin-name labels need the opposite text edge so
117
+ * their baseline starts on the same visual side after the signed rotation
118
+ * flips.
119
+ * @param {{ recordType?: string, rotation?: number, isMirrored?: boolean, y?: number }} text
120
+ * @param {'start' | 'middle' | 'end'} anchor
121
+ * @param {{ y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin
122
+ * @returns {'start' | 'middle' | 'end'}
123
+ */
124
+ static resolveSchematicTextAnchor(text, anchor, matchedOwnerPin) {
125
+ if (
126
+ anchor !== 'start' ||
127
+ !text?.isMirrored ||
128
+ !text?.rotation ||
129
+ text.recordType !== '4'
130
+ ) {
131
+ return anchor
132
+ }
133
+
134
+ if (!matchedOwnerPin) {
135
+ return anchor
136
+ }
137
+
138
+ return Number(text.y) >= Number(matchedOwnerPin.y) ? 'end' : 'start'
139
+ }
140
+
141
+ /**
142
+ * Moves left/right pin numbers outward by the same horizontal correction
143
+ * already applied to their explicit owner pin-name labels.
144
+ * @param {{ orientation: 'left' | 'right' | 'top' | 'bottom', ownerIndex?: string, name?: string }} pin
145
+ * @param {number} baseX
146
+ * @param {Map<string, number>} explicitOwnerPinLabelOffsets
147
+ * @returns {number}
148
+ */
149
+ static resolveExplicitOwnerPinNumberX(
150
+ pin,
151
+ baseX,
152
+ explicitOwnerPinLabelOffsets
153
+ ) {
154
+ const key = SchematicOwnerPinLabelLayout.buildOwnerPinLabelKey(
155
+ pin.ownerIndex,
156
+ pin.name
157
+ )
158
+ const delta = Number(explicitOwnerPinLabelOffsets.get(key) || 0)
159
+
160
+ if (!delta) {
161
+ return baseX
162
+ }
163
+
164
+ switch (pin.orientation) {
165
+ case 'left':
166
+ return baseX - delta
167
+ case 'right':
168
+ return baseX + delta
169
+ default:
170
+ return baseX
171
+ }
172
+ }
173
+ }