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.
@@ -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
  }