altium-toolkit 1.0.1 → 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/package.json
CHANGED
|
@@ -141,6 +141,7 @@
|
|
|
141
141
|
--pcb-via-hole-fill: #0f746c;
|
|
142
142
|
--pcb-footprint-fill: rgba(247, 230, 117, 0.14);
|
|
143
143
|
--pcb-footprint-track-color: rgba(237, 172, 36, 1);
|
|
144
|
+
--pcb-text-knockout-fill: rgba(248, 246, 239, 0.96);
|
|
144
145
|
--pcb-component-top-fill: rgba(244, 219, 198, 0.92);
|
|
145
146
|
--pcb-component-bottom-fill: rgba(15, 116, 108, 0.84);
|
|
146
147
|
--pcb-component-stroke: rgba(110, 64, 38, 0.28);
|
|
@@ -178,6 +179,8 @@
|
|
|
178
179
|
fill: var(--pcb-board-fill);
|
|
179
180
|
stroke: var(--pcb-board-stroke);
|
|
180
181
|
stroke-width: 18;
|
|
182
|
+
stroke-linecap: round;
|
|
183
|
+
stroke-linejoin: round;
|
|
181
184
|
}
|
|
182
185
|
|
|
183
186
|
.board-outline--stroke {
|
|
@@ -252,7 +255,7 @@
|
|
|
252
255
|
}
|
|
253
256
|
|
|
254
257
|
.pcb-footprint-region {
|
|
255
|
-
fill: var(--pcb-footprint-track-color);
|
|
258
|
+
fill: var(--pcb-footprint-region-fill, var(--pcb-footprint-track-color));
|
|
256
259
|
}
|
|
257
260
|
|
|
258
261
|
.pcb-footprint-track,
|
|
@@ -278,6 +281,14 @@
|
|
|
278
281
|
pointer-events: none;
|
|
279
282
|
}
|
|
280
283
|
|
|
284
|
+
.pcb-text__knockout-fill {
|
|
285
|
+
fill: var(--pcb-text-knockout-fill);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.pcb-text__knockout-glyphs {
|
|
289
|
+
fill: #000;
|
|
290
|
+
}
|
|
291
|
+
|
|
281
292
|
.bom-panel {
|
|
282
293
|
display: grid;
|
|
283
294
|
gap: 1rem;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detects native overlay artwork that already contains text knockout holes.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbNativeTextKnockoutDetector {
|
|
9
|
+
static #DENSE_OVERLAY_MIN_REGION_AREA_RATIO = 0.2
|
|
10
|
+
static #DENSE_OVERLAY_MIN_TRACK_COUNT = 250
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns true when side-resolved overlay primitives carry native text
|
|
14
|
+
* knockouts, so source inverted TrueType labels would duplicate artwork.
|
|
15
|
+
* @param {{ fills?: object[], regions?: object[], tracks?: object[], arcs?: object[] }} primitives Side-resolved overlay primitives.
|
|
16
|
+
* @param {{ widthMil?: number, heightMil?: number }} board Board bounds.
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
static hasNativeTextKnockouts(primitives, board) {
|
|
20
|
+
const fills = [
|
|
21
|
+
...(Array.isArray(primitives?.fills) ? primitives.fills : []),
|
|
22
|
+
...(Array.isArray(primitives?.regions) ? primitives.regions : [])
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
PcbNativeTextKnockoutDetector.#isDenseOverlayArtwork(
|
|
27
|
+
{
|
|
28
|
+
fills,
|
|
29
|
+
tracks: primitives?.tracks,
|
|
30
|
+
arcs: primitives?.arcs
|
|
31
|
+
},
|
|
32
|
+
board
|
|
33
|
+
) &&
|
|
34
|
+
fills.some(
|
|
35
|
+
(fill) => Array.isArray(fill?.holes) && fill.holes.length > 0
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Detects dense overlay artwork from structural density.
|
|
42
|
+
* @param {{ fills?: object[], tracks?: object[], arcs?: object[] }} side Side primitives.
|
|
43
|
+
* @param {{ widthMil?: number, heightMil?: number }} board Board bounds.
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
static #isDenseOverlayArtwork(side, board) {
|
|
47
|
+
const strokeCount =
|
|
48
|
+
(Array.isArray(side?.tracks) ? side.tracks.length : 0) +
|
|
49
|
+
(Array.isArray(side?.arcs) ? side.arcs.length : 0)
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
strokeCount >=
|
|
53
|
+
PcbNativeTextKnockoutDetector.#DENSE_OVERLAY_MIN_TRACK_COUNT &&
|
|
54
|
+
PcbNativeTextKnockoutDetector.#maxFillAreaRatio(
|
|
55
|
+
side?.fills,
|
|
56
|
+
board
|
|
57
|
+
) >=
|
|
58
|
+
PcbNativeTextKnockoutDetector
|
|
59
|
+
.#DENSE_OVERLAY_MIN_REGION_AREA_RATIO
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolves the largest fill-to-board bounding-box area ratio.
|
|
65
|
+
* @param {object[] | undefined} fills Fill primitives.
|
|
66
|
+
* @param {{ widthMil?: number, heightMil?: number }} board Board bounds.
|
|
67
|
+
* @returns {number}
|
|
68
|
+
*/
|
|
69
|
+
static #maxFillAreaRatio(fills, board) {
|
|
70
|
+
const boardArea =
|
|
71
|
+
Math.max(Number(board?.widthMil || 0), 0) *
|
|
72
|
+
Math.max(Number(board?.heightMil || 0), 0)
|
|
73
|
+
if (!boardArea) {
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (Array.isArray(fills) ? fills : []).reduce((maxRatio, fill) => {
|
|
78
|
+
const bounds =
|
|
79
|
+
PcbNativeTextKnockoutDetector.#resolveFillBounds(fill)
|
|
80
|
+
if (!bounds) {
|
|
81
|
+
return maxRatio
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const fillArea =
|
|
85
|
+
Math.max(bounds.maxX - bounds.minX, 0) *
|
|
86
|
+
Math.max(bounds.maxY - bounds.minY, 0)
|
|
87
|
+
|
|
88
|
+
return Math.max(maxRatio, fillArea / boardArea)
|
|
89
|
+
}, 0)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolves rough authored bounds for one rectangular or polygon fill.
|
|
94
|
+
* @param {{ x1?: number, y1?: number, x2?: number, y2?: number, points?: { x?: number, y?: number }[] }} fill Fill primitive.
|
|
95
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
96
|
+
*/
|
|
97
|
+
static #resolveFillBounds(fill) {
|
|
98
|
+
const points = Array.isArray(fill?.points)
|
|
99
|
+
? fill.points
|
|
100
|
+
.map((point) => ({
|
|
101
|
+
x: Number(point?.x),
|
|
102
|
+
y: Number(point?.y)
|
|
103
|
+
}))
|
|
104
|
+
.filter(
|
|
105
|
+
(point) =>
|
|
106
|
+
Number.isFinite(point.x) && Number.isFinite(point.y)
|
|
107
|
+
)
|
|
108
|
+
: [
|
|
109
|
+
{ x: Number(fill?.x1), y: Number(fill?.y1) },
|
|
110
|
+
{ x: Number(fill?.x2), y: Number(fill?.y2) }
|
|
111
|
+
].filter(
|
|
112
|
+
(point) =>
|
|
113
|
+
Number.isFinite(point.x) && Number.isFinite(point.y)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if (points.length < 2) {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const xs = points.map((point) => point.x)
|
|
121
|
+
const ys = points.map((point) => point.y)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
minX: Math.min(...xs),
|
|
125
|
+
minY: Math.min(...ys),
|
|
126
|
+
maxX: Math.max(...xs),
|
|
127
|
+
maxY: Math.max(...ys)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -7,7 +7,9 @@ import { PcbEdgeFacingGlyphNormalizer } from './PcbEdgeFacingGlyphNormalizer.mjs
|
|
|
7
7
|
import { PcbEmbeddedFontFaceRenderer } from './PcbEmbeddedFontFaceRenderer.mjs'
|
|
8
8
|
import { PcbFootprintPrimitiveSelector } from './PcbFootprintPrimitiveSelector.mjs'
|
|
9
9
|
import { PcbCopperPrimitiveSplitter } from './PcbCopperPrimitiveSplitter.mjs'
|
|
10
|
+
import { PcbNativeTextKnockoutDetector } from './PcbNativeTextKnockoutDetector.mjs'
|
|
10
11
|
import { PcbRegionPrimitiveRenderer } from './PcbRegionPrimitiveRenderer.mjs'
|
|
12
|
+
import { PcbScene3dBoardOutlineRefiner } from './PcbScene3dBoardOutlineRefiner.mjs'
|
|
11
13
|
import { PcbTextPrimitiveRenderer } from './PcbTextPrimitiveRenderer.mjs'
|
|
12
14
|
import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
|
|
13
15
|
/**
|
|
@@ -27,7 +29,10 @@ export class PcbSvgRenderer {
|
|
|
27
29
|
if (!pcb) {
|
|
28
30
|
return '<section class="altium-renderer-empty">No PCB entities were recovered from this file.</section>'
|
|
29
31
|
}
|
|
30
|
-
const outline =
|
|
32
|
+
const outline = PcbScene3dBoardOutlineRefiner.refine(
|
|
33
|
+
{ board: pcb.boardOutline },
|
|
34
|
+
documentModel
|
|
35
|
+
).board
|
|
31
36
|
const polygons = pcb.polygons || []
|
|
32
37
|
const fills = pcb.fills || []
|
|
33
38
|
const tracks = pcb.tracks || []
|
|
@@ -43,11 +48,6 @@ export class PcbSvgRenderer {
|
|
|
43
48
|
const stackLayers = Array.isArray(pcb.layers) ? pcb.layers : []
|
|
44
49
|
const primitiveLayers = pcb.primitiveLayers || []
|
|
45
50
|
const displayLayers = stackLayers.length ? stackLayers : primitiveLayers
|
|
46
|
-
const texts = PcbTextPrimitiveRenderer.select(
|
|
47
|
-
primitiveLayers,
|
|
48
|
-
pcb.texts || [],
|
|
49
|
-
'top'
|
|
50
|
-
)
|
|
51
51
|
const copperGroups = PcbCopperPrimitiveSplitter.split(
|
|
52
52
|
polygons,
|
|
53
53
|
fills,
|
|
@@ -66,6 +66,18 @@ export class PcbSvgRenderer {
|
|
|
66
66
|
),
|
|
67
67
|
outline
|
|
68
68
|
)
|
|
69
|
+
const texts = PcbTextPrimitiveRenderer.select(
|
|
70
|
+
primitiveLayers,
|
|
71
|
+
pcb.texts || [],
|
|
72
|
+
'top',
|
|
73
|
+
{
|
|
74
|
+
nativeTextKnockouts:
|
|
75
|
+
PcbNativeTextKnockoutDetector.hasNativeTextKnockouts(
|
|
76
|
+
footprintPrimitives,
|
|
77
|
+
outline
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
)
|
|
69
81
|
const path = PcbSvgRenderer.#buildBoardPath(outline.segments)
|
|
70
82
|
const clipPathId = 'pcb-board-clip'
|
|
71
83
|
const viewBox = PcbSvgRenderer.#buildViewBox(
|
|
@@ -256,6 +268,9 @@ export class PcbSvgRenderer {
|
|
|
256
268
|
'pcb-footprint-region'
|
|
257
269
|
)
|
|
258
270
|
const textMarkup = PcbTextPrimitiveRenderer.render(texts)
|
|
271
|
+
const textGroupTransform = PcbSvgRenderer.#renderTextGroupTransform(
|
|
272
|
+
pcb.textGroupTransform
|
|
273
|
+
)
|
|
259
274
|
const fontFaceMarkup = PcbEmbeddedFontFaceRenderer.buildMarkup(
|
|
260
275
|
pcb.embeddedFonts || []
|
|
261
276
|
)
|
|
@@ -355,17 +370,19 @@ export class PcbSvgRenderer {
|
|
|
355
370
|
footprintArcMarkup +
|
|
356
371
|
footprintRegionMarkup +
|
|
357
372
|
'</g>' +
|
|
373
|
+
'<g class="pcb-components">' +
|
|
374
|
+
componentMarkup +
|
|
375
|
+
'</g>' +
|
|
358
376
|
'<g class="pcb-texts" clip-path="url(#' +
|
|
359
377
|
clipPathId +
|
|
360
|
-
')"
|
|
378
|
+
')"' +
|
|
379
|
+
textGroupTransform +
|
|
380
|
+
'>' +
|
|
361
381
|
textMarkup +
|
|
362
382
|
'</g>' +
|
|
363
383
|
'<path class="board-outline board-outline--stroke" d="' +
|
|
364
384
|
SchematicSvgUtils.escapeHtml(path) +
|
|
365
385
|
'" />' +
|
|
366
|
-
'<g class="pcb-components">' +
|
|
367
|
-
componentMarkup +
|
|
368
|
-
'</g>' +
|
|
369
386
|
'</svg></div></section>'
|
|
370
387
|
)
|
|
371
388
|
}
|
|
@@ -415,6 +432,62 @@ export class PcbSvgRenderer {
|
|
|
415
432
|
return path + ' Z'
|
|
416
433
|
}
|
|
417
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Renders an optional transform for the whole PCB text layer.
|
|
437
|
+
* @param {{ translateX?: number, translateY?: number, scaleX?: number, scaleY?: number } | undefined} transform Text group transform.
|
|
438
|
+
* @returns {string}
|
|
439
|
+
*/
|
|
440
|
+
static #renderTextGroupTransform(transform) {
|
|
441
|
+
if (!transform || typeof transform !== 'object') {
|
|
442
|
+
return ''
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const translateX = Number(transform.translateX || 0)
|
|
446
|
+
const translateY = Number(transform.translateY || 0)
|
|
447
|
+
const scaleX =
|
|
448
|
+
transform.scaleX === null || transform.scaleX === undefined
|
|
449
|
+
? 1
|
|
450
|
+
: Number(transform.scaleX)
|
|
451
|
+
const scaleY =
|
|
452
|
+
transform.scaleY === null || transform.scaleY === undefined
|
|
453
|
+
? 1
|
|
454
|
+
: Number(transform.scaleY)
|
|
455
|
+
if (
|
|
456
|
+
!Number.isFinite(translateX) ||
|
|
457
|
+
!Number.isFinite(translateY) ||
|
|
458
|
+
!Number.isFinite(scaleX) ||
|
|
459
|
+
!Number.isFinite(scaleY) ||
|
|
460
|
+
(translateX === 0 &&
|
|
461
|
+
translateY === 0 &&
|
|
462
|
+
scaleX === 1 &&
|
|
463
|
+
scaleY === 1)
|
|
464
|
+
) {
|
|
465
|
+
return ''
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const parts = []
|
|
469
|
+
if (translateX !== 0 || translateY !== 0) {
|
|
470
|
+
parts.push(
|
|
471
|
+
'translate(' +
|
|
472
|
+
SchematicSvgUtils.formatNumber(translateX) +
|
|
473
|
+
' ' +
|
|
474
|
+
SchematicSvgUtils.formatNumber(translateY) +
|
|
475
|
+
')'
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
if (scaleX !== 1 || scaleY !== 1) {
|
|
479
|
+
parts.push(
|
|
480
|
+
'scale(' +
|
|
481
|
+
SchematicSvgUtils.formatNumber(scaleX) +
|
|
482
|
+
' ' +
|
|
483
|
+
SchematicSvgUtils.formatNumber(scaleY) +
|
|
484
|
+
')'
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return ' transform="' + parts.join(' ') + '"'
|
|
489
|
+
}
|
|
490
|
+
|
|
418
491
|
/**
|
|
419
492
|
* Computes a reasonable viewBox.
|
|
420
493
|
* @param {{ minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }} outline
|
|
@@ -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
|
}
|