Files
svgedit/tests/unit/paint.test.js
JFH 97386d20b5 Jan2026 fixes (#1077)
* fix release script
* fix svgcanvas edge cases
* Update path-actions.js
* add modern js
* update deps
* Update CHANGES.md
2026-01-11 00:57:06 +01:00

571 lines
19 KiB
JavaScript

import { describe, expect, it } from 'vitest'
import Paint from '../../packages/svgcanvas/core/paint.js'
const createLinear = (id) => {
const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient')
if (id) grad.id = id
return grad
}
const createRadial = (id) => {
const grad = document.createElementNS('http://www.w3.org/2000/svg', 'radialGradient')
if (id) grad.id = id
grad.setAttribute('cx', '0.5')
grad.setAttribute('cy', '0.5')
return grad
}
describe('Paint', () => {
it('defaults to an empty paint when no options are provided', () => {
const paint = new Paint()
expect(paint.type).toBe('none')
expect(paint.alpha).toBe(100)
expect(paint.solidColor).toBeNull()
expect(paint.linearGradient).toBeNull()
expect(paint.radialGradient).toBeNull()
})
it('normalizes solid colors and copies alpha', () => {
const base = new Paint({ solidColor: '#00ff00', alpha: 65 })
const copy = new Paint({ copy: base })
expect(copy.type).toBe('solidColor')
expect(copy.alpha).toBe(65)
expect(copy.solidColor).toBe('00ff00')
expect(copy.linearGradient).toBeNull()
expect(copy.radialGradient).toBeNull()
})
it('copies gradients by cloning the underlying nodes', () => {
const linear = createLinear('lin1')
const base = new Paint({ linearGradient: linear })
const clone = new Paint({ copy: base })
expect(clone.type).toBe('linearGradient')
expect(clone.linearGradient).not.toBe(base.linearGradient)
expect(clone.linearGradient?.isEqualNode(base.linearGradient)).toBe(true)
})
it('resolves linked linear gradients via href/xlink:href', () => {
const referenced = createLinear('refGrad')
referenced.setAttribute('gradientUnits', 'userSpaceOnUse')
const stop0 = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
stop0.setAttribute('offset', '0')
stop0.setAttribute('stop-color', '#000000')
stop0.setAttribute('stop-opacity', '1')
const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
stop1.setAttribute('offset', '1')
stop1.setAttribute('stop-color', '#ffffff')
stop1.setAttribute('stop-opacity', '1')
referenced.append(stop0, stop1)
document.body.append(referenced)
const referencing = createLinear('linkGrad')
referencing.setAttribute('xlink:href', '#refGrad')
referencing.setAttribute('x2', '0.5')
const paint = new Paint({ linearGradient: referencing })
expect(paint.type).toBe('linearGradient')
expect(paint.linearGradient).not.toBeNull()
expect(paint.linearGradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse')
expect(paint.linearGradient?.getAttribute('x2')).toBe('0.5')
expect(paint.linearGradient?.querySelectorAll('stop')).toHaveLength(2)
expect(paint.linearGradient?.hasAttribute('xlink:href')).toBe(false)
})
it('creates radial gradients from provided element when no href is set', () => {
const radial = createRadial('rad1')
const paint = new Paint({ radialGradient: radial })
expect(paint.type).toBe('radialGradient')
expect(paint.radialGradient).not.toBe(radial)
expect(paint.radialGradient?.id).toBe('rad1')
expect(paint.linearGradient).toBeNull()
})
it('resolves multi-level gradient chains and strips href', () => {
const base = createLinear('baseGrad')
base.setAttribute('gradientUnits', 'userSpaceOnUse')
base.setAttribute('y2', '0.75')
const baseStop = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
baseStop.setAttribute('offset', '0')
baseStop.setAttribute('stop-color', '#111111')
base.append(baseStop)
const mid = createLinear('midGrad')
mid.setAttribute('href', '#baseGrad')
mid.setAttribute('x1', '0.2')
document.body.append(base, mid)
const top = createLinear('topGrad')
top.setAttribute('xlink:href', '#midGrad')
top.setAttribute('x2', '0.9')
const paint = new Paint({ linearGradient: top })
expect(paint.linearGradient?.getAttribute('x2')).toBe('0.9')
expect(paint.linearGradient?.getAttribute('x1')).toBe('0.2')
expect(paint.linearGradient?.getAttribute('y2')).toBe('0.75')
expect(paint.linearGradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse')
expect(paint.linearGradient?.querySelectorAll('stop')).toHaveLength(1)
expect(paint.linearGradient?.hasAttribute('href')).toBe(false)
expect(paint.linearGradient?.hasAttribute('xlink:href')).toBe(false)
base.remove()
mid.remove()
})
it('should handle paint with null linearGradient', () => {
const paint = new Paint({ linearGradient: null })
expect(paint.type).toBe('none')
expect(paint.linearGradient).toBe(null)
})
it('should handle paint with undefined radialGradient', () => {
const paint = new Paint({ radialGradient: undefined })
expect(paint.type).toBe('none')
})
it('should handle paint with solidColor', () => {
const paint = new Paint({ solidColor: '#ff0000' })
expect(paint.type).toBe('solidColor')
})
it('should handle paint with alpha value', () => {
const paint = new Paint({ alpha: 0.5 })
expect(paint.alpha).toBe(0.5)
})
it('should handle radialGradient with href chain', () => {
const base = createRadial('baseRadialGrad')
base.setAttribute('cx', '0.5')
base.setAttribute('cy', '0.5')
base.setAttribute('r', '0.5')
document.body.append(base)
const top = createRadial('topRadialGrad')
top.setAttribute('href', '#baseRadialGrad')
top.setAttribute('fx', '0.3')
const paint = new Paint({ radialGradient: top })
expect(paint.radialGradient?.getAttribute('fx')).toBe('0.3')
expect(paint.radialGradient?.getAttribute('cx')).toBe('0.5')
base.remove()
})
it('should handle linearGradient with no stops', () => {
const grad = createLinear('noStopsGrad')
const paint = new Paint({ linearGradient: grad })
expect(paint.linearGradient?.querySelectorAll('stop')).toHaveLength(0)
})
it('should copy paint object with type none', () => {
const original = new Paint({})
const copy = new Paint({ copy: original })
expect(copy.type).toBe('none')
expect(copy.solidColor).toBe(null)
})
it('should copy paint object with solidColor', () => {
const original = new Paint({ solidColor: '#ff0000' })
const copy = new Paint({ copy: original, alpha: 75 })
expect(copy.type).toBe('solidColor')
expect(copy.solidColor).toBe('ff0000')
expect(copy.alpha).toBe(original.alpha)
})
it('should copy paint object with linearGradient', () => {
const grad = createLinear('copyLinearGrad')
const original = new Paint({ linearGradient: grad })
const copy = new Paint({ copy: original })
expect(copy.type).toBe('linearGradient')
expect(copy.linearGradient).not.toBe(original.linearGradient)
expect(copy.linearGradient?.id).toBe('copyLinearGrad')
})
it('should copy paint object with radialGradient', () => {
const grad = createRadial('copyRadialGrad')
document.body.append(grad)
const original = new Paint({ radialGradient: grad })
const copy = new Paint({ copy: original })
expect(copy.type).toBe('radialGradient')
expect(copy.radialGradient).not.toBe(original.radialGradient)
expect(copy.radialGradient?.id).toBe('copyRadialGrad')
grad.remove()
})
it('should handle gradient with invalid href reference', () => {
const grad = createLinear('invalidHrefGrad')
grad.setAttribute('href', '#nonExistentGradient')
const paint = new Paint({ linearGradient: grad })
expect(paint.linearGradient?.id).toBe('invalidHrefGrad')
})
it('should normalize alpha values correctly', () => {
const paint1 = new Paint({ alpha: 150 })
expect(paint1.alpha).toBe(100)
const paint2 = new Paint({ alpha: -10 })
expect(paint2.alpha).toBe(0)
const paint3 = new Paint({ alpha: 'invalid' })
expect(paint3.alpha).toBe(100)
})
it('should handle solidColor with none value', () => {
const paint = new Paint({ solidColor: 'none' })
expect(paint.type).toBe('solidColor')
expect(paint.solidColor).toBe('none')
})
it('should normalize solidColor without hash', () => {
const paint = new Paint({ solidColor: 'red' })
expect(paint.type).toBe('solidColor')
expect(paint.solidColor).toBe('red')
})
it('should handle linearGradient with url() format in href', () => {
const base = createLinear('baseUrlGrad')
base.setAttribute('x1', '0')
base.setAttribute('x2', '1')
document.body.append(base)
const top = createLinear('topUrlGrad')
top.setAttribute('href', 'url(#baseUrlGrad)')
const paint = new Paint({ linearGradient: top })
expect(paint.linearGradient?.getAttribute('x1')).toBe('0')
expect(paint.linearGradient?.getAttribute('x2')).toBe('1')
base.remove()
})
it('should handle gradient with empty string attributes', () => {
const base = createLinear('baseEmptyGrad')
base.setAttribute('x1', '0.5')
document.body.append(base)
const top = createLinear('topEmptyGrad')
top.setAttribute('href', '#baseEmptyGrad')
top.setAttribute('x1', '')
const paint = new Paint({ linearGradient: top })
// Empty attribute should be replaced by inherited value
expect(paint.linearGradient?.getAttribute('x1')).toBe('0.5')
base.remove()
})
it('should handle gradient with stops inheritance', () => {
const base = createLinear('baseStopsGrad')
const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
stop1.setAttribute('offset', '0')
const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
stop2.setAttribute('offset', '1')
base.append(stop1, stop2)
document.body.append(base)
const top = createLinear('topNoStopsGrad')
top.setAttribute('href', '#baseStopsGrad')
const paint = new Paint({ linearGradient: top })
expect(paint.linearGradient?.querySelectorAll('stop')).toHaveLength(2)
base.remove()
})
it('should handle mismatched gradient types', () => {
const base = createLinear('baseMismatchGrad')
document.body.append(base)
const top = createRadial('topMismatchGrad')
top.setAttribute('href', '#baseMismatchGrad')
const paint = new Paint({ radialGradient: top })
// Should not inherit from mismatched type
expect(paint.radialGradient?.id).toBe('topMismatchGrad')
base.remove()
})
it('should handle circular gradient references', () => {
const grad1 = createLinear('circularGrad1')
grad1.setAttribute('href', '#circularGrad2')
document.body.append(grad1)
const grad2 = createLinear('circularGrad2')
grad2.setAttribute('href', '#circularGrad1')
document.body.append(grad2)
const paint = new Paint({ linearGradient: grad1 })
// Should handle circular reference without infinite loop
expect(paint.linearGradient?.id).toBe('circularGrad1')
grad1.remove()
grad2.remove()
})
it('should normalize alpha with null value', () => {
const paint = new Paint({ alpha: null })
expect(paint.alpha).toBe(0)
})
it('should normalize alpha with undefined', () => {
const paint = new Paint({ alpha: undefined })
expect(paint.alpha).toBe(100)
})
it('should normalize solidColor with empty string', () => {
const paint = new Paint({ solidColor: '' })
expect(paint.type).toBe('none')
expect(paint.solidColor).toBe(null)
})
it('should normalize solidColor with whitespace', () => {
const paint = new Paint({ solidColor: ' ' })
expect(paint.type).toBe('solidColor')
expect(paint.solidColor).toBe(null)
})
it('should handle extractHrefId with path in URL', () => {
const grad = createLinear('pathGrad')
grad.setAttribute('href', 'file.svg#targetGrad')
document.body.append(grad)
const paint = new Paint({ linearGradient: grad })
expect(paint.linearGradient?.id).toBe('pathGrad')
grad.remove()
})
it('should handle gradient without ownerDocument', () => {
const grad = createLinear('noDocGrad')
const paint = new Paint({ linearGradient: grad })
expect(paint.linearGradient?.id).toBe('noDocGrad')
})
it('should copy paint with null linearGradient', () => {
const original = new Paint({ linearGradient: null })
const copy = new Paint({ copy: original })
expect(copy.type).toBe('none')
expect(copy.linearGradient).toBe(null)
})
it('should handle href with double quotes in url()', () => {
const base = createLinear('doubleQuoteGrad')
base.setAttribute('x1', '0.25')
document.body.append(base)
const top = createLinear('topDoubleQuoteGrad')
top.setAttribute('href', 'url("#doubleQuoteGrad")')
const paint = new Paint({ linearGradient: top })
expect(paint.linearGradient?.getAttribute('x1')).toBe('0.25')
base.remove()
})
it('should handle href with single quotes in url()', () => {
const base = createLinear('singleQuoteGrad')
base.setAttribute('y1', '0.75')
document.body.append(base)
const top = createLinear('topSingleQuoteGrad')
top.setAttribute('href', "url('#singleQuoteGrad')")
const paint = new Paint({ linearGradient: top })
expect(paint.linearGradient?.getAttribute('y1')).toBe('0.75')
base.remove()
})
it('should handle gradient with non-matching tagName case', () => {
const base = createLinear('baseCaseGrad')
document.body.append(base)
const top = createRadial('topCaseGrad')
top.setAttribute('href', '#baseCaseGrad')
const paint = new Paint({ radialGradient: top })
// Should not inherit from wrong gradient type
expect(paint.radialGradient?.id).toBe('topCaseGrad')
base.remove()
})
it('should handle gradient href with just hash', () => {
const base = createLinear('hashOnlyGrad')
base.setAttribute('x2', '1')
document.body.append(base)
const top = createLinear('topHashGrad')
top.setAttribute('href', '#hashOnlyGrad')
const paint = new Paint({ linearGradient: top })
expect(paint.linearGradient?.getAttribute('x2')).toBe('1')
base.remove()
})
it('should handle invalid alpha values', () => {
const paint1 = new Paint({ alpha: NaN })
expect(paint1.alpha).toBe(100)
const paint2 = new Paint({ alpha: Infinity })
expect(paint2.alpha).toBe(100)
const paint3 = new Paint({ alpha: -Infinity })
expect(paint3.alpha).toBe(100)
})
it('should handle copy with missing clone method', () => {
const original = new Paint({ linearGradient: createLinear('copyGrad') })
original.linearGradient = { id: 'fake', cloneNode: null }
const copy = new Paint({ copy: original })
expect(copy.linearGradient).toBe(null)
})
it('should handle alpha at exact boundaries', () => {
const paint1 = new Paint({ alpha: 0 })
expect(paint1.alpha).toBe(0)
const paint2 = new Paint({ alpha: 100 })
expect(paint2.alpha).toBe(100)
const paint3 = new Paint({ alpha: 50 })
expect(paint3.alpha).toBe(50)
})
it('should handle gradient with null getAttribute', () => {
const grad = createLinear('nullAttrGrad')
const paint = new Paint({ linearGradient: grad })
expect(paint.linearGradient?.id).toBe('nullAttrGrad')
})
it('should handle referenced gradient with no attributes', () => {
const base = createLinear('emptyAttrGrad')
document.body.append(base)
const top = createLinear('topEmptyAttrGrad')
top.setAttribute('href', '#emptyAttrGrad')
const paint = new Paint({ linearGradient: top })
expect(paint.linearGradient?.id).toBe('topEmptyAttrGrad')
base.remove()
})
it('should handle href with spaces in url()', () => {
const base = createLinear('spacesGrad')
base.setAttribute('gradientUnits', 'userSpaceOnUse')
document.body.append(base)
const top = createLinear('topSpacesGrad')
top.setAttribute('href', 'url( #spacesGrad )')
const paint = new Paint({ linearGradient: top })
expect(paint.linearGradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse')
base.remove()
})
it('should handle solidColor with hash prefix', () => {
const paint = new Paint({ solidColor: '#ff0000' })
expect(paint.type).toBe('solidColor')
expect(paint.solidColor).toBe('ff0000')
})
it('should handle solidColor without hash prefix', () => {
const paint = new Paint({ solidColor: 'blue' })
expect(paint.type).toBe('solidColor')
expect(paint.solidColor).toBe('blue')
})
it('should handle gradient with id attribute skip', () => {
const base = createLinear('idTestGrad')
base.setAttribute('x1', '0.1')
base.setAttribute('id', 'differentId')
document.body.append(base)
const top = createLinear('topIdTestGrad')
top.setAttribute('href', '#idTestGrad')
const paint = new Paint({ linearGradient: top })
// Should not copy id attribute
expect(paint.linearGradient?.id).not.toBe('differentId')
base.remove()
})
it('should handle gradient with xlink:href attribute skip', () => {
const base = createLinear('xlinkTestGrad')
base.setAttribute('y1', '0.2')
document.body.append(base)
const top = createLinear('topXlinkTestGrad')
top.setAttribute('xlink:href', '#xlinkTestGrad')
const paint = new Paint({ linearGradient: top })
expect(paint.linearGradient?.getAttribute('y1')).toBe('0.2')
// xlink:href should be removed
expect(paint.linearGradient?.hasAttribute('xlink:href')).toBe(false)
base.remove()
})
it('should handle href pointing to path with hash', () => {
const grad = createLinear('pathHashGrad')
grad.setAttribute('href', 'images/file.svg#someGrad')
const paint = new Paint({ linearGradient: grad })
expect(paint.linearGradient?.id).toBe('pathHashGrad')
})
it('should handle href ending with just hash', () => {
const grad = createLinear('trailingHashGrad')
grad.setAttribute('href', 'file.svg#')
const paint = new Paint({ linearGradient: grad })
expect(paint.linearGradient?.id).toBe('trailingHashGrad')
})
it('should handle href with no hash', () => {
const grad = createLinear('noHashGrad')
grad.setAttribute('href', 'file.svg')
const paint = new Paint({ linearGradient: grad })
expect(paint.linearGradient?.id).toBe('noHashGrad')
})
it('should handle empty href attribute', () => {
const grad = createLinear('emptyHrefGrad')
grad.setAttribute('href', '')
const paint = new Paint({ linearGradient: grad })
expect(paint.linearGradient?.id).toBe('emptyHrefGrad')
})
it('should handle gradient with null ownerDocument fallback', () => {
const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient')
grad.setAttribute('id', 'nullDocGrad2')
// Don't append to document
const paint = new Paint({ linearGradient: grad })
expect(paint.linearGradient?.id).toBe('nullDocGrad2')
})
it('should handle radialGradient with xlink:href', () => {
const grad = createRadial('xlinkRadial')
grad.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#baseRadial')
const paint = new Paint({ radialGradient: grad })
expect(paint.radialGradient?.id).toBe('xlinkRadial')
})
it('should handle gradient with both href and xlink:href', () => {
const grad = createLinear('dualHref')
grad.setAttribute('href', '#newer')
grad.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#older')
const paint = new Paint({ linearGradient: grad })
expect(paint.linearGradient?.id).toBe('dualHref')
})
})