@emasoft/svg-matrix 1.0.4 → 1.0.5
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 +116 -0
- package/package.json +1 -1
- package/samples/preserveAspectRatio_SVG.svg +63 -0
- package/src/svg-flatten.js +380 -0
package/README.md
CHANGED
|
@@ -278,6 +278,19 @@ const [x, y, z] = Transforms3D.applyTransform(M, 1, 0, 0);
|
|
|
278
278
|
|
|
279
279
|
| Function | Description |
|
|
280
280
|
|----------|-------------|
|
|
281
|
+
| **viewBox & Viewport** | |
|
|
282
|
+
| `parseViewBox(str)` | Parse viewBox attribute "minX minY width height" |
|
|
283
|
+
| `parsePreserveAspectRatio(str)` | Parse preserveAspectRatio (align, meet/slice) |
|
|
284
|
+
| `computeViewBoxTransform(vb, vpW, vpH, par)` | Compute viewBox to viewport matrix |
|
|
285
|
+
| `SVGViewport` class | Represents viewport with viewBox + preserveAspectRatio |
|
|
286
|
+
| `buildFullCTM(hierarchy)` | Build CTM from SVG/group/element hierarchy |
|
|
287
|
+
| **Units & Percentages** | |
|
|
288
|
+
| `resolveLength(value, ref, dpi?)` | Resolve px, %, em, pt, in, cm, mm, pc units |
|
|
289
|
+
| `resolvePercentages(x, y, vpW, vpH)` | Resolve x/y percentages to viewport |
|
|
290
|
+
| `normalizedDiagonal(w, h)` | Compute sqrt(w^2+h^2)/sqrt(2) for percentages |
|
|
291
|
+
| **Object Bounding Box** | |
|
|
292
|
+
| `objectBoundingBoxTransform(x, y, w, h)` | Transform for objectBoundingBox units |
|
|
293
|
+
| **Transform Parsing** | |
|
|
281
294
|
| `parseTransformFunction(func, args)` | Parse a single SVG transform function |
|
|
282
295
|
| `parseTransformAttribute(str)` | Parse a full SVG transform attribute string |
|
|
283
296
|
| `buildCTM(transformStack)` | Build CTM from array of transform strings |
|
|
@@ -396,6 +409,109 @@ import { SVGFlatten } from '@emasoft/svg-matrix';
|
|
|
396
409
|
// 5. Convert CTM back to SVG: SVGFlatten.toSVGMatrix(ctm, 6)
|
|
397
410
|
```
|
|
398
411
|
|
|
412
|
+
### viewBox and preserveAspectRatio
|
|
413
|
+
|
|
414
|
+
Per the [SVG 2 specification](https://www.w3.org/TR/SVG2/coords.html), the viewBox establishes a new coordinate system that maps to the viewport:
|
|
415
|
+
|
|
416
|
+
```js
|
|
417
|
+
import { Decimal, SVGFlatten } from '@emasoft/svg-matrix';
|
|
418
|
+
|
|
419
|
+
Decimal.set({ precision: 80 });
|
|
420
|
+
|
|
421
|
+
// Parse viewBox and preserveAspectRatio
|
|
422
|
+
const viewBox = SVGFlatten.parseViewBox('0 0 100 100');
|
|
423
|
+
const par = SVGFlatten.parsePreserveAspectRatio('xMidYMid meet');
|
|
424
|
+
|
|
425
|
+
// Compute the viewBox-to-viewport transform
|
|
426
|
+
const vbTransform = SVGFlatten.computeViewBoxTransform(viewBox, 800, 600, par);
|
|
427
|
+
|
|
428
|
+
// Point (50, 50) in viewBox coords maps to viewport
|
|
429
|
+
const point = SVGFlatten.applyToPoint(vbTransform, 50, 50);
|
|
430
|
+
console.log('Viewport coords:', point.x.toFixed(2), point.y.toFixed(2));
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### Full CTM with viewBox and Nested Elements
|
|
434
|
+
|
|
435
|
+
Use `buildFullCTM` for complete SVG hierarchies including viewBox transforms:
|
|
436
|
+
|
|
437
|
+
```js
|
|
438
|
+
import { Decimal, SVGFlatten } from '@emasoft/svg-matrix';
|
|
439
|
+
|
|
440
|
+
Decimal.set({ precision: 80 });
|
|
441
|
+
|
|
442
|
+
// Real SVG structure with viewBox and nested transforms
|
|
443
|
+
const hierarchy = [
|
|
444
|
+
{ type: 'svg', width: 800, height: 600, viewBox: '0 0 400 300', preserveAspectRatio: 'xMidYMid meet' },
|
|
445
|
+
{ type: 'g', transform: 'translate(50, 50)' },
|
|
446
|
+
{ type: 'g', transform: 'rotate(45)' },
|
|
447
|
+
{ type: 'element', transform: 'scale(2)' }
|
|
448
|
+
];
|
|
449
|
+
|
|
450
|
+
const ctm = SVGFlatten.buildFullCTM(hierarchy);
|
|
451
|
+
const local = { x: new Decimal('10'), y: new Decimal('10') };
|
|
452
|
+
const viewport = SVGFlatten.applyToPoint(ctm, local.x, local.y);
|
|
453
|
+
|
|
454
|
+
// Round-trip with perfect precision
|
|
455
|
+
const inverse = ctm.inverse();
|
|
456
|
+
const recovered = SVGFlatten.applyToPoint(inverse, viewport.x, viewport.y);
|
|
457
|
+
// Error: X=2e-78, Y=2e-78
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### Nested SVG Viewports
|
|
461
|
+
|
|
462
|
+
Handle deeply nested `<svg>` elements, each with its own viewBox:
|
|
463
|
+
|
|
464
|
+
```js
|
|
465
|
+
const nestedHierarchy = [
|
|
466
|
+
{ type: 'svg', width: 1000, height: 800, viewBox: '0 0 500 400' },
|
|
467
|
+
{ type: 'g', transform: 'translate(100, 100)' },
|
|
468
|
+
{ type: 'svg', width: 200, height: 150, viewBox: '0 0 100 75' },
|
|
469
|
+
{ type: 'element' }
|
|
470
|
+
];
|
|
471
|
+
|
|
472
|
+
const ctm = SVGFlatten.buildFullCTM(nestedHierarchy);
|
|
473
|
+
// Point (10, 10) in innermost viewBox -> (240, 240) in outer viewport
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### Unit and Percentage Resolution
|
|
477
|
+
|
|
478
|
+
Resolve SVG length values with units or percentages:
|
|
479
|
+
|
|
480
|
+
```js
|
|
481
|
+
import { Decimal, SVGFlatten } from '@emasoft/svg-matrix';
|
|
482
|
+
|
|
483
|
+
const viewportWidth = new Decimal(800);
|
|
484
|
+
|
|
485
|
+
// Percentages resolve relative to reference size
|
|
486
|
+
SVGFlatten.resolveLength('50%', viewportWidth); // -> 400
|
|
487
|
+
SVGFlatten.resolveLength('25%', viewportWidth); // -> 200
|
|
488
|
+
|
|
489
|
+
// Absolute units (at 96 DPI)
|
|
490
|
+
SVGFlatten.resolveLength('1in', viewportWidth); // -> 96
|
|
491
|
+
SVGFlatten.resolveLength('2.54cm', viewportWidth);// -> 96 (1 inch)
|
|
492
|
+
SVGFlatten.resolveLength('72pt', viewportWidth); // -> 96 (1 inch)
|
|
493
|
+
SVGFlatten.resolveLength('6pc', viewportWidth); // -> 96 (1 inch)
|
|
494
|
+
|
|
495
|
+
// Font-relative units (assumes 16px base)
|
|
496
|
+
SVGFlatten.resolveLength('2em', viewportWidth); // -> 32
|
|
497
|
+
|
|
498
|
+
// Normalized diagonal for non-directional percentages
|
|
499
|
+
const diag = SVGFlatten.normalizedDiagonal(800, 600); // -> 707.1
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### Object Bounding Box Transform
|
|
503
|
+
|
|
504
|
+
For gradients/patterns with `objectBoundingBox` units:
|
|
505
|
+
|
|
506
|
+
```js
|
|
507
|
+
// Element bounding box: x=100, y=50, width=200, height=100
|
|
508
|
+
const bboxTransform = SVGFlatten.objectBoundingBoxTransform(100, 50, 200, 100);
|
|
509
|
+
|
|
510
|
+
// (0, 0) in bbox -> (100, 50) in user space
|
|
511
|
+
// (1, 1) in bbox -> (300, 150) in user space
|
|
512
|
+
// (0.5, 0.5) in bbox -> (200, 100) in user space (center)
|
|
513
|
+
```
|
|
514
|
+
|
|
399
515
|
### CDN Usage
|
|
400
516
|
|
|
401
517
|
```html
|
package/package.json
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<?xml version="1.0" standalone="no"?>
|
|
2
|
+
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20000802//EN"
|
|
3
|
+
"http://www.w3.org/TR/2000/CR-SVG-20000802/DTD/svg-20000802.dtd" [
|
|
4
|
+
<!ENTITY Smile "
|
|
5
|
+
<rect x='.5' y='.5' width='29' height='39' style='fill:yellow; stroke:red'/>
|
|
6
|
+
<g transform='rotate(90)'>
|
|
7
|
+
<text x='10' y='10' style='font-family:Verdana;
|
|
8
|
+
font-weight:bold; font-size:14'>:)</text>
|
|
9
|
+
</g>">
|
|
10
|
+
<!ENTITY Viewport1 "<rect x='.5' y='.5' width='49' height='29'
|
|
11
|
+
style='fill:none; stroke:blue'/>">
|
|
12
|
+
<!ENTITY Viewport2 "<rect x='.5' y='.5' width='29' height='59'
|
|
13
|
+
style='fill:none; stroke:blue'/>">
|
|
14
|
+
]>
|
|
15
|
+
<svg width="480px" height="270px" style="font-family:Verdana; font-size:8">
|
|
16
|
+
<desc>Example PreserveAspectRatio - demonstrate available options</desc>
|
|
17
|
+
<text x="10" y="30">SVG to fit</text>
|
|
18
|
+
<g transform="translate(20,40)">&Smile;</g>
|
|
19
|
+
<text x="10" y="110">Viewport 1</text>
|
|
20
|
+
<g transform="translate(10,120)">&Viewport1;</g>
|
|
21
|
+
<text x="10" y="180">Viewport 2</text>
|
|
22
|
+
<g transform="translate(20,190)">&Viewport2;</g>
|
|
23
|
+
<text x="100" y="30">--------------- meet ---------------</text>
|
|
24
|
+
<g transform="translate(100,60)"><text y="-10">xMin*</text>&Viewport1;
|
|
25
|
+
<svg preserveAspectRatio="xMinYMin meet" viewBox="0 0 30 40"
|
|
26
|
+
width="50" height="30">&Smile;</svg></g>
|
|
27
|
+
<g transform="translate(170,60)"><text y="-10">xMid*</text>&Viewport1;
|
|
28
|
+
<svg preserveAspectRatio="xMidYMid meet" viewBox="0 0 30 40"
|
|
29
|
+
width="50" height="30">&Smile;</svg></g>
|
|
30
|
+
<g transform="translate(240,60)"><text y="-10">xMax*</text>&Viewport1;
|
|
31
|
+
<svg preserveAspectRatio="xMaxYMax meet" viewBox="0 0 30 40"
|
|
32
|
+
width="50" height="30">&Smile;</svg></g>
|
|
33
|
+
<text x="330" y="30">---------- meet ----------</text>
|
|
34
|
+
<g transform="translate(330,60)"><text y="-10">*YMin</text>&Viewport2;
|
|
35
|
+
<svg preserveAspectRatio="xMinYMin meet" viewBox="0 0 30 40"
|
|
36
|
+
width="30" height="60">&Smile;</svg></g>
|
|
37
|
+
<g transform="translate(380,60)"><text y="-10">*YMid</text>&Viewport2;
|
|
38
|
+
<svg preserveAspectRatio="xMidYMid meet" viewBox="0 0 30 40"
|
|
39
|
+
width="30" height="60">&Smile;</svg></g>
|
|
40
|
+
<g transform="translate(430,60)"><text y="-10">*YMax</text>&Viewport2;
|
|
41
|
+
<svg preserveAspectRatio="xMaxYMax meet" viewBox="0 0 30 40"
|
|
42
|
+
width="30" height="60">&Smile;</svg></g>
|
|
43
|
+
<text x="100" y="160">---------- slice ----------</text>
|
|
44
|
+
<g transform="translate(100,190)"><text y="-10">xMin*</text>&Viewport2;
|
|
45
|
+
<svg preserveAspectRatio="xMinYMin slice" viewBox="0 0 30 40"
|
|
46
|
+
width="30" height="60">&Smile;</svg></g>
|
|
47
|
+
<g transform="translate(150,190)"><text y="-10">xMid*</text>&Viewport2;
|
|
48
|
+
<svg preserveAspectRatio="xMidYMid slice" viewBox="0 0 30 40"
|
|
49
|
+
width="30" height="60">&Smile;</svg></g>
|
|
50
|
+
<g transform="translate(200,190)"><text y="-10">xMax*</text>&Viewport2;
|
|
51
|
+
<svg preserveAspectRatio="xMaxYMax slice" viewBox="0 0 30 40"
|
|
52
|
+
width="30" height="60">&Smile;</svg></g>
|
|
53
|
+
<text x="270" y="160">--------------- slice ---------------</text>
|
|
54
|
+
<g transform="translate(270,190)"><text y="-10">*YMin</text>&Viewport1;
|
|
55
|
+
<svg preserveAspectRatio="xMinYMin slice" viewBox="0 0 30 40"
|
|
56
|
+
width="50" height="30">&Smile;</svg></g>
|
|
57
|
+
<g transform="translate(340,190)"><text y="-10">*YMid</text>&Viewport1;
|
|
58
|
+
<svg preserveAspectRatio="xMidYMid slice" viewBox="0 0 30 40"
|
|
59
|
+
width="50" height="30">&Smile;</svg></g>
|
|
60
|
+
<g transform="translate(410,190)"><text y="-10">*YMax</text>&Viewport1;
|
|
61
|
+
<svg preserveAspectRatio="xMaxYMax slice" viewBox="0 0 30 40"
|
|
62
|
+
width="50" height="30">&Smile;</svg></g>
|
|
63
|
+
</svg>
|
package/src/svg-flatten.js
CHANGED
|
@@ -14,6 +14,373 @@ import * as Transforms2D from './transforms2d.js';
|
|
|
14
14
|
// Set high precision for all calculations
|
|
15
15
|
Decimal.set({ precision: 80 });
|
|
16
16
|
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// viewBox and preserveAspectRatio Parsing
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse an SVG viewBox attribute.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} viewBoxStr - viewBox attribute value "minX minY width height"
|
|
25
|
+
* @returns {{minX: Decimal, minY: Decimal, width: Decimal, height: Decimal}|null}
|
|
26
|
+
*/
|
|
27
|
+
export function parseViewBox(viewBoxStr) {
|
|
28
|
+
if (!viewBoxStr || viewBoxStr.trim() === '') {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const parts = viewBoxStr.trim().split(/[\s,]+/).map(s => new Decimal(s));
|
|
33
|
+
if (parts.length !== 4) {
|
|
34
|
+
console.warn(`Invalid viewBox: ${viewBoxStr}`);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
minX: parts[0],
|
|
40
|
+
minY: parts[1],
|
|
41
|
+
width: parts[2],
|
|
42
|
+
height: parts[3]
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse an SVG preserveAspectRatio attribute.
|
|
48
|
+
* Format: "[defer] <align> [<meetOrSlice>]"
|
|
49
|
+
*
|
|
50
|
+
* @param {string} parStr - preserveAspectRatio attribute value
|
|
51
|
+
* @returns {{defer: boolean, align: string, meetOrSlice: string}}
|
|
52
|
+
*/
|
|
53
|
+
export function parsePreserveAspectRatio(parStr) {
|
|
54
|
+
const result = {
|
|
55
|
+
defer: false,
|
|
56
|
+
align: 'xMidYMid', // default
|
|
57
|
+
meetOrSlice: 'meet' // default
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (!parStr || parStr.trim() === '') {
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const parts = parStr.trim().split(/\s+/);
|
|
65
|
+
let idx = 0;
|
|
66
|
+
|
|
67
|
+
// Check for 'defer' (only applies to <image>)
|
|
68
|
+
if (parts[idx] === 'defer') {
|
|
69
|
+
result.defer = true;
|
|
70
|
+
idx++;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Alignment value
|
|
74
|
+
if (parts[idx]) {
|
|
75
|
+
result.align = parts[idx];
|
|
76
|
+
idx++;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// meetOrSlice
|
|
80
|
+
if (parts[idx]) {
|
|
81
|
+
result.meetOrSlice = parts[idx].toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Compute the transformation matrix from viewBox coordinates to viewport.
|
|
89
|
+
* Implements the SVG 2 algorithm for viewBox + preserveAspectRatio.
|
|
90
|
+
*
|
|
91
|
+
* @param {Object} viewBox - Parsed viewBox {minX, minY, width, height}
|
|
92
|
+
* @param {number|Decimal} viewportWidth - Viewport width in pixels
|
|
93
|
+
* @param {number|Decimal} viewportHeight - Viewport height in pixels
|
|
94
|
+
* @param {Object} par - Parsed preserveAspectRatio {align, meetOrSlice}
|
|
95
|
+
* @returns {Matrix} 3x3 transformation matrix
|
|
96
|
+
*/
|
|
97
|
+
export function computeViewBoxTransform(viewBox, viewportWidth, viewportHeight, par = null) {
|
|
98
|
+
const D = x => new Decimal(x);
|
|
99
|
+
|
|
100
|
+
if (!viewBox) {
|
|
101
|
+
return Matrix.identity(3);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const vbX = viewBox.minX;
|
|
105
|
+
const vbY = viewBox.minY;
|
|
106
|
+
const vbW = viewBox.width;
|
|
107
|
+
const vbH = viewBox.height;
|
|
108
|
+
const vpW = D(viewportWidth);
|
|
109
|
+
const vpH = D(viewportHeight);
|
|
110
|
+
|
|
111
|
+
// Default preserveAspectRatio
|
|
112
|
+
if (!par) {
|
|
113
|
+
par = { align: 'xMidYMid', meetOrSlice: 'meet' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Handle 'none' - stretch to fill
|
|
117
|
+
if (par.align === 'none') {
|
|
118
|
+
const scaleX = vpW.div(vbW);
|
|
119
|
+
const scaleY = vpH.div(vbH);
|
|
120
|
+
// translate(-minX, -minY) then scale
|
|
121
|
+
const translateM = Transforms2D.translation(vbX.neg(), vbY.neg());
|
|
122
|
+
const scaleM = Transforms2D.scale(scaleX, scaleY);
|
|
123
|
+
return scaleM.mul(translateM);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Compute uniform scale factor
|
|
127
|
+
let scaleX = vpW.div(vbW);
|
|
128
|
+
let scaleY = vpH.div(vbH);
|
|
129
|
+
let scale;
|
|
130
|
+
|
|
131
|
+
if (par.meetOrSlice === 'slice') {
|
|
132
|
+
// Use larger scale (content may overflow)
|
|
133
|
+
scale = Decimal.max(scaleX, scaleY);
|
|
134
|
+
} else {
|
|
135
|
+
// 'meet' - use smaller scale (content fits entirely)
|
|
136
|
+
scale = Decimal.min(scaleX, scaleY);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Compute translation for alignment
|
|
140
|
+
const scaledW = vbW.mul(scale);
|
|
141
|
+
const scaledH = vbH.mul(scale);
|
|
142
|
+
|
|
143
|
+
let translateX = D(0);
|
|
144
|
+
let translateY = D(0);
|
|
145
|
+
|
|
146
|
+
// Parse alignment string (e.g., 'xMidYMid', 'xMinYMax')
|
|
147
|
+
const align = par.align;
|
|
148
|
+
|
|
149
|
+
// X alignment
|
|
150
|
+
if (align.includes('xMid')) {
|
|
151
|
+
translateX = vpW.minus(scaledW).div(2);
|
|
152
|
+
} else if (align.includes('xMax')) {
|
|
153
|
+
translateX = vpW.minus(scaledW);
|
|
154
|
+
}
|
|
155
|
+
// xMin is default (translateX = 0)
|
|
156
|
+
|
|
157
|
+
// Y alignment
|
|
158
|
+
if (align.includes('YMid')) {
|
|
159
|
+
translateY = vpH.minus(scaledH).div(2);
|
|
160
|
+
} else if (align.includes('YMax')) {
|
|
161
|
+
translateY = vpH.minus(scaledH);
|
|
162
|
+
}
|
|
163
|
+
// YMin is default (translateY = 0)
|
|
164
|
+
|
|
165
|
+
// Build the transform: translate(translateX, translateY) scale(scale) translate(-minX, -minY)
|
|
166
|
+
// Applied right-to-left: first translate by -minX,-minY, then scale, then translate for alignment
|
|
167
|
+
const translateMinM = Transforms2D.translation(vbX.neg(), vbY.neg());
|
|
168
|
+
const scaleM = Transforms2D.scale(scale, scale);
|
|
169
|
+
const translateAlignM = Transforms2D.translation(translateX, translateY);
|
|
170
|
+
|
|
171
|
+
return translateAlignM.mul(scaleM).mul(translateMinM);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Represents an SVG viewport with its coordinate system parameters.
|
|
176
|
+
* Used for building the full CTM through nested viewports.
|
|
177
|
+
*/
|
|
178
|
+
export class SVGViewport {
|
|
179
|
+
/**
|
|
180
|
+
* @param {number|Decimal} width - Viewport width
|
|
181
|
+
* @param {number|Decimal} height - Viewport height
|
|
182
|
+
* @param {string|null} viewBox - viewBox attribute value
|
|
183
|
+
* @param {string|null} preserveAspectRatio - preserveAspectRatio attribute value
|
|
184
|
+
* @param {string|null} transform - transform attribute value
|
|
185
|
+
*/
|
|
186
|
+
constructor(width, height, viewBox = null, preserveAspectRatio = null, transform = null) {
|
|
187
|
+
this.width = new Decimal(width);
|
|
188
|
+
this.height = new Decimal(height);
|
|
189
|
+
this.viewBox = viewBox ? parseViewBox(viewBox) : null;
|
|
190
|
+
this.preserveAspectRatio = parsePreserveAspectRatio(preserveAspectRatio);
|
|
191
|
+
this.transform = transform;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Compute the transformation matrix for this viewport.
|
|
196
|
+
* @returns {Matrix} 3x3 transformation matrix
|
|
197
|
+
*/
|
|
198
|
+
getTransformMatrix() {
|
|
199
|
+
let result = Matrix.identity(3);
|
|
200
|
+
|
|
201
|
+
// Apply viewBox transform first (if present)
|
|
202
|
+
if (this.viewBox) {
|
|
203
|
+
const vbTransform = computeViewBoxTransform(
|
|
204
|
+
this.viewBox,
|
|
205
|
+
this.width,
|
|
206
|
+
this.height,
|
|
207
|
+
this.preserveAspectRatio
|
|
208
|
+
);
|
|
209
|
+
result = result.mul(vbTransform);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Then apply the transform attribute (if present)
|
|
213
|
+
if (this.transform) {
|
|
214
|
+
const transformMatrix = parseTransformAttribute(this.transform);
|
|
215
|
+
result = result.mul(transformMatrix);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Build the complete CTM including viewports, viewBox transforms, and element transforms.
|
|
224
|
+
*
|
|
225
|
+
* @param {Array} hierarchy - Array of objects describing the hierarchy from root to element.
|
|
226
|
+
* Each object can be:
|
|
227
|
+
* - {type: 'svg', width, height, viewBox?, preserveAspectRatio?, transform?}
|
|
228
|
+
* - {type: 'g', transform?}
|
|
229
|
+
* - {type: 'element', transform?}
|
|
230
|
+
* Or simply a transform string for backwards compatibility
|
|
231
|
+
* @returns {Matrix} Combined CTM as 3x3 matrix
|
|
232
|
+
*/
|
|
233
|
+
export function buildFullCTM(hierarchy) {
|
|
234
|
+
let ctm = Matrix.identity(3);
|
|
235
|
+
|
|
236
|
+
for (const item of hierarchy) {
|
|
237
|
+
if (typeof item === 'string') {
|
|
238
|
+
// Backwards compatibility: treat string as transform attribute
|
|
239
|
+
if (item) {
|
|
240
|
+
const matrix = parseTransformAttribute(item);
|
|
241
|
+
ctm = ctm.mul(matrix);
|
|
242
|
+
}
|
|
243
|
+
} else if (item.type === 'svg') {
|
|
244
|
+
// SVG viewport with potential viewBox
|
|
245
|
+
const viewport = new SVGViewport(
|
|
246
|
+
item.width,
|
|
247
|
+
item.height,
|
|
248
|
+
item.viewBox || null,
|
|
249
|
+
item.preserveAspectRatio || null,
|
|
250
|
+
item.transform || null
|
|
251
|
+
);
|
|
252
|
+
ctm = ctm.mul(viewport.getTransformMatrix());
|
|
253
|
+
} else if (item.type === 'g' || item.type === 'element') {
|
|
254
|
+
// Group or element with optional transform
|
|
255
|
+
if (item.transform) {
|
|
256
|
+
const matrix = parseTransformAttribute(item.transform);
|
|
257
|
+
ctm = ctm.mul(matrix);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return ctm;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ============================================================================
|
|
266
|
+
// Unit and Percentage Resolution
|
|
267
|
+
// ============================================================================
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Resolve a length value that may include units or percentages.
|
|
271
|
+
*
|
|
272
|
+
* @param {string|number} value - Length value (e.g., "50%", "10px", "5em", 100)
|
|
273
|
+
* @param {Decimal} referenceSize - Reference size for percentage resolution
|
|
274
|
+
* @param {number} [dpi=96] - DPI for absolute unit conversion
|
|
275
|
+
* @returns {Decimal} Resolved length in user units (px)
|
|
276
|
+
*/
|
|
277
|
+
export function resolveLength(value, referenceSize, dpi = 96) {
|
|
278
|
+
const D = x => new Decimal(x);
|
|
279
|
+
|
|
280
|
+
if (typeof value === 'number') {
|
|
281
|
+
return D(value);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const str = String(value).trim();
|
|
285
|
+
|
|
286
|
+
// Percentage
|
|
287
|
+
if (str.endsWith('%')) {
|
|
288
|
+
const pct = D(str.slice(0, -1));
|
|
289
|
+
return pct.div(100).mul(referenceSize);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Extract numeric value and unit
|
|
293
|
+
const match = str.match(/^([+-]?[\d.]+(?:e[+-]?\d+)?)(.*)?$/i);
|
|
294
|
+
if (!match) {
|
|
295
|
+
return D(0);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const num = D(match[1]);
|
|
299
|
+
const unit = (match[2] || '').toLowerCase().trim();
|
|
300
|
+
|
|
301
|
+
// Convert to user units (px)
|
|
302
|
+
switch (unit) {
|
|
303
|
+
case '':
|
|
304
|
+
case 'px':
|
|
305
|
+
return num;
|
|
306
|
+
case 'em':
|
|
307
|
+
return num.mul(16); // Assume 16px font-size
|
|
308
|
+
case 'rem':
|
|
309
|
+
return num.mul(16);
|
|
310
|
+
case 'pt':
|
|
311
|
+
return num.mul(dpi).div(72);
|
|
312
|
+
case 'pc':
|
|
313
|
+
return num.mul(dpi).div(6);
|
|
314
|
+
case 'in':
|
|
315
|
+
return num.mul(dpi);
|
|
316
|
+
case 'cm':
|
|
317
|
+
return num.mul(dpi).div(2.54);
|
|
318
|
+
case 'mm':
|
|
319
|
+
return num.mul(dpi).div(25.4);
|
|
320
|
+
default:
|
|
321
|
+
return num; // Unknown unit, treat as px
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Resolve percentage values for x/width (relative to viewport width)
|
|
327
|
+
* and y/height (relative to viewport height).
|
|
328
|
+
*
|
|
329
|
+
* @param {string|number} xOrWidth - X coordinate or width value
|
|
330
|
+
* @param {string|number} yOrHeight - Y coordinate or height value
|
|
331
|
+
* @param {Decimal} viewportWidth - Viewport width for reference
|
|
332
|
+
* @param {Decimal} viewportHeight - Viewport height for reference
|
|
333
|
+
* @returns {{x: Decimal, y: Decimal}} Resolved coordinates
|
|
334
|
+
*/
|
|
335
|
+
export function resolvePercentages(xOrWidth, yOrHeight, viewportWidth, viewportHeight) {
|
|
336
|
+
return {
|
|
337
|
+
x: resolveLength(xOrWidth, viewportWidth),
|
|
338
|
+
y: resolveLength(yOrHeight, viewportHeight)
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Compute the normalized diagonal for resolving percentages that
|
|
344
|
+
* aren't clearly x or y oriented (per SVG spec).
|
|
345
|
+
* Formula: sqrt(width^2 + height^2) / sqrt(2)
|
|
346
|
+
*
|
|
347
|
+
* @param {Decimal} width - Viewport width
|
|
348
|
+
* @param {Decimal} height - Viewport height
|
|
349
|
+
* @returns {Decimal} Normalized diagonal
|
|
350
|
+
*/
|
|
351
|
+
export function normalizedDiagonal(width, height) {
|
|
352
|
+
const w = new Decimal(width);
|
|
353
|
+
const h = new Decimal(height);
|
|
354
|
+
const sqrt2 = Decimal.sqrt(2);
|
|
355
|
+
return Decimal.sqrt(w.mul(w).plus(h.mul(h))).div(sqrt2);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ============================================================================
|
|
359
|
+
// Object Bounding Box Transform
|
|
360
|
+
// ============================================================================
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Create a transformation matrix for objectBoundingBox coordinates.
|
|
364
|
+
* Maps (0,0) to (bboxX, bboxY) and (1,1) to (bboxX+bboxW, bboxY+bboxH).
|
|
365
|
+
*
|
|
366
|
+
* @param {number|Decimal} bboxX - Bounding box X
|
|
367
|
+
* @param {number|Decimal} bboxY - Bounding box Y
|
|
368
|
+
* @param {number|Decimal} bboxWidth - Bounding box width
|
|
369
|
+
* @param {number|Decimal} bboxHeight - Bounding box height
|
|
370
|
+
* @returns {Matrix} 3x3 transformation matrix
|
|
371
|
+
*/
|
|
372
|
+
export function objectBoundingBoxTransform(bboxX, bboxY, bboxWidth, bboxHeight) {
|
|
373
|
+
const D = x => new Decimal(x);
|
|
374
|
+
// Transform: scale(bboxWidth, bboxHeight) then translate(bboxX, bboxY)
|
|
375
|
+
const scaleM = Transforms2D.scale(bboxWidth, bboxHeight);
|
|
376
|
+
const translateM = Transforms2D.translation(bboxX, bboxY);
|
|
377
|
+
return translateM.mul(scaleM);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ============================================================================
|
|
381
|
+
// Transform Parsing (existing code)
|
|
382
|
+
// ============================================================================
|
|
383
|
+
|
|
17
384
|
/**
|
|
18
385
|
* Parse a single SVG transform function and return a 3x3 matrix.
|
|
19
386
|
* Supports: translate, scale, rotate, skewX, skewY, matrix
|
|
@@ -324,6 +691,19 @@ export const PRECISION_INFO = {
|
|
|
324
691
|
};
|
|
325
692
|
|
|
326
693
|
export default {
|
|
694
|
+
// viewBox and preserveAspectRatio
|
|
695
|
+
parseViewBox,
|
|
696
|
+
parsePreserveAspectRatio,
|
|
697
|
+
computeViewBoxTransform,
|
|
698
|
+
SVGViewport,
|
|
699
|
+
buildFullCTM,
|
|
700
|
+
// Unit resolution
|
|
701
|
+
resolveLength,
|
|
702
|
+
resolvePercentages,
|
|
703
|
+
normalizedDiagonal,
|
|
704
|
+
// Object bounding box
|
|
705
|
+
objectBoundingBoxTransform,
|
|
706
|
+
// Transform parsing
|
|
327
707
|
parseTransformFunction,
|
|
328
708
|
parseTransformAttribute,
|
|
329
709
|
buildCTM,
|