@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.
- package/common/browser.js +104 -37
- package/common/logger.js +151 -0
- package/common/util.js +96 -155
- package/core/blur-event.js +106 -42
- package/core/clear.js +13 -3
- package/core/coords.js +214 -90
- package/core/copy-elem.js +27 -13
- package/core/dataStorage.js +84 -21
- package/core/draw.js +80 -40
- package/core/elem-get-set.js +247 -115
- package/core/event.js +171 -29
- package/core/history.js +105 -50
- package/core/historyrecording.js +4 -2
- package/core/json.js +54 -12
- package/core/layer.js +11 -17
- package/core/math.js +102 -23
- package/core/namespaces.js +5 -5
- package/core/paint.js +95 -24
- package/core/paste-elem.js +58 -19
- package/core/path-actions.js +812 -791
- package/core/path-method.js +236 -37
- package/core/path.js +45 -10
- package/core/recalculate.js +410 -15
- package/core/sanitize.js +51 -18
- package/core/select.js +44 -20
- package/core/selected-elem.js +159 -38
- package/core/selection.js +16 -6
- package/core/svg-exec.js +99 -27
- package/core/svgroot.js +1 -1
- package/core/text-actions.js +363 -307
- package/core/undo.js +47 -15
- package/core/units.js +8 -6
- package/core/utilities.js +282 -170
- package/dist/svgcanvas.js +40021 -55172
- package/dist/svgcanvas.js.map +1 -1
- package/package.json +55 -54
- package/publish.md +1 -6
- package/svgcanvas.d.ts +225 -0
- package/svgcanvas.js +11 -9
- package/vite.config.mjs +20 -0
- package/rollup.config.mjs +0 -38
package/core/recalculate.js
CHANGED
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { convertToNum } from './units.js'
|
|
8
|
-
import {
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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', '
|
|
42
|
-
image: [
|
|
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', '
|
|
59
|
-
text: ['clip-path', 'clip-rule', 'dominant-baseline', 'fill', 'fill-opacity', 'fill-rule', 'filter', '
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
141
|
+
for (const [elt, atts] of Object.entries(svgWhiteList_)) {
|
|
134
142
|
const attNS = {}
|
|
135
|
-
|
|
143
|
+
for (const att of atts) {
|
|
136
144
|
if (att.includes(':')) {
|
|
137
|
-
const
|
|
138
|
-
attNS[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|