Skip to content

Commit 1c43605

Browse files
tinchox5claude
andcommitted
fix(bbox): correct bleed/transform math for inset shadows, blur chains, root scale
Three fidelity bugs in the bbox/bleed helpers: - parseBoxShadow counted inset shadows in the outer bleed (they are clipped to the padding box), inflating the viewBox with phantom transparent padding under outerShadows. Now inset layers are skipped. The layer splitter was also rewritten to split on top-level commas — the old `),` regex never separated multi-layer computed values (which put the color first), so multiple layers were merged. - parseFilterBlur matched only the first blur() and ignored -webkit-filter. Now it reads filter (falling back to -webkit-filter) and sums every blur() in the chain, without double-counting when the engine mirrors the two properties. - normalizeRootTransforms ignored the individual `scale` property when transform was none (matrixFromComputed only reads `transform`), leaving the viewBox unscaled and clipping scaled roots on the outerTransforms:false path. The scale is now parsed straight to a matrix for bbox expansion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent db69e78 commit 1c43605

2 files changed

Lines changed: 80 additions & 12 deletions

File tree

__tests__/utils.transforms.helpers.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,27 @@ describe('parseBoxShadow', () => {
4040
expect(res.top).toBeGreaterThanOrEqual(0)
4141
expect(res.right).toBeGreaterThanOrEqual(0)
4242
})
43+
44+
it('ignores inset shadows (no outer bleed)', () => {
45+
const div = document.createElement('div')
46+
div.style.boxShadow = 'inset 10px 10px 5px 2px rgba(0,0,0,0.5)'
47+
document.body.appendChild(div)
48+
const res = parseBoxShadow(getComputedStyle(div))
49+
expect(res).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
50+
})
51+
52+
it('counts only the outer layer when mixed with an inset layer', () => {
53+
const div = document.createElement('div')
54+
div.style.boxShadow = 'inset 0 0 40px red, 6px 6px 0 0 blue'
55+
document.body.appendChild(div)
56+
const res = parseBoxShadow(getComputedStyle(div))
57+
// The inset 40px blur must not leak into the bleed (the bug counted it everywhere);
58+
// only the 6px outer layer contributes — right/bottom = |6|+6 = 12, all sides << 40.
59+
expect(res.right).toBe(12)
60+
expect(res.bottom).toBe(12)
61+
expect(res.top).toBeLessThanOrEqual(12)
62+
expect(res.left).toBeLessThanOrEqual(12)
63+
})
4364
})
4465

4566
describe('parseFilterBlur', () => {
@@ -56,8 +77,17 @@ describe('parseFilterBlur', () => {
5677
document.body.appendChild(div)
5778
const cs = getComputedStyle(div)
5879
const res = parseFilterBlur(cs)
80+
// Also guards against double-counting when the engine mirrors filter into webkitFilter.
5981
expect(res.top).toBe(5)
6082
})
83+
84+
it('sums multiple blur() in the chain (combined spread)', () => {
85+
const div = document.createElement('div')
86+
div.style.filter = 'blur(2px) blur(3px)'
87+
document.body.appendChild(div)
88+
const res = parseFilterBlur(getComputedStyle(div))
89+
expect(res).toEqual({ top: 5, right: 5, bottom: 5, left: 5 })
90+
})
6191
})
6292

6393
describe('parseOutline', () => {
@@ -223,4 +253,16 @@ describe('normalizeRootTransforms', () => {
223253
expect(res).not.toBeNull()
224254
expect(res.a).toBe(2)
225255
})
256+
257+
it('captures the individual `scale` property when transform is none', () => {
258+
const orig = document.createElement('div')
259+
orig.style.scale = '2' // individual property, NOT transform
260+
const clone = document.createElement('div')
261+
document.body.appendChild(orig)
262+
const res = normalizeRootTransforms(orig, clone)
263+
// The viewBox must expand by the scale, otherwise the scaled clone gets clipped.
264+
expect(res).not.toBeNull()
265+
expect(res.a).toBeCloseTo(2, 5)
266+
expect(res.d).toBeCloseTo(2, 5)
267+
})
226268
})

src/utils/transforms.helpers.js

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,22 @@ import { limitDecimals } from './capture.helpers.js'
1313
export function parseBoxShadow(cs) {
1414
const v = cs.boxShadow || ''
1515
if (!v || v === 'none') return { top: 0, right: 0, bottom: 0, left: 0 }
16-
const parts = v.split(/\),(?=(?:[^()]*\([^()]*\))*[^()]*$)/).map((s) => s.trim())
16+
// Split into layers on top-level commas only (commas inside rgb()/rgba() must not split).
17+
// A regex on `),` fails for computed values, which put the color first (color()-last is
18+
// author syntax) — so a multi-layer shadow would not be separated.
19+
const parts = []
20+
let buf = '', depth = 0
21+
for (let i = 0; i < v.length; i++) {
22+
const ch = v[i]
23+
if (ch === '(') depth++
24+
else if (ch === ')') depth = Math.max(0, depth - 1)
25+
if (ch === ',' && depth === 0) { parts.push(buf); buf = '' } else buf += ch
26+
}
27+
if (buf.trim()) parts.push(buf)
1728
let t = 0, r = 0, b2 = 0, l = 0
1829
for (const part of parts) {
30+
// inset shadows are clipped to the padding box and contribute no outer bleed.
31+
if (/\binset\b/i.test(part)) continue
1932
const nums = part.match(/-?\d+(\.\d+)?px/g)?.map((n) => parseFloat(n)) || []
2033
if (nums.length < 2) continue
2134
const [ox2, oy2, blur = 0, spread = 0] = nums
@@ -35,8 +48,15 @@ export function parseBoxShadow(cs) {
3548
* @returns {{top: number, right: number, bottom: number, left: number}}
3649
*/
3750
export function parseFilterBlur(cs) {
38-
const m = (cs.filter || '').match(/blur\(\s*([0-9.]+)px\s*\)/)
39-
const b2 = m ? Math.ceil(parseFloat(m[1]) || 0) : 0
51+
// Read the standard `filter`, falling back to `-webkit-filter` when filter is unset, so
52+
// WebKit-only blurs still expand the bleed. Use the standard one alone when present to
53+
// avoid double-counting (browsers often mirror filter into webkitFilter). Sum every
54+
// blur() in the chosen list to cover the rare multi-blur case.
55+
const raw = (cs.filter && cs.filter !== 'none') ? cs.filter : (cs.webkitFilter || '')
56+
const re = /blur\(\s*([0-9.]+)px\s*\)/gi
57+
let total = 0, m
58+
while ((m = re.exec(raw))) total += parseFloat(m[1]) || 0
59+
const b2 = Math.ceil(total)
4060
return { top: b2, right: b2, bottom: b2, left: b2 }
4161
}
4262

@@ -115,15 +135,21 @@ export function normalizeRootTransforms(originalEl, cloneRoot) {
115135

116136
const tr = cs.transform || 'none'
117137
if (!tr || tr === 'none') {
118-
// May still have individual scale; let computed matrix capture it
119-
try {
120-
const M = matrixFromComputed(originalEl)
121-
// If identity, nothing to apply
122-
if ((M.a === 1 && M.b === 0 && M.c === 0 && M.d === 1)) {
123-
cloneRoot.style.transform = 'none'
124-
return { a: 1, b: 0, c: 0, d: 1 }
125-
}
126-
} catch { }
138+
// No `transform`, but the element may still use the individual `scale` property, which
139+
// composes separately from `transform` (matrixFromComputed only reads `transform`, so it
140+
// would miss it and leave the viewBox unscaled, clipping the content). translate/rotate
141+
// were already stripped from the clone above and the clone keeps `scale`, so report the
142+
// scale matrix for bbox expansion — do NOT write it to transform or scale applies twice.
143+
let scaleStr = null
144+
try { scaleStr = readIndividualTransforms(originalEl).scale } catch { }
145+
try { cloneRoot.style.transform = 'none' } catch { }
146+
if (!scaleStr) return { a: 1, b: 0, c: 0, d: 1 }
147+
// `scale` is unitless: "sx" or "sx sy". Parse straight to a diagonal matrix — it does not
148+
// surface in computed `transform`, so a temp-element round-trip would read back identity.
149+
const sv = scaleStr.trim().split(/\s+/).map(parseFloat)
150+
const sx = Number.isFinite(sv[0]) ? sv[0] : 1
151+
const sy = Number.isFinite(sv[1]) ? sv[1] : sx
152+
return { a: sx, b: 0, c: 0, d: sy }
127153
}
128154

129155
// Helper: decompose 2D matrix components (a,b,c,d) into scale+shear without rotation

0 commit comments

Comments
 (0)