@emasoft/svg-matrix 1.0.24 → 1.0.26
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/README.md +64 -4
- package/bin/svg-matrix.js +1 -1
- package/package.json +1 -1
- package/src/index.js +3 -6
- package/src/svg-toolbox.js +0 -16
- package/src/text-to-path.js +0 -820
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
<p align="center">
|
|
13
13
|
<a href="#part-1-core-math-library">Core Math</a> •
|
|
14
14
|
<a href="#part-2-svg-toolbox">SVG Toolbox</a> •
|
|
15
|
+
<a href="#svgm---svgo-compatible-optimizer-drop-in-replacement">svgm (SVGO replacement)</a> •
|
|
15
16
|
<a href="#installation">Install</a> •
|
|
16
17
|
<a href="API.md">API Reference</a>
|
|
17
18
|
</p>
|
|
@@ -185,14 +186,17 @@ SVG files are pictures made of shapes, paths, and effects. This toolbox can:
|
|
|
185
186
|
|
|
186
187
|
### Why Use This Instead of SVGO?
|
|
187
188
|
|
|
188
|
-
| | SVGO |
|
|
189
|
+
| | SVGO | svgm (this package) |
|
|
189
190
|
|--|------|-----------|
|
|
190
191
|
| **Math precision** | 15 digits (can accumulate errors) | 80 digits (no errors) |
|
|
191
192
|
| **Verification** | None (hope it works) | Mathematical proof each step is correct |
|
|
192
193
|
| **Attribute handling** | May lose clip-path, mask, filter | Guarantees ALL attributes preserved |
|
|
194
|
+
| **CLI syntax** | `svgo input.svg -o out.svg` | `svgm input.svg -o out.svg` (identical!) |
|
|
193
195
|
| **Use case** | Quick file size reduction | Precision-critical applications |
|
|
194
196
|
|
|
195
|
-
**
|
|
197
|
+
**Drop-in replacement:** The `svgm` command uses the exact same syntax as SVGO. Just replace `svgo` with `svgm` in your scripts.
|
|
198
|
+
|
|
199
|
+
**Use svgm/svg-matrix when:** CAD, GIS, scientific visualization, animation, or when visual correctness matters.
|
|
196
200
|
|
|
197
201
|
**Use SVGO when:** Quick optimization where small rounding errors are acceptable.
|
|
198
202
|
|
|
@@ -200,7 +204,62 @@ SVG files are pictures made of shapes, paths, and effects. This toolbox can:
|
|
|
200
204
|
|
|
201
205
|
## Command Line Tools
|
|
202
206
|
|
|
203
|
-
### `
|
|
207
|
+
### `svgm` - SVGO-Compatible Optimizer (Drop-in Replacement)
|
|
208
|
+
|
|
209
|
+
A drop-in replacement for SVGO with identical syntax. Simply replace `svgo` with `svgm`:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
# Basic optimization (same as SVGO)
|
|
213
|
+
svgm input.svg -o output.svg
|
|
214
|
+
|
|
215
|
+
# Optimize folder recursively
|
|
216
|
+
svgm -f ./icons/ -o ./optimized/ -r
|
|
217
|
+
|
|
218
|
+
# Multiple passes for maximum compression
|
|
219
|
+
svgm --multipass input.svg -o output.svg
|
|
220
|
+
|
|
221
|
+
# Pretty print output
|
|
222
|
+
svgm --pretty --indent 2 input.svg -o output.svg
|
|
223
|
+
|
|
224
|
+
# Set precision
|
|
225
|
+
svgm -p 2 input.svg -o output.svg
|
|
226
|
+
|
|
227
|
+
# Show available optimizations
|
|
228
|
+
svgm --show-plugins
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Options (SVGO-compatible):**
|
|
232
|
+
|
|
233
|
+
| Option | What It Does |
|
|
234
|
+
|--------|--------------|
|
|
235
|
+
| `-o <file>` | Output file or folder |
|
|
236
|
+
| `-f <folder>` | Input folder (batch mode) |
|
|
237
|
+
| `-r, --recursive` | Process folders recursively |
|
|
238
|
+
| `-p <n>` | Decimal precision |
|
|
239
|
+
| `--multipass` | Multiple optimization passes |
|
|
240
|
+
| `--pretty` | Pretty print output |
|
|
241
|
+
| `--indent <n>` | Indentation for pretty print |
|
|
242
|
+
| `-q, --quiet` | Suppress output |
|
|
243
|
+
| `--datauri <type>` | Output as data URI (base64, enc, unenc) |
|
|
244
|
+
| `--show-plugins` | List available optimizations |
|
|
245
|
+
|
|
246
|
+
**Default optimizations (matching SVGO preset-default):**
|
|
247
|
+
|
|
248
|
+
- Remove DOCTYPE, XML processing instructions, comments, metadata
|
|
249
|
+
- Remove editor namespaces (Inkscape, Illustrator, etc.)
|
|
250
|
+
- Cleanup IDs, attributes, numeric values
|
|
251
|
+
- Convert colors to shorter forms
|
|
252
|
+
- Collapse groups, merge paths
|
|
253
|
+
- Sort attributes for better gzip compression
|
|
254
|
+
- And 20+ more optimizations
|
|
255
|
+
|
|
256
|
+
Run `svgm --help` for all options.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
### `svg-matrix` - Advanced SVG Processing
|
|
261
|
+
|
|
262
|
+
For precision-critical operations beyond simple optimization:
|
|
204
263
|
|
|
205
264
|
```bash
|
|
206
265
|
# Flatten all transforms into coordinates
|
|
@@ -232,6 +291,8 @@ svg-matrix info input.svg
|
|
|
232
291
|
|
|
233
292
|
Run `svg-matrix --help` for all options.
|
|
234
293
|
|
|
294
|
+
---
|
|
295
|
+
|
|
235
296
|
### `svglinter` - Find problems in SVG files
|
|
236
297
|
|
|
237
298
|
```bash
|
|
@@ -308,7 +369,6 @@ console.log(fixed.svg); // Fixed SVG string
|
|
|
308
369
|
| `flattenGradients()` | Bake gradients into fills |
|
|
309
370
|
| `flattenPatterns()` | Expand pattern tiles |
|
|
310
371
|
| `flattenUseElements()` | Inline use/symbol references |
|
|
311
|
-
| `textToPath()` | Convert text to path outlines |
|
|
312
372
|
| `detectCollisions()` | GJK collision detection |
|
|
313
373
|
| `validateSVG()` | W3C schema validation |
|
|
314
374
|
| `decomposeTransform()` | Matrix decomposition |
|
package/bin/svg-matrix.js
CHANGED
|
@@ -1203,7 +1203,7 @@ const TOOLBOX_FUNCTIONS = {
|
|
|
1203
1203
|
detection: ['detectCollisions', 'measureDistance'],
|
|
1204
1204
|
};
|
|
1205
1205
|
|
|
1206
|
-
const SKIP_TOOLBOX_FUNCTIONS = ['
|
|
1206
|
+
const SKIP_TOOLBOX_FUNCTIONS = ['imageToPath', 'detectCollisions', 'measureDistance'];
|
|
1207
1207
|
|
|
1208
1208
|
function getTimestamp() {
|
|
1209
1209
|
const now = new Date();
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* SVG path conversion, and 2D/3D affine transformations using Decimal.js.
|
|
6
6
|
*
|
|
7
7
|
* @module @emasoft/svg-matrix
|
|
8
|
-
* @version 1.0.
|
|
8
|
+
* @version 1.0.26
|
|
9
9
|
* @license MIT
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
@@ -43,7 +43,6 @@ import * as PatternResolver from './pattern-resolver.js';
|
|
|
43
43
|
import * as UseSymbolResolver from './use-symbol-resolver.js';
|
|
44
44
|
import * as MarkerResolver from './marker-resolver.js';
|
|
45
45
|
import * as MeshGradient from './mesh-gradient.js';
|
|
46
|
-
import * as TextToPath from './text-to-path.js';
|
|
47
46
|
import * as SVGParser from './svg-parser.js';
|
|
48
47
|
import * as FlattenPipeline from './flatten-pipeline.js';
|
|
49
48
|
import * as Verification from './verification.js';
|
|
@@ -86,7 +85,7 @@ Decimal.set({ precision: 80 });
|
|
|
86
85
|
* Library version
|
|
87
86
|
* @constant {string}
|
|
88
87
|
*/
|
|
89
|
-
export const VERSION = '1.0.
|
|
88
|
+
export const VERSION = '1.0.26';
|
|
90
89
|
|
|
91
90
|
/**
|
|
92
91
|
* Default precision for path output (decimal places)
|
|
@@ -119,7 +118,7 @@ export { GeometryToPath, PolygonClip };
|
|
|
119
118
|
export { SVGFlatten, BrowserVerify };
|
|
120
119
|
export { ClipPathResolver, MaskResolver, PatternResolver };
|
|
121
120
|
export { UseSymbolResolver, MarkerResolver };
|
|
122
|
-
export { MeshGradient
|
|
121
|
+
export { MeshGradient };
|
|
123
122
|
export { SVGParser, FlattenPipeline, Verification };
|
|
124
123
|
|
|
125
124
|
// ============================================================================
|
|
@@ -233,7 +232,6 @@ export {
|
|
|
233
232
|
flattenPatterns,
|
|
234
233
|
flattenFilters,
|
|
235
234
|
flattenUseElements,
|
|
236
|
-
textToPath,
|
|
237
235
|
imageToPath,
|
|
238
236
|
detectCollisions,
|
|
239
237
|
measureDistance,
|
|
@@ -576,7 +574,6 @@ export default {
|
|
|
576
574
|
UseSymbolResolver,
|
|
577
575
|
MarkerResolver,
|
|
578
576
|
MeshGradient,
|
|
579
|
-
TextToPath,
|
|
580
577
|
SVGParser,
|
|
581
578
|
FlattenPipeline,
|
|
582
579
|
Verification,
|
package/src/svg-toolbox.js
CHANGED
|
@@ -3702,21 +3702,6 @@ export const flattenUseElements = createOperation(async (doc, options = {}) => {
|
|
|
3702
3702
|
return parseSVG(result.svg);
|
|
3703
3703
|
});
|
|
3704
3704
|
|
|
3705
|
-
/**
|
|
3706
|
-
* Convert text to path geometry (requires font info - stub)
|
|
3707
|
-
*/
|
|
3708
|
-
export const textToPath = createOperation((doc, options = {}) => {
|
|
3709
|
-
// Note: True text-to-path conversion requires font rendering engine
|
|
3710
|
-
// This is a placeholder that removes text elements
|
|
3711
|
-
const texts = doc.getElementsByTagName("text");
|
|
3712
|
-
for (const text of [...texts]) {
|
|
3713
|
-
// In real implementation, would convert using font metrics
|
|
3714
|
-
// For now, just mark with comment
|
|
3715
|
-
text.setAttribute("data-text-to-path-needed", "true");
|
|
3716
|
-
}
|
|
3717
|
-
return doc;
|
|
3718
|
-
});
|
|
3719
|
-
|
|
3720
3705
|
/**
|
|
3721
3706
|
* Trace raster images to paths (basic - stub)
|
|
3722
3707
|
*/
|
|
@@ -7421,7 +7406,6 @@ export default {
|
|
|
7421
7406
|
flattenPatterns,
|
|
7422
7407
|
flattenFilters,
|
|
7423
7408
|
flattenUseElements,
|
|
7424
|
-
textToPath,
|
|
7425
7409
|
imageToPath,
|
|
7426
7410
|
detectCollisions,
|
|
7427
7411
|
measureDistance,
|
package/src/text-to-path.js
DELETED
|
@@ -1,820 +0,0 @@
|
|
|
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
|
-
}
|