@svgedit/svgcanvas 7.2.7 → 7.4.2

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,9 +7,11 @@
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()
14
+ const FONT_ATTRIBUTES = ['font-family', 'font-size', 'font-stretch', 'font-style', 'font-weight']
13
15
 
14
16
  // Todo: Split out into core attributes, presentation attributes, etc. so consistent
15
17
  /**
@@ -38,8 +40,12 @@ const svgWhiteList_ = {
38
40
  feOffset: ['dx', 'in', 'dy', 'result'],
39
41
  filter: ['color-interpolation-filters', 'filterRes', 'filterUnits', 'height', 'href', 'primitiveUnits', 'requiredFeatures', 'width', 'x', 'xlink:href', 'y'],
40
42
  foreignObject: ['font-size', 'height', 'opacity', 'requiredFeatures', 'width', 'x', 'y'],
41
- 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
+ g: [...FONT_ATTRIBUTES, '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', 'text-anchor'],
44
+ image: [
45
+ 'clip-path', 'clip-rule', 'filter', 'height', 'mask', 'opacity',
46
+ 'preserveAspectRatio', 'requiredFeatures', 'systemLanguage', 'viewBox',
47
+ 'width', 'x', 'href', 'xlink:href', 'xlink:title', 'y'
48
+ ],
43
49
  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
50
  linearGradient: ['gradientTransform', 'gradientUnits', 'requiredFeatures', 'spreadMethod', 'systemLanguage', 'x1', 'x2', 'href', 'xlink:href', 'y1', 'y2'],
45
51
  marker: ['markerHeight', 'markerUnits', 'markerWidth', 'orient', 'preserveAspectRatio', 'refX', 'refY', 'se_type', 'systemLanguage', 'viewBox'],
@@ -55,11 +61,11 @@ const svgWhiteList_ = {
55
61
  style: ['type'],
56
62
  svg: ['clip-path', 'clip-rule', 'enable-background', 'filter', 'height', 'mask', 'preserveAspectRatio', 'requiredFeatures', 'systemLanguage', 'version', 'viewBox', 'width', 'x', 'xmlns', 'xmlns:se', 'xmlns:xlink', 'xmlns:oi', 'oi:animations', 'y', 'stroke-linejoin', 'fill-rule', 'aria-label', 'stroke-width', 'fill-rule', 'xml:space'],
57
63
  switch: ['requiredFeatures', 'systemLanguage'],
58
- symbol: ['fill', 'fill-opacity', 'fill-rule', 'filter', 'font-family', 'font-size', 'font-style', 'font-weight', 'opacity', 'overflow', 'preserveAspectRatio', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'viewBox', 'width', 'height'],
59
- text: ['clip-path', 'clip-rule', 'dominant-baseline', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'font-family', 'font-size', 'font-style', 'font-weight', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'text-anchor', 'letter-spacing', 'word-spacing', 'text-decoration', 'textLength', 'lengthAdjust', 'x', 'xml:space', 'y'],
64
+ symbol: [...FONT_ATTRIBUTES, 'fill', 'fill-opacity', 'fill-rule', 'filter', 'opacity', 'overflow', 'preserveAspectRatio', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'viewBox', 'width', 'height'],
65
+ text: [...FONT_ATTRIBUTES, 'clip-path', 'clip-rule', 'dominant-baseline', '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', 'text-anchor', 'letter-spacing', 'word-spacing', 'text-decoration', 'textLength', 'lengthAdjust', 'x', 'xml:space', 'y'],
60
66
  textPath: ['dominant-baseline', 'href', 'method', 'requiredFeatures', 'spacing', 'startOffset', 'systemLanguage', 'xlink:href'],
61
67
  title: [],
62
- tspan: ['clip-path', 'clip-rule', 'dx', 'dy', 'dominant-baseline', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'font-family', 'font-size', 'font-style', 'font-weight', 'mask', 'opacity', 'requiredFeatures', 'rotate', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'text-anchor', 'textLength', 'x', 'xml:space', 'y'],
68
+ tspan: [...FONT_ATTRIBUTES, 'clip-path', 'clip-rule', 'dx', 'dy', 'dominant-baseline', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'mask', 'opacity', 'requiredFeatures', 'rotate', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'text-anchor', 'textLength', 'x', 'xml:space', 'y'],
63
69
  use: ['clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'height', 'href', 'mask', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'width', 'x', 'xlink:href', 'y', 'overflow'],
64
70
  // Filter Primitives
65
71
  feComponentTransfer: ['in', 'result'],
@@ -126,22 +132,24 @@ const svgWhiteList_ = {
126
132
  }
127
133
 
128
134
  // add generic attributes to all elements of the whitelist
129
- Object.keys(svgWhiteList_).forEach((element) => { svgWhiteList_[element] = [...svgWhiteList_[element], ...svgGenericWhiteList] })
135
+ for (const [element, attrs] of Object.entries(svgWhiteList_)) {
136
+ svgWhiteList_[element] = [...attrs, ...svgGenericWhiteList]
137
+ }
130
138
 
131
139
  // Produce a Namespace-aware version of svgWhitelist
132
140
  const svgWhiteListNS_ = {}
133
- Object.entries(svgWhiteList_).forEach(([elt, atts]) => {
141
+ for (const [elt, atts] of Object.entries(svgWhiteList_)) {
134
142
  const attNS = {}
135
- Object.entries(atts).forEach(([_i, att]) => {
143
+ for (const att of atts) {
136
144
  if (att.includes(':')) {
137
- const v = att.split(':')
138
- attNS[v[1]] = NS[(v[0]).toUpperCase()]
145
+ const [prefix, localName] = att.split(':')
146
+ attNS[localName] = NS[prefix.toUpperCase()]
139
147
  } else {
140
148
  attNS[att] = att === 'xmlns' ? NS.XMLNS : null
141
149
  }
142
- })
150
+ }
143
151
  svgWhiteListNS_[elt] = attNS
144
- })
152
+ }
145
153
 
146
154
  /**
147
155
  * Sanitizes the input node and its children.
@@ -201,7 +209,7 @@ export const sanitizeSvg = (node) => {
201
209
  const seAttrNS = (attrName.startsWith('se:')) ? NS.SE : ((attrName.startsWith('oi:')) ? NS.OI : null)
202
210
  seAttrs.push([attrName, attr.value, seAttrNS])
203
211
  } else {
204
- console.warn(`sanitizeSvg: attribute ${attrName} in element ${node.nodeName} not in whitelist is removed: ${node.outerHTML}`)
212
+ warn(`attribute ${attrName} in element ${node.nodeName} not in whitelist is removed: ${node.outerHTML}`, null, 'sanitize')
205
213
  node.removeAttributeNS(attrNsURI, attrLocalName)
206
214
  }
207
215
  }
@@ -224,6 +232,13 @@ export const sanitizeSvg = (node) => {
224
232
  }
225
233
  }
226
234
 
235
+ // If legacy xlink:href is present but href is missing, mirror it to href for modern browsers
236
+ const xlinkHref = node.getAttributeNS(NS.XLINK, 'href')
237
+ if (xlinkHref) {
238
+ node.setAttribute('href', xlinkHref)
239
+ node.removeAttributeNS(NS.XLINK, 'href')
240
+ }
241
+
227
242
  Object.values(seAttrs).forEach(([att, val, ns]) => {
228
243
  node.setAttributeNS(ns, att, val)
229
244
  })
@@ -236,17 +251,35 @@ export const sanitizeSvg = (node) => {
236
251
  'radialGradient', 'textPath', 'use'].includes(node.nodeName) && href[0] !== '#') {
237
252
  // remove the attribute (but keep the element)
238
253
  setHref(node, '')
239
- console.warn(`sanitizeSvg: attribute href in element ${node.nodeName} pointing to a non-local reference (${href}) is removed: ${node.outerHTML}`)
254
+ warn(`attribute href in element ${node.nodeName} pointing to a non-local reference (${href}) is removed: ${node.outerHTML}`, null, 'sanitize')
240
255
  node.removeAttributeNS(NS.XLINK, 'href')
241
256
  node.removeAttribute('href')
242
257
  }
243
258
 
244
259
  // Safari crashes on a <use> without a xlink:href, so we just remove the node here
245
260
  if (node.nodeName === 'use' && !getHref(node)) {
246
- console.warn(`sanitizeSvg: element ${node.nodeName} without a xlink:href or href is removed: ${node.outerHTML}`)
261
+ warn(`element ${node.nodeName} without a xlink:href or href is removed: ${node.outerHTML}`, null, 'sanitize')
247
262
  node.remove()
248
263
  return
249
264
  }
265
+ // For <use> elements with missing width/height, derive defaults from referenced viewBox/size for proper sizing/selection
266
+ if (node.nodeName === 'use') {
267
+ const ref = getRefElem(getHref(node))
268
+ if (ref) {
269
+ const refViewBox = ref.getAttribute('viewBox')
270
+ const viewBoxParts = refViewBox ? refViewBox.split(/[\s,]+/).map(Number) : null
271
+ const refWidth = Number(ref.getAttribute('width'))
272
+ const refHeight = Number(ref.getAttribute('height'))
273
+ if (!node.hasAttribute('width')) {
274
+ const width = viewBoxParts?.[2] || refWidth
275
+ if (width) node.setAttribute('width', width)
276
+ }
277
+ if (!node.hasAttribute('height')) {
278
+ const height = viewBoxParts?.[3] || refHeight
279
+ if (height) node.setAttribute('height', height)
280
+ }
281
+ }
282
+ }
250
283
  // if the element has attributes pointing to a non-local reference,
251
284
  // need to remove the attribute
252
285
  ['clip-path', 'fill', 'filter', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke'].forEach((attr) => {
@@ -256,7 +289,7 @@ export const sanitizeSvg = (node) => {
256
289
  // simply check for first character being a '#'
257
290
  if (val && val[0] !== '#') {
258
291
  node.setAttribute(attr, '')
259
- console.warn(`sanitizeSvg: attribute ${attr} in element ${node.nodeName} pointing to a non-local reference (${val}) is removed: ${node.outerHTML}`)
292
+ warn(`attribute ${attr} in element ${node.nodeName} pointing to a non-local reference (${val}) is removed: ${node.outerHTML}`, null, 'sanitize')
260
293
  node.removeAttribute(attr)
261
294
  }
262
295
  }
@@ -269,7 +302,7 @@ export const sanitizeSvg = (node) => {
269
302
  } else {
270
303
  // remove all children from this node and insert them before this node
271
304
  // 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}`)
305
+ warn(`element ${node.nodeName} not supported is removed: ${node.outerHTML}`, null, 'sanitize')
273
306
  const children = []
274
307
  while (node.hasChildNodes()) {
275
308
  children.push(parent.insertBefore(node.firstChild, node))