From 75bfd856a803f93aa163fb2b8de33b2922862aa7 Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Tue, 2 Jun 2026 10:34:43 +0200 Subject: [PATCH 1/2] fix(joint-core): guard against non-invertible screen CTM in getRelativeTransformation When a target SVG element is nested under an ancestor with a singular transform (e.g. scale(0)), its getScreenCTM() returns a matrix with det = 0. Calling .inverse() on such a matrix throws or produces a zero/NaN matrix, breaking getTransformToElement for any caller in that subtree. Detect this case and return null so getTransformToElement falls back to the identity matrix. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/joint-core/src/V/transform.mjs | 12 ++++- .../joint-core/test/vectorizer/vectorizer.js | 51 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/joint-core/src/V/transform.mjs b/packages/joint-core/src/V/transform.mjs index ffec7afaed..58f5de7ab3 100644 --- a/packages/joint-core/src/V/transform.mjs +++ b/packages/joint-core/src/V/transform.mjs @@ -128,6 +128,16 @@ function getRootSVG(node) { return svg; } +/** + * @param {SVGMatrix} matrix + * @returns {boolean} + * @description Checks if the given matrix is invertible. + */ +function isMatrixInvertible(matrix) { + const det = matrix.a * matrix.d - matrix.b * matrix.c; + return Number.isFinite(det) && det !== 0; +} + /** * * @param {SVGElement} a @@ -146,7 +156,7 @@ export function getRelativeTransformation(a, b) { if (rootA !== rootB) return null; // Get the transformation matrix from `a` to `b`. const am = b.getScreenCTM(); - if (!am) return null; + if (!am || !isMatrixInvertible(am)) return null; const bm = a.getScreenCTM(); if (!bm) return null; return am.inverse().multiply(bm); diff --git a/packages/joint-core/test/vectorizer/vectorizer.js b/packages/joint-core/test/vectorizer/vectorizer.js index fee74e8193..8c6ceb772f 100644 --- a/packages/joint-core/test/vectorizer/vectorizer.js +++ b/packages/joint-core/test/vectorizer/vectorizer.js @@ -1648,6 +1648,57 @@ QUnit.module('vectorizer', function(hooks) { assert.equal(V.matrixToTransformString(V(svgPath2).getTransformToElement(svgGroup1, { safe: true })), 'matrix(2,0,0,2,20,20)'); }); + QUnit.test('non-invertible target screen CTM returns identity (no throw)', function(assert) { + + // A scale(0) ancestor produces a singular screen CTM (det = 0) + // for descendants. Without the invertibility guard, inverting it + // yields a zero/NaN matrix or throws. With the guard, + // getRelativeTransformation returns null and getTransformToElement + // falls back to identity. + var hostGroup = V('g', { transform: 'translate(100, 200)' }); + var innerPath = V('path', { d: 'M 0 0 L 10 10', transform: 'translate(20, 30)' }); + hostGroup.append(innerPath); + V(svgContainer).append(hostGroup); + + // Baseline: with an invertible ancestor, the source/target + // transforms produce a real, non-identity matrix. This proves + // the identity result below is the guard's fallback, not a + // coincidence of trivial CTMs. + var baseline = V(svgCircle).getTransformToElement(innerPath.node); + assert.notEqual( + V.matrixToTransformString(baseline), + 'matrix(1,0,0,1,0,0)', + 'Baseline (invertible ancestor) is not identity' + ); + + // Collapse the ancestor → target's screen CTM becomes singular. + hostGroup.node.setAttribute('transform', 'scale(0)'); + + var ctm = innerPath.node.getScreenCTM(); + assert.ok(ctm, 'Target getScreenCTM returns a matrix'); + assert.equal(ctm.a, 0, 'Target CTM.a is 0'); + assert.equal(ctm.b, 0, 'Target CTM.b is 0'); + assert.equal(ctm.c, 0, 'Target CTM.c is 0'); + assert.equal(ctm.d, 0, 'Target CTM.d is 0'); + assert.equal(ctm.a * ctm.d - ctm.b * ctm.c, 0, 'Target CTM determinant is 0'); + + var matrix; + var threw = false; + try { + matrix = V(svgCircle).getTransformToElement(innerPath.node); + } catch (e) { + threw = true; + } + assert.notOk(threw, 'Does not throw on non-invertible target screen CTM'); + assert.equal( + V.matrixToTransformString(matrix), + 'matrix(1,0,0,1,0,0)', + 'Falls back to identity matrix' + ); + + hostGroup.remove(); + }); + QUnit.test('native getTransformToElement vs VElement getTransformToElement - translate', function(assert) { var container = V(svgContainer); From 669ac9afa1bbc3ee7dc2b3a0cde0abac76556cd0 Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Tue, 2 Jun 2026 10:45:44 +0200 Subject: [PATCH 2/2] test(joint-core): drop unused catch binding in non-invertible CTM test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QUnit fails the test automatically when an exception bubbles up, so the manual try/catch was redundant — and the unused `e` binding tripped the `no-unused-vars` rule. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/joint-core/test/vectorizer/vectorizer.js | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/joint-core/test/vectorizer/vectorizer.js b/packages/joint-core/test/vectorizer/vectorizer.js index 8c6ceb772f..5a27df994f 100644 --- a/packages/joint-core/test/vectorizer/vectorizer.js +++ b/packages/joint-core/test/vectorizer/vectorizer.js @@ -1682,18 +1682,12 @@ QUnit.module('vectorizer', function(hooks) { assert.equal(ctm.d, 0, 'Target CTM.d is 0'); assert.equal(ctm.a * ctm.d - ctm.b * ctm.c, 0, 'Target CTM determinant is 0'); - var matrix; - var threw = false; - try { - matrix = V(svgCircle).getTransformToElement(innerPath.node); - } catch (e) { - threw = true; - } - assert.notOk(threw, 'Does not throw on non-invertible target screen CTM'); + // If the call throws, QUnit reports the test as failed. + var matrix = V(svgCircle).getTransformToElement(innerPath.node); assert.equal( V.matrixToTransformString(matrix), 'matrix(1,0,0,1,0,0)', - 'Falls back to identity matrix' + 'Falls back to identity matrix (no throw)' ); hostGroup.remove();