diff --git a/packages/core/src/icons/svg.ts b/packages/core/src/icons/svg.ts index b78d78638..5b9c37c1a 100644 --- a/packages/core/src/icons/svg.ts +++ b/packages/core/src/icons/svg.ts @@ -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 - 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 } diff --git a/packages/core/src/io/formats/svg/document.ts b/packages/core/src/io/formats/svg/document.ts new file mode 100644 index 000000000..2eb1247ea --- /dev/null +++ b/packages/core/src/io/formats/svg/document.ts @@ -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(`${source}`) +} diff --git a/packages/core/src/io/formats/svg/metadata.ts b/packages/core/src/io/formats/svg/metadata.ts index 66cf00619..0c23b56bb 100644 --- a/packages/core/src/io/formats/svg/metadata.ts +++ b/packages/core/src/io/formats/svg/metadata.ts @@ -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 diff --git a/packages/core/src/vector/vectorize/svg/gradients.ts b/packages/core/src/vector/vectorize/svg/gradients.ts index 65cf90723..0519b0818 100644 --- a/packages/core/src/vector/vectorize/svg/gradients.ts +++ b/packages/core/src/vector/vectorize/svg/gradients.ts @@ -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 { const map = new Map() - 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 { } 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[] { diff --git a/tests/engine/icons/svg.test.ts b/tests/engine/icons/svg.test.ts new file mode 100644 index 000000000..9a431d3ee --- /dev/null +++ b/tests/engine/icons/svg.test.ts @@ -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 = `` + + 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(` + + + + + + + + `) + + 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(``) + + expect(paths).toHaveLength(1) + expect(paths[0]).toMatchObject({ fill: 'currentColor', stroke: null }) + }) + + test('rejects malformed SVG documents', () => { + expect(extractPaths('')).toEqual([]) + expect(parseSVGViewBox('')).toBeNull() + }) +})