349 lines
11 KiB
JavaScript
349 lines
11 KiB
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}` })
|
|
}
|
|
}
|