@emasoft/svg-matrix 1.0.5 → 1.0.6

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,820 @@
1
+ /**
2
+ * Text-to-Path Module - Convert SVG text elements to path elements
3
+ *
4
+ * Converts text, tspan, and textPath elements to path elements for
5
+ * precise clipping and transformation operations.
6
+ *
7
+ * Note: Requires opentype.js for font parsing (optional dependency).
8
+ * Falls back to bounding box approximation if fonts unavailable.
9
+ *
10
+ * @module text-to-path
11
+ */
12
+
13
+ import Decimal from 'decimal.js';
14
+ import { Matrix } from './matrix.js';
15
+ import * as Transforms2D from './transforms2d.js';
16
+ import * as PolygonClip from './polygon-clip.js';
17
+
18
+ Decimal.set({ precision: 80 });
19
+
20
+ const D = x => (x instanceof Decimal ? x : new Decimal(x));
21
+
22
+ // Default font metrics when actual font unavailable
23
+ const DEFAULT_FONT_SIZE = 16;
24
+ const DEFAULT_UNITS_PER_EM = 1000;
25
+ const DEFAULT_ASCENDER = 800;
26
+ const DEFAULT_DESCENDER = -200;
27
+ const DEFAULT_CAP_HEIGHT = 700;
28
+
29
+ /**
30
+ * Text alignment options for horizontal positioning.
31
+ *
32
+ * Controls where text is anchored relative to its x coordinate:
33
+ * - START: Text begins at x coordinate (default, left-aligned for LTR text)
34
+ * - MIDDLE: Text is centered on x coordinate
35
+ * - END: Text ends at x coordinate (right-aligned for LTR text)
36
+ *
37
+ * @enum {string}
38
+ * @example
39
+ * // Left-align text starting at x=100
40
+ * textToPath("Hello", { x: 100, textAnchor: TextAnchor.START })
41
+ *
42
+ * // Center text on x=100
43
+ * textToPath("Hello", { x: 100, textAnchor: TextAnchor.MIDDLE })
44
+ *
45
+ * // Right-align text ending at x=100
46
+ * textToPath("Hello", { x: 100, textAnchor: TextAnchor.END })
47
+ */
48
+ export const TextAnchor = {
49
+ START: 'start',
50
+ MIDDLE: 'middle',
51
+ END: 'end'
52
+ };
53
+
54
+ /**
55
+ * Baseline alignment options for vertical positioning.
56
+ *
57
+ * Controls where text sits relative to its y coordinate based on font metrics:
58
+ * - AUTO/ALPHABETIC: y is the alphabetic baseline (bottom of most letters, default)
59
+ * - MIDDLE/CENTRAL: y is the vertical center of the text
60
+ * - HANGING: y is the top of hanging scripts (e.g., Devanagari)
61
+ * - TEXT_BEFORE_EDGE: y is the top edge of the em box
62
+ * - TEXT_AFTER_EDGE: y is the bottom edge of the em box
63
+ * - IDEOGRAPHIC: y is the ideographic baseline (bottom for CJK characters)
64
+ * - MATHEMATICAL: y is the mathematical baseline
65
+ *
66
+ * Font metrics terminology:
67
+ * - Ascent: Distance from baseline to top of tallest glyphs (e.g., 'd', 'h')
68
+ * - Descent: Distance from baseline to bottom of lowest glyphs (e.g., 'g', 'p')
69
+ * - Em box: The design space containing all glyphs (ascent + descent)
70
+ *
71
+ * @enum {string}
72
+ * @example
73
+ * // Text sits on baseline at y=100 (default)
74
+ * textToPath("Hello", { y: 100, dominantBaseline: DominantBaseline.ALPHABETIC })
75
+ *
76
+ * // Text is vertically centered at y=100
77
+ * textToPath("Hello", { y: 100, dominantBaseline: DominantBaseline.MIDDLE })
78
+ *
79
+ * // Top of text aligns with y=100
80
+ * textToPath("Hello", { y: 100, dominantBaseline: DominantBaseline.HANGING })
81
+ */
82
+ export const DominantBaseline = {
83
+ AUTO: 'auto',
84
+ MIDDLE: 'middle',
85
+ HANGING: 'hanging',
86
+ ALPHABETIC: 'alphabetic',
87
+ IDEOGRAPHIC: 'ideographic',
88
+ MATHEMATICAL: 'mathematical',
89
+ CENTRAL: 'central',
90
+ TEXT_AFTER_EDGE: 'text-after-edge',
91
+ TEXT_BEFORE_EDGE: 'text-before-edge'
92
+ };
93
+
94
+ /**
95
+ * Parse CSS font-size value and convert to pixels.
96
+ *
97
+ * Supports common CSS units:
98
+ * - px: Pixels (1:1 ratio)
99
+ * - pt: Points (1pt = 1.333px, based on 96 DPI)
100
+ * - em/rem: Relative to default font size (16px)
101
+ * - %: Percentage of default font size
102
+ *
103
+ * @param {string|number} value - Font size value (e.g., "16px", "1.5em", "12pt", "120%")
104
+ * @returns {number} Font size in pixels
105
+ *
106
+ * @example
107
+ * parseFontSize("16px") // → 16
108
+ * parseFontSize("12pt") // → 16 (12 * 1.333)
109
+ * parseFontSize("1.5em") // → 24 (1.5 * 16)
110
+ * parseFontSize("150%") // → 24 (1.5 * 16)
111
+ * parseFontSize(20) // → 20 (assumes px)
112
+ * parseFontSize("invalid") // → 16 (default)
113
+ */
114
+ export function parseFontSize(value) {
115
+ if (!value) return DEFAULT_FONT_SIZE;
116
+
117
+ const match = value.match(/^([\d.]+)(px|pt|em|rem|%)?$/i);
118
+ if (!match) return DEFAULT_FONT_SIZE;
119
+
120
+ const num = parseFloat(match[1]);
121
+ const unit = (match[2] || 'px').toLowerCase();
122
+
123
+ switch (unit) {
124
+ case 'px': return num;
125
+ case 'pt': return num * 1.333; // 1pt = 1.333px
126
+ case 'em':
127
+ case 'rem': return num * DEFAULT_FONT_SIZE;
128
+ case '%': return num / 100 * DEFAULT_FONT_SIZE;
129
+ default: return num;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Calculate text metrics (dimensions and baseline offsets) for a string.
135
+ *
136
+ * Uses actual font metrics when available via opentype.js, otherwise
137
+ * falls back to character-count estimation.
138
+ *
139
+ * Font metrics explained:
140
+ * - Width: Horizontal advance width of the text
141
+ * - Height: Total vertical space (ascent + descent)
142
+ * - Ascent: Distance from baseline to top of tallest glyphs (e.g., 'd', 'h', 'b')
143
+ * - Descent: Distance from baseline to bottom of lowest glyphs (e.g., 'g', 'p', 'y')
144
+ *
145
+ * The baseline is the invisible line that most letters "sit" on. Letters like
146
+ * 'a', 'e', 'n' rest on the baseline, while 'd' extends above (ascent) and 'g'
147
+ * extends below (descent).
148
+ *
149
+ * @param {string} text - Text content to measure
150
+ * @param {Object} [style={}] - Text style options
151
+ * @param {string} [style.fontSize='16px'] - Font size (CSS value)
152
+ * @param {string} [style.fontFamily='sans-serif'] - Font family name
153
+ * @param {Object|null} [font=null] - Opentype.js font object for accurate metrics
154
+ * @returns {Object} Text metrics
155
+ * @returns {Decimal} returns.width - Text width in pixels
156
+ * @returns {Decimal} returns.height - Text height in pixels (ascent + descent)
157
+ * @returns {Decimal} returns.ascent - Distance from baseline to top
158
+ * @returns {Decimal} returns.descent - Distance from baseline to bottom (positive value)
159
+ *
160
+ * @example
161
+ * // With font object (accurate)
162
+ * const font = opentype.loadSync('Arial.ttf');
163
+ * const metrics = measureText("Hello", { fontSize: "20px" }, font);
164
+ * // metrics.width → Decimal(55.2)
165
+ * // metrics.ascent → Decimal(16.0)
166
+ * // metrics.descent → Decimal(4.0)
167
+ *
168
+ * @example
169
+ * // Without font (estimated)
170
+ * const metrics = measureText("Hello", { fontSize: "16px" });
171
+ * // metrics.width → Decimal(44.0) // Estimated: 5 chars * 0.55 * 16px
172
+ */
173
+ export function measureText(text, style = {}, font = null) {
174
+ const fontSize = parseFontSize(style.fontSize);
175
+ const scale = fontSize / DEFAULT_UNITS_PER_EM;
176
+
177
+ if (font && font.getAdvanceWidth) {
178
+ // Use actual font metrics
179
+ const width = font.getAdvanceWidth(text, fontSize);
180
+ const ascent = font.ascender * scale;
181
+ const descent = -font.descender * scale;
182
+
183
+ return {
184
+ width: D(width),
185
+ height: D(ascent + descent),
186
+ ascent: D(ascent),
187
+ descent: D(descent)
188
+ };
189
+ }
190
+
191
+ // Fallback: estimate based on character count
192
+ // Average character width is ~0.5em for proportional fonts
193
+ const avgCharWidth = fontSize * 0.55;
194
+ const width = text.length * avgCharWidth;
195
+ const ascent = DEFAULT_ASCENDER * scale;
196
+ const descent = -DEFAULT_DESCENDER * scale;
197
+
198
+ return {
199
+ width: D(width),
200
+ height: D(ascent + descent),
201
+ ascent: D(ascent),
202
+ descent: D(descent)
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Convert a single character to SVG path data using font glyph outlines.
208
+ *
209
+ * A glyph is the visual representation of a character in a specific font.
210
+ * This function extracts the glyph's vector outline and converts it to
211
+ * SVG path commands (M, L, C, Z, etc.).
212
+ *
213
+ * When font is unavailable, returns a rectangular approximation based on
214
+ * estimated character dimensions.
215
+ *
216
+ * @param {string} char - Single character to convert to path
217
+ * @param {Object|null} font - Opentype.js font object (if null, uses fallback)
218
+ * @param {number} fontSize - Font size in pixels
219
+ * @param {number} [x=0] - X position of the character baseline
220
+ * @param {number} [y=0] - Y position of the character baseline
221
+ * @returns {string} SVG path data string (e.g., "M10,20 L30,40 Z")
222
+ *
223
+ * @example
224
+ * // With font object (produces actual glyph outline)
225
+ * const font = opentype.loadSync('Arial.ttf');
226
+ * const path = getCharPath('A', font, 24, 100, 50);
227
+ * // → "M100,50 L112,20 L124,50 Z M106,35 L118,35 Z"
228
+ *
229
+ * @example
230
+ * // Without font (produces rectangle approximation)
231
+ * const path = getCharPath('A', null, 16, 0, 0);
232
+ * // → "M0,-12.8 L8.8,-12.8 L8.8,3.2 L0,3.2 Z"
233
+ */
234
+ export function getCharPath(char, font, fontSize, x = 0, y = 0) {
235
+ if (font && font.getPath) {
236
+ const path = font.getPath(char, x, y, fontSize);
237
+ return path.toPathData();
238
+ }
239
+
240
+ // Fallback: return a rectangle approximation
241
+ const metrics = measureText(char, { fontSize: fontSize + 'px' });
242
+ const width = Number(metrics.width);
243
+ const ascent = Number(metrics.ascent);
244
+ const descent = Number(metrics.descent);
245
+
246
+ return `M${x},${y - ascent} L${x + width},${y - ascent} L${x + width},${y + descent} L${x},${y + descent} Z`;
247
+ }
248
+
249
+ /**
250
+ * Convert a text string to SVG path data with full layout support.
251
+ *
252
+ * This is the main text-to-path conversion function. It:
253
+ * 1. Measures the text to determine its bounding box
254
+ * 2. Applies text-anchor alignment (horizontal positioning)
255
+ * 3. Applies dominant-baseline alignment (vertical positioning)
256
+ * 4. Converts each character to a glyph path
257
+ * 5. Positions each glyph with letter-spacing applied
258
+ *
259
+ * Text rendering concepts:
260
+ * - Advance width: The horizontal distance to move after rendering a character
261
+ * - Letter spacing: Additional space added between characters
262
+ * - Kerning: Font-specific spacing adjustments between character pairs (not yet supported)
263
+ *
264
+ * @param {string} text - Text content to convert
265
+ * @param {Object} [options={}] - Layout and styling options
266
+ * @param {number} [options.x=0] - X coordinate of text anchor point
267
+ * @param {number} [options.y=0] - Y coordinate of text baseline
268
+ * @param {number|string} [options.fontSize=16] - Font size (number in px or CSS string)
269
+ * @param {string} [options.fontFamily='sans-serif'] - Font family name
270
+ * @param {string} [options.textAnchor='start'] - Horizontal alignment (start|middle|end)
271
+ * @param {string} [options.dominantBaseline='auto'] - Vertical alignment
272
+ * @param {number|string} [options.letterSpacing=0] - Extra space between characters
273
+ * @param {Object|null} [options.font=null] - Opentype.js font object for accurate rendering
274
+ * @returns {string} Combined SVG path data for all characters (space-separated)
275
+ *
276
+ * @example
277
+ * // Basic usage with default alignment (left-aligned, sitting on baseline)
278
+ * const path = textToPath("Hello", { x: 100, y: 100, fontSize: 20 });
279
+ *
280
+ * @example
281
+ * // Center-aligned, vertically centered text
282
+ * const path = textToPath("Hello", {
283
+ * x: 100,
284
+ * y: 100,
285
+ * fontSize: 20,
286
+ * textAnchor: TextAnchor.MIDDLE,
287
+ * dominantBaseline: DominantBaseline.MIDDLE
288
+ * });
289
+ *
290
+ * @example
291
+ * // With font object and letter spacing
292
+ * const font = opentype.loadSync('Arial.ttf');
293
+ * const path = textToPath("Spaced", {
294
+ * x: 0,
295
+ * y: 50,
296
+ * fontSize: 24,
297
+ * letterSpacing: 5,
298
+ * font: font
299
+ * });
300
+ */
301
+ export function textToPath(text, options = {}) {
302
+ const {
303
+ x = 0,
304
+ y = 0,
305
+ fontSize = DEFAULT_FONT_SIZE,
306
+ fontFamily = 'sans-serif',
307
+ textAnchor = TextAnchor.START,
308
+ dominantBaseline = DominantBaseline.AUTO,
309
+ letterSpacing = 0,
310
+ font = null
311
+ } = options;
312
+
313
+ if (!text || text.length === 0) return '';
314
+
315
+ const fontSizePx = parseFontSize(fontSize + (typeof fontSize === 'number' ? 'px' : ''));
316
+ const metrics = measureText(text, { fontSize: fontSizePx + 'px' }, font);
317
+
318
+ // Calculate starting position based on text-anchor
319
+ let startX = D(x);
320
+ switch (textAnchor) {
321
+ case TextAnchor.MIDDLE:
322
+ startX = startX.minus(metrics.width.div(2));
323
+ break;
324
+ case TextAnchor.END:
325
+ startX = startX.minus(metrics.width);
326
+ break;
327
+ }
328
+
329
+ // Adjust for dominant-baseline
330
+ let baselineY = D(y);
331
+ switch (dominantBaseline) {
332
+ case DominantBaseline.MIDDLE:
333
+ case DominantBaseline.CENTRAL:
334
+ baselineY = baselineY.plus(metrics.ascent.minus(metrics.height.div(2)));
335
+ break;
336
+ case DominantBaseline.HANGING:
337
+ case DominantBaseline.TEXT_BEFORE_EDGE:
338
+ baselineY = baselineY.plus(metrics.ascent);
339
+ break;
340
+ case DominantBaseline.IDEOGRAPHIC:
341
+ case DominantBaseline.TEXT_AFTER_EDGE:
342
+ baselineY = baselineY.minus(metrics.descent);
343
+ break;
344
+ // AUTO and ALPHABETIC use default baseline
345
+ }
346
+
347
+ // Build path for each character
348
+ const paths = [];
349
+ let currentX = startX;
350
+ const letterSpacingPx = parseFontSize(letterSpacing + (typeof letterSpacing === 'number' ? 'px' : ''));
351
+
352
+ for (const char of text) {
353
+ if (char === ' ') {
354
+ // Space - just advance
355
+ const spaceMetrics = measureText(' ', { fontSize: fontSizePx + 'px' }, font);
356
+ currentX = currentX.plus(spaceMetrics.width).plus(letterSpacingPx);
357
+ continue;
358
+ }
359
+
360
+ const charPath = getCharPath(char, font, fontSizePx, Number(currentX), Number(baselineY));
361
+ paths.push(charPath);
362
+
363
+ const charMetrics = measureText(char, { fontSize: fontSizePx + 'px' }, font);
364
+ currentX = currentX.plus(charMetrics.width).plus(letterSpacingPx);
365
+ }
366
+
367
+ return paths.join(' ');
368
+ }
369
+
370
+ /**
371
+ * Convert text to a polygon representation of its bounding box.
372
+ *
373
+ * This function is used for clipping operations. Instead of converting
374
+ * text to actual glyph paths (which is computationally expensive), it
375
+ * creates a simple rectangular polygon that approximates the text's area.
376
+ *
377
+ * The polygon is a rectangle with 4 vertices, positioned according to
378
+ * text-anchor and dominant-baseline alignment rules.
379
+ *
380
+ * Use case: When you need to clip text against shapes, this provides
381
+ * a fast approximation. For precise clipping of actual letter shapes,
382
+ * use textToPath() instead.
383
+ *
384
+ * @param {Object} textElement - Text element configuration
385
+ * @param {number} [textElement.x=0] - X coordinate
386
+ * @param {number} [textElement.y=0] - Y coordinate
387
+ * @param {number|string} [textElement.fontSize=16] - Font size
388
+ * @param {string} [textElement.textAnchor='start'] - Horizontal alignment
389
+ * @param {string} [textElement.dominantBaseline='auto'] - Vertical alignment
390
+ * @param {string} textElement.text - Text content
391
+ * @param {Object} [options={}] - Additional options
392
+ * @param {number} [options.samples=10] - Unused (reserved for future path sampling)
393
+ * @param {Object|null} [options.font=null] - Opentype.js font for accurate metrics
394
+ * @returns {Array<Array<Decimal>>} Array of 4 polygon vertices [[x,y], [x,y], [x,y], [x,y]]
395
+ *
396
+ * @example
397
+ * // Get bounding box polygon for text
398
+ * const polygon = textToPolygon({
399
+ * x: 100,
400
+ * y: 100,
401
+ * fontSize: 20,
402
+ * text: "Hello",
403
+ * textAnchor: TextAnchor.START
404
+ * });
405
+ * // → [[Decimal(100), Decimal(84)], [Decimal(155), Decimal(84)],
406
+ * // [Decimal(155), Decimal(104)], [Decimal(100), Decimal(104)]]
407
+ *
408
+ * @example
409
+ * // Use for clipping operations
410
+ * const textBox = textToPolygon({ x: 50, y: 50, text: "Clip me" });
411
+ * const circle = createCirclePolygon(50, 50, 30);
412
+ * const clipped = PolygonClip.polygonIntersection(textBox, circle);
413
+ */
414
+ export function textToPolygon(textElement, options = {}) {
415
+ const {
416
+ x = 0,
417
+ y = 0,
418
+ fontSize = DEFAULT_FONT_SIZE,
419
+ textAnchor = TextAnchor.START,
420
+ dominantBaseline = DominantBaseline.AUTO,
421
+ text = ''
422
+ } = textElement;
423
+
424
+ const { samples = 10, font = null } = options;
425
+ const fontSizePx = parseFontSize(fontSize + (typeof fontSize === 'number' ? 'px' : ''));
426
+ const metrics = measureText(text, { fontSize: fontSizePx + 'px' }, font);
427
+
428
+ // Calculate bounding box
429
+ let startX = D(x);
430
+ switch (textAnchor) {
431
+ case TextAnchor.MIDDLE:
432
+ startX = startX.minus(metrics.width.div(2));
433
+ break;
434
+ case TextAnchor.END:
435
+ startX = startX.minus(metrics.width);
436
+ break;
437
+ }
438
+
439
+ let topY = D(y).minus(metrics.ascent);
440
+ switch (dominantBaseline) {
441
+ case DominantBaseline.MIDDLE:
442
+ case DominantBaseline.CENTRAL:
443
+ topY = D(y).minus(metrics.height.div(2));
444
+ break;
445
+ case DominantBaseline.HANGING:
446
+ case DominantBaseline.TEXT_BEFORE_EDGE:
447
+ topY = D(y);
448
+ break;
449
+ case DominantBaseline.IDEOGRAPHIC:
450
+ case DominantBaseline.TEXT_AFTER_EDGE:
451
+ topY = D(y).minus(metrics.height).plus(metrics.descent);
452
+ break;
453
+ }
454
+
455
+ // Return bounding box as polygon
456
+ const endX = startX.plus(metrics.width);
457
+ const bottomY = topY.plus(metrics.height);
458
+
459
+ return [
460
+ PolygonClip.point(startX, topY),
461
+ PolygonClip.point(endX, topY),
462
+ PolygonClip.point(endX, bottomY),
463
+ PolygonClip.point(startX, bottomY)
464
+ ];
465
+ }
466
+
467
+ /**
468
+ * Parse an SVG <text> DOM element into a structured data object.
469
+ *
470
+ * Extracts all relevant attributes and child elements (tspan) from an
471
+ * SVG text element, converting them into a format suitable for processing.
472
+ *
473
+ * SVG text structure:
474
+ * - <text>: Main container with position and style
475
+ * - <tspan>: Inline spans within text that can have different positions/styles
476
+ * - Direct text nodes: Text content not wrapped in tspan
477
+ *
478
+ * Position attributes:
479
+ * - x, y: Absolute position
480
+ * - dx, dy: Relative offset from x, y
481
+ *
482
+ * Style can come from:
483
+ * 1. Inline style attribute: style="font-size: 20px; fill: red"
484
+ * 2. Direct attributes: font-size="20" fill="red"
485
+ * 3. CSS classes (not handled by this function)
486
+ *
487
+ * @param {Element} textElement - SVG <text> DOM element to parse
488
+ * @returns {Object} Structured text data
489
+ * @returns {number} returns.x - X coordinate
490
+ * @returns {number} returns.y - Y coordinate
491
+ * @returns {number} returns.dx - X offset
492
+ * @returns {number} returns.dy - Y offset
493
+ * @returns {string} returns.text - Direct text content (excluding tspans)
494
+ * @returns {Object} returns.style - Extracted style properties
495
+ * @returns {string} returns.style.fontSize - Font size (e.g., "16px")
496
+ * @returns {string} returns.style.fontFamily - Font family name
497
+ * @returns {string} returns.style.fontWeight - Font weight (normal|bold|100-900)
498
+ * @returns {string} returns.style.fontStyle - Font style (normal|italic|oblique)
499
+ * @returns {string} returns.style.textAnchor - Horizontal alignment
500
+ * @returns {string} returns.style.dominantBaseline - Vertical alignment
501
+ * @returns {string} returns.style.letterSpacing - Letter spacing
502
+ * @returns {string} returns.style.fill - Fill color
503
+ * @returns {Array<Object>} returns.tspans - Array of tspan elements
504
+ * @returns {string|null} returns.transform - Transform attribute value
505
+ *
506
+ * @example
507
+ * // Parse a simple text element
508
+ * const svg = document.querySelector('svg');
509
+ * const textEl = svg.querySelector('text');
510
+ * const data = parseTextElement(textEl);
511
+ * // data.text → "Hello"
512
+ * // data.style.fontSize → "20px"
513
+ *
514
+ * @example
515
+ * // Parse text with tspans
516
+ * // <text x="10" y="20">Hello <tspan dy="5">World</tspan></text>
517
+ * const data = parseTextElement(textEl);
518
+ * // data.text → "Hello"
519
+ * // data.tspans[0].text → "World"
520
+ * // data.tspans[0].dy → 5
521
+ */
522
+ export function parseTextElement(textElement) {
523
+ const data = {
524
+ x: parseFloat(textElement.getAttribute('x') || '0'),
525
+ y: parseFloat(textElement.getAttribute('y') || '0'),
526
+ dx: parseFloat(textElement.getAttribute('dx') || '0'),
527
+ dy: parseFloat(textElement.getAttribute('dy') || '0'),
528
+ text: '',
529
+ style: {},
530
+ tspans: [],
531
+ transform: textElement.getAttribute('transform') || null
532
+ };
533
+
534
+ // Extract style attributes
535
+ const style = textElement.getAttribute('style') || '';
536
+ const styleObj = {};
537
+ style.split(';').forEach(prop => {
538
+ const [key, value] = prop.split(':').map(s => s.trim());
539
+ if (key && value) styleObj[key] = value;
540
+ });
541
+
542
+ data.style = {
543
+ fontSize: styleObj['font-size'] || textElement.getAttribute('font-size') || '16px',
544
+ fontFamily: styleObj['font-family'] || textElement.getAttribute('font-family') || 'sans-serif',
545
+ fontWeight: styleObj['font-weight'] || textElement.getAttribute('font-weight') || 'normal',
546
+ fontStyle: styleObj['font-style'] || textElement.getAttribute('font-style') || 'normal',
547
+ textAnchor: styleObj['text-anchor'] || textElement.getAttribute('text-anchor') || 'start',
548
+ dominantBaseline: styleObj['dominant-baseline'] || textElement.getAttribute('dominant-baseline') || 'auto',
549
+ letterSpacing: styleObj['letter-spacing'] || textElement.getAttribute('letter-spacing') || '0',
550
+ fill: styleObj['fill'] || textElement.getAttribute('fill') || 'black'
551
+ };
552
+
553
+ // Get direct text content (excluding tspan content)
554
+ const directText = [];
555
+ for (const node of textElement.childNodes) {
556
+ if (node.nodeType === 3) { // Text node
557
+ directText.push(node.textContent);
558
+ }
559
+ }
560
+ data.text = directText.join('').trim();
561
+
562
+ // Parse tspan children
563
+ const tspans = textElement.querySelectorAll('tspan');
564
+ tspans.forEach(tspan => {
565
+ data.tspans.push({
566
+ x: tspan.getAttribute('x') ? parseFloat(tspan.getAttribute('x')) : null,
567
+ y: tspan.getAttribute('y') ? parseFloat(tspan.getAttribute('y')) : null,
568
+ dx: parseFloat(tspan.getAttribute('dx') || '0'),
569
+ dy: parseFloat(tspan.getAttribute('dy') || '0'),
570
+ text: tspan.textContent || ''
571
+ });
572
+ });
573
+
574
+ return data;
575
+ }
576
+
577
+ /**
578
+ * Convert parsed text data to SVG path element data with tspan support.
579
+ *
580
+ * This is a high-level function that processes an entire text element
581
+ * including all its tspan children. It:
582
+ * 1. Converts the main text content to path data
583
+ * 2. Converts each tspan to path data at its specific position
584
+ * 3. Combines all paths into a single path data string
585
+ * 4. Preserves fill color and transform attributes
586
+ *
587
+ * tspan positioning rules:
588
+ * - If tspan has x/y: Use those absolute coordinates
589
+ * - If tspan has only dx/dy: Add to previous position
590
+ * - If tspan has both: x/y sets absolute, dx/dy adds offset
591
+ *
592
+ * The function tracks the "current position" which advances after each
593
+ * tspan based on its text width.
594
+ *
595
+ * @param {Object} textData - Parsed text data from parseTextElement()
596
+ * @param {number} textData.x - Text X position
597
+ * @param {number} textData.y - Text Y position
598
+ * @param {number} textData.dx - X offset
599
+ * @param {number} textData.dy - Y offset
600
+ * @param {string} textData.text - Main text content
601
+ * @param {Object} textData.style - Text styling
602
+ * @param {Array<Object>} textData.tspans - Array of tspan data
603
+ * @param {string|null} textData.transform - Transform attribute
604
+ * @param {Object} [options={}] - Conversion options
605
+ * @param {Object|null} [options.font=null] - Opentype.js font object
606
+ * @returns {Object} Path element data ready for SVG rendering
607
+ * @returns {string} returns.d - Combined SVG path data string
608
+ * @returns {string} returns.fill - Fill color
609
+ * @returns {string|null} returns.transform - Transform attribute (preserved)
610
+ *
611
+ * @example
612
+ * // Convert simple text
613
+ * const textData = parseTextElement(textElement);
614
+ * const pathData = textElementToPath(textData);
615
+ * // pathData.d → "M10,20 L15,20 C20,25..." (path commands)
616
+ * // pathData.fill → "black"
617
+ * // pathData.transform → null
618
+ *
619
+ * @example
620
+ * // Convert text with tspans and transform
621
+ * // <text x="10" y="20" transform="rotate(45)">
622
+ * // Hello <tspan dy="5">World</tspan>
623
+ * // </text>
624
+ * const pathData = textElementToPath(textData, { font: myFont });
625
+ * // pathData.d → "M10,20 L... M15,25 L..." (main text + tspan paths)
626
+ * // pathData.transform → "rotate(45)"
627
+ */
628
+ export function textElementToPath(textData, options = {}) {
629
+ const { font = null } = options;
630
+
631
+ const pathOptions = {
632
+ x: textData.x + textData.dx,
633
+ y: textData.y + textData.dy,
634
+ fontSize: textData.style.fontSize,
635
+ fontFamily: textData.style.fontFamily,
636
+ textAnchor: textData.style.textAnchor,
637
+ dominantBaseline: textData.style.dominantBaseline,
638
+ letterSpacing: textData.style.letterSpacing,
639
+ font
640
+ };
641
+
642
+ // Convert main text
643
+ let pathData = textToPath(textData.text, pathOptions);
644
+
645
+ // Convert tspans
646
+ let currentX = pathOptions.x;
647
+ let currentY = pathOptions.y;
648
+
649
+ for (const tspan of textData.tspans) {
650
+ const tspanX = tspan.x !== null ? tspan.x : currentX + tspan.dx;
651
+ const tspanY = tspan.y !== null ? tspan.y : currentY + tspan.dy;
652
+
653
+ const tspanPath = textToPath(tspan.text, {
654
+ ...pathOptions,
655
+ x: tspanX,
656
+ y: tspanY
657
+ });
658
+
659
+ if (tspanPath) {
660
+ pathData += ' ' + tspanPath;
661
+ }
662
+
663
+ // Update position for next tspan
664
+ const metrics = measureText(tspan.text, { fontSize: pathOptions.fontSize }, font);
665
+ currentX = tspanX + Number(metrics.width);
666
+ currentY = tspanY;
667
+ }
668
+
669
+ return {
670
+ d: pathData.trim(),
671
+ fill: textData.style.fill,
672
+ transform: textData.transform
673
+ };
674
+ }
675
+
676
+ /**
677
+ * Calculate the axis-aligned bounding box for text.
678
+ *
679
+ * Returns the smallest rectangle that completely contains the text,
680
+ * accounting for text-anchor alignment. This is useful for:
681
+ * - Hit testing (checking if point is inside text area)
682
+ * - Layout calculations (positioning elements around text)
683
+ * - Viewport optimization (determining what text is visible)
684
+ *
685
+ * The bounding box includes all text and tspans combined.
686
+ *
687
+ * Note: This returns the bounding box in the text's local coordinate space,
688
+ * before any transforms are applied. If textData has a transform attribute,
689
+ * you'll need to apply it separately to get screen coordinates.
690
+ *
691
+ * @param {Object} textData - Parsed text data from parseTextElement()
692
+ * @param {number} textData.x - Text X position
693
+ * @param {number} textData.y - Text Y position
694
+ * @param {number} textData.dx - X offset
695
+ * @param {number} textData.dy - Y offset
696
+ * @param {string} textData.text - Text content
697
+ * @param {Object} textData.style - Text styling
698
+ * @param {string} textData.style.fontSize - Font size
699
+ * @param {string} textData.style.textAnchor - Horizontal alignment
700
+ * @param {Array<Object>} textData.tspans - Array of tspan data
701
+ * @param {Object} [options={}] - Options
702
+ * @param {Object|null} [options.font=null] - Opentype.js font object
703
+ * @returns {Object} Axis-aligned bounding box
704
+ * @returns {number} returns.x - Left edge X coordinate
705
+ * @returns {number} returns.y - Top edge Y coordinate
706
+ * @returns {number} returns.width - Box width in pixels
707
+ * @returns {number} returns.height - Box height in pixels
708
+ *
709
+ * @example
710
+ * // Get bounding box for left-aligned text
711
+ * const textData = { x: 100, y: 50, dx: 0, dy: 0, text: "Hello",
712
+ * style: { fontSize: "20px", textAnchor: "start" },
713
+ * tspans: [] };
714
+ * const bbox = getTextBBox(textData);
715
+ * // bbox → { x: 100, y: 34, width: 55, height: 20 }
716
+ *
717
+ * @example
718
+ * // Get bounding box for center-aligned text
719
+ * const textData = { x: 100, y: 50, dx: 0, dy: 0, text: "Hello",
720
+ * style: { fontSize: "20px", textAnchor: "middle" },
721
+ * tspans: [] };
722
+ * const bbox = getTextBBox(textData);
723
+ * // bbox → { x: 72.5, y: 34, width: 55, height: 20 }
724
+ */
725
+ export function getTextBBox(textData, options = {}) {
726
+ const { font = null } = options;
727
+
728
+ const fontSize = parseFontSize(textData.style.fontSize);
729
+ let totalText = textData.text;
730
+
731
+ for (const tspan of textData.tspans) {
732
+ totalText += tspan.text;
733
+ }
734
+
735
+ const metrics = measureText(totalText, { fontSize: fontSize + 'px' }, font);
736
+
737
+ let x = D(textData.x + textData.dx);
738
+ const textAnchor = textData.style.textAnchor;
739
+
740
+ switch (textAnchor) {
741
+ case TextAnchor.MIDDLE:
742
+ x = x.minus(metrics.width.div(2));
743
+ break;
744
+ case TextAnchor.END:
745
+ x = x.minus(metrics.width);
746
+ break;
747
+ }
748
+
749
+ return {
750
+ x: Number(x),
751
+ y: textData.y + textData.dy - Number(metrics.ascent),
752
+ width: Number(metrics.width),
753
+ height: Number(metrics.height)
754
+ };
755
+ }
756
+
757
+ /**
758
+ * Clip text against a polygon using bounding box intersection.
759
+ *
760
+ * This function performs polygon clipping between a text's bounding box
761
+ * and an arbitrary clipping polygon. It's used to determine which parts
762
+ * of the text are visible within a clipping region.
763
+ *
764
+ * Clipping process:
765
+ * 1. Convert text to its bounding box polygon (4 vertices)
766
+ * 2. Compute intersection with the clip polygon
767
+ * 3. Return resulting polygon(s) representing visible area
768
+ *
769
+ * The result is an array of polygons because clipping can produce:
770
+ * - Empty array: Text is completely clipped (not visible)
771
+ * - One polygon: Simple intersection
772
+ * - Multiple polygons: If clip region creates disconnected visible areas
773
+ *
774
+ * Note: This clips the text's bounding box, not the actual glyph shapes.
775
+ * For precise glyph-level clipping, convert text to paths first using
776
+ * textToPath() then clip those paths.
777
+ *
778
+ * @param {Object} textData - Parsed text data from parseTextElement()
779
+ * @param {number} textData.x - Text X position
780
+ * @param {number} textData.y - Text Y position
781
+ * @param {number} textData.dx - X offset
782
+ * @param {number} textData.dy - Y offset
783
+ * @param {string} textData.text - Text content
784
+ * @param {Object} textData.style - Text styling with fontSize, textAnchor, dominantBaseline
785
+ * @param {Array<Array<Decimal>>} clipPolygon - Clipping polygon vertices [[x,y], [x,y], ...]
786
+ * @param {Object} [options={}] - Options
787
+ * @param {Object|null} [options.font=null] - Opentype.js font object
788
+ * @returns {Array<Array<Array<Decimal>>>} Array of clipped polygons (may be empty)
789
+ *
790
+ * @example
791
+ * // Clip text against a rectangular region
792
+ * const textData = parseTextElement(textElement);
793
+ * const clipRect = [
794
+ * PolygonClip.point(0, 0),
795
+ * PolygonClip.point(100, 0),
796
+ * PolygonClip.point(100, 100),
797
+ * PolygonClip.point(0, 100)
798
+ * ];
799
+ * const clipped = clipText(textData, clipRect);
800
+ * // clipped → [[[x1,y1], [x2,y2], ...]] (visible portion)
801
+ *
802
+ * @example
803
+ * // Check if text is completely clipped
804
+ * const clipped = clipText(textData, smallClipRegion);
805
+ * if (clipped.length === 0) {
806
+ * console.log("Text is not visible");
807
+ * }
808
+ */
809
+ export function clipText(textData, clipPolygon, options = {}) {
810
+ const textPolygon = textToPolygon({
811
+ x: textData.x + textData.dx,
812
+ y: textData.y + textData.dy,
813
+ fontSize: textData.style.fontSize,
814
+ textAnchor: textData.style.textAnchor,
815
+ dominantBaseline: textData.style.dominantBaseline,
816
+ text: textData.text
817
+ }, options);
818
+
819
+ return PolygonClip.polygonIntersection(textPolygon, clipPolygon);
820
+ }