altium-toolkit 0.1.20 → 0.1.21

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.
@@ -0,0 +1,453 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds overlay fill cutouts from drilled pad and via metadata.
7
+ */
8
+ export class PcbScene3dDrillCutoutBuilder {
9
+ static #CIRCLE_SEGMENTS = 24
10
+ static #SLOT_CAP_SEGMENTS = 12
11
+ static #EPSILON = 0.001
12
+
13
+ /**
14
+ * Adds drill-shaped holes to every fill intersected by a pad or via drill.
15
+ * @param {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][], x1?: number, y1?: number, x2?: number, y2?: number }[]} fills
16
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number, holeSlotLength?: number, slotLength?: number, rotation?: number, holeRotation?: number }[]} pads
17
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number }[]} vias
18
+ * @returns {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][], x1?: number, y1?: number, x2?: number, y2?: number }[]}
19
+ */
20
+ static clipFills(fills, pads, vias) {
21
+ const cutouts = PcbScene3dDrillCutoutBuilder.#buildCutouts(pads, vias)
22
+
23
+ if (!cutouts.length) {
24
+ return fills
25
+ }
26
+
27
+ return fills.map((fill) => {
28
+ const holes = cutouts
29
+ .filter((cutout) =>
30
+ PcbScene3dDrillCutoutBuilder.#cutoutTouchesFill(
31
+ cutout,
32
+ fill
33
+ )
34
+ )
35
+ .map((cutout) => cutout.points)
36
+
37
+ if (!holes.length) {
38
+ return fill
39
+ }
40
+
41
+ return {
42
+ ...fill,
43
+ holes: [
44
+ ...PcbScene3dDrillCutoutBuilder.#resolveExistingHoles(fill),
45
+ ...holes
46
+ ]
47
+ }
48
+ })
49
+ }
50
+
51
+ /**
52
+ * Builds all known drill cutout contours.
53
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number, holeSlotLength?: number, slotLength?: number, rotation?: number, holeRotation?: number }[]} pads
54
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number }[]} vias
55
+ * @returns {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }[]}
56
+ */
57
+ static #buildCutouts(pads, vias) {
58
+ return [
59
+ ...PcbScene3dDrillCutoutBuilder.#buildPadCutouts(pads),
60
+ ...PcbScene3dDrillCutoutBuilder.#buildViaCutouts(vias)
61
+ ]
62
+ }
63
+
64
+ /**
65
+ * Builds drill contours for drilled pads.
66
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number, holeSlotLength?: number, slotLength?: number, rotation?: number, holeRotation?: number }[]} pads
67
+ * @returns {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }[]}
68
+ */
69
+ static #buildPadCutouts(pads) {
70
+ return (Array.isArray(pads) ? pads : [])
71
+ .map((pad) => {
72
+ const x = Number(pad?.x)
73
+ const y = Number(pad?.y)
74
+ const diameter =
75
+ PcbScene3dDrillCutoutBuilder.#resolvePositiveNumber(pad, [
76
+ 'holeDiameter',
77
+ 'drillDiameter'
78
+ ])
79
+ const slotLength =
80
+ PcbScene3dDrillCutoutBuilder.#resolvePositiveNumber(pad, [
81
+ 'holeSlotLength',
82
+ 'slotLength'
83
+ ])
84
+ const rotationDeg =
85
+ Number(pad?.rotation || 0) + Number(pad?.holeRotation || 0)
86
+
87
+ return PcbScene3dDrillCutoutBuilder.#buildCutout(
88
+ x,
89
+ y,
90
+ diameter,
91
+ slotLength,
92
+ rotationDeg
93
+ )
94
+ })
95
+ .filter(Boolean)
96
+ }
97
+
98
+ /**
99
+ * Builds circular drill contours for vias.
100
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number }[]} vias
101
+ * @returns {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }[]}
102
+ */
103
+ static #buildViaCutouts(vias) {
104
+ return (Array.isArray(vias) ? vias : [])
105
+ .map((via) => {
106
+ const x = Number(via?.x)
107
+ const y = Number(via?.y)
108
+ const diameter =
109
+ PcbScene3dDrillCutoutBuilder.#resolvePositiveNumber(via, [
110
+ 'holeDiameter',
111
+ 'drillDiameter'
112
+ ])
113
+
114
+ return PcbScene3dDrillCutoutBuilder.#buildCutout(
115
+ x,
116
+ y,
117
+ diameter,
118
+ 0,
119
+ 0
120
+ )
121
+ })
122
+ .filter(Boolean)
123
+ }
124
+
125
+ /**
126
+ * Builds one drill contour from center, diameter, and optional slot length.
127
+ * @param {number} x
128
+ * @param {number} y
129
+ * @param {number} diameter
130
+ * @param {number} slotLength
131
+ * @param {number} rotationDeg
132
+ * @returns {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] } | null}
133
+ */
134
+ static #buildCutout(x, y, diameter, slotLength, rotationDeg) {
135
+ if (
136
+ !Number.isFinite(x) ||
137
+ !Number.isFinite(y) ||
138
+ !Number.isFinite(diameter) ||
139
+ diameter <= PcbScene3dDrillCutoutBuilder.#EPSILON
140
+ ) {
141
+ return null
142
+ }
143
+
144
+ const points =
145
+ Number.isFinite(slotLength) && slotLength > diameter
146
+ ? PcbScene3dDrillCutoutBuilder.#buildSlotPoints(
147
+ x,
148
+ y,
149
+ diameter,
150
+ slotLength,
151
+ rotationDeg
152
+ )
153
+ : PcbScene3dDrillCutoutBuilder.#buildCirclePoints(
154
+ x,
155
+ y,
156
+ diameter
157
+ )
158
+
159
+ return {
160
+ x,
161
+ y,
162
+ points,
163
+ bounds: PcbScene3dDrillCutoutBuilder.#resolvePointBounds(points)
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Builds a polygonal circular drill contour.
169
+ * @param {number} x
170
+ * @param {number} y
171
+ * @param {number} diameter
172
+ * @returns {{ x: number, y: number }[]}
173
+ */
174
+ static #buildCirclePoints(x, y, diameter) {
175
+ const radius = diameter / 2
176
+
177
+ return Array.from(
178
+ { length: PcbScene3dDrillCutoutBuilder.#CIRCLE_SEGMENTS },
179
+ (_, index) => {
180
+ const angle =
181
+ (Math.PI * 2 * index) /
182
+ PcbScene3dDrillCutoutBuilder.#CIRCLE_SEGMENTS
183
+
184
+ return {
185
+ x: x + Math.cos(angle) * radius,
186
+ y: y + Math.sin(angle) * radius
187
+ }
188
+ }
189
+ )
190
+ }
191
+
192
+ /**
193
+ * Builds a polygonal slotted drill contour.
194
+ * @param {number} x
195
+ * @param {number} y
196
+ * @param {number} diameter
197
+ * @param {number} slotLength
198
+ * @param {number} rotationDeg
199
+ * @returns {{ x: number, y: number }[]}
200
+ */
201
+ static #buildSlotPoints(x, y, diameter, slotLength, rotationDeg) {
202
+ const radius = diameter / 2
203
+ const halfStraight = Math.max((slotLength - diameter) / 2, 0)
204
+ const rotation = (rotationDeg * Math.PI) / 180
205
+ const points = []
206
+
207
+ for (
208
+ let index = 0;
209
+ index <= PcbScene3dDrillCutoutBuilder.#SLOT_CAP_SEGMENTS;
210
+ index += 1
211
+ ) {
212
+ const angle =
213
+ -Math.PI / 2 +
214
+ (Math.PI * index) /
215
+ PcbScene3dDrillCutoutBuilder.#SLOT_CAP_SEGMENTS
216
+ points.push(
217
+ PcbScene3dDrillCutoutBuilder.#rotatePoint(
218
+ x,
219
+ y,
220
+ halfStraight + Math.cos(angle) * radius,
221
+ Math.sin(angle) * radius,
222
+ rotation
223
+ )
224
+ )
225
+ }
226
+
227
+ for (
228
+ let index = 0;
229
+ index <= PcbScene3dDrillCutoutBuilder.#SLOT_CAP_SEGMENTS;
230
+ index += 1
231
+ ) {
232
+ const angle =
233
+ Math.PI / 2 +
234
+ (Math.PI * index) /
235
+ PcbScene3dDrillCutoutBuilder.#SLOT_CAP_SEGMENTS
236
+ points.push(
237
+ PcbScene3dDrillCutoutBuilder.#rotatePoint(
238
+ x,
239
+ y,
240
+ -halfStraight + Math.cos(angle) * radius,
241
+ Math.sin(angle) * radius,
242
+ rotation
243
+ )
244
+ )
245
+ }
246
+
247
+ return points
248
+ }
249
+
250
+ /**
251
+ * Rotates one local point around a drill center.
252
+ * @param {number} centerX
253
+ * @param {number} centerY
254
+ * @param {number} localX
255
+ * @param {number} localY
256
+ * @param {number} rotation
257
+ * @returns {{ x: number, y: number }}
258
+ */
259
+ static #rotatePoint(centerX, centerY, localX, localY, rotation) {
260
+ const cos = Math.cos(rotation)
261
+ const sin = Math.sin(rotation)
262
+
263
+ return {
264
+ x: centerX + localX * cos - localY * sin,
265
+ y: centerY + localX * sin + localY * cos
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Returns true when a drill contour should cut one fill.
271
+ * @param {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }} cutout
272
+ * @param {{ points?: { x: number, y: number }[], x1?: number, y1?: number, x2?: number, y2?: number }} fill
273
+ * @returns {boolean}
274
+ */
275
+ static #cutoutTouchesFill(cutout, fill) {
276
+ const fillBounds = PcbScene3dDrillCutoutBuilder.#resolveFillBounds(fill)
277
+
278
+ if (
279
+ !fillBounds ||
280
+ !PcbScene3dDrillCutoutBuilder.#boundsOverlap(
281
+ cutout.bounds,
282
+ fillBounds
283
+ )
284
+ ) {
285
+ return false
286
+ }
287
+
288
+ const fillPoints = PcbScene3dDrillCutoutBuilder.#resolveFillPoints(fill)
289
+ if (fillPoints.length < 3) {
290
+ return true
291
+ }
292
+
293
+ return (
294
+ PcbScene3dDrillCutoutBuilder.#isPointInPolygon(
295
+ cutout,
296
+ fillPoints
297
+ ) ||
298
+ cutout.points.some((point) =>
299
+ PcbScene3dDrillCutoutBuilder.#isPointInPolygon(
300
+ point,
301
+ fillPoints
302
+ )
303
+ )
304
+ )
305
+ }
306
+
307
+ /**
308
+ * Resolves existing authored holes from a fill.
309
+ * @param {{ holes?: { x: number, y: number }[][] }} fill
310
+ * @returns {{ x: number, y: number }[][]}
311
+ */
312
+ static #resolveExistingHoles(fill) {
313
+ return Array.isArray(fill?.holes)
314
+ ? fill.holes.filter((hole) => Array.isArray(hole))
315
+ : []
316
+ }
317
+
318
+ /**
319
+ * Resolves finite polygon points from a fill.
320
+ * @param {{ points?: { x: number, y: number }[] }} fill
321
+ * @returns {{ x: number, y: number }[]}
322
+ */
323
+ static #resolveFillPoints(fill) {
324
+ return (Array.isArray(fill?.points) ? fill.points : [])
325
+ .map((point) => ({
326
+ x: Number(point?.x),
327
+ y: Number(point?.y)
328
+ }))
329
+ .filter(
330
+ (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
331
+ )
332
+ }
333
+
334
+ /**
335
+ * Resolves one fill's bounds.
336
+ * @param {{ points?: { x: number, y: number }[], x1?: number, y1?: number, x2?: number, y2?: number }} fill
337
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
338
+ */
339
+ static #resolveFillBounds(fill) {
340
+ const points = PcbScene3dDrillCutoutBuilder.#resolveFillPoints(fill)
341
+
342
+ if (points.length) {
343
+ return PcbScene3dDrillCutoutBuilder.#resolvePointBounds(points)
344
+ }
345
+
346
+ const x1 = Number(fill?.x1)
347
+ const y1 = Number(fill?.y1)
348
+ const x2 = Number(fill?.x2)
349
+ const y2 = Number(fill?.y2)
350
+
351
+ if (
352
+ !Number.isFinite(x1) ||
353
+ !Number.isFinite(y1) ||
354
+ !Number.isFinite(x2) ||
355
+ !Number.isFinite(y2)
356
+ ) {
357
+ return null
358
+ }
359
+
360
+ return {
361
+ minX: Math.min(x1, x2),
362
+ minY: Math.min(y1, y2),
363
+ maxX: Math.max(x1, x2),
364
+ maxY: Math.max(y1, y2)
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Resolves bounds for a point list.
370
+ * @param {{ x: number, y: number }[]} points
371
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
372
+ */
373
+ static #resolvePointBounds(points) {
374
+ return points.reduce(
375
+ (bounds, point) => ({
376
+ minX: Math.min(bounds.minX, point.x),
377
+ minY: Math.min(bounds.minY, point.y),
378
+ maxX: Math.max(bounds.maxX, point.x),
379
+ maxY: Math.max(bounds.maxY, point.y)
380
+ }),
381
+ {
382
+ minX: Infinity,
383
+ minY: Infinity,
384
+ maxX: -Infinity,
385
+ maxY: -Infinity
386
+ }
387
+ )
388
+ }
389
+
390
+ /**
391
+ * Returns true when two axis-aligned boxes overlap.
392
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} a
393
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} b
394
+ * @returns {boolean}
395
+ */
396
+ static #boundsOverlap(a, b) {
397
+ return (
398
+ a.minX <= b.maxX &&
399
+ a.maxX >= b.minX &&
400
+ a.minY <= b.maxY &&
401
+ a.maxY >= b.minY
402
+ )
403
+ }
404
+
405
+ /**
406
+ * Returns true when a point lies inside a polygon.
407
+ * @param {{ x: number, y: number }} point
408
+ * @param {{ x: number, y: number }[]} polygon
409
+ * @returns {boolean}
410
+ */
411
+ static #isPointInPolygon(point, polygon) {
412
+ let inside = false
413
+
414
+ for (
415
+ let index = 0, previousIndex = polygon.length - 1;
416
+ index < polygon.length;
417
+ previousIndex = index, index += 1
418
+ ) {
419
+ const current = polygon[index]
420
+ const previous = polygon[previousIndex]
421
+ const intersects =
422
+ current.y > point.y !== previous.y > point.y &&
423
+ point.x <
424
+ ((previous.x - current.x) * (point.y - current.y)) /
425
+ (previous.y - current.y) +
426
+ current.x
427
+
428
+ if (intersects) {
429
+ inside = !inside
430
+ }
431
+ }
432
+
433
+ return inside
434
+ }
435
+
436
+ /**
437
+ * Resolves the first positive numeric field from an object.
438
+ * @param {Record<string, unknown>} source
439
+ * @param {string[]} keys
440
+ * @returns {number}
441
+ */
442
+ static #resolvePositiveNumber(source, keys) {
443
+ for (const key of keys) {
444
+ const value = Number(source?.[key])
445
+
446
+ if (Number.isFinite(value) && value > 0) {
447
+ return value
448
+ }
449
+ }
450
+
451
+ return 0
452
+ }
453
+ }
@@ -6,6 +6,7 @@ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
6
6
  import { SchematicColorResolver } from './SchematicColorResolver.mjs'
7
7
 
8
8
  const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
9
+ const MISSING_IMAGE_WRAP_SAFETY_FACTOR = 0.96
9
10
 
10
11
  /**
11
12
  * Renders normalized schematic image placements.
@@ -13,7 +14,7 @@ const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
13
14
  export class SchematicImageRenderer {
14
15
  /**
15
16
  * Builds markup for embedded schematic images and unresolved placeholders.
16
- * @param {{ x: number, y: number, cornerX: number, cornerY: number, mimeType?: string, dataBase64?: string, diagnosticState?: string, keepAspect?: boolean }[]} images
17
+ * @param {{ x: number, y: number, cornerX: number, cornerY: number, fileName?: string, mimeType?: string, dataBase64?: string, diagnosticState?: string, keepAspect?: boolean }[]} images
17
18
  * @param {number} sheetHeight
18
19
  * @returns {string}
19
20
  */
@@ -63,20 +64,16 @@ export class SchematicImageRenderer {
63
64
 
64
65
  /**
65
66
  * Builds one placeholder frame when an image payload is unavailable.
66
- * @param {{ x: number, y: number, cornerX: number, cornerY: number }} image
67
+ * @param {{ x: number, y: number, cornerX: number, cornerY: number, fileName?: string }} image
67
68
  * @param {number} sheetHeight
68
69
  * @returns {string}
69
70
  */
70
71
  static #buildPlaceholderMarkup(image, sheetHeight) {
71
72
  const bounds = SchematicImageRenderer.#resolveBounds(image, sheetHeight)
72
- const stroke = SchematicColorResolver.resolveColor(
73
- '#c0c0c0',
74
- '--schematic-note-border-color'
75
- )
76
73
 
77
74
  return (
78
75
  '<g class="schematic-image-placeholder">' +
79
- '<rect x="' +
76
+ '<svg x="' +
80
77
  formatNumber(bounds.x) +
81
78
  '" y="' +
82
79
  formatNumber(bounds.y) +
@@ -84,35 +81,140 @@ export class SchematicImageRenderer {
84
81
  formatNumber(bounds.width) +
85
82
  '" height="' +
86
83
  formatNumber(bounds.height) +
87
- '" fill="none" stroke="' +
88
- escapeHtml(stroke) +
89
- '" stroke-width="1" />' +
90
- '<line x1="' +
91
- formatNumber(bounds.x) +
92
- '" y1="' +
93
- formatNumber(bounds.y) +
94
- '" x2="' +
95
- formatNumber(bounds.x + bounds.width) +
96
- '" y2="' +
97
- formatNumber(bounds.y + bounds.height) +
98
- '" stroke="' +
99
- escapeHtml(stroke) +
100
- '" stroke-width="1" />' +
101
- '<line x1="' +
102
- formatNumber(bounds.x) +
103
- '" y1="' +
104
- formatNumber(bounds.y + bounds.height) +
105
- '" x2="' +
106
- formatNumber(bounds.x + bounds.width) +
107
- '" y2="' +
108
- formatNumber(bounds.y) +
109
- '" stroke="' +
110
- escapeHtml(stroke) +
111
- '" stroke-width="1" />' +
84
+ '" overflow="hidden">' +
85
+ SchematicImageRenderer.#buildMissingImageMessageMarkup(
86
+ image,
87
+ bounds
88
+ ) +
89
+ '</svg>' +
112
90
  '</g>'
113
91
  )
114
92
  }
115
93
 
94
+ /**
95
+ * Builds the visible message shown by Altium for unavailable image files.
96
+ * @param {{ fileName?: string }} image
97
+ * @param {{ x: number, y: number, width: number, height: number }} bounds
98
+ * @returns {string}
99
+ */
100
+ static #buildMissingImageMessageMarkup(image, bounds) {
101
+ const padding = Math.min(6, Math.max(bounds.width * 0.08, 2))
102
+ const fontSize = Math.min(8, Math.max(bounds.height / 18, 5))
103
+ const lineHeight = fontSize * 1.18
104
+ const usableWidth = Math.max(bounds.width - padding * 2, 1)
105
+ const lines = SchematicImageRenderer.#buildMissingImageMessageLines(
106
+ image.fileName,
107
+ usableWidth * MISSING_IMAGE_WRAP_SAFETY_FACTOR,
108
+ fontSize
109
+ )
110
+ const textColor = SchematicColorResolver.resolveColor(
111
+ '#2c3134',
112
+ '--schematic-text-color'
113
+ )
114
+ const startX = padding
115
+ const startY = padding + fontSize
116
+
117
+ return (
118
+ '<text class="schematic-image-placeholder-message" x="' +
119
+ formatNumber(startX) +
120
+ '" y="' +
121
+ formatNumber(startY) +
122
+ '" fill="' +
123
+ escapeHtml(textColor) +
124
+ '" font-family="Times New Roman" font-size="' +
125
+ formatNumber(fontSize) +
126
+ '">' +
127
+ lines
128
+ .map(
129
+ (line, index) =>
130
+ '<tspan x="' +
131
+ formatNumber(startX) +
132
+ '" dy="' +
133
+ formatNumber(index === 0 ? 0 : lineHeight) +
134
+ '">' +
135
+ escapeHtml(line) +
136
+ '</tspan>'
137
+ )
138
+ .join('') +
139
+ '</text>'
140
+ )
141
+ }
142
+
143
+ /**
144
+ * Wraps one missing-image message to the image placeholder width.
145
+ * @param {string | undefined} fileName
146
+ * @param {number} width
147
+ * @param {number} fontSize
148
+ * @returns {string[]}
149
+ */
150
+ static #buildMissingImageMessageLines(fileName, width, fontSize) {
151
+ return [
152
+ 'Cannot open file',
153
+ ...SchematicImageRenderer.#wrapMissingImageFileName(
154
+ fileName || 'image file',
155
+ width,
156
+ fontSize
157
+ ),
158
+ '. File does not exist.'
159
+ ]
160
+ }
161
+
162
+ /**
163
+ * Wraps file names using conservative estimated rendered glyph widths.
164
+ * @param {string} fileName
165
+ * @param {number} width
166
+ * @param {number} fontSize
167
+ * @returns {string[]}
168
+ */
169
+ static #wrapMissingImageFileName(fileName, width, fontSize) {
170
+ const maxWidth = Math.max(Number(width || 0), 1)
171
+ const value = String(fileName || '').trim()
172
+ const lines = []
173
+ let line = ''
174
+ let lineWidth = 0
175
+
176
+ for (const character of value) {
177
+ const characterWidth =
178
+ SchematicImageRenderer.#estimateMissingImageCharacterWidth(
179
+ character,
180
+ fontSize
181
+ )
182
+
183
+ if (line && lineWidth + characterWidth > maxWidth) {
184
+ lines.push(line)
185
+ line = character
186
+ lineWidth = characterWidth
187
+ continue
188
+ }
189
+
190
+ line += character
191
+ lineWidth += characterWidth
192
+ }
193
+
194
+ if (line) {
195
+ lines.push(line)
196
+ }
197
+
198
+ return lines.length ? lines : ['image file']
199
+ }
200
+
201
+ /**
202
+ * Estimates one placeholder glyph width for Times-like schematic text.
203
+ * @param {string} character
204
+ * @param {number} fontSize
205
+ * @returns {number}
206
+ */
207
+ static #estimateMissingImageCharacterWidth(character, fontSize) {
208
+ if (/[^\x00-\x7F]/u.test(character)) return fontSize
209
+ if (/[A-Z]/.test(character)) return fontSize * 0.62
210
+ if (/[a-z]/.test(character)) return fontSize * 0.45
211
+ if (/[0-9]/.test(character)) return fontSize * 0.5
212
+ if (/[\\/]/.test(character)) return fontSize * 0.32
213
+ if (/[.:\-_]/.test(character)) return fontSize * 0.28
214
+
215
+ return fontSize * 0.35
216
+ }
217
+
116
218
  /**
117
219
  * Resolves one image placement into SVG-space bounds.
118
220
  * @param {{ x: number, y: number, cornerX: number, cornerY: number }} image