altium-toolkit 0.1.23 → 1.0.2
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/README.md +4 -3
- package/docs/api.md +47 -8
- package/docs/model-format.md +30 -6
- package/package.json +2 -1
- package/spec/library-scope.md +2 -1
- package/src/core/altium/AltiumParser.mjs +17 -4
- package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +826 -0
- package/src/core/circuit-json/CircuitJsonModelAdapterPrimitives.mjs +354 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +83 -0
- package/src/core/netlist-query/CircuitTraversal.mjs +325 -0
- package/src/core/netlist-query/ComponentGrouping.mjs +355 -0
- package/src/core/netlist-query/LoadedDesignNetlistService.mjs +732 -0
- package/src/core/netlist-query/QueryNetlistBuilder.mjs +180 -0
- package/src/core/netlist-query/RegexPattern.mjs +89 -0
- package/src/netlist-query.mjs +12 -0
- package/src/parser.mjs +2 -0
- package/src/styles/altium-renderers.css +12 -1
- package/src/ui/PcbNativeTextKnockoutDetector.mjs +130 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +402 -0
- package/src/ui/PcbScene3dBuilder.mjs +16 -5
- package/src/ui/PcbScene3dDrillCutoutBuilder.mjs +26 -15
- package/src/ui/PcbSvgRenderer.mjs +83 -10
- package/src/ui/PcbTextPrimitiveRenderer.mjs +648 -19
|
@@ -8,14 +8,25 @@ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
|
|
|
8
8
|
* Renders recovered PCB text primitives.
|
|
9
9
|
*/
|
|
10
10
|
export class PcbTextPrimitiveRenderer {
|
|
11
|
+
static #DEFAULT_TRUETYPE_EM_SCALE = 0.895
|
|
12
|
+
static #TEXTURE_PADDING_RATIO = 0.14
|
|
13
|
+
static #MIN_CANVAS_PADDING = 2
|
|
14
|
+
static #LINE_HEIGHT_RATIO = 1.16
|
|
15
|
+
static #FONT_ASCENT_RATIO = 0.82
|
|
16
|
+
static #FONT_DESCENT_RATIO = 0.18
|
|
17
|
+
static #DEFAULT_GLYPH_WIDTH_RATIO = 0.56
|
|
18
|
+
static #MONOSPACE_GLYPH_WIDTH_RATIO = 0.62
|
|
19
|
+
static #DEFAULT_FONT_FAMILY = 'Arial'
|
|
20
|
+
|
|
11
21
|
/**
|
|
12
22
|
* Selects texts that belong to the requested board-side composite.
|
|
13
23
|
* @param {{ layerId: number, name: string }[]} primitiveLayers
|
|
14
24
|
* @param {{ text: string, x: number, y: number, height?: number, rotation?: number, layerId?: number, visible?: boolean }[]} texts
|
|
15
25
|
* @param {'top' | 'bottom'} [side]
|
|
26
|
+
* @param {{ nativeTextKnockouts?: boolean }} [options] Selection options.
|
|
16
27
|
* @returns {{ text: string, x: number, y: number, height?: number, rotation?: number, layerId?: number, visible?: boolean }[]}
|
|
17
28
|
*/
|
|
18
|
-
static select(primitiveLayers, texts, side = 'top') {
|
|
29
|
+
static select(primitiveLayers, texts, side = 'top', options = {}) {
|
|
19
30
|
const layerIds = PcbTextPrimitiveRenderer.#resolveLayerIds(
|
|
20
31
|
primitiveLayers || [],
|
|
21
32
|
side
|
|
@@ -27,6 +38,10 @@ export class PcbTextPrimitiveRenderer {
|
|
|
27
38
|
text?.visible !== false &&
|
|
28
39
|
String(text?.text || '').trim() &&
|
|
29
40
|
!PcbTextPrimitiveRenderer.#isPlaceholderText(text) &&
|
|
41
|
+
!PcbTextPrimitiveRenderer.#shouldSkipNativeTextKnockout(
|
|
42
|
+
text,
|
|
43
|
+
options
|
|
44
|
+
) &&
|
|
30
45
|
Number.isInteger(layerId) &&
|
|
31
46
|
layerIds.has(layerId)
|
|
32
47
|
)
|
|
@@ -40,22 +55,33 @@ export class PcbTextPrimitiveRenderer {
|
|
|
40
55
|
*/
|
|
41
56
|
static render(texts) {
|
|
42
57
|
return (texts || [])
|
|
43
|
-
.map((text) =>
|
|
58
|
+
.map((text, index) =>
|
|
59
|
+
PcbTextPrimitiveRenderer.#renderText(text, index)
|
|
60
|
+
)
|
|
44
61
|
.join('')
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
/**
|
|
48
65
|
* Renders one PCB text primitive.
|
|
49
66
|
* @param {{ text: string, x: number, y: number, height?: number, rotation?: number, layerId?: number, fontFamily?: string, fontWeight?: number, fontStyle?: string }} text
|
|
67
|
+
* @param {number} index Text index for stable SVG resource ids.
|
|
50
68
|
* @returns {string}
|
|
51
69
|
*/
|
|
52
|
-
static #renderText(text) {
|
|
53
|
-
const fontSize =
|
|
70
|
+
static #renderText(text, index) {
|
|
71
|
+
const fontSize = PcbTextPrimitiveRenderer.#resolveFontSize(text)
|
|
54
72
|
const rotation = Number(text.rotation || 0)
|
|
55
|
-
const lines =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
73
|
+
const lines = PcbTextPrimitiveRenderer.#textLines(text)
|
|
74
|
+
|
|
75
|
+
if (PcbTextPrimitiveRenderer.#isInvertedText(text)) {
|
|
76
|
+
return PcbTextPrimitiveRenderer.#renderInvertedText(
|
|
77
|
+
text,
|
|
78
|
+
index,
|
|
79
|
+
fontSize,
|
|
80
|
+
rotation,
|
|
81
|
+
lines
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
const content = lines.length
|
|
60
86
|
? PcbTextPrimitiveRenderer.#renderTextLines(lines, fontSize)
|
|
61
87
|
: SchematicSvgUtils.escapeHtml(String(text.text || ''))
|
|
@@ -63,14 +89,10 @@ export class PcbTextPrimitiveRenderer {
|
|
|
63
89
|
return (
|
|
64
90
|
'<text class="pcb-text pcb-text--layer-' +
|
|
65
91
|
SchematicSvgUtils.escapeHtml(String(Number(text.layerId || 0))) +
|
|
66
|
-
'" transform="
|
|
67
|
-
|
|
68
|
-
' ' +
|
|
69
|
-
|
|
70
|
-
') rotate(' +
|
|
71
|
-
SchematicSvgUtils.formatNumber(rotation) +
|
|
72
|
-
')" font-size="' +
|
|
73
|
-
SchematicSvgUtils.formatNumber(fontSize) +
|
|
92
|
+
'" transform="' +
|
|
93
|
+
PcbTextPrimitiveRenderer.#renderTextTransform(text, rotation) +
|
|
94
|
+
'" font-size="' +
|
|
95
|
+
PcbTextPrimitiveRenderer.#formatTextNumber(fontSize) +
|
|
74
96
|
'"' +
|
|
75
97
|
PcbTextPrimitiveRenderer.#renderFontAttributes(text) +
|
|
76
98
|
' text-anchor="start" dominant-baseline="alphabetic">' +
|
|
@@ -79,6 +101,599 @@ export class PcbTextPrimitiveRenderer {
|
|
|
79
101
|
)
|
|
80
102
|
}
|
|
81
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Renders one inverted PCB text primitive as filled artwork with glyph
|
|
106
|
+
* cutouts, matching Altium's TrueType bottom overlay treatment.
|
|
107
|
+
* @param {{ text: string, x: number, y: number, height?: number, rotation?: number, layerId?: number, fontFamily?: string, fontWeight?: number, fontStyle?: string, isInverted?: boolean, marginBorderWidth?: number, textboxRectWidth?: number, textboxRectHeight?: number, textboxRectJustification?: number, useInvertedRectangle?: boolean }} text
|
|
108
|
+
* @param {number} index Text index for stable SVG mask ids.
|
|
109
|
+
* @param {number} fontSize Text font size in board units.
|
|
110
|
+
* @param {number} rotation Text rotation in degrees.
|
|
111
|
+
* @param {string[]} lines Text lines to render.
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
static #renderInvertedText(text, index, fontSize, rotation, lines) {
|
|
115
|
+
const metrics = PcbTextPrimitiveRenderer.#measureLines(
|
|
116
|
+
text,
|
|
117
|
+
lines,
|
|
118
|
+
fontSize
|
|
119
|
+
)
|
|
120
|
+
const padding = PcbTextPrimitiveRenderer.#resolveTextPadding(
|
|
121
|
+
text,
|
|
122
|
+
fontSize
|
|
123
|
+
)
|
|
124
|
+
const layout = PcbTextPrimitiveRenderer.#resolveTextLayout(
|
|
125
|
+
text,
|
|
126
|
+
metrics,
|
|
127
|
+
padding
|
|
128
|
+
)
|
|
129
|
+
const maskId = 'pcb-text-knockout-' + String(index)
|
|
130
|
+
const content = PcbTextPrimitiveRenderer.#renderTextLines(
|
|
131
|
+
lines,
|
|
132
|
+
metrics.lineHeight
|
|
133
|
+
)
|
|
134
|
+
const rectX = -layout.anchorX
|
|
135
|
+
const rectY = layout.anchorY - layout.height
|
|
136
|
+
const cornerRadius = Math.min(
|
|
137
|
+
padding,
|
|
138
|
+
layout.width / 2,
|
|
139
|
+
layout.height / 2
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
'<g class="pcb-text pcb-text--layer-' +
|
|
144
|
+
SchematicSvgUtils.escapeHtml(String(Number(text.layerId || 0))) +
|
|
145
|
+
' pcb-text--inverted" transform="' +
|
|
146
|
+
PcbTextPrimitiveRenderer.#renderTextTransform(text, rotation) +
|
|
147
|
+
'">' +
|
|
148
|
+
'<mask id="' +
|
|
149
|
+
SchematicSvgUtils.escapeHtml(maskId) +
|
|
150
|
+
'" maskUnits="userSpaceOnUse" mask-type="luminance" x="' +
|
|
151
|
+
SchematicSvgUtils.formatNumber(rectX) +
|
|
152
|
+
'" y="' +
|
|
153
|
+
SchematicSvgUtils.formatNumber(rectY) +
|
|
154
|
+
'" width="' +
|
|
155
|
+
SchematicSvgUtils.formatNumber(layout.width) +
|
|
156
|
+
'" height="' +
|
|
157
|
+
SchematicSvgUtils.formatNumber(layout.height) +
|
|
158
|
+
'">' +
|
|
159
|
+
'<rect class="pcb-text__knockout-mask-fill" x="' +
|
|
160
|
+
SchematicSvgUtils.formatNumber(rectX) +
|
|
161
|
+
'" y="' +
|
|
162
|
+
SchematicSvgUtils.formatNumber(rectY) +
|
|
163
|
+
'" width="' +
|
|
164
|
+
SchematicSvgUtils.formatNumber(layout.width) +
|
|
165
|
+
'" height="' +
|
|
166
|
+
SchematicSvgUtils.formatNumber(layout.height) +
|
|
167
|
+
'" rx="' +
|
|
168
|
+
SchematicSvgUtils.formatNumber(cornerRadius) +
|
|
169
|
+
'" fill="#fff" />' +
|
|
170
|
+
'<text class="pcb-text__knockout-glyphs" x="0" y="0" font-size="' +
|
|
171
|
+
PcbTextPrimitiveRenderer.#formatTextNumber(fontSize) +
|
|
172
|
+
'"' +
|
|
173
|
+
PcbTextPrimitiveRenderer.#renderFontAttributes(text) +
|
|
174
|
+
' text-anchor="start" dominant-baseline="alphabetic" fill="#000">' +
|
|
175
|
+
content +
|
|
176
|
+
'</text>' +
|
|
177
|
+
'</mask>' +
|
|
178
|
+
'<rect class="pcb-text__knockout-fill" x="' +
|
|
179
|
+
SchematicSvgUtils.formatNumber(rectX) +
|
|
180
|
+
'" y="' +
|
|
181
|
+
SchematicSvgUtils.formatNumber(rectY) +
|
|
182
|
+
'" width="' +
|
|
183
|
+
SchematicSvgUtils.formatNumber(layout.width) +
|
|
184
|
+
'" height="' +
|
|
185
|
+
SchematicSvgUtils.formatNumber(layout.height) +
|
|
186
|
+
'" rx="' +
|
|
187
|
+
SchematicSvgUtils.formatNumber(cornerRadius) +
|
|
188
|
+
'" mask="url(#' +
|
|
189
|
+
SchematicSvgUtils.escapeHtml(maskId) +
|
|
190
|
+
')" />' +
|
|
191
|
+
'</g>'
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Renders the local text transform, including authored PCB text mirroring.
|
|
197
|
+
* @param {{ x?: number, y?: number, mirrored?: boolean }} text Text record.
|
|
198
|
+
* @param {number} rotation Text rotation in degrees.
|
|
199
|
+
* @returns {string}
|
|
200
|
+
*/
|
|
201
|
+
static #renderTextTransform(text, rotation) {
|
|
202
|
+
const mirrorTransform = text?.mirrored === true ? ' scale(-1 1)' : ''
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
'translate(' +
|
|
206
|
+
SchematicSvgUtils.formatNumber(Number(text.x || 0)) +
|
|
207
|
+
' ' +
|
|
208
|
+
SchematicSvgUtils.formatNumber(Number(text.y || 0)) +
|
|
209
|
+
') rotate(' +
|
|
210
|
+
SchematicSvgUtils.formatNumber(rotation) +
|
|
211
|
+
')' +
|
|
212
|
+
mirrorTransform
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolves the SVG font size from Altium text metadata.
|
|
218
|
+
* @param {{ height?: number, sizeX?: number, sizeY?: number, trueTypeFontScale?: number, fontMetrics?: { emScaleFromPcbHeight?: number }, fontType?: number | string, fontTypeName?: string, isTrueType?: boolean }} text Text record.
|
|
219
|
+
* @returns {number}
|
|
220
|
+
*/
|
|
221
|
+
static #resolveFontSize(text) {
|
|
222
|
+
const height = Math.max(
|
|
223
|
+
Number(text?.sizeX) || Number(text?.height) || Number(text?.sizeY),
|
|
224
|
+
1
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if (PcbTextPrimitiveRenderer.#isTrueTypeText(text)) {
|
|
228
|
+
return height * PcbTextPrimitiveRenderer.#fontScale(text)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return Math.max(height, 8)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Checks whether a text primitive uses imported TrueType glyph geometry.
|
|
236
|
+
* @param {{ fontType?: number | string, fontTypeName?: string, isTrueType?: boolean }} text Text record.
|
|
237
|
+
* @returns {boolean}
|
|
238
|
+
*/
|
|
239
|
+
static #isTrueTypeText(text) {
|
|
240
|
+
const fontTypeName = String(text?.fontTypeName || '').toUpperCase()
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
text?.isTrueType === true ||
|
|
244
|
+
Number(text?.fontType) === 1 ||
|
|
245
|
+
fontTypeName.includes('TRUETYPE')
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Resolves the imported TrueType scale used by browser outline fonts.
|
|
251
|
+
* @param {{ trueTypeFontScale?: number, fontMetrics?: { emScaleFromPcbHeight?: number } }} text Text record.
|
|
252
|
+
* @returns {number}
|
|
253
|
+
*/
|
|
254
|
+
static #fontScale(text) {
|
|
255
|
+
if (Number.isFinite(Number(text?.trueTypeFontScale))) {
|
|
256
|
+
return Math.max(Number(text.trueTypeFontScale), 0.01)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (Number.isFinite(Number(text?.fontMetrics?.emScaleFromPcbHeight))) {
|
|
260
|
+
return Math.max(Number(text.fontMetrics.emScaleFromPcbHeight), 0.01)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return PcbTextPrimitiveRenderer.#DEFAULT_TRUETYPE_EM_SCALE
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Checks whether a text primitive should draw as reversed knockout artwork.
|
|
268
|
+
* @param {{ isInverted?: boolean }} text Text record.
|
|
269
|
+
* @returns {boolean}
|
|
270
|
+
*/
|
|
271
|
+
static #isInvertedText(text) {
|
|
272
|
+
return Boolean(text?.isInverted)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Splits one text record into drawable lines.
|
|
277
|
+
* @param {{ value?: string, text?: string }} text Text record.
|
|
278
|
+
* @returns {string[]}
|
|
279
|
+
*/
|
|
280
|
+
static #textLines(text) {
|
|
281
|
+
const lines = String(text?.value ?? text?.text ?? '').split(/\r?\n/u)
|
|
282
|
+
|
|
283
|
+
return lines.length ? lines : ['']
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Estimates SVG text metrics using the same fallback ratios as the 3D text
|
|
288
|
+
* texture path when browser canvas measurements are unavailable.
|
|
289
|
+
* @param {{ fontFamily?: string, fontName?: string }} text Text record.
|
|
290
|
+
* @param {string[]} lines Text lines.
|
|
291
|
+
* @param {number} fontSize Text font size.
|
|
292
|
+
* @returns {{ width: number, height: number, ascent: number, descent: number, lineHeight: number }}
|
|
293
|
+
*/
|
|
294
|
+
static #measureLines(text, lines, fontSize) {
|
|
295
|
+
const measured = PcbTextPrimitiveRenderer.#measureCanvasLines(
|
|
296
|
+
text,
|
|
297
|
+
lines,
|
|
298
|
+
fontSize
|
|
299
|
+
)
|
|
300
|
+
if (measured) {
|
|
301
|
+
return measured
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const ascent = fontSize * PcbTextPrimitiveRenderer.#FONT_ASCENT_RATIO
|
|
305
|
+
const descent = fontSize * PcbTextPrimitiveRenderer.#FONT_DESCENT_RATIO
|
|
306
|
+
const glyphHeight = Math.max(ascent + descent, 1)
|
|
307
|
+
const lineHeight = Math.max(
|
|
308
|
+
glyphHeight * PcbTextPrimitiveRenderer.#LINE_HEIGHT_RATIO,
|
|
309
|
+
glyphHeight
|
|
310
|
+
)
|
|
311
|
+
const glyphWidth =
|
|
312
|
+
fontSize * PcbTextPrimitiveRenderer.#glyphWidthRatio(text)
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
width: Math.max(
|
|
316
|
+
...lines.map((line) =>
|
|
317
|
+
Math.max(String(line || ' ').length * glyphWidth, 1)
|
|
318
|
+
)
|
|
319
|
+
),
|
|
320
|
+
height: glyphHeight + lineHeight * (lines.length - 1),
|
|
321
|
+
ascent,
|
|
322
|
+
descent,
|
|
323
|
+
lineHeight
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Resolves a stable glyph-width ratio for coarse SVG layout.
|
|
329
|
+
* @param {{ fontFamily?: string, fontName?: string }} text Text record.
|
|
330
|
+
* @returns {number}
|
|
331
|
+
*/
|
|
332
|
+
static #glyphWidthRatio(text) {
|
|
333
|
+
const metricsRatio =
|
|
334
|
+
PcbTextPrimitiveRenderer.#fontMetricsAverageWidthRatio(text)
|
|
335
|
+
if (metricsRatio) {
|
|
336
|
+
return metricsRatio
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const family = PcbTextPrimitiveRenderer.#cleanFontFamily(
|
|
340
|
+
text?.fontFamily || text?.fontName
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
return PcbTextPrimitiveRenderer.#isMonospaceFamily(family)
|
|
344
|
+
? PcbTextPrimitiveRenderer.#MONOSPACE_GLYPH_WIDTH_RATIO
|
|
345
|
+
: PcbTextPrimitiveRenderer.#DEFAULT_GLYPH_WIDTH_RATIO
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Measures text with a browser canvas when the renderer runs in the app.
|
|
350
|
+
* @param {{ fontFamily?: string, fontName?: string, isBold?: boolean, fontWeight?: number, isItalic?: boolean }} text Text record.
|
|
351
|
+
* @param {string[]} lines Text lines.
|
|
352
|
+
* @param {number} fontSize Text font size.
|
|
353
|
+
* @returns {{ width: number, height: number, ascent: number, descent: number, lineHeight: number } | null}
|
|
354
|
+
*/
|
|
355
|
+
static #measureCanvasLines(text, lines, fontSize) {
|
|
356
|
+
const canvas = globalThis.document?.createElement?.('canvas')
|
|
357
|
+
const context = canvas?.getContext?.('2d')
|
|
358
|
+
|
|
359
|
+
if (!context) {
|
|
360
|
+
return null
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
context.font = PcbTextPrimitiveRenderer.#buildCanvasFont(text, fontSize)
|
|
364
|
+
|
|
365
|
+
const measured = lines.map((line) => context.measureText(line || ' '))
|
|
366
|
+
const ascent = PcbTextPrimitiveRenderer.#resolveMeasuredExtent(
|
|
367
|
+
measured,
|
|
368
|
+
'actualBoundingBoxAscent',
|
|
369
|
+
fontSize * PcbTextPrimitiveRenderer.#FONT_ASCENT_RATIO
|
|
370
|
+
)
|
|
371
|
+
const descent = PcbTextPrimitiveRenderer.#resolveMeasuredExtent(
|
|
372
|
+
measured,
|
|
373
|
+
'actualBoundingBoxDescent',
|
|
374
|
+
fontSize * PcbTextPrimitiveRenderer.#FONT_DESCENT_RATIO
|
|
375
|
+
)
|
|
376
|
+
const glyphHeight = Math.max(ascent + descent, 1)
|
|
377
|
+
const lineHeight = Math.max(
|
|
378
|
+
glyphHeight * PcbTextPrimitiveRenderer.#LINE_HEIGHT_RATIO,
|
|
379
|
+
glyphHeight
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
width: Math.max(...measured.map((metric) => Number(metric.width))),
|
|
384
|
+
height: glyphHeight + lineHeight * (lines.length - 1),
|
|
385
|
+
ascent,
|
|
386
|
+
descent,
|
|
387
|
+
lineHeight
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Resolves a measured text extent with a stable fallback.
|
|
393
|
+
* @param {TextMetrics[]} measured Browser text metrics.
|
|
394
|
+
* @param {'actualBoundingBoxAscent' | 'actualBoundingBoxDescent'} field Extent field.
|
|
395
|
+
* @param {number} fallback Fallback extent.
|
|
396
|
+
* @returns {number}
|
|
397
|
+
*/
|
|
398
|
+
static #resolveMeasuredExtent(measured, field, fallback) {
|
|
399
|
+
const values = measured
|
|
400
|
+
.map((metric) => Number(metric?.[field]))
|
|
401
|
+
.filter((value) => Number.isFinite(value) && value > 0)
|
|
402
|
+
|
|
403
|
+
return values.length ? Math.max(...values) : fallback
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Builds the CSS font string used for optional canvas measurement.
|
|
408
|
+
* @param {{ fontFamily?: string, fontName?: string, isBold?: boolean, fontWeight?: number, isItalic?: boolean }} text Text record.
|
|
409
|
+
* @param {number} fontSize Text font size.
|
|
410
|
+
* @returns {string}
|
|
411
|
+
*/
|
|
412
|
+
static #buildCanvasFont(text, fontSize) {
|
|
413
|
+
const weight =
|
|
414
|
+
text?.isBold || Number(text?.fontWeight) >= 600 ? '700' : '400'
|
|
415
|
+
const style = text?.isItalic ? 'italic' : 'normal'
|
|
416
|
+
const family = PcbTextPrimitiveRenderer.#buildCanvasFontFamily(
|
|
417
|
+
text?.fontFamily || text?.fontName
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
return `${style} ${weight} ${fontSize}px ${family}`
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Builds a browser font-family stack matching the 3D TrueType path.
|
|
425
|
+
* @param {unknown} family Font family value.
|
|
426
|
+
* @returns {string}
|
|
427
|
+
*/
|
|
428
|
+
static #buildCanvasFontFamily(family) {
|
|
429
|
+
const cleaned = PcbTextPrimitiveRenderer.#cleanFontFamily(family)
|
|
430
|
+
const quoted = PcbTextPrimitiveRenderer.#quoteFontFamily(cleaned)
|
|
431
|
+
|
|
432
|
+
if (PcbTextPrimitiveRenderer.#isMonospaceFamily(cleaned)) {
|
|
433
|
+
return [
|
|
434
|
+
quoted,
|
|
435
|
+
'"Menlo"',
|
|
436
|
+
'"Monaco"',
|
|
437
|
+
'"Liberation Mono"',
|
|
438
|
+
'"Courier New"',
|
|
439
|
+
'monospace'
|
|
440
|
+
].join(', ')
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (PcbTextPrimitiveRenderer.#isArialFamily(cleaned)) {
|
|
444
|
+
return [
|
|
445
|
+
quoted,
|
|
446
|
+
'"Helvetica Neue"',
|
|
447
|
+
'Helvetica',
|
|
448
|
+
'Arial',
|
|
449
|
+
'sans-serif'
|
|
450
|
+
].join(', ')
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return [quoted, 'Arial', 'sans-serif'].join(', ')
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Quotes one CSS font-family token.
|
|
458
|
+
* @param {string} family Font family name.
|
|
459
|
+
* @returns {string}
|
|
460
|
+
*/
|
|
461
|
+
static #quoteFontFamily(family) {
|
|
462
|
+
return `"${family.replace(/["\\]/gu, '\\$&')}"`
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Resolves embedded font average advance width when available.
|
|
467
|
+
* @param {{ fontMetrics?: { averageAdvanceWidth?: number, unitsPerEm?: number } }} text Text record.
|
|
468
|
+
* @returns {number | null}
|
|
469
|
+
*/
|
|
470
|
+
static #fontMetricsAverageWidthRatio(text) {
|
|
471
|
+
const averageAdvanceWidth = Number(
|
|
472
|
+
text?.fontMetrics?.averageAdvanceWidth
|
|
473
|
+
)
|
|
474
|
+
const unitsPerEm = Number(text?.fontMetrics?.unitsPerEm)
|
|
475
|
+
const ratio = averageAdvanceWidth / unitsPerEm
|
|
476
|
+
|
|
477
|
+
return Number.isFinite(ratio) && ratio >= 0.3 && ratio <= 1.2
|
|
478
|
+
? ratio
|
|
479
|
+
: null
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Resolves padding around one inverted text primitive.
|
|
484
|
+
* @param {{ marginBorderWidth?: number }} text Text record.
|
|
485
|
+
* @param {number} fontSize Text font size.
|
|
486
|
+
* @returns {number}
|
|
487
|
+
*/
|
|
488
|
+
static #resolveTextPadding(text, fontSize) {
|
|
489
|
+
const basePadding = Math.max(
|
|
490
|
+
fontSize * PcbTextPrimitiveRenderer.#TEXTURE_PADDING_RATIO,
|
|
491
|
+
PcbTextPrimitiveRenderer.#MIN_CANVAS_PADDING
|
|
492
|
+
)
|
|
493
|
+
const marginBorderWidth = Number(text?.marginBorderWidth)
|
|
494
|
+
|
|
495
|
+
return Number.isFinite(marginBorderWidth) && marginBorderWidth >= 0
|
|
496
|
+
? marginBorderWidth
|
|
497
|
+
: basePadding
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Resolves the local rectangle and baseline used to anchor SVG text at the
|
|
502
|
+
* Altium insertion point.
|
|
503
|
+
* @param {{ useInvertedRectangle?: boolean, textboxRectWidth?: number, textboxRectHeight?: number, textboxRectJustification?: number }} text Text record.
|
|
504
|
+
* @param {{ width: number, height: number, ascent: number }} metrics Text metrics.
|
|
505
|
+
* @param {number} padding Background padding.
|
|
506
|
+
* @returns {{ width: number, height: number, baselineX: number, baselineY: number, anchorX: number, anchorY: number }}
|
|
507
|
+
*/
|
|
508
|
+
static #resolveTextLayout(text, metrics, padding) {
|
|
509
|
+
const authoredRectangle =
|
|
510
|
+
PcbTextPrimitiveRenderer.#resolveAuthoredRectangle(text)
|
|
511
|
+
const width =
|
|
512
|
+
authoredRectangle?.width ||
|
|
513
|
+
Math.max(Number(metrics.width || 0), 1) + padding * 2
|
|
514
|
+
const height =
|
|
515
|
+
authoredRectangle?.height ||
|
|
516
|
+
Math.max(Number(metrics.height || 0), 1) + padding * 2
|
|
517
|
+
|
|
518
|
+
if (!authoredRectangle) {
|
|
519
|
+
return {
|
|
520
|
+
width,
|
|
521
|
+
height,
|
|
522
|
+
baselineX: padding,
|
|
523
|
+
baselineY: padding + Number(metrics.ascent || 0),
|
|
524
|
+
anchorX: padding,
|
|
525
|
+
anchorY: padding + Number(metrics.ascent || 0)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const baselineX = PcbTextPrimitiveRenderer.#authoredBaselineX(
|
|
530
|
+
text,
|
|
531
|
+
metrics,
|
|
532
|
+
width,
|
|
533
|
+
padding
|
|
534
|
+
)
|
|
535
|
+
const baselineY = PcbTextPrimitiveRenderer.#authoredBaselineY(
|
|
536
|
+
text,
|
|
537
|
+
metrics,
|
|
538
|
+
height,
|
|
539
|
+
padding
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
width,
|
|
544
|
+
height,
|
|
545
|
+
baselineX,
|
|
546
|
+
baselineY,
|
|
547
|
+
anchorX: baselineX,
|
|
548
|
+
anchorY: baselineY
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Resolves authored inverted rectangle dimensions from source metadata.
|
|
554
|
+
* @param {{ useInvertedRectangle?: boolean, textboxRectWidth?: number, textboxRectHeight?: number }} text Text record.
|
|
555
|
+
* @returns {{ width: number, height: number } | null}
|
|
556
|
+
*/
|
|
557
|
+
static #resolveAuthoredRectangle(text) {
|
|
558
|
+
const width = Number(text?.textboxRectWidth)
|
|
559
|
+
const height = Number(text?.textboxRectHeight)
|
|
560
|
+
|
|
561
|
+
if (
|
|
562
|
+
!Boolean(text?.useInvertedRectangle) ||
|
|
563
|
+
!Number.isFinite(width) ||
|
|
564
|
+
!Number.isFinite(height) ||
|
|
565
|
+
width <= 0 ||
|
|
566
|
+
height <= 0
|
|
567
|
+
) {
|
|
568
|
+
return null
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return { width, height }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Resolves horizontal baseline placement inside an authored rectangle.
|
|
576
|
+
* @param {{ textboxRectJustification?: number }} text Text record.
|
|
577
|
+
* @param {{ width: number }} metrics Text metrics.
|
|
578
|
+
* @param {number} width Rectangle width.
|
|
579
|
+
* @param {number} padding Background padding.
|
|
580
|
+
* @returns {number}
|
|
581
|
+
*/
|
|
582
|
+
static #authoredBaselineX(text, metrics, width, padding) {
|
|
583
|
+
const column = PcbTextPrimitiveRenderer.#justificationColumn(text)
|
|
584
|
+
const remainingWidth = Math.max(width - Number(metrics.width || 0), 0)
|
|
585
|
+
|
|
586
|
+
if (column === 1) {
|
|
587
|
+
return remainingWidth / 2
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (column === 2) {
|
|
591
|
+
return remainingWidth
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return Math.min(padding, remainingWidth)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Resolves vertical baseline placement inside an authored rectangle.
|
|
599
|
+
* @param {{ textboxRectJustification?: number }} text Text record.
|
|
600
|
+
* @param {{ height: number, ascent: number }} metrics Text metrics.
|
|
601
|
+
* @param {number} height Rectangle height.
|
|
602
|
+
* @param {number} padding Background padding.
|
|
603
|
+
* @returns {number}
|
|
604
|
+
*/
|
|
605
|
+
static #authoredBaselineY(text, metrics, height, padding) {
|
|
606
|
+
const row = PcbTextPrimitiveRenderer.#justificationRow(text)
|
|
607
|
+
const remainingHeight = Math.max(
|
|
608
|
+
height - Number(metrics.height || 0),
|
|
609
|
+
0
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
if (row === 1) {
|
|
613
|
+
return remainingHeight / 2 + Number(metrics.ascent || 0)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (row === 2) {
|
|
617
|
+
return remainingHeight + Number(metrics.ascent || 0)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return Math.min(padding, remainingHeight) + Number(metrics.ascent || 0)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Resolves the authored horizontal justification column.
|
|
625
|
+
* @param {{ textboxRectJustification?: number }} text Text record.
|
|
626
|
+
* @returns {0 | 1 | 2 | null}
|
|
627
|
+
*/
|
|
628
|
+
static #justificationColumn(text) {
|
|
629
|
+
const justification = Number(text?.textboxRectJustification)
|
|
630
|
+
|
|
631
|
+
return Number.isInteger(justification) && justification > 0
|
|
632
|
+
? Math.max(0, Math.min(2, Math.floor((justification - 1) / 3)))
|
|
633
|
+
: null
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Resolves the authored vertical justification row.
|
|
638
|
+
* @param {{ textboxRectJustification?: number }} text Text record.
|
|
639
|
+
* @returns {0 | 1 | 2 | null}
|
|
640
|
+
*/
|
|
641
|
+
static #justificationRow(text) {
|
|
642
|
+
const justification = Number(text?.textboxRectJustification)
|
|
643
|
+
|
|
644
|
+
return Number.isInteger(justification) && justification > 0
|
|
645
|
+
? (justification - 1) % 3
|
|
646
|
+
: null
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Removes fixed-field padding from one font family name.
|
|
651
|
+
* @param {unknown} family Font family value.
|
|
652
|
+
* @returns {string}
|
|
653
|
+
*/
|
|
654
|
+
static #cleanFontFamily(family) {
|
|
655
|
+
const cleaned = String(
|
|
656
|
+
family || PcbTextPrimitiveRenderer.#DEFAULT_FONT_FAMILY
|
|
657
|
+
)
|
|
658
|
+
.split('\0')[0]
|
|
659
|
+
?.trim()
|
|
660
|
+
|
|
661
|
+
return cleaned || PcbTextPrimitiveRenderer.#DEFAULT_FONT_FAMILY
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Checks whether one font family is a common monospace PCB font.
|
|
666
|
+
* @param {unknown} family Font family value.
|
|
667
|
+
* @returns {boolean}
|
|
668
|
+
*/
|
|
669
|
+
static #isMonospaceFamily(family) {
|
|
670
|
+
return /^(consolas|courier|courier new|menlo|monaco|liberation mono)$/iu.test(
|
|
671
|
+
PcbTextPrimitiveRenderer.#cleanFontFamily(family)
|
|
672
|
+
)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Checks whether an imported family is Arial-like.
|
|
677
|
+
* @param {unknown} family Font family value.
|
|
678
|
+
* @returns {boolean}
|
|
679
|
+
*/
|
|
680
|
+
static #isArialFamily(family) {
|
|
681
|
+
return /^arial(?:\b|$)/iu.test(
|
|
682
|
+
PcbTextPrimitiveRenderer.#cleanFontFamily(family)
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Formats concise text metrics without keeping cosmetic trailing zeros.
|
|
688
|
+
* @param {number} value Numeric metric value.
|
|
689
|
+
* @returns {string}
|
|
690
|
+
*/
|
|
691
|
+
static #formatTextNumber(value) {
|
|
692
|
+
return SchematicSvgUtils.formatNumber(value)
|
|
693
|
+
.replace(/(\.\d*?)0+$/u, '$1')
|
|
694
|
+
.replace(/\.$/u, '')
|
|
695
|
+
}
|
|
696
|
+
|
|
82
697
|
/**
|
|
83
698
|
* Renders optional SVG font attributes for TrueType text primitives.
|
|
84
699
|
* @param {{ fontFamily?: string, fontWeight?: number, fontStyle?: string }} text
|
|
@@ -114,10 +729,10 @@ export class PcbTextPrimitiveRenderer {
|
|
|
114
729
|
/**
|
|
115
730
|
* Renders one or more text lines with SVG tspans.
|
|
116
731
|
* @param {string[]} lines
|
|
117
|
-
* @param {number}
|
|
732
|
+
* @param {number} lineStep
|
|
118
733
|
* @returns {string}
|
|
119
734
|
*/
|
|
120
|
-
static #renderTextLines(lines,
|
|
735
|
+
static #renderTextLines(lines, lineStep) {
|
|
121
736
|
if (lines.length === 1) {
|
|
122
737
|
return SchematicSvgUtils.escapeHtml(lines[0])
|
|
123
738
|
}
|
|
@@ -126,7 +741,7 @@ export class PcbTextPrimitiveRenderer {
|
|
|
126
741
|
.map(
|
|
127
742
|
(line, index) =>
|
|
128
743
|
'<tspan x="0" dy="' +
|
|
129
|
-
SchematicSvgUtils.formatNumber(index === 0 ? 0 :
|
|
744
|
+
SchematicSvgUtils.formatNumber(index === 0 ? 0 : lineStep) +
|
|
130
745
|
'">' +
|
|
131
746
|
SchematicSvgUtils.escapeHtml(line) +
|
|
132
747
|
'</tspan>'
|
|
@@ -249,4 +864,18 @@ export class PcbTextPrimitiveRenderer {
|
|
|
249
864
|
static #isPlaceholderText(text) {
|
|
250
865
|
return text?.isPlaceholder === true
|
|
251
866
|
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Returns true when native overlay holes already contain inverted glyphs.
|
|
870
|
+
* @param {{ isInverted?: boolean, fontType?: number | string, fontTypeName?: string, isTrueType?: boolean }} text Text record.
|
|
871
|
+
* @param {{ nativeTextKnockouts?: boolean }} options Selection options.
|
|
872
|
+
* @returns {boolean}
|
|
873
|
+
*/
|
|
874
|
+
static #shouldSkipNativeTextKnockout(text, options) {
|
|
875
|
+
return (
|
|
876
|
+
Boolean(options?.nativeTextKnockouts) &&
|
|
877
|
+
Boolean(text?.isInverted) &&
|
|
878
|
+
PcbTextPrimitiveRenderer.#isTrueTypeText(text)
|
|
879
|
+
)
|
|
880
|
+
}
|
|
252
881
|
}
|