@emasoft/svg-matrix 1.0.5 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@emasoft/svg-matrix",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
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",
7
7
  "scripts": {
8
8
  "test": "node test/examples.js",
9
+ "test:browser": "node test/browser-verify.mjs",
10
+ "test:playwright": "node test/playwright-diagnose.js",
9
11
  "ci-test": "npm ci && npm test",
10
12
  "prepublishOnly": "npm test"
11
13
  },
@@ -40,7 +42,23 @@
40
42
  },
41
43
  "author": "Emasoft",
42
44
  "license": "MIT",
45
+ "engines": {
46
+ "node": ">=24.0.0"
47
+ },
48
+ "files": [
49
+ "src/",
50
+ "LICENSE",
51
+ "README.md"
52
+ ],
43
53
  "dependencies": {
44
54
  "decimal.js": "^10.6.0"
55
+ },
56
+ "peerDependencies": {
57
+ "playwright": "^1.57.0"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "playwright": {
61
+ "optional": true
62
+ }
45
63
  }
46
64
  }
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Browser Verification Module
3
+ *
4
+ * Provides functions to verify SVG coordinate transformations against
5
+ * Chrome's native implementation using Playwright.
6
+ *
7
+ * This is the authoritative way to verify correctness since browsers
8
+ * implement the W3C SVG2 specification.
9
+ *
10
+ * IMPORTANT: This module requires Playwright as an optional peer dependency.
11
+ * Install with: npm install playwright && npx playwright install chromium
12
+ *
13
+ * @module browser-verify
14
+ */
15
+
16
+ import Decimal from 'decimal.js';
17
+ import { Matrix } from './matrix.js';
18
+ import * as SVGFlatten from './svg-flatten.js';
19
+
20
+ // Playwright is loaded dynamically to avoid crashes when not installed
21
+ let chromium = null;
22
+
23
+ /**
24
+ * Load Playwright dynamically. Throws helpful error if not installed.
25
+ * @returns {Promise<void>}
26
+ */
27
+ async function loadPlaywright() {
28
+ if (chromium) return;
29
+ try {
30
+ const playwright = await import('playwright');
31
+ chromium = playwright.chromium;
32
+ } catch (e) {
33
+ throw new Error(
34
+ 'Playwright is required for browser verification but not installed.\n' +
35
+ 'Install with: npm install playwright && npx playwright install chromium'
36
+ );
37
+ }
38
+ }
39
+
40
+ // Set high precision
41
+ Decimal.set({ precision: 80 });
42
+
43
+ /**
44
+ * Browser verification session.
45
+ * Manages a Chromium browser instance for CTM verification.
46
+ */
47
+ export class BrowserVerifier {
48
+ constructor() {
49
+ this.browser = null;
50
+ this.page = null;
51
+ }
52
+
53
+ /**
54
+ * Initialize the browser session.
55
+ * Must be called before using verification methods.
56
+ *
57
+ * @param {Object} options - Playwright launch options
58
+ * @returns {Promise<void>}
59
+ * @throws {Error} If Playwright is not installed
60
+ */
61
+ async init(options = {}) {
62
+ await loadPlaywright();
63
+ this.browser = await chromium.launch(options);
64
+ this.page = await this.browser.newPage();
65
+ }
66
+
67
+ /**
68
+ * Close the browser session.
69
+ * Should be called when done with verification.
70
+ *
71
+ * @returns {Promise<void>}
72
+ */
73
+ async close() {
74
+ if (this.browser) {
75
+ await this.browser.close();
76
+ this.browser = null;
77
+ this.page = null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Get the browser's native CTM for an SVG element configuration.
83
+ *
84
+ * @param {Object} config - SVG configuration
85
+ * @param {number} config.width - Viewport width
86
+ * @param {number} config.height - Viewport height
87
+ * @param {string} [config.viewBox] - viewBox attribute
88
+ * @param {string} [config.preserveAspectRatio] - preserveAspectRatio attribute
89
+ * @param {string} [config.transform] - transform attribute on child element
90
+ * @returns {Promise<{a: number, b: number, c: number, d: number, e: number, f: number}>}
91
+ */
92
+ async getBrowserCTM(config) {
93
+ if (!this.page) {
94
+ throw new Error('BrowserVerifier not initialized. Call init() first.');
95
+ }
96
+
97
+ return await this.page.evaluate((cfg) => {
98
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
99
+ svg.setAttribute('width', cfg.width);
100
+ svg.setAttribute('height', cfg.height);
101
+ if (cfg.viewBox) svg.setAttribute('viewBox', cfg.viewBox);
102
+ if (cfg.preserveAspectRatio) svg.setAttribute('preserveAspectRatio', cfg.preserveAspectRatio);
103
+
104
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
105
+ rect.setAttribute('x', '0');
106
+ rect.setAttribute('y', '0');
107
+ rect.setAttribute('width', '10');
108
+ rect.setAttribute('height', '10');
109
+ if (cfg.transform) rect.setAttribute('transform', cfg.transform);
110
+ svg.appendChild(rect);
111
+
112
+ document.body.appendChild(svg);
113
+ const ctm = rect.getCTM();
114
+ document.body.removeChild(svg);
115
+
116
+ return { a: ctm.a, b: ctm.b, c: ctm.c, d: ctm.d, e: ctm.e, f: ctm.f };
117
+ }, config);
118
+ }
119
+
120
+ /**
121
+ * Get the browser's screen CTM (includes page scroll/zoom).
122
+ *
123
+ * @param {Object} config - SVG configuration (same as getBrowserCTM)
124
+ * @returns {Promise<{a: number, b: number, c: number, d: number, e: number, f: number}>}
125
+ */
126
+ async getBrowserScreenCTM(config) {
127
+ if (!this.page) {
128
+ throw new Error('BrowserVerifier not initialized. Call init() first.');
129
+ }
130
+
131
+ return await this.page.evaluate((cfg) => {
132
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
133
+ svg.setAttribute('width', cfg.width);
134
+ svg.setAttribute('height', cfg.height);
135
+ if (cfg.viewBox) svg.setAttribute('viewBox', cfg.viewBox);
136
+ if (cfg.preserveAspectRatio) svg.setAttribute('preserveAspectRatio', cfg.preserveAspectRatio);
137
+
138
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
139
+ rect.setAttribute('x', '0');
140
+ rect.setAttribute('y', '0');
141
+ rect.setAttribute('width', '10');
142
+ rect.setAttribute('height', '10');
143
+ if (cfg.transform) rect.setAttribute('transform', cfg.transform);
144
+ svg.appendChild(rect);
145
+
146
+ document.body.appendChild(svg);
147
+ const ctm = rect.getScreenCTM();
148
+ document.body.removeChild(svg);
149
+
150
+ return { a: ctm.a, b: ctm.b, c: ctm.c, d: ctm.d, e: ctm.e, f: ctm.f };
151
+ }, config);
152
+ }
153
+
154
+ /**
155
+ * Transform a point using the browser's native SVG transformation.
156
+ *
157
+ * @param {Object} config - SVG configuration
158
+ * @param {number} x - X coordinate in local space
159
+ * @param {number} y - Y coordinate in local space
160
+ * @returns {Promise<{x: number, y: number}>} Transformed point
161
+ */
162
+ async transformPoint(config, x, y) {
163
+ if (!this.page) {
164
+ throw new Error('BrowserVerifier not initialized. Call init() first.');
165
+ }
166
+
167
+ return await this.page.evaluate(({ cfg, px, py }) => {
168
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
169
+ svg.setAttribute('width', cfg.width);
170
+ svg.setAttribute('height', cfg.height);
171
+ if (cfg.viewBox) svg.setAttribute('viewBox', cfg.viewBox);
172
+ if (cfg.preserveAspectRatio) svg.setAttribute('preserveAspectRatio', cfg.preserveAspectRatio);
173
+
174
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
175
+ if (cfg.transform) rect.setAttribute('transform', cfg.transform);
176
+ svg.appendChild(rect);
177
+
178
+ document.body.appendChild(svg);
179
+
180
+ const ctm = rect.getCTM();
181
+ const point = svg.createSVGPoint();
182
+ point.x = px;
183
+ point.y = py;
184
+ const transformed = point.matrixTransform(ctm);
185
+
186
+ document.body.removeChild(svg);
187
+
188
+ return { x: transformed.x, y: transformed.y };
189
+ }, { cfg: config, px: x, py: y });
190
+ }
191
+
192
+ /**
193
+ * Verify a matrix against the browser's CTM.
194
+ *
195
+ * @param {Matrix} matrix - Our computed matrix
196
+ * @param {Object} config - SVG configuration to compare against
197
+ * @param {number} [tolerance=1e-10] - Tolerance for comparison
198
+ * @returns {Promise<{matches: boolean, browserCTM: Object, libraryCTM: Object, differences: Object}>}
199
+ */
200
+ async verifyMatrix(matrix, config, tolerance = 1e-10) {
201
+ const browserCTM = await this.getBrowserCTM(config);
202
+
203
+ const libraryCTM = {
204
+ a: matrix.data[0][0].toNumber(),
205
+ b: matrix.data[1][0].toNumber(),
206
+ c: matrix.data[0][1].toNumber(),
207
+ d: matrix.data[1][1].toNumber(),
208
+ e: matrix.data[0][2].toNumber(),
209
+ f: matrix.data[1][2].toNumber(),
210
+ };
211
+
212
+ const differences = {
213
+ a: Math.abs(browserCTM.a - libraryCTM.a),
214
+ b: Math.abs(browserCTM.b - libraryCTM.b),
215
+ c: Math.abs(browserCTM.c - libraryCTM.c),
216
+ d: Math.abs(browserCTM.d - libraryCTM.d),
217
+ e: Math.abs(browserCTM.e - libraryCTM.e),
218
+ f: Math.abs(browserCTM.f - libraryCTM.f),
219
+ };
220
+
221
+ const matches = Object.values(differences).every(d => d < tolerance);
222
+
223
+ return { matches, browserCTM, libraryCTM, differences };
224
+ }
225
+
226
+ /**
227
+ * Verify a viewBox transform computation.
228
+ *
229
+ * @param {number} width - Viewport width
230
+ * @param {number} height - Viewport height
231
+ * @param {string} viewBox - viewBox attribute
232
+ * @param {string} [preserveAspectRatio='xMidYMid meet'] - preserveAspectRatio
233
+ * @param {number} [tolerance=1e-10] - Tolerance for comparison
234
+ * @returns {Promise<{matches: boolean, browserCTM: Object, libraryCTM: Object, differences: Object}>}
235
+ */
236
+ async verifyViewBoxTransform(width, height, viewBox, preserveAspectRatio = 'xMidYMid meet', tolerance = 1e-10) {
237
+ const vb = SVGFlatten.parseViewBox(viewBox);
238
+ const par = SVGFlatten.parsePreserveAspectRatio(preserveAspectRatio);
239
+ const matrix = SVGFlatten.computeViewBoxTransform(vb, width, height, par);
240
+
241
+ return await this.verifyMatrix(matrix, {
242
+ width,
243
+ height,
244
+ viewBox,
245
+ preserveAspectRatio
246
+ }, tolerance);
247
+ }
248
+
249
+ /**
250
+ * Verify a transform attribute parsing.
251
+ *
252
+ * @param {string} transform - SVG transform attribute string
253
+ * @param {number} [tolerance=1e-10] - Tolerance for comparison
254
+ * @returns {Promise<{matches: boolean, browserCTM: Object, libraryCTM: Object, differences: Object}>}
255
+ */
256
+ async verifyTransformAttribute(transform, tolerance = 1e-10) {
257
+ const matrix = SVGFlatten.parseTransformAttribute(transform);
258
+
259
+ // Use a simple 100x100 SVG without viewBox to test just the transform
260
+ return await this.verifyMatrix(matrix, {
261
+ width: 100,
262
+ height: 100,
263
+ transform
264
+ }, tolerance);
265
+ }
266
+
267
+ /**
268
+ * Verify a point transformation.
269
+ *
270
+ * @param {Matrix} matrix - Our computed matrix
271
+ * @param {number} x - X coordinate
272
+ * @param {number} y - Y coordinate
273
+ * @param {Object} config - SVG configuration
274
+ * @param {number} [tolerance=1e-10] - Tolerance for comparison
275
+ * @returns {Promise<{matches: boolean, browserPoint: Object, libraryPoint: Object, difference: number}>}
276
+ */
277
+ async verifyPointTransform(matrix, x, y, config, tolerance = 1e-10) {
278
+ const browserPoint = await this.transformPoint(config, x, y);
279
+ const libraryPoint = SVGFlatten.applyToPoint(matrix, x, y);
280
+
281
+ const libPt = {
282
+ x: libraryPoint.x.toNumber(),
283
+ y: libraryPoint.y.toNumber()
284
+ };
285
+
286
+ const difference = Math.sqrt(
287
+ Math.pow(browserPoint.x - libPt.x, 2) +
288
+ Math.pow(browserPoint.y - libPt.y, 2)
289
+ );
290
+
291
+ return {
292
+ matches: difference < tolerance,
293
+ browserPoint,
294
+ libraryPoint: libPt,
295
+ difference
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Run a batch of verification tests.
301
+ *
302
+ * @param {Array<Object>} testCases - Array of test configurations
303
+ * @param {number} [tolerance=1e-10] - Tolerance for comparison
304
+ * @returns {Promise<{passed: number, failed: number, results: Array}>}
305
+ */
306
+ async runBatch(testCases, tolerance = 1e-10) {
307
+ const results = [];
308
+ let passed = 0;
309
+ let failed = 0;
310
+
311
+ for (const tc of testCases) {
312
+ const result = await this.verifyViewBoxTransform(
313
+ tc.width,
314
+ tc.height,
315
+ tc.viewBox,
316
+ tc.preserveAspectRatio || 'xMidYMid meet',
317
+ tolerance
318
+ );
319
+
320
+ results.push({
321
+ name: tc.name || `${tc.width}x${tc.height} viewBox="${tc.viewBox}"`,
322
+ ...result
323
+ });
324
+
325
+ if (result.matches) passed++;
326
+ else failed++;
327
+ }
328
+
329
+ return { passed, failed, results };
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Quick verification function - creates a temporary browser session.
335
+ * For multiple verifications, use BrowserVerifier class instead.
336
+ *
337
+ * @param {Matrix} matrix - Matrix to verify
338
+ * @param {Object} config - SVG configuration
339
+ * @param {number} [tolerance=1e-10] - Tolerance
340
+ * @returns {Promise<boolean>} True if matrix matches browser's CTM
341
+ */
342
+ export async function quickVerify(matrix, config, tolerance = 1e-10) {
343
+ const verifier = new BrowserVerifier();
344
+ await verifier.init({ headless: true });
345
+
346
+ try {
347
+ const result = await verifier.verifyMatrix(matrix, config, tolerance);
348
+ return result.matches;
349
+ } finally {
350
+ await verifier.close();
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Verify a viewBox transform with a quick one-off browser session.
356
+ *
357
+ * @param {number} width - Viewport width
358
+ * @param {number} height - Viewport height
359
+ * @param {string} viewBox - viewBox attribute
360
+ * @param {string} [preserveAspectRatio='xMidYMid meet'] - preserveAspectRatio
361
+ * @returns {Promise<{matches: boolean, browserCTM: Object, libraryCTM: Object}>}
362
+ */
363
+ export async function verifyViewBox(width, height, viewBox, preserveAspectRatio = 'xMidYMid meet') {
364
+ const verifier = new BrowserVerifier();
365
+ await verifier.init({ headless: true });
366
+
367
+ try {
368
+ return await verifier.verifyViewBoxTransform(width, height, viewBox, preserveAspectRatio);
369
+ } finally {
370
+ await verifier.close();
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Verify a transform attribute parsing with a quick one-off browser session.
376
+ *
377
+ * @param {string} transform - SVG transform attribute string
378
+ * @returns {Promise<{matches: boolean, browserCTM: Object, libraryCTM: Object}>}
379
+ */
380
+ export async function verifyTransform(transform) {
381
+ const verifier = new BrowserVerifier();
382
+ await verifier.init({ headless: true });
383
+
384
+ try {
385
+ return await verifier.verifyTransformAttribute(transform);
386
+ } finally {
387
+ await verifier.close();
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Run the standard verification test suite.
393
+ * Tests all common viewBox/preserveAspectRatio combinations.
394
+ *
395
+ * @param {Object} [options] - Options
396
+ * @param {boolean} [options.verbose=true] - Print results to console
397
+ * @returns {Promise<{passed: number, failed: number, results: Array}>}
398
+ */
399
+ export async function runStandardTests(options = { verbose: true }) {
400
+ const testCases = [
401
+ // W3C SVG WG issue #215 cases
402
+ { width: 21, height: 10, viewBox: '11 13 3 2', preserveAspectRatio: 'none', name: 'Issue #215: none' },
403
+ { width: 21, height: 10, viewBox: '11 13 3 2', preserveAspectRatio: 'xMinYMin meet', name: 'Issue #215: xMinYMin meet' },
404
+ { width: 21, height: 10, viewBox: '11 13 3 2', preserveAspectRatio: 'xMidYMid meet', name: 'Issue #215: xMidYMid meet' },
405
+ { width: 21, height: 10, viewBox: '11 13 3 2', preserveAspectRatio: 'xMaxYMax meet', name: 'Issue #215: xMaxYMax meet' },
406
+ { width: 21, height: 10, viewBox: '11 13 3 2', preserveAspectRatio: 'xMinYMin slice', name: 'Issue #215: xMinYMin slice' },
407
+ { width: 21, height: 10, viewBox: '11 13 3 2', preserveAspectRatio: 'xMidYMid slice', name: 'Issue #215: xMidYMid slice' },
408
+ { width: 21, height: 10, viewBox: '11 13 3 2', preserveAspectRatio: 'xMaxYMax slice', name: 'Issue #215: xMaxYMax slice' },
409
+
410
+ // Standard cases
411
+ { width: 200, height: 200, viewBox: '0 0 100 100', preserveAspectRatio: 'xMidYMid meet', name: 'Square 2x scale' },
412
+ { width: 100, height: 100, viewBox: '0 0 100 100', preserveAspectRatio: 'xMidYMid meet', name: '1:1 identity' },
413
+ { width: 400, height: 200, viewBox: '0 0 100 100', preserveAspectRatio: 'xMidYMid meet', name: 'Wide viewport' },
414
+ { width: 200, height: 400, viewBox: '0 0 100 100', preserveAspectRatio: 'xMidYMid meet', name: 'Tall viewport' },
415
+
416
+ // Non-zero origins
417
+ { width: 300, height: 200, viewBox: '50 50 100 100', preserveAspectRatio: 'xMidYMid meet', name: 'Offset origin' },
418
+ { width: 300, height: 200, viewBox: '-50 -50 200 200', preserveAspectRatio: 'xMidYMid meet', name: 'Negative origin' },
419
+
420
+ // Stretch
421
+ { width: 200, height: 100, viewBox: '0 0 100 100', preserveAspectRatio: 'none', name: 'Stretch non-uniform' },
422
+
423
+ // Slice modes
424
+ { width: 200, height: 400, viewBox: '0 0 100 100', preserveAspectRatio: 'xMidYMid slice', name: 'Tall slice' },
425
+ { width: 400, height: 200, viewBox: '0 0 100 100', preserveAspectRatio: 'xMidYMid slice', name: 'Wide slice' },
426
+ ];
427
+
428
+ const verifier = new BrowserVerifier();
429
+ await verifier.init({ headless: true });
430
+
431
+ try {
432
+ const { passed, failed, results } = await verifier.runBatch(testCases);
433
+
434
+ if (options.verbose) {
435
+ console.log('\n=== SVG-Matrix Browser Verification ===\n');
436
+
437
+ for (const r of results) {
438
+ const icon = r.matches ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
439
+ console.log(` ${icon} ${r.name}`);
440
+ }
441
+
442
+ console.log('\n' + '─'.repeat(50));
443
+ if (failed === 0) {
444
+ console.log(`\x1b[32mAll ${passed} tests PASSED!\x1b[0m`);
445
+ console.log('Library matches browser\'s W3C SVG2 implementation.\n');
446
+ } else {
447
+ console.log(`\x1b[31m${failed} tests FAILED\x1b[0m, ${passed} passed\n`);
448
+ }
449
+ }
450
+
451
+ return { passed, failed, results };
452
+ } finally {
453
+ await verifier.close();
454
+ }
455
+ }
456
+
457
+ export default {
458
+ BrowserVerifier,
459
+ quickVerify,
460
+ verifyViewBox,
461
+ verifyTransform,
462
+ runStandardTests
463
+ };