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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -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 = pcb.boardOutline
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) => PcbTextPrimitiveRenderer.#renderText(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 = Math.max(Number(text.height || 0), 8)
70
+ static #renderText(text, index) {
71
+ const fontSize = PcbTextPrimitiveRenderer.#resolveFontSize(text)
54
72
  const rotation = Number(text.rotation || 0)
55
- const lines = String(text.text || '')
56
- .replace(/\r\n?/gu, '\n')
57
- .split('\n')
58
- .filter((line) => line.length > 0)
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="translate(' +
67
- SchematicSvgUtils.formatNumber(Number(text.x || 0)) +
68
- ' ' +
69
- SchematicSvgUtils.formatNumber(Number(text.y || 0)) +
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} fontSize
732
+ * @param {number} lineStep
118
733
  * @returns {string}
119
734
  */
120
- static #renderTextLines(lines, fontSize) {
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 : fontSize) +
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
  }