@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emasoft/svg-matrix",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
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",
@@ -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>
@@ -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,