@svgedit/svgcanvas 7.2.7 → 7.4.1

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.
@@ -5,7 +5,14 @@
5
5
  */
6
6
 
7
7
  import { convertToNum } from './units.js'
8
- import { getRotationAngle, getBBox, getRefElem } from './utilities.js'
8
+ import { NS } from './namespaces.js'
9
+ import {
10
+ getRotationAngle,
11
+ getBBox,
12
+ getHref,
13
+ getRefElem,
14
+ findDefs
15
+ } from './utilities.js'
9
16
  import { BatchCommand, ChangeElementCommand } from './history.js'
10
17
  import { remapElement } from './coords.js'
11
18
  import {
@@ -36,20 +43,93 @@ export const init = canvas => {
36
43
  * @param {string} attr - The clip-path attribute value containing the clipPath's ID
37
44
  * @param {number} tx - The translation's x value
38
45
  * @param {number} ty - The translation's y value
39
- * @returns {void}
46
+ * @param {Element} elem - The element referencing the clipPath
47
+ * @returns {string|undefined} The clip-path attribute used after updates.
40
48
  */
41
- export const updateClipPath = (attr, tx, ty) => {
49
+ export const updateClipPath = (attr, tx, ty, elem) => {
42
50
  const clipPath = getRefElem(attr)
43
- if (!clipPath) return
44
- const path = clipPath.firstChild
51
+ if (!clipPath) return undefined
52
+ if (elem && clipPath.id) {
53
+ const svgContent = svgCanvas.getSvgContent?.()
54
+ if (svgContent) {
55
+ const refSelector = `[clip-path="url(#${clipPath.id})"]`
56
+ const users = svgContent.querySelectorAll(refSelector)
57
+ if (users.length > 1) {
58
+ const newClipPath = clipPath.cloneNode(true)
59
+ newClipPath.id = svgCanvas.getNextId()
60
+ findDefs().append(newClipPath)
61
+ elem.setAttribute('clip-path', `url(#${newClipPath.id})`)
62
+ return updateClipPath(`url(#${newClipPath.id})`, tx, ty)
63
+ }
64
+ }
65
+ }
66
+ const path = clipPath.firstElementChild
67
+ if (!path) return attr
45
68
  const cpXform = getTransformList(path)
69
+ if (!cpXform) {
70
+ const tag = (path.tagName || '').toLowerCase()
71
+ if (tag === 'rect') {
72
+ const x = convertToNum('x', path.getAttribute('x') || 0) + tx
73
+ const y = convertToNum('y', path.getAttribute('y') || 0) + ty
74
+ path.setAttribute('x', x)
75
+ path.setAttribute('y', y)
76
+ } else if (tag === 'circle' || tag === 'ellipse') {
77
+ const cx = convertToNum('cx', path.getAttribute('cx') || 0) + tx
78
+ const cy = convertToNum('cy', path.getAttribute('cy') || 0) + ty
79
+ path.setAttribute('cx', cx)
80
+ path.setAttribute('cy', cy)
81
+ } else if (tag === 'line') {
82
+ path.setAttribute('x1', convertToNum('x1', path.getAttribute('x1') || 0) + tx)
83
+ path.setAttribute('y1', convertToNum('y1', path.getAttribute('y1') || 0) + ty)
84
+ path.setAttribute('x2', convertToNum('x2', path.getAttribute('x2') || 0) + tx)
85
+ path.setAttribute('y2', convertToNum('y2', path.getAttribute('y2') || 0) + ty)
86
+ } else if (tag === 'polyline' || tag === 'polygon') {
87
+ const points = (path.getAttribute('points') || '').trim()
88
+ if (points) {
89
+ const updated = points.split(/\s+/).map((pair) => {
90
+ const [x, y] = pair.split(',')
91
+ const nx = Number(x) + tx
92
+ const ny = Number(y) + ty
93
+ return `${nx},${ny}`
94
+ })
95
+ path.setAttribute('points', updated.join(' '))
96
+ }
97
+ } else {
98
+ path.setAttribute('transform', `translate(${tx},${ty})`)
99
+ }
100
+ return attr
101
+ }
102
+ if (cpXform.numberOfItems) {
103
+ const translate = svgCanvas.getSvgRoot().createSVGMatrix()
104
+ translate.e = tx
105
+ translate.f = ty
106
+ const combined = matrixMultiply(transformListToTransform(cpXform).matrix, translate)
107
+ const merged = svgCanvas.getSvgRoot().createSVGTransform()
108
+ merged.setMatrix(combined)
109
+ cpXform.clear()
110
+ cpXform.appendItem(merged)
111
+ return attr
112
+ }
113
+ const tag = (path.tagName || '').toLowerCase()
114
+ if ((tag === 'polyline' || tag === 'polygon') && !path.points?.numberOfItems) {
115
+ const points = (path.getAttribute('points') || '').trim()
116
+ if (points) {
117
+ const updated = points.split(/\s+/).map((pair) => {
118
+ const [x, y] = pair.split(',')
119
+ const nx = Number(x) + tx
120
+ const ny = Number(y) + ty
121
+ return `${nx},${ny}`
122
+ })
123
+ path.setAttribute('points', updated.join(' '))
124
+ }
125
+ return
126
+ }
46
127
  const newTranslate = svgCanvas.getSvgRoot().createSVGTransform()
47
128
  newTranslate.setTranslate(tx, ty)
48
129
 
49
130
  cpXform.appendItem(newTranslate)
50
-
51
- // Update clipPath's dimensions
52
131
  recalculateDimensions(path)
132
+ return attr
53
133
  }
54
134
 
55
135
  /**
@@ -60,6 +140,20 @@ export const updateClipPath = (attr, tx, ty) => {
60
140
  */
61
141
  export const recalculateDimensions = selected => {
62
142
  if (!selected) return null
143
+
144
+ // Don't recalculate dimensions for groups - this would push their transforms down to children
145
+ // Groups should maintain their transform attribute on the group element itself
146
+ if (selected.tagName === 'g' || selected.tagName === 'a') {
147
+ return null
148
+ }
149
+
150
+ if (
151
+ (selected.getAttribute?.('clip-path')) &&
152
+ selected.querySelector?.('[clip-path]')
153
+ ) {
154
+ // Keep transforms when clip-paths are present to avoid mutating defs.
155
+ return null
156
+ }
63
157
  const svgroot = svgCanvas.getSvgRoot()
64
158
  const dataStorage = svgCanvas.getDataStorage()
65
159
  const tlist = getTransformList(selected)
@@ -105,6 +199,11 @@ export const recalculateDimensions = selected => {
105
199
  return null
106
200
  }
107
201
 
202
+ // Avoid remapping transforms on <use> to preserve referenced positioning/rotation
203
+ if (selected.tagName === 'use') {
204
+ return null
205
+ }
206
+
108
207
  // Set up undo command
109
208
  const batchCmd = new BatchCommand('Transform')
110
209
 
@@ -206,14 +305,310 @@ export const recalculateDimensions = selected => {
206
305
 
207
306
  // Handle group elements ('g' or 'a')
208
307
  if ((selected.tagName === 'g' && !gsvg) || selected.tagName === 'a') {
209
- // Group handling code
210
- // [Group handling code remains unchanged]
211
- // For brevity, group handling code is not included here
212
- // Ensure to handle group elements correctly as per original logic
213
- // This includes processing child elements and applying transformations appropriately
214
- // ... [Start of group handling code]
215
- // The group handling code is complex and extensive; it remains the same as in the original code.
216
- // ... [End of group handling code]
308
+ const box = getBBox(selected)
309
+
310
+ oldcenter = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
311
+ newcenter = transformPoint(
312
+ box.x + box.width / 2,
313
+ box.y + box.height / 2,
314
+ transformListToTransform(tlist).matrix
315
+ )
316
+
317
+ const gangle = getRotationAngle(selected)
318
+ if (gangle) {
319
+ const a = gangle * Math.PI / 180
320
+ const s = Math.abs(a) > (1.0e-10) ? Math.sin(a) / (1 - Math.cos(a)) : 2 / a
321
+ for (let i = 0; i < tlist.numberOfItems; ++i) {
322
+ const xform = tlist.getItem(i)
323
+ if (xform.type === SVGTransform.SVG_TRANSFORM_ROTATE) {
324
+ const rm = xform.matrix
325
+ oldcenter.y = (s * rm.e + rm.f) / 2
326
+ oldcenter.x = (rm.e - s * rm.f) / 2
327
+ tlist.removeItem(i)
328
+ break
329
+ }
330
+ }
331
+ }
332
+
333
+ const N = tlist.numberOfItems
334
+ let tx = 0
335
+ let ty = 0
336
+ let operation = 0
337
+
338
+ let firstM
339
+ if (N) {
340
+ firstM = tlist.getItem(0).matrix
341
+ }
342
+
343
+ let oldStartTransform
344
+ if (
345
+ N >= 3 &&
346
+ tlist.getItem(N - 2).type === SVGTransform.SVG_TRANSFORM_SCALE &&
347
+ tlist.getItem(N - 3).type === SVGTransform.SVG_TRANSFORM_TRANSLATE &&
348
+ tlist.getItem(N - 1).type === SVGTransform.SVG_TRANSFORM_TRANSLATE
349
+ ) {
350
+ operation = 3 // scale
351
+
352
+ const tm = tlist.getItem(N - 3).matrix
353
+ const sm = tlist.getItem(N - 2).matrix
354
+ const tmn = tlist.getItem(N - 1).matrix
355
+
356
+ const children = selected.childNodes
357
+ let c = children.length
358
+ while (c--) {
359
+ const child = children.item(c)
360
+ if (child.nodeType !== 1) continue
361
+
362
+ const childTlist = getTransformList(child)
363
+ if (!childTlist) continue
364
+
365
+ const m = transformListToTransform(childTlist).matrix
366
+
367
+ const angle = getRotationAngle(child)
368
+ oldStartTransform = svgCanvas.getStartTransform()
369
+ svgCanvas.setStartTransform(child.getAttribute('transform'))
370
+
371
+ if (angle || hasMatrixTransform(childTlist)) {
372
+ const e2t = svgroot.createSVGTransform()
373
+ e2t.setMatrix(matrixMultiply(tm, sm, tmn, m))
374
+ childTlist.clear()
375
+ childTlist.appendItem(e2t)
376
+ } else {
377
+ const t2n = matrixMultiply(m.inverse(), tmn, m)
378
+ const t2 = svgroot.createSVGMatrix()
379
+ t2.e = -t2n.e
380
+ t2.f = -t2n.f
381
+
382
+ const s2 = matrixMultiply(
383
+ t2.inverse(),
384
+ m.inverse(),
385
+ tm,
386
+ sm,
387
+ tmn,
388
+ m,
389
+ t2n.inverse()
390
+ )
391
+
392
+ const translateOrigin = svgroot.createSVGTransform()
393
+ const scale = svgroot.createSVGTransform()
394
+ const translateBack = svgroot.createSVGTransform()
395
+ translateOrigin.setTranslate(t2n.e, t2n.f)
396
+ scale.setScale(s2.a, s2.d)
397
+ translateBack.setTranslate(t2.e, t2.f)
398
+ childTlist.appendItem(translateBack)
399
+ childTlist.appendItem(scale)
400
+ childTlist.appendItem(translateOrigin)
401
+ }
402
+
403
+ const recalculatedDimensions = recalculateDimensions(child)
404
+ if (recalculatedDimensions) {
405
+ batchCmd.addSubCommand(recalculatedDimensions)
406
+ }
407
+ svgCanvas.setStartTransform(oldStartTransform)
408
+ }
409
+
410
+ tlist.removeItem(N - 1)
411
+ tlist.removeItem(N - 2)
412
+ tlist.removeItem(N - 3)
413
+ } else if (N >= 3 && tlist.getItem(N - 1).type === SVGTransform.SVG_TRANSFORM_MATRIX) {
414
+ operation = 3 // scale (matrix imposition)
415
+ const m = transformListToTransform(tlist).matrix
416
+ const e2t = svgroot.createSVGTransform()
417
+ e2t.setMatrix(m)
418
+ tlist.clear()
419
+ tlist.appendItem(e2t)
420
+ } else if (
421
+ (N === 1 ||
422
+ (N > 1 && tlist.getItem(1).type !== SVGTransform.SVG_TRANSFORM_SCALE)) &&
423
+ tlist.getItem(0).type === SVGTransform.SVG_TRANSFORM_TRANSLATE
424
+ ) {
425
+ operation = 2 // translate
426
+ const tM = transformListToTransform(tlist).matrix
427
+ tlist.removeItem(0)
428
+ const mInv = transformListToTransform(tlist).matrix.inverse()
429
+ const m2 = matrixMultiply(mInv, tM)
430
+
431
+ tx = m2.e
432
+ ty = m2.f
433
+
434
+ if (tx !== 0 || ty !== 0) {
435
+ const selectedClipPath = selected.getAttribute?.('clip-path')
436
+ if (selectedClipPath) {
437
+ updateClipPath(selectedClipPath, tx, ty, selected)
438
+ }
439
+
440
+ const children = selected.childNodes
441
+ let c = children.length
442
+
443
+ const clipPathsDone = []
444
+ while (c--) {
445
+ const child = children.item(c)
446
+ if (child.nodeType !== 1) continue
447
+
448
+ const clipPathAttr = child.getAttribute('clip-path')
449
+ if (clipPathAttr && !clipPathsDone.includes(clipPathAttr)) {
450
+ const updatedAttr = updateClipPath(clipPathAttr, tx, ty, child)
451
+ clipPathsDone.push(updatedAttr || clipPathAttr)
452
+ }
453
+
454
+ const childTlist = getTransformList(child)
455
+ if (!childTlist) continue
456
+
457
+ oldStartTransform = svgCanvas.getStartTransform()
458
+ svgCanvas.setStartTransform(child.getAttribute('transform'))
459
+
460
+ const newxlate = svgroot.createSVGTransform()
461
+ newxlate.setTranslate(tx, ty)
462
+ if (childTlist.numberOfItems) {
463
+ childTlist.insertItemBefore(newxlate, 0)
464
+ } else {
465
+ childTlist.appendItem(newxlate)
466
+ }
467
+ const recalculatedDimensions = recalculateDimensions(child)
468
+ if (recalculatedDimensions) {
469
+ batchCmd.addSubCommand(recalculatedDimensions)
470
+ }
471
+
472
+ const uses = selected.getElementsByTagNameNS(NS.SVG, 'use')
473
+ const href = `#${child.id}`
474
+ let u = uses.length
475
+ while (u--) {
476
+ const useElem = uses.item(u)
477
+ if (href === getHref(useElem)) {
478
+ const usexlate = svgroot.createSVGTransform()
479
+ usexlate.setTranslate(-tx, -ty)
480
+ const useTlist = getTransformList(useElem)
481
+ useTlist?.insertItemBefore(usexlate, 0)
482
+ const useRecalc = recalculateDimensions(useElem)
483
+ if (useRecalc) {
484
+ batchCmd.addSubCommand(useRecalc)
485
+ }
486
+ }
487
+ }
488
+
489
+ svgCanvas.setStartTransform(oldStartTransform)
490
+ }
491
+ }
492
+ } else if (
493
+ N === 1 &&
494
+ tlist.getItem(0).type === SVGTransform.SVG_TRANSFORM_MATRIX &&
495
+ !gangle
496
+ ) {
497
+ operation = 1
498
+ const m = tlist.getItem(0).matrix
499
+ const children = selected.childNodes
500
+ let c = children.length
501
+ while (c--) {
502
+ const child = children.item(c)
503
+ if (child.nodeType !== 1) continue
504
+
505
+ const childTlist = getTransformList(child)
506
+ if (!childTlist) continue
507
+
508
+ oldStartTransform = svgCanvas.getStartTransform()
509
+ svgCanvas.setStartTransform(child.getAttribute('transform'))
510
+
511
+ const em = matrixMultiply(m, transformListToTransform(childTlist).matrix)
512
+ const e2m = svgroot.createSVGTransform()
513
+ e2m.setMatrix(em)
514
+ childTlist.clear()
515
+ childTlist.appendItem(e2m)
516
+
517
+ const recalculatedDimensions = recalculateDimensions(child)
518
+ if (recalculatedDimensions) {
519
+ batchCmd.addSubCommand(recalculatedDimensions)
520
+ }
521
+ svgCanvas.setStartTransform(oldStartTransform)
522
+
523
+ const sw = child.getAttribute('stroke-width')
524
+ if (child.getAttribute('stroke') !== 'none' && !Number.isNaN(Number(sw))) {
525
+ const avg = (Math.abs(em.a) + Math.abs(em.d)) / 2
526
+ child.setAttribute('stroke-width', Number(sw) * avg)
527
+ }
528
+ }
529
+ tlist.clear()
530
+ } else {
531
+ if (gangle) {
532
+ const newRot = svgroot.createSVGTransform()
533
+ newRot.setRotate(gangle, newcenter.x, newcenter.y)
534
+ if (tlist.numberOfItems) {
535
+ tlist.insertItemBefore(newRot, 0)
536
+ } else {
537
+ tlist.appendItem(newRot)
538
+ }
539
+ }
540
+ if (tlist.numberOfItems === 0) {
541
+ selected.removeAttribute('transform')
542
+ }
543
+ return null
544
+ }
545
+
546
+ if (operation === 2) {
547
+ if (gangle) {
548
+ newcenter = {
549
+ x: oldcenter.x + firstM.e,
550
+ y: oldcenter.y + firstM.f
551
+ }
552
+
553
+ const newRot = svgroot.createSVGTransform()
554
+ newRot.setRotate(gangle, newcenter.x, newcenter.y)
555
+ if (tlist.numberOfItems) {
556
+ tlist.insertItemBefore(newRot, 0)
557
+ } else {
558
+ tlist.appendItem(newRot)
559
+ }
560
+ }
561
+ } else if (operation === 3) {
562
+ const m = transformListToTransform(tlist).matrix
563
+ const roldt = svgroot.createSVGTransform()
564
+ roldt.setRotate(gangle, oldcenter.x, oldcenter.y)
565
+ const rold = roldt.matrix
566
+ const rnew = svgroot.createSVGTransform()
567
+ rnew.setRotate(gangle, newcenter.x, newcenter.y)
568
+ const rnewInv = rnew.matrix.inverse()
569
+ const mInv = m.inverse()
570
+ const extrat = matrixMultiply(mInv, rnewInv, rold, m)
571
+
572
+ tx = extrat.e
573
+ ty = extrat.f
574
+
575
+ if (tx !== 0 || ty !== 0) {
576
+ const children = selected.childNodes
577
+ let c = children.length
578
+ while (c--) {
579
+ const child = children.item(c)
580
+ if (child.nodeType !== 1) continue
581
+
582
+ const childTlist = getTransformList(child)
583
+ if (!childTlist) continue
584
+
585
+ oldStartTransform = svgCanvas.getStartTransform()
586
+ svgCanvas.setStartTransform(child.getAttribute('transform'))
587
+
588
+ const newxlate = svgroot.createSVGTransform()
589
+ newxlate.setTranslate(tx, ty)
590
+ if (childTlist.numberOfItems) {
591
+ childTlist.insertItemBefore(newxlate, 0)
592
+ } else {
593
+ childTlist.appendItem(newxlate)
594
+ }
595
+
596
+ const recalculatedDimensions = recalculateDimensions(child)
597
+ if (recalculatedDimensions) {
598
+ batchCmd.addSubCommand(recalculatedDimensions)
599
+ }
600
+ svgCanvas.setStartTransform(oldStartTransform)
601
+ }
602
+ }
603
+
604
+ if (gangle) {
605
+ if (tlist.numberOfItems) {
606
+ tlist.insertItemBefore(rnew, 0)
607
+ } else {
608
+ tlist.appendItem(rnew)
609
+ }
610
+ }
611
+ }
217
612
  } else {
218
613
  // Non-group elements
219
614
 
package/core/sanitize.js CHANGED
@@ -7,7 +7,8 @@
7
7
  */
8
8
 
9
9
  import { getReverseNS, NS } from './namespaces.js'
10
- import { getHref, setHref, getUrlFromAttr } from './utilities.js'
10
+ import { getHref, getRefElem, setHref, getUrlFromAttr } from './utilities.js'
11
+ import { warn } from '../common/logger.js'
11
12
 
12
13
  const REVERSE_NS = getReverseNS()
13
14
 
@@ -39,7 +40,11 @@ const svgWhiteList_ = {
39
40
  filter: ['color-interpolation-filters', 'filterRes', 'filterUnits', 'height', 'href', 'primitiveUnits', 'requiredFeatures', 'width', 'x', 'xlink:href', 'y'],
40
41
  foreignObject: ['font-size', 'height', 'opacity', 'requiredFeatures', 'width', 'x', 'y'],
41
42
  g: ['clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'font-family', 'font-size', 'font-style', 'font-weight', 'text-anchor'],
42
- image: ['clip-path', 'clip-rule', 'filter', 'height', 'mask', 'opacity', 'requiredFeatures', 'systemLanguage', 'width', 'x', 'href', 'xlink:href', 'xlink:title', 'y'],
43
+ image: [
44
+ 'clip-path', 'clip-rule', 'filter', 'height', 'mask', 'opacity',
45
+ 'preserveAspectRatio', 'requiredFeatures', 'systemLanguage', 'viewBox',
46
+ 'width', 'x', 'href', 'xlink:href', 'xlink:title', 'y'
47
+ ],
43
48
  line: ['clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'x1', 'x2', 'y1', 'y2'],
44
49
  linearGradient: ['gradientTransform', 'gradientUnits', 'requiredFeatures', 'spreadMethod', 'systemLanguage', 'x1', 'x2', 'href', 'xlink:href', 'y1', 'y2'],
45
50
  marker: ['markerHeight', 'markerUnits', 'markerWidth', 'orient', 'preserveAspectRatio', 'refX', 'refY', 'se_type', 'systemLanguage', 'viewBox'],
@@ -126,22 +131,24 @@ const svgWhiteList_ = {
126
131
  }
127
132
 
128
133
  // add generic attributes to all elements of the whitelist
129
- Object.keys(svgWhiteList_).forEach((element) => { svgWhiteList_[element] = [...svgWhiteList_[element], ...svgGenericWhiteList] })
134
+ for (const [element, attrs] of Object.entries(svgWhiteList_)) {
135
+ svgWhiteList_[element] = [...attrs, ...svgGenericWhiteList]
136
+ }
130
137
 
131
138
  // Produce a Namespace-aware version of svgWhitelist
132
139
  const svgWhiteListNS_ = {}
133
- Object.entries(svgWhiteList_).forEach(([elt, atts]) => {
140
+ for (const [elt, atts] of Object.entries(svgWhiteList_)) {
134
141
  const attNS = {}
135
- Object.entries(atts).forEach(([_i, att]) => {
142
+ for (const att of atts) {
136
143
  if (att.includes(':')) {
137
- const v = att.split(':')
138
- attNS[v[1]] = NS[(v[0]).toUpperCase()]
144
+ const [prefix, localName] = att.split(':')
145
+ attNS[localName] = NS[prefix.toUpperCase()]
139
146
  } else {
140
147
  attNS[att] = att === 'xmlns' ? NS.XMLNS : null
141
148
  }
142
- })
149
+ }
143
150
  svgWhiteListNS_[elt] = attNS
144
- })
151
+ }
145
152
 
146
153
  /**
147
154
  * Sanitizes the input node and its children.
@@ -201,7 +208,7 @@ export const sanitizeSvg = (node) => {
201
208
  const seAttrNS = (attrName.startsWith('se:')) ? NS.SE : ((attrName.startsWith('oi:')) ? NS.OI : null)
202
209
  seAttrs.push([attrName, attr.value, seAttrNS])
203
210
  } else {
204
- console.warn(`sanitizeSvg: attribute ${attrName} in element ${node.nodeName} not in whitelist is removed: ${node.outerHTML}`)
211
+ warn(`attribute ${attrName} in element ${node.nodeName} not in whitelist is removed: ${node.outerHTML}`, null, 'sanitize')
205
212
  node.removeAttributeNS(attrNsURI, attrLocalName)
206
213
  }
207
214
  }
@@ -224,6 +231,13 @@ export const sanitizeSvg = (node) => {
224
231
  }
225
232
  }
226
233
 
234
+ // If legacy xlink:href is present but href is missing, mirror it to href for modern browsers
235
+ const xlinkHref = node.getAttributeNS(NS.XLINK, 'href')
236
+ if (xlinkHref) {
237
+ node.setAttribute('href', xlinkHref)
238
+ node.removeAttributeNS(NS.XLINK, 'href')
239
+ }
240
+
227
241
  Object.values(seAttrs).forEach(([att, val, ns]) => {
228
242
  node.setAttributeNS(ns, att, val)
229
243
  })
@@ -236,17 +250,35 @@ export const sanitizeSvg = (node) => {
236
250
  'radialGradient', 'textPath', 'use'].includes(node.nodeName) && href[0] !== '#') {
237
251
  // remove the attribute (but keep the element)
238
252
  setHref(node, '')
239
- console.warn(`sanitizeSvg: attribute href in element ${node.nodeName} pointing to a non-local reference (${href}) is removed: ${node.outerHTML}`)
253
+ warn(`attribute href in element ${node.nodeName} pointing to a non-local reference (${href}) is removed: ${node.outerHTML}`, null, 'sanitize')
240
254
  node.removeAttributeNS(NS.XLINK, 'href')
241
255
  node.removeAttribute('href')
242
256
  }
243
257
 
244
258
  // Safari crashes on a <use> without a xlink:href, so we just remove the node here
245
259
  if (node.nodeName === 'use' && !getHref(node)) {
246
- console.warn(`sanitizeSvg: element ${node.nodeName} without a xlink:href or href is removed: ${node.outerHTML}`)
260
+ warn(`element ${node.nodeName} without a xlink:href or href is removed: ${node.outerHTML}`, null, 'sanitize')
247
261
  node.remove()
248
262
  return
249
263
  }
264
+ // For <use> elements with missing width/height, derive defaults from referenced viewBox/size for proper sizing/selection
265
+ if (node.nodeName === 'use') {
266
+ const ref = getRefElem(getHref(node))
267
+ if (ref) {
268
+ const refViewBox = ref.getAttribute('viewBox')
269
+ const viewBoxParts = refViewBox ? refViewBox.split(/[\s,]+/).map(Number) : null
270
+ const refWidth = Number(ref.getAttribute('width'))
271
+ const refHeight = Number(ref.getAttribute('height'))
272
+ if (!node.hasAttribute('width')) {
273
+ const width = viewBoxParts?.[2] || refWidth
274
+ if (width) node.setAttribute('width', width)
275
+ }
276
+ if (!node.hasAttribute('height')) {
277
+ const height = viewBoxParts?.[3] || refHeight
278
+ if (height) node.setAttribute('height', height)
279
+ }
280
+ }
281
+ }
250
282
  // if the element has attributes pointing to a non-local reference,
251
283
  // need to remove the attribute
252
284
  ['clip-path', 'fill', 'filter', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke'].forEach((attr) => {
@@ -256,7 +288,7 @@ export const sanitizeSvg = (node) => {
256
288
  // simply check for first character being a '#'
257
289
  if (val && val[0] !== '#') {
258
290
  node.setAttribute(attr, '')
259
- console.warn(`sanitizeSvg: attribute ${attr} in element ${node.nodeName} pointing to a non-local reference (${val}) is removed: ${node.outerHTML}`)
291
+ warn(`attribute ${attr} in element ${node.nodeName} pointing to a non-local reference (${val}) is removed: ${node.outerHTML}`, null, 'sanitize')
260
292
  node.removeAttribute(attr)
261
293
  }
262
294
  }
@@ -269,7 +301,7 @@ export const sanitizeSvg = (node) => {
269
301
  } else {
270
302
  // remove all children from this node and insert them before this node
271
303
  // TODO: in the case of animation elements this will hardly ever be correct
272
- console.warn(`sanitizeSvg: element ${node.nodeName} not supported is removed: ${node.outerHTML}`)
304
+ warn(`element ${node.nodeName} not supported is removed: ${node.outerHTML}`, null, 'sanitize')
273
305
  const children = []
274
306
  while (node.hasChildNodes()) {
275
307
  children.push(parent.insertBefore(node.firstChild, node))