@@ -11,6 +11,27 @@ import { embedCustomFonts, collectUsedFontVariants, collectUsedCodepoints, ensur
1111import { cache , applyCachePolicy } from '../core/cache.js'
1212import { 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 ( / \b b l u r \( [ ^ ( ) ] * \) \s * / gi, '' )
29+ . replace ( / \b d r o p - s h a d o w \( [ ^ ( ) ] * \) \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-
2763export 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 ( / d r o p - s h a d o w \( (?: [ ^ ( ) ] | \( [ ^ ( ) ] * \) ) * \) / 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 + ) ? p x / 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 ( / ^ m a t r i x \( \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+
250382function parseBoxShadow ( cs ) {
251383 const v = cs . boxShadow || ''
252384 if ( ! v || v === 'none' ) return { top : 0 , right : 0 , bottom : 0 , left : 0 }
0 commit comments