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.
- package/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +20 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +22 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/GPL-3.0-or-later.txt +232 -0
- package/NOTICE.md +32 -0
- package/README.md +116 -0
- package/docs/api.md +73 -0
- package/docs/model-format.md +36 -0
- package/docs/testing.md +25 -0
- package/examples/README.md +47 -0
- package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
- package/examples/arduino-uno/SvgViewportController.mjs +306 -0
- package/examples/arduino-uno/example.mjs +480 -0
- package/examples/arduino-uno/index.html +163 -0
- package/examples/arduino-uno/styles.css +552 -0
- package/examples/server.mjs +212 -0
- package/package.json +53 -0
- package/spec/library-scope.md +32 -0
- package/src/core/BinaryReader.mjs +127 -0
- package/src/core/altium/AltiumLayoutParser.mjs +485 -0
- package/src/core/altium/AltiumParser.mjs +1007 -0
- package/src/core/altium/AsciiRecordParser.mjs +151 -0
- package/src/core/altium/ParserUtils.mjs +173 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
- package/src/core/altium/PcbModelParser.mjs +336 -0
- package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
- package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
- package/src/core/altium/PcbStreamExtractor.mjs +210 -0
- package/src/core/altium/PrintableTextDecoder.mjs +156 -0
- package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
- package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
- package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
- package/src/core/altium/SchematicImageParser.mjs +173 -0
- package/src/core/altium/SchematicJunctionParser.mjs +43 -0
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
- package/src/core/altium/SchematicPinParser.mjs +767 -0
- package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
- package/src/core/altium/SchematicSheetParser.mjs +241 -0
- package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
- package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
- package/src/core/altium/SchematicTextParser.mjs +708 -0
- package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
- package/src/core/ole/OleCompoundDocument.mjs +439 -0
- package/src/core/ole/OleConstants.mjs +64 -0
- package/src/core/ole/OleDirectoryEntry.mjs +95 -0
- package/src/index.mjs +7 -0
- package/src/parser.mjs +21 -0
- package/src/renderers.mjs +15 -0
- package/src/scene3d.mjs +9 -0
- package/src/styles/altium-renderers.css +358 -0
- package/src/ui/BomTableRenderer.mjs +46 -0
- package/src/ui/PcbArcUtils.mjs +189 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
- package/src/ui/PcbScene3dBuilder.mjs +742 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
- package/src/ui/PcbScene3dPackages.mjs +137 -0
- package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
- package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
- package/src/ui/PcbSvgRenderer.mjs +906 -0
- package/src/ui/SchematicColorResolver.mjs +132 -0
- package/src/ui/SchematicContentLayout.mjs +661 -0
- package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
- package/src/ui/SchematicImageRenderer.mjs +135 -0
- package/src/ui/SchematicJunctionRenderer.mjs +381 -0
- package/src/ui/SchematicNoteRenderer.mjs +427 -0
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
- package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
- package/src/ui/SchematicPortRenderer.mjs +558 -0
- package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
- package/src/ui/SchematicRegionRenderer.mjs +94 -0
- package/src/ui/SchematicShapeRenderer.mjs +398 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
- package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
- package/src/ui/SchematicSvgRenderer.mjs +756 -0
- package/src/ui/SchematicSvgUtils.mjs +182 -0
- package/src/ui/SchematicTypography.mjs +204 -0
- 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
|
+
}
|