@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 CHANGED
@@ -12,6 +12,7 @@
12
12
  <p align="center">
13
13
  <a href="#part-1-core-math-library">Core Math</a> &#8226;
14
14
  <a href="#part-2-svg-toolbox">SVG Toolbox</a> &#8226;
15
+ <a href="#svgm---svgo-compatible-optimizer-drop-in-replacement">svgm (SVGO replacement)</a> &#8226;
15
16
  <a href="#installation">Install</a> &#8226;
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 | svg-matrix |
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
- **Use svg-matrix when:** CAD, GIS, scientific visualization, or when visual correctness matters more than file size.
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
- ### `svg-matrix` - Process SVG files
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 = ['textToPath', 'imageToPath', 'detectCollisions', 'measureDistance'];
1206
+ const SKIP_TOOLBOX_FUNCTIONS = ['imageToPath', 'detectCollisions', 'measureDistance'];
1207
1207
 
1208
1208
  function getTimestamp() {
1209
1209
  const now = new Date();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emasoft/svg-matrix",
3
- "version": "1.0.24",
3
+ "version": "1.0.26",
4
4
  "description": "Arbitrary-precision matrix, vector and affine transformation library for JavaScript using decimal.js",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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.24
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.24';
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, TextToPath };
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,
@@ -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,
@@ -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
- }