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:
Danila Poyarkov 2026-07-27 19:04:43 +03:00
parent a6940bcab8
commit 42d3122323
5 changed files with 254 additions and 130 deletions

View file

@ -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
}

View 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>`)
}

View file

@ -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

View file

@ -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[] {

View 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()
})
})