fix(core): parse SVG markup as XML
- Replace attribute and element regexes with the existing xmldom parser - Preserve nested presentation attributes and transforms while skipping non-rendered definitions - Read gradient coordinates through svgpath segments instead of reparsing path strings - Cover malformed markup, quoted attributes, inheritance, and attribute-like text
This commit is contained in:
parent
a6940bcab8
commit
42d3122323
|
|
@ -1,131 +1,176 @@
|
|||
import { iconToSVG } from '@iconify/utils'
|
||||
import type { Element, Node } from '@xmldom/xmldom'
|
||||
import svgpath from 'svgpath'
|
||||
|
||||
import { parseSVGPath } from '@open-pencil/scene-graph/parse-path'
|
||||
|
||||
import { parseSVGFragment } from '#core/io/formats/svg/document'
|
||||
|
||||
import type { IconData, IconifyIconEntry, IconPathInfo } from './types'
|
||||
|
||||
function attrValue(tag: string, attr: string): string | null {
|
||||
const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(["'])(.*?)\\1`, 'i'))
|
||||
return match?.[2] ?? null
|
||||
interface PresentationAttributes {
|
||||
fill: string
|
||||
stroke: string
|
||||
strokeWidth: string
|
||||
strokeCap: string
|
||||
strokeJoin: string
|
||||
fillRule: string
|
||||
}
|
||||
|
||||
function num(tag: string, attr: string, fallback = 0): number {
|
||||
const value = attrValue(tag, attr)
|
||||
return value !== null ? Number.parseFloat(value) : fallback
|
||||
const DEFAULT_PRESENTATION: PresentationAttributes = {
|
||||
fill: 'currentColor',
|
||||
stroke: 'none',
|
||||
strokeWidth: '1',
|
||||
strokeCap: 'butt',
|
||||
strokeJoin: 'miter',
|
||||
fillRule: 'nonzero'
|
||||
}
|
||||
|
||||
function shapeToD(tagName: string, tag: string): string | null {
|
||||
const SHAPE_NAMES = new Set(['path', 'circle', 'ellipse', 'rect', 'line', 'polygon', 'polyline'])
|
||||
const NON_RENDERED_CONTAINERS = new Set(['defs', 'clipPath', 'mask', 'symbol'])
|
||||
|
||||
function isElement(node: Node): node is Element {
|
||||
return node.nodeType === node.ELEMENT_NODE
|
||||
}
|
||||
|
||||
function inheritedAttribute(element: Element, name: string, inherited: string): string {
|
||||
return element.hasAttribute(name) ? (element.getAttribute(name) ?? inherited) : inherited
|
||||
}
|
||||
|
||||
function presentationFor(
|
||||
element: Element,
|
||||
inherited: PresentationAttributes
|
||||
): PresentationAttributes {
|
||||
return {
|
||||
fill: inheritedAttribute(element, 'fill', inherited.fill),
|
||||
stroke: inheritedAttribute(element, 'stroke', inherited.stroke),
|
||||
strokeWidth: inheritedAttribute(element, 'stroke-width', inherited.strokeWidth),
|
||||
strokeCap: inheritedAttribute(element, 'stroke-linecap', inherited.strokeCap),
|
||||
strokeJoin: inheritedAttribute(element, 'stroke-linejoin', inherited.strokeJoin),
|
||||
fillRule: inheritedAttribute(element, 'fill-rule', inherited.fillRule)
|
||||
}
|
||||
}
|
||||
|
||||
function num(element: Element, attr: string, fallback = 0): number {
|
||||
const value = element.getAttribute(attr)
|
||||
if (value === null) return fallback
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
function circleToD(element: Element): string | null {
|
||||
const cx = num(element, 'cx')
|
||||
const cy = num(element, 'cy')
|
||||
const r = num(element, 'r')
|
||||
return r > 0
|
||||
? `M${cx - r},${cy}A${r},${r},0,1,0,${cx + r},${cy}A${r},${r},0,1,0,${cx - r},${cy}Z`
|
||||
: null
|
||||
}
|
||||
|
||||
function ellipseToD(element: Element): string | null {
|
||||
const cx = num(element, 'cx')
|
||||
const cy = num(element, 'cy')
|
||||
const rx = num(element, 'rx')
|
||||
const ry = num(element, 'ry')
|
||||
return rx > 0 && ry > 0
|
||||
? `M${cx - rx},${cy}A${rx},${ry},0,1,0,${cx + rx},${cy}A${rx},${ry},0,1,0,${cx - rx},${cy}Z`
|
||||
: null
|
||||
}
|
||||
|
||||
function rectToD(element: Element): string | null {
|
||||
const x = num(element, 'x')
|
||||
const y = num(element, 'y')
|
||||
const width = num(element, 'width')
|
||||
const height = num(element, 'height')
|
||||
if (width <= 0 || height <= 0) return null
|
||||
const rx = Math.min(num(element, 'rx'), width / 2)
|
||||
const ry = Math.min(num(element, 'ry', rx), height / 2)
|
||||
if (rx > 0 || ry > 0) {
|
||||
const arcX = rx || ry
|
||||
const arcY = ry || rx
|
||||
return `M${x + arcX},${y}H${x + width - arcX}A${arcX},${arcY},0,0,1,${x + width},${y + arcY}V${y + height - arcY}A${arcX},${arcY},0,0,1,${x + width - arcX},${y + height}H${x + arcX}A${arcX},${arcY},0,0,1,${x},${y + height - arcY}V${y + arcY}A${arcX},${arcY},0,0,1,${x + arcX},${y}Z`
|
||||
}
|
||||
return `M${x},${y}H${x + width}V${y + height}H${x}Z`
|
||||
}
|
||||
|
||||
function pointsToD(element: Element, close: boolean): string | null {
|
||||
const points = element.getAttribute('points')
|
||||
if (!points) return null
|
||||
const values = points
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number)
|
||||
if (values.length < 4 || values.length % 2 !== 0) return null
|
||||
let path = `M${values[0]},${values[1]}`
|
||||
for (let index = 2; index < values.length; index += 2) {
|
||||
path += `L${values[index]},${values[index + 1]}`
|
||||
}
|
||||
return close ? `${path}Z` : path
|
||||
}
|
||||
|
||||
function shapeToD(tagName: string, element: Element): string | null {
|
||||
switch (tagName) {
|
||||
case 'circle': {
|
||||
const cx = num(tag, 'cx'),
|
||||
cy = num(tag, 'cy'),
|
||||
r = num(tag, 'r')
|
||||
return r > 0
|
||||
? `M${cx - r},${cy}A${r},${r},0,1,0,${cx + r},${cy}A${r},${r},0,1,0,${cx - r},${cy}Z`
|
||||
: null
|
||||
}
|
||||
case 'ellipse': {
|
||||
const cx = num(tag, 'cx'),
|
||||
cy = num(tag, 'cy'),
|
||||
rx = num(tag, 'rx'),
|
||||
ry = num(tag, 'ry')
|
||||
return rx > 0 && ry > 0
|
||||
? `M${cx - rx},${cy}A${rx},${ry},0,1,0,${cx + rx},${cy}A${rx},${ry},0,1,0,${cx - rx},${cy}Z`
|
||||
: null
|
||||
}
|
||||
case 'rect': {
|
||||
const x = num(tag, 'x'),
|
||||
y = num(tag, 'y'),
|
||||
width = num(tag, 'width'),
|
||||
height = num(tag, 'height')
|
||||
if (width <= 0 || height <= 0) return null
|
||||
const rx = Math.min(num(tag, 'rx'), width / 2),
|
||||
ry = Math.min(num(tag, 'ry', rx), height / 2)
|
||||
if (rx > 0 || ry > 0) {
|
||||
const arx = rx || ry,
|
||||
ary = ry || rx
|
||||
return `M${x + arx},${y}H${x + width - arx}A${arx},${ary},0,0,1,${x + width},${y + ary}V${y + height - ary}A${arx},${ary},0,0,1,${x + width - arx},${y + height}H${x + arx}A${arx},${ary},0,0,1,${x},${y + height - ary}V${y + ary}A${arx},${ary},0,0,1,${x + arx},${y}Z`
|
||||
}
|
||||
return `M${x},${y}H${x + width}V${y + height}H${x}Z`
|
||||
}
|
||||
case 'line': {
|
||||
const x1 = num(tag, 'x1'),
|
||||
y1 = num(tag, 'y1'),
|
||||
x2 = num(tag, 'x2'),
|
||||
y2 = num(tag, 'y2')
|
||||
return `M${x1},${y1}L${x2},${y2}`
|
||||
}
|
||||
case 'circle':
|
||||
return circleToD(element)
|
||||
case 'ellipse':
|
||||
return ellipseToD(element)
|
||||
case 'rect':
|
||||
return rectToD(element)
|
||||
case 'line':
|
||||
return `M${num(element, 'x1')},${num(element, 'y1')}L${num(element, 'x2')},${num(element, 'y2')}`
|
||||
case 'polygon':
|
||||
case 'polyline': {
|
||||
const points = attrValue(tag, 'points')
|
||||
if (!points) return null
|
||||
const values = points
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number)
|
||||
if (values.length < 4) return null
|
||||
let d = `M${values[0]},${values[1]}`
|
||||
for (let i = 2; i < values.length; i += 2) d += `L${values[i]},${values[i + 1]}`
|
||||
if (tagName === 'polygon') d += 'Z'
|
||||
return d
|
||||
}
|
||||
return pointsToD(element, true)
|
||||
case 'polyline':
|
||||
return pointsToD(element, false)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAttr(
|
||||
explicit: string | null,
|
||||
group: string | null,
|
||||
fallback: string | null
|
||||
): string | null {
|
||||
if (explicit !== null) return explicit === 'none' ? null : explicit
|
||||
if (group !== null) return group === 'none' ? null : group
|
||||
return fallback
|
||||
function combinedTransform(parent: string | null, element: Element): string | null {
|
||||
const current = element.getAttribute('transform')
|
||||
if (parent && current) return `${parent} ${current}`
|
||||
return current ?? parent
|
||||
}
|
||||
|
||||
function collectPaths(
|
||||
element: Element,
|
||||
inherited: PresentationAttributes,
|
||||
parentTransform: string | null,
|
||||
result: IconPathInfo[]
|
||||
): void {
|
||||
const tagName = element.localName || element.tagName
|
||||
if (NON_RENDERED_CONTAINERS.has(tagName)) return
|
||||
|
||||
const presentation = presentationFor(element, inherited)
|
||||
const transform = combinedTransform(parentTransform, element)
|
||||
if (SHAPE_NAMES.has(tagName)) {
|
||||
const pathData = tagName === 'path' ? element.getAttribute('d') : shapeToD(tagName, element)
|
||||
if (pathData) {
|
||||
const strokeWidth = Number.parseFloat(presentation.strokeWidth)
|
||||
result.push({
|
||||
d: pathData,
|
||||
fill: presentation.fill === 'none' ? null : presentation.fill,
|
||||
stroke: presentation.stroke === 'none' ? null : presentation.stroke,
|
||||
strokeWidth: Number.isFinite(strokeWidth) ? strokeWidth : 1,
|
||||
strokeCap: presentation.strokeCap,
|
||||
strokeJoin: presentation.strokeJoin,
|
||||
fillRule: presentation.fillRule === 'evenodd' ? 'EVENODD' : 'NONZERO',
|
||||
transform
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of Array.from(element.childNodes)) {
|
||||
if (isElement(child)) collectPaths(child, presentation, transform, result)
|
||||
}
|
||||
}
|
||||
|
||||
export function extractPaths(svgBody: string): IconPathInfo[] {
|
||||
const groupAttrs = {
|
||||
fill: null as string | null,
|
||||
stroke: null as string | null,
|
||||
strokeWidth: null as string | null,
|
||||
strokeCap: null as string | null,
|
||||
strokeJoin: null as string | null
|
||||
}
|
||||
const groupRe = /<g\b[^>]*>/g
|
||||
let groupMatch
|
||||
while ((groupMatch = groupRe.exec(svgBody)) !== null) {
|
||||
groupAttrs.fill ??= attrValue(groupMatch[0], 'fill')
|
||||
groupAttrs.stroke ??= attrValue(groupMatch[0], 'stroke')
|
||||
groupAttrs.strokeWidth ??= attrValue(groupMatch[0], 'stroke-width')
|
||||
groupAttrs.strokeCap ??= attrValue(groupMatch[0], 'stroke-linecap')
|
||||
groupAttrs.strokeJoin ??= attrValue(groupMatch[0], 'stroke-linejoin')
|
||||
}
|
||||
|
||||
const root = parseSVGFragment(svgBody)?.documentElement
|
||||
if (!root) return []
|
||||
const result: IconPathInfo[] = []
|
||||
const shapeRe = /<(path|circle|ellipse|rect|line|polygon|polyline)\b[^>]*>/g
|
||||
let match
|
||||
while ((match = shapeRe.exec(svgBody)) !== null) {
|
||||
const tag = match[0],
|
||||
tagName = match[1]
|
||||
const d = tagName === 'path' ? attrValue(tag, 'd') : shapeToD(tagName, tag)
|
||||
if (!d) continue
|
||||
|
||||
const fillRuleAttr = attrValue(tag, 'fill-rule')
|
||||
result.push({
|
||||
d,
|
||||
fill: resolveAttr(attrValue(tag, 'fill'), groupAttrs.fill, 'currentColor'),
|
||||
stroke: resolveAttr(attrValue(tag, 'stroke'), groupAttrs.stroke, null),
|
||||
strokeWidth: Number.parseFloat(
|
||||
attrValue(tag, 'stroke-width') ?? groupAttrs.strokeWidth ?? '1'
|
||||
),
|
||||
strokeCap: attrValue(tag, 'stroke-linecap') ?? groupAttrs.strokeCap ?? 'butt',
|
||||
strokeJoin: attrValue(tag, 'stroke-linejoin') ?? groupAttrs.strokeJoin ?? 'miter',
|
||||
fillRule: fillRuleAttr === 'evenodd' ? 'EVENODD' : 'NONZERO',
|
||||
transform: attrValue(tag, 'transform')
|
||||
})
|
||||
}
|
||||
collectPaths(root, DEFAULT_PRESENTATION, null, result)
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
|
|||
24
packages/core/src/io/formats/svg/document.ts
Normal file
24
packages/core/src/io/formats/svg/document.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { DOMParser, type Document } from '@xmldom/xmldom'
|
||||
|
||||
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
|
||||
|
||||
function parseXML(source: string): Document | null {
|
||||
try {
|
||||
return new DOMParser({
|
||||
onError: (level, message) => {
|
||||
if (level !== 'warning') throw new Error(message)
|
||||
}
|
||||
}).parseFromString(source, 'image/svg+xml')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSVGDocument(source: string): Document | null {
|
||||
const xmlDocument = parseXML(source)
|
||||
return xmlDocument?.documentElement?.localName === 'svg' ? xmlDocument : null
|
||||
}
|
||||
|
||||
export function parseSVGFragment(source: string): Document | null {
|
||||
return parseXML(`<svg xmlns="${SVG_NAMESPACE}">${source}</svg>`)
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
import type { Element } from '@xmldom/xmldom'
|
||||
|
||||
import type { Rect, Size } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
function attributeValue(svg: string, attribute: string): string | null {
|
||||
const match = svg.match(new RegExp(`\\b${attribute}\\s*=\\s*(["'])(.*?)\\1`, 'i'))
|
||||
return match?.[2] ?? null
|
||||
import { parseSVGDocument } from './document'
|
||||
|
||||
function rootElement(svg: string): Element | null {
|
||||
return parseSVGDocument(svg)?.documentElement ?? null
|
||||
}
|
||||
|
||||
export function parseSVGViewBox(svg: string): Rect | null {
|
||||
const value = attributeValue(svg, 'viewBox')
|
||||
function parseViewBoxValue(value: string | null): Rect | null {
|
||||
if (!value) return null
|
||||
const values = value
|
||||
.trim()
|
||||
|
|
@ -18,17 +20,22 @@ export function parseSVGViewBox(svg: string): Rect | null {
|
|||
return { x, y, width, height }
|
||||
}
|
||||
|
||||
function parseSVGDimension(svg: string, attribute: string): number | null {
|
||||
const value = attributeValue(svg, attribute)
|
||||
export function parseSVGViewBox(svg: string): Rect | null {
|
||||
return parseViewBoxValue(rootElement(svg)?.getAttribute('viewBox') ?? null)
|
||||
}
|
||||
|
||||
function parseSVGDimension(root: Element | null, attribute: string): number | null {
|
||||
const value = root?.getAttribute(attribute)
|
||||
if (!value) return null
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
export function parseSVGSize(svg: string, fallback: Size = { width: 24, height: 24 }): Size {
|
||||
const viewBox = parseSVGViewBox(svg)
|
||||
const width = parseSVGDimension(svg, 'width')
|
||||
const height = parseSVGDimension(svg, 'height')
|
||||
const root = rootElement(svg)
|
||||
const viewBox = parseViewBoxValue(root?.getAttribute('viewBox') ?? null)
|
||||
const width = parseSVGDimension(root, 'width')
|
||||
const height = parseSVGDimension(root, 'height')
|
||||
if (width && height) return { width, height }
|
||||
if (viewBox) return { width: viewBox.width, height: viewBox.height }
|
||||
return fallback
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@
|
|||
* normalize into each node's bounding box (objectBoundingBox) space, matching the
|
||||
* gradientTransform convention used by the SVG exporter (see io/formats/svg/defs).
|
||||
*/
|
||||
import { DOMParser } from '@xmldom/xmldom'
|
||||
import svgpath from 'svgpath'
|
||||
|
||||
import type { Fill, GradientStop } from '@open-pencil/scene-graph'
|
||||
import type { Color, Matrix, Rect, Size, Vector } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import { parseColor } from '#core/color'
|
||||
import { parseSVGDocument } from '#core/io/formats/svg/document'
|
||||
|
||||
interface RawStop {
|
||||
offset: number
|
||||
|
|
@ -78,12 +78,8 @@ function readStops(gradient: SvgElementLike): RawStop[] {
|
|||
*/
|
||||
export function parseSVGGradients(svg: string): Map<string, ParsedGradient> {
|
||||
const map = new Map<string, ParsedGradient>()
|
||||
let doc: SvgQueryable
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
|
||||
} catch {
|
||||
return map
|
||||
}
|
||||
const doc: SvgQueryable | null = parseSVGDocument(svg)
|
||||
if (!doc) return map
|
||||
|
||||
for (const kind of ['linear', 'radial'] as const) {
|
||||
const els = Array.from(doc.getElementsByTagName(`${kind}Gradient`))
|
||||
|
|
@ -115,9 +111,12 @@ export function parseSVGGradients(svg: string): Map<string, ParsedGradient> {
|
|||
}
|
||||
|
||||
function gradientIdFromFill(fill: string | null): string | null {
|
||||
if (!fill) return null
|
||||
const m = fill.match(/^url\(\s*#([^)\s]+)\s*\)$/)
|
||||
return m ? m[1] : null
|
||||
const value = fill?.trim()
|
||||
if (!value?.startsWith('url(') || !value.endsWith(')')) return null
|
||||
const reference = value.slice(4, -1).trim()
|
||||
if (!reference.startsWith('#')) return null
|
||||
const id = reference.slice(1).trim()
|
||||
return id && !id.includes(' ') ? id : null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -138,10 +137,13 @@ function mapUserPoint(
|
|||
if (gradientTransform) sp = sp.transform(gradientTransform)
|
||||
if (elementTransform) sp = sp.transform(elementTransform)
|
||||
sp = sp.translate(-space.x, -space.y).scale(sx, sy)
|
||||
const out = sp.toString()
|
||||
const m = out.match(/M\s*(-?[\d.eE+-]+)[ ,]+(-?[\d.eE+-]+)/)
|
||||
if (!m) return { x, y }
|
||||
return { x: Number.parseFloat(m[1]), y: Number.parseFloat(m[2]) }
|
||||
const points: Vector[] = []
|
||||
sp.abs().iterate((segment) => {
|
||||
if (points.length === 0 && segment[0] === 'M') {
|
||||
points.push({ x: segment[1], y: segment[2] })
|
||||
}
|
||||
})
|
||||
return points[0] ?? { x, y }
|
||||
}
|
||||
|
||||
function gradientStops(stops: RawStop[]): GradientStop[] {
|
||||
|
|
|
|||
46
tests/engine/icons/svg.test.ts
Normal file
46
tests/engine/icons/svg.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { extractPaths } from '#core/icons/svg'
|
||||
import { parseSVGSize, parseSVGViewBox } from '#core/io/formats/svg/metadata'
|
||||
|
||||
describe('SVG XML parsing', () => {
|
||||
test('reads quoted root metadata through the XML parser', () => {
|
||||
const svg = `<svg width='120' height="80" viewBox='10 20 300 200'><path d='M0 0'/></svg>`
|
||||
|
||||
expect(parseSVGSize(svg)).toEqual({ width: 120, height: 80 })
|
||||
expect(parseSVGViewBox(svg)).toEqual({ x: 10, y: 20, width: 300, height: 200 })
|
||||
})
|
||||
|
||||
test('walks nested groups with inherited presentation and transforms', () => {
|
||||
const paths = extractPaths(`
|
||||
<g fill="#ff0000" transform="translate(4 5)">
|
||||
<path d="M0 0L10 0L10 10Z" />
|
||||
<g stroke="#0000ff" stroke-width="2" transform="scale(2)">
|
||||
<line x1="0" y1="0" x2="4" y2="4" />
|
||||
</g>
|
||||
</g>
|
||||
<defs><path d="M20 20L30 30" /></defs>
|
||||
`)
|
||||
|
||||
expect(paths).toHaveLength(2)
|
||||
expect(paths[0]).toMatchObject({ fill: '#ff0000', transform: 'translate(4 5)' })
|
||||
expect(paths[1]).toMatchObject({
|
||||
fill: '#ff0000',
|
||||
stroke: '#0000ff',
|
||||
strokeWidth: 2,
|
||||
transform: 'translate(4 5) scale(2)'
|
||||
})
|
||||
})
|
||||
|
||||
test('does not interpret attribute-like path text as markup', () => {
|
||||
const paths = extractPaths(`<path d="M0 0L10 10" data-note="fill='red' stroke='blue'"/>`)
|
||||
|
||||
expect(paths).toHaveLength(1)
|
||||
expect(paths[0]).toMatchObject({ fill: 'currentColor', stroke: null })
|
||||
})
|
||||
|
||||
test('rejects malformed SVG documents', () => {
|
||||
expect(extractPaths('<path d="M0 0"><g>')).toEqual([])
|
||||
expect(parseSVGViewBox('<svg viewBox="0 0 20 20">')).toBeNull()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue