svgedit
Version:
Powerful SVG-Editor for your browser
349 lines (314 loc) • 11.4 kB
JavaScript
import { AssertionError, strict as assert } from 'node:assert'
// Provide a global assert (some legacy tests expect it).
globalThis.assert = assert
// Add a lightweight closeTo helper to mimic chai.assert.closeTo.
assert.closeTo = function (actual, expected, delta, message) {
const ok = Math.abs(actual - expected) <= delta
if (!ok) {
throw new AssertionError({
message: message || `expected ${actual} to be within ${delta} of ${expected}`,
actual,
expected
})
}
}
// Mocha-style aliases expected by legacy tests.
globalThis.before = globalThis.beforeAll
globalThis.after = globalThis.afterAll
// JSDOM lacks many SVG APIs; provide minimal stubs used in tests.
const win = globalThis.window || globalThis
// Simple SVG matrix/transform/point polyfills good enough for unit tests.
class SVGMatrixPolyfill {
constructor (a = 1, b = 0, c = 0, d = 1, e = 0, f = 0) {
this.a = a; this.b = b; this.c = c; this.d = d; this.e = e; this.f = f
}
multiply (m) {
return new SVGMatrixPolyfill(
this.a * m.a + this.c * m.b,
this.b * m.a + this.d * m.b,
this.a * m.c + this.c * m.d,
this.b * m.c + this.d * m.d,
this.a * m.e + this.c * m.f + this.e,
this.b * m.e + this.d * m.f + this.f
)
}
translate (x, y) { return this.multiply(new SVGMatrixPolyfill(1, 0, 0, 1, x, y)) }
scale (s) { return this.multiply(new SVGMatrixPolyfill(s, 0, 0, s, 0, 0)) }
scaleNonUniform (sx, sy) { return this.multiply(new SVGMatrixPolyfill(sx, 0, 0, sy, 0, 0)) }
rotate (deg) {
const rad = deg * Math.PI / 180
const cos = Math.cos(rad)
const sin = Math.sin(rad)
return this.multiply(new SVGMatrixPolyfill(cos, sin, -sin, cos, 0, 0))
}
flipX () { return this.scale(-1, 1) }
flipY () { return this.scale(1, -1) }
skewX (deg) {
const rad = deg * Math.PI / 180
return this.multiply(new SVGMatrixPolyfill(1, 0, Math.tan(rad), 1, 0, 0))
}
skewY (deg) {
const rad = deg * Math.PI / 180
return this.multiply(new SVGMatrixPolyfill(1, Math.tan(rad), 0, 1, 0, 0))
}
inverse () {
const det = this.a * this.d - this.b * this.c
if (!det) return new SVGMatrixPolyfill()
return new SVGMatrixPolyfill(
this.d / det,
-this.b / det,
-this.c / det,
this.a / det,
(this.c * this.f - this.d * this.e) / det,
(this.b * this.e - this.a * this.f) / det
)
}
}
class SVGTransformPolyfill {
constructor (type = SVGTransformPolyfill.SVG_TRANSFORM_MATRIX, matrix = new SVGMatrixPolyfill()) {
this.type = type
this.matrix = matrix
}
setMatrix (matrix) {
this.type = SVGTransformPolyfill.SVG_TRANSFORM_MATRIX
this.matrix = matrix
}
setTranslate (x, y) {
this.type = SVGTransformPolyfill.SVG_TRANSFORM_TRANSLATE
this.matrix = new SVGMatrixPolyfill(1, 0, 0, 1, x, y)
}
setScale (sx, sy = sx) {
this.type = SVGTransformPolyfill.SVG_TRANSFORM_SCALE
this.matrix = new SVGMatrixPolyfill(sx, 0, 0, sy, 0, 0)
}
setRotate (angle, cx = 0, cy = 0) {
// Translate to center, rotate, then translate back.
const ang = Number(angle) || 0
const cxNum = Number(cx) || 0
const cyNum = Number(cy) || 0
const rotate = new SVGMatrixPolyfill().translate(cxNum, cyNum).rotate(ang).translate(-cxNum, -cyNum)
this.type = SVGTransformPolyfill.SVG_TRANSFORM_ROTATE
this.angle = ang
this.cx = cxNum
this.cy = cyNum
this.matrix = rotate
}
}
SVGTransformPolyfill.SVG_TRANSFORM_UNKNOWN = 0
SVGTransformPolyfill.SVG_TRANSFORM_MATRIX = 1
SVGTransformPolyfill.SVG_TRANSFORM_TRANSLATE = 2
SVGTransformPolyfill.SVG_TRANSFORM_SCALE = 3
SVGTransformPolyfill.SVG_TRANSFORM_ROTATE = 4
SVGTransformPolyfill.SVG_TRANSFORM_SKEWX = 5
SVGTransformPolyfill.SVG_TRANSFORM_SKEWY = 6
class SVGTransformListPolyfill {
constructor () {
this._items = []
}
get numberOfItems () { return this._items.length }
getItem (i) { return this._items[i] }
appendItem (item) { this._items.push(item); return item }
insertItemBefore (item, index) {
const idx = Math.max(0, Math.min(index, this._items.length))
this._items.splice(idx, 0, item)
return item
}
removeItem (index) {
if (index < 0 || index >= this._items.length) return undefined
const [removed] = this._items.splice(index, 1)
return removed
}
clear () { this._items = [] }
initialize (item) { this._items = [item]; return item }
consolidate () {
if (!this._items.length) return null
const matrix = this._items.reduce(
(acc, t) => acc.multiply(t.matrix),
new SVGMatrixPolyfill()
)
const consolidated = new SVGTransformPolyfill()
consolidated.setMatrix(matrix)
this._items = [consolidated]
return consolidated
}
}
const parseTransformAttr = (attr) => {
const list = new SVGTransformListPolyfill()
if (!attr) return list
const matcher = /([a-zA-Z]+)\(([^)]+)\)/g
let match
while ((match = matcher.exec(attr))) {
const [, type, raw] = match
const nums = raw.split(/[,\s]+/).filter(Boolean).map(Number)
const t = new SVGTransformPolyfill()
switch (type) {
case 'matrix':
t.setMatrix(new SVGMatrixPolyfill(...nums))
break
case 'translate':
t.setTranslate(nums[0] ?? 0, nums[1] ?? 0)
break
case 'scale':
t.setScale(nums[0] ?? 1, nums[1] ?? nums[0] ?? 1)
break
case 'rotate':
t.setRotate(nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0)
break
default:
t.setMatrix(new SVGMatrixPolyfill())
break
}
list.appendItem(t)
}
return list
}
const ensureTransformList = (elem) => {
if (!elem.__transformList) {
const parsed = parseTransformAttr(elem.getAttribute?.('transform'))
elem.__transformList = parsed
}
return elem.__transformList
}
if (!win.SVGElement) {
win.SVGElement = win.Element
}
const svgElementProto = win.SVGElement?.prototype
// Basic constructors for missing SVG types.
if (!win.SVGSVGElement) win.SVGSVGElement = win.SVGElement
if (!win.SVGGraphicsElement) win.SVGGraphicsElement = win.SVGElement
if (!win.SVGGeometryElement) win.SVGGeometryElement = win.SVGElement
// Ensure SVGPathElement exists so the pathseg polyfill can patch it.
win.SVGPathElement = win.SVGElement || function SVGPathElement () {}
// Matrix/transform helpers.
win.SVGMatrix = win.SVGMatrix || SVGMatrixPolyfill
win.DOMMatrix = win.DOMMatrix || SVGMatrixPolyfill
win.SVGTransform = win.SVGTransform || SVGTransformPolyfill
win.SVGTransformList = win.SVGTransformList || SVGTransformListPolyfill
if (svgElementProto) {
if (!svgElementProto.createSVGMatrix) {
svgElementProto.createSVGMatrix = () => new SVGMatrixPolyfill()
}
if (!svgElementProto.createSVGTransform) {
svgElementProto.createSVGTransform = () => new SVGTransformPolyfill()
}
if (!svgElementProto.createSVGTransformFromMatrix) {
svgElementProto.createSVGTransformFromMatrix = (matrix) => {
const t = new SVGTransformPolyfill()
t.setMatrix(matrix)
return t
}
}
if (!svgElementProto.createSVGPoint) {
svgElementProto.createSVGPoint = () => ({
x: 0,
y: 0,
matrixTransform (m) {
return {
x: m.a * this.x + m.c * this.y + m.e,
y: m.b * this.x + m.d * this.y + m.f
}
}
})
}
svgElementProto.getBBox = function () {
const tag = (this.tagName || '').toLowerCase()
const parseLength = (attr, fallback = 0) => {
const raw = this.getAttribute?.(attr)
if (raw == null) return fallback
const str = String(raw)
const n = Number.parseFloat(str)
if (Number.isNaN(n)) return fallback
if (str.endsWith('in')) return n * 96
if (str.endsWith('cm')) return n * 96 / 2.54
if (str.endsWith('mm')) return n * 96 / 25.4
if (str.endsWith('pt')) return n * 96 / 72
if (str.endsWith('pc')) return n * 16
if (str.endsWith('em')) return n * 16
if (str.endsWith('ex')) return n * 8
return n
}
const parsePoints = () => (this.getAttribute?.('points') || '')
.trim()
.split(/\\s+/)
.map(pair => pair.split(',').map(Number))
.filter(([x, y]) => !Number.isNaN(x) && !Number.isNaN(y))
if (tag === 'path') {
const d = this.getAttribute?.('d') || ''
const nums = (d.match(/-?\\d*\\.?\\d+/g) || [])
.map(Number)
.filter(n => !Number.isNaN(n))
if (nums.length >= 2) {
let minx = Infinity; let miny = Infinity
let maxx = -Infinity; let maxy = -Infinity
for (let i = 0; i < nums.length; i += 2) {
const x = nums[i]; const y = nums[i + 1]
if (x < minx) minx = x
if (x > maxx) maxx = x
if (y < miny) miny = y
if (y > maxy) maxy = y
}
return {
x: minx === Infinity ? 0 : minx,
y: miny === Infinity ? 0 : miny,
width: maxx === -Infinity ? 0 : maxx - minx,
height: maxy === -Infinity ? 0 : maxy - miny
}
}
return { x: 0, y: 0, width: 0, height: 0 }
}
if (tag === 'rect') {
const x = parseLength('x')
const y = parseLength('y')
const width = parseLength('width')
const height = parseLength('height')
return { x, y, width, height }
}
if (tag === 'line') {
const x1 = parseLength('x1'); const y1 = parseLength('y1')
const x2 = parseLength('x2'); const y2 = parseLength('y2')
const minx = Math.min(x1, x2); const miny = Math.min(y1, y2)
return { x: minx, y: miny, width: Math.abs(x2 - x1), height: Math.abs(y2 - y1) }
}
if (tag === 'circle') {
const cx = parseLength('cx'); const cy = parseLength('cy'); const r = parseLength('r') || parseLength('rx') || parseLength('ry')
return { x: cx - r, y: cy - r, width: r * 2, height: r * 2 }
}
if (tag === 'ellipse') {
const cx = parseLength('cx'); const cy = parseLength('cy'); const rx = parseLength('rx'); const ry = parseLength('ry')
return { x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2 }
}
if (tag === 'polyline' || tag === 'polygon') {
const pts = parsePoints()
if (!pts.length) return { x: 0, y: 0, width: 0, height: 0 }
const xs = pts.map(([x]) => x)
const ys = pts.map(([, y]) => y)
const minx = Math.min(...xs); const maxx = Math.max(...xs)
const miny = Math.min(...ys); const maxy = Math.max(...ys)
return { x: minx, y: miny, width: maxx - minx, height: maxy - miny }
}
return { x: 0, y: 0, width: 0, height: 0 }
}
if (!Object.getOwnPropertyDescriptor(svgElementProto, 'transform')) {
Object.defineProperty(svgElementProto, 'transform', {
get () {
const baseVal = ensureTransformList(this)
return { baseVal }
}
})
}
}
// Ensure pathseg polyfill can attach to prototypes.
await import('pathseg')
// Add minimal chai-like helpers some legacy tests expect.
assert.close = (actual, expected, delta, message) =>
assert.closeTo(actual, expected, delta, message)
assert.notOk = (val, message) => {
if (val) {
throw new AssertionError({ message: message || `expected ${val} to be falsy`, actual: val, expected: false })
}
}
assert.isBelow = (val, limit, message) => {
if (!(val < limit)) {
throw new AssertionError({ message: message || `expected ${val} to be below ${limit}`, actual: val, expected: `< ${limit}` })
}
}