Skip to content

Commit 8c9a75f

Browse files
committed
Add two new options to control transforms and shadows on root element
1 parent 34158e0 commit 8c9a75f

3 files changed

Lines changed: 214 additions & 61 deletions

File tree

src/core/capture.js

Lines changed: 187 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,27 @@ import { embedCustomFonts, collectUsedFontVariants, collectUsedCodepoints, ensur
1111
import { cache, applyCachePolicy } from '../core/cache.js'
1212
import { lineClamp } from '../modules/lineClamp.js'
1313

14+
/**
15+
* Strip shadow-like visuals on the CLONE ROOT ONLY (box/text-shadow, outline, blur()/drop-shadow()).
16+
* Children remain intact.
17+
* @param {Element} originalEl
18+
* @param {HTMLElement} cloneRoot
19+
*/
20+
function stripRootShadows(originalEl, cloneRoot) {
21+
if (!originalEl || !cloneRoot || !cloneRoot.style) return
22+
const cs = getComputedStyle(originalEl)
23+
try { cloneRoot.style.boxShadow = 'none' } catch {}
24+
try { cloneRoot.style.textShadow = 'none' } catch {}
25+
try { cloneRoot.style.outline = 'none' } catch {}
26+
const f = cs.filter || ''
27+
const cleaned = f
28+
.replace(/\bblur\([^()]*\)\s*/gi, '')
29+
.replace(/\bdrop-shadow\([^()]*\)\s*/gi, '')
30+
.trim()
31+
.replace(/\s+/g, ' ')
32+
try { cloneRoot.style.filter = cleaned.length ? cleaned : 'none' } catch {}
33+
}
34+
1435
/**
1536
* Captures an HTML element as an SVG data URL, inlining styles, images, backgrounds, and optionally fonts.
1637
*
@@ -21,36 +42,68 @@ import { lineClamp } from '../modules/lineClamp.js'
2142
* @param {number} [options.scale=1] - Output scale multiplier
2243
* @param {string[]} [options.exclude] - CSS selectors for elements to exclude
2344
* @param {Function} [options.filter] - Custom filter function
45+
* @param {boolean} [options.straighten=false] - Normalize root by removing translate*rotate* (keep scale/skew)
46+
* @param {boolean} [options.noShadows=false] - Do not expand bleed for shadows/blur/outline on root (and strip root shadows visually)
47+
* @returns {Promise<string>} Promise that resolves to an SVG data URL
48+
*/
49+
/**
50+
* Captures an HTML element as an SVG data URL, inlining styles, images, backgrounds, and optionally fonts.
51+
*
52+
* @param {Element} element - DOM element to capture
53+
* @param {Object} [options={}] - Capture options
54+
* @param {boolean} [options.embedFonts=false] - Whether to embed custom fonts
55+
* @param {boolean} [options.fast=true] - Whether to skip idle delay for faster results
56+
* @param {number} [options.scale=1] - Output scale multiplier
57+
* @param {string[]} [options.exclude] - CSS selectors for elements to exclude
58+
* @param {Function} [options.filter] - Custom filter function
59+
* @param {boolean} [options.straighten=false] - Normalize root by removing translate/rotate (keep scale/skew)
60+
* @param {boolean} [options.noShadows=false] - Do not expand bleed for shadows/blur/outline on root (and strip root shadows visually)
2461
* @returns {Promise<string>} Promise that resolves to an SVG data URL
2562
*/
26-
2763
export async function captureDOM(element, options) {
2864
if (!element) throw new Error('Element cannot be null or undefined')
2965
applyCachePolicy(options.cache)
3066
const fast = options.fast
67+
const straighten = !!options.straighten
68+
const noShadows = !!options.noShadows
69+
3170
let clone, classCSS, styleCache
3271
let fontsCSS = ''
3372
let baseCSS = ''
3473
let dataURL
3574
let svgString
75+
// NEW: store root transform (scale/skew) when straighten is on
76+
let rootTransform2D = null
77+
3678
const undoClamp = lineClamp(element)
3779
try {
3880
({ clone, classCSS, styleCache } = await prepareClone(element, options))
81+
82+
// ——— apply flags ONLY on the cloned root ———
83+
if (straighten && clone) {
84+
rootTransform2D = normalizeRootTransforms(element, clone) // {a,b,c,d} or null
85+
}
86+
if (noShadows && clone) {
87+
stripRootShadows(element, clone)
88+
}
3989
} finally {
4090
undoClamp()
4191
}
92+
4293
await new Promise((resolve) => {
4394
idle(async () => {
4495
await inlineImages(clone, options)
4596
resolve()
4697
}, { fast })
4798
})
99+
48100
await new Promise((resolve) => {
49101
idle(async () => {
50102
await inlineBackgroundImages(element, clone, styleCache, options)
51103
resolve()
52104
}, { fast })
53105
})
106+
54107
if (options.embedFonts) {
55108
await new Promise((resolve) => {
56109
idle(async () => {
@@ -60,7 +113,7 @@ export async function captureDOM(element, options) {
60113
const families = new Set(
61114
Array.from(required).map((k) => String(k).split('__')[0]).filter(Boolean)
62115
)
63-
await ensureFontsReady(families, 2)
116+
await ensureFontsReady(families, 1)
64117
}
65118
fontsCSS = await embedCustomFonts({
66119
required,
@@ -73,6 +126,7 @@ export async function captureDOM(element, options) {
73126
}, { fast })
74127
})
75128
}
129+
76130
const usedTags = collectUsedTagNames(clone).sort()
77131
const tagKey = usedTags.join(',')
78132
if (cache.baseStyle.has(tagKey)) {
@@ -86,24 +140,20 @@ export async function captureDOM(element, options) {
86140
}, { fast })
87141
})
88142
}
143+
89144
await new Promise((resolve) => {
90145
idle(() => {
91146
const csEl = getComputedStyle(element)
147+
92148
function parseFilterDropShadows(cs) {
93-
// Soporta 'filter' y '-webkit-filter'; puede haber múltiples drop-shadow()
94149
const raw = `${cs.filter || ''} ${cs.webkitFilter || ''}`.trim()
95150
if (!raw || raw === 'none') {
96151
return { bleed: { top: 0, right: 0, bottom: 0, left: 0 }, has: false }
97152
}
98-
99-
// Captura tokens drop-shadow(...) tolerando paréntesis en colores (rgb(a), hsl(a))
100153
const tokens = raw.match(/drop-shadow\((?:[^()]|\([^()]*\))*\)/gi) || []
101-
let t = 0, r = 0, b = 0, l = 0
102-
let found = false
103-
154+
let t = 0, r = 0, b = 0, l = 0; let found = false
104155
for (const tok of tokens) {
105156
found = true
106-
// Extrae offsets/blur en px (ox oy [blur]); 'spread' no existe en drop-shadow()
107157
const nums = tok.match(/-?\d+(?:\.\d+)?px/gi)?.map(v => parseFloat(v)) || []
108158
const [ox = 0, oy = 0, blur = 0] = nums
109159
const extX = Math.abs(ox) + blur
@@ -113,16 +163,13 @@ export async function captureDOM(element, options) {
113163
b = Math.max(b, extY + Math.max(oy, 0))
114164
t = Math.max(t, extY + Math.max(-oy, 0))
115165
}
116-
117-
return {
118-
bleed: { top: Math.ceil(t), right: Math.ceil(r), bottom: Math.ceil(b), left: Math.ceil(l) },
119-
has: found
120-
}
166+
return { bleed: { top: Math.ceil(t), right: Math.ceil(r), bottom: Math.ceil(b), left: Math.ceil(l) }, has: found }
121167
}
122168

123169
const rect = element.getBoundingClientRect()
124170
const w0 = Math.max(1, Math.ceil(element.offsetWidth || parseFloat(csEl.width) || rect.width || 1))
125171
const h0 = Math.max(1, Math.ceil(element.offsetHeight || parseFloat(csEl.height) || rect.height || 1))
172+
126173
const coerceNum = (v, def = NaN) => {
127174
const n = typeof v === 'string' ? parseFloat(v) : v
128175
return Number.isFinite(n) ? n : def
@@ -148,36 +195,53 @@ export async function captureDOM(element, options) {
148195
h = h0
149196
}
150197

198+
// ——— BBOX ———
151199
let minX = 0, minY = 0, maxX = w0, maxY = h0
152-
if (hasTFBBox(element)) {
153-
const baseTransform2 = csEl.transform && csEl.transform !== 'none' ? csEl.transform : ''
154-
const ind2 = readIndividualTransforms(element)
155-
const TOTAL = readTotalTransformMatrix({
156-
baseTransform: baseTransform2,
157-
rotate: ind2.rotate || '0deg',
158-
scale: ind2.scale,
159-
translate: ind2.translate
160-
})
161-
const { ox: ox2, oy: oy2 } = parseTransformOriginPx(csEl, w0, h0)
162-
const M = TOTAL.is2D ? TOTAL : new DOMMatrix(TOTAL.toString())
163-
const bb = bboxWithOriginFull(w0, h0, M, ox2, oy2)
164-
minX = bb.minX
165-
minY = bb.minY
166-
maxX = bb.maxX
167-
maxY = bb.maxY
200+
201+
// NEW: if straighten => expand bbox using the post-normalization 2D matrix
202+
if (straighten && rootTransform2D && Number.isFinite(rootTransform2D.a)) {
203+
const M2 = { a: rootTransform2D.a, b: rootTransform2D.b || 0, c: rootTransform2D.c || 0, d: rootTransform2D.d || 1, e: 0, f: 0 }
204+
const bb2 = bboxWithOriginFull(w0, h0, M2, 0, 0) // anchored at 0,0 (transformOrigin forced to 0 0)
205+
minX = bb2.minX
206+
minY = bb2.minY
207+
maxX = bb2.maxX
208+
maxY = bb2.maxY
209+
} else {
210+
// Classic path (only when NOT straighten): bbox-aware to transforms
211+
const useTFBBox = !straighten && hasTFBBox(element)
212+
if (useTFBBox) {
213+
const baseTransform2 = csEl.transform && csEl.transform !== 'none' ? csEl.transform : ''
214+
const ind2 = readIndividualTransforms(element)
215+
const TOTAL = readTotalTransformMatrix({
216+
baseTransform: baseTransform2,
217+
rotate: ind2.rotate || '0deg',
218+
scale: ind2.scale,
219+
translate: ind2.translate
220+
})
221+
const { ox: ox2, oy: oy2 } = parseTransformOriginPx(csEl, w0, h0)
222+
const M = TOTAL.is2D ? TOTAL : new DOMMatrix(TOTAL.toString())
223+
const bb = bboxWithOriginFull(w0, h0, M, ox2, oy2)
224+
minX = bb.minX
225+
minY = bb.minY
226+
maxX = bb.maxX
227+
maxY = bb.maxY
228+
}
168229
}
230+
231+
// ——— BLEED ———
169232
const bleedShadow = parseBoxShadow(csEl)
170233
const bleedBlur = parseFilterBlur(csEl)
171234
const bleedOutline = parseOutline(csEl)
172235
const drop = parseFilterDropShadows(csEl)
173236

174-
// Suma a los bleeds
175-
const bleed = {
176-
top: bleedShadow.top + bleedBlur.top + bleedOutline.top + drop.bleed.top,
177-
right: bleedShadow.right + bleedBlur.right + bleedOutline.right + drop.bleed.right,
178-
bottom: bleedShadow.bottom + bleedBlur.bottom + bleedOutline.bottom + drop.bleed.bottom,
179-
left: bleedShadow.left + bleedBlur.left + bleedOutline.left + drop.bleed.left
180-
}
237+
const bleed = (noShadows)
238+
? { top: 0, right: 0, bottom: 0, left: 0 }
239+
: {
240+
top: bleedShadow.top + bleedBlur.top + bleedOutline.top + drop.bleed.top,
241+
right: bleedShadow.right + bleedBlur.right + bleedOutline.right + drop.bleed.right,
242+
bottom: bleedShadow.bottom + bleedBlur.bottom + bleedOutline.bottom + drop.bleed.bottom,
243+
left: bleedShadow.left + bleedBlur.left + bleedOutline.left + drop.bleed.left
244+
}
181245

182246
minX -= bleed.left
183247
minY -= bleed.top
@@ -188,8 +252,12 @@ export async function captureDOM(element, options) {
188252
const vbH0 = Math.max(1, Math.ceil(maxY - minY))
189253
const outW = Math.max(1, Math.round(vbW0 * (hasW || hasH ? w / w0 : 1)))
190254
const outH = Math.max(1, Math.round(vbH0 * (hasH || hasW ? h / h0 : 1)))
255+
191256
const svgNS = 'http://www.w3.org/2000/svg'
192-
const pad = isSafari() ? 1 : 0
257+
const basePad = isSafari() ? 1 : 0
258+
const extraPad = straighten ? 1 : 0 // NEW: tiny safety margin for rounding
259+
const pad = basePad + extraPad
260+
193261
const fo = document.createElementNS(svgNS, 'foreignObject')
194262
const vbMinX = Math.floor(minX)
195263
const vbMinY = Math.floor(minY)
@@ -198,55 +266,119 @@ export async function captureDOM(element, options) {
198266
fo.setAttribute('width', String(Math.ceil(w0 + pad * 2)))
199267
fo.setAttribute('height', String(Math.ceil(h0 + pad * 2)))
200268
fo.style.overflow = 'visible'
269+
201270
const styleTag = document.createElement('style')
202271
styleTag.textContent = baseCSS + fontsCSS + 'svg{overflow:visible;} foreignObject{overflow:visible;}' + classCSS
203272
fo.appendChild(styleTag)
273+
204274
const container = document.createElement('div')
205275
container.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')
206276
container.style.width = `${w0}px`
207277
container.style.height = `${h0}px`
208278
container.style.overflow = 'visible'
279+
209280
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')
210281
container.appendChild(clone)
211-
212282
fo.appendChild(container)
213283

214284
const serializer = new XMLSerializer()
215285
const foString = serializer.serializeToString(fo)
216286
const vbW = vbW0 + pad * 2
217287
const vbH = vbH0 + pad * 2
218-
219288
const wantsSize = hasW || hasH
220289

221-
// Guardar todo en un bloque meta
222-
options.meta = {
223-
w0, // ancho natural del elemento
224-
h0, // alto natural
225-
vbW, // ancho del viewBox
226-
vbH, // alto del viewBox
227-
targetW: w, // ancho deseado según options.width
228-
targetH: h // alto deseado según options.height
229-
}
290+
options.meta = { w0, h0, vbW, vbH, targetW: w, targetH: h }
230291

231-
// SVG header: si es Safari + width/height => mantener natural
232292
const svgOutW = (isSafari() && wantsSize) ? vbW : (outW + pad * 2)
233293
const svgOutH = (isSafari() && wantsSize) ? vbH : (outH + pad * 2)
234-
235-
const svgHeader =
236-
`<svg xmlns="${svgNS}" width="${svgOutW}" height="${svgOutH}" viewBox="0 0 ${vbW} ${vbH}">`
237-
238-
// const svgHeader = `<svg xmlns="${svgNS}" width="${outW + pad * 2}" height="${outH + pad * 2}" viewBox="0 0 ${vbW} ${vbH}">`;
294+
const svgHeader = `<svg xmlns="${svgNS}" width="${svgOutW}" height="${svgOutH}" viewBox="0 0 ${vbW} ${vbH}">`
239295
const svgFooter = '</svg>'
240296
svgString = svgHeader + foString + svgFooter
241297
dataURL = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`
242298
resolve()
243299
}, { fast })
244300
})
301+
245302
const sandbox = document.getElementById('snapdom-sandbox')
246303
if (sandbox && sandbox.style.position === 'absolute') sandbox.remove()
247304
return dataURL
248305
}
249306

307+
/**
308+
* Remove only translate/rotate from CLONE ROOT transform, keeping scale/skew.
309+
* Also forces transformOrigin to 0 0 to avoid negative offsets.
310+
* Returns the applied 2D matrix components so the caller can expand the viewBox accordingly.
311+
*
312+
* @param {Element} originalEl
313+
* @param {HTMLElement} cloneRoot
314+
* @returns {{a:number,b:number,c:number,d:number}|null} The 2D matrix (without translation) or null if not applicable.
315+
*/
316+
function normalizeRootTransforms(originalEl, cloneRoot) {
317+
if (!originalEl || !cloneRoot || !cloneRoot.style) return null
318+
const cs = getComputedStyle(originalEl)
319+
320+
// Always anchor at top-left so scale/skew doesn't push content into negative coords
321+
try { cloneRoot.style.transformOrigin = '0 0' } catch {}
322+
323+
// Try individual properties first (no-op safe)
324+
try {
325+
if ('translate' in cloneRoot.style) cloneRoot.style.translate = 'none'
326+
if ('rotate' in cloneRoot.style) cloneRoot.style.rotate = 'none'
327+
// do NOT touch 'scale'
328+
} catch {}
329+
330+
const tr = cs.transform || 'none'
331+
if (!tr || tr === 'none') {
332+
// May still have individual scale; let computed matrix capture it
333+
try {
334+
const M = matrixFromComputed(originalEl)
335+
// If identity, nothing to apply
336+
if ((M.a === 1 && M.b === 0 && M.c === 0 && M.d === 1)) {
337+
cloneRoot.style.transform = 'none'
338+
return { a: 1, b: 0, c: 0, d: 1 }
339+
}
340+
} catch {}
341+
}
342+
343+
// Composite path: decompose 2D; keep scale/skew, drop translate (e,f) and rotation
344+
const m2d = tr.match(/^matrix\(\s*([^)]+)\)$/i)
345+
if (m2d) {
346+
const nums = m2d[1].split(',').map(v => parseFloat(v.trim()))
347+
if (nums.length === 6 && nums.every(Number.isFinite)) {
348+
const [a, b, c, d] = nums // ignore e,f
349+
// Decompose to isolate scale + shear, remove rotation:
350+
const scaleX = Math.sqrt(a * a + b * b) || 0
351+
let a1 = 0, b1 = 0, shear = 0, c2 = 0, d2 = 0, scaleY = 0
352+
if (scaleX > 0) {
353+
a1 = a / scaleX
354+
b1 = b / scaleX
355+
shear = a1 * c + b1 * d
356+
c2 = c - a1 * shear
357+
d2 = d - b1 * shear
358+
scaleY = Math.sqrt(c2 * c2 + d2 * d2) || 0
359+
if (scaleY > 0) shear = shear / scaleY
360+
else shear = 0
361+
}
362+
const aP = scaleX
363+
const bP = 0 // rotation removed
364+
const cP = shear * scaleY // 2D shear component
365+
const dP = scaleY
366+
try { cloneRoot.style.transform = `matrix(${aP}, ${bP}, ${cP}, ${dP}, 0, 0)` } catch {}
367+
return { a: aP, b: bP, c: cP, d: dP }
368+
}
369+
}
370+
371+
// 3D or unknown: best-effort — neutralize move/rotate at the end
372+
try {
373+
const legacy = String(tr).trim()
374+
cloneRoot.style.transform = legacy + ' translate(0px, 0px) rotate(0deg)'
375+
// We cannot reliably derive pure 2D here; return null to skip bbox expansion
376+
return null
377+
} catch {
378+
return null
379+
}
380+
}
381+
250382
function parseBoxShadow(cs) {
251383
const v = cs.boxShadow || ''
252384
if (!v || v === 'none') return { top: 0, right: 0, bottom: 0, left: 0 }

0 commit comments

Comments
 (0)