import { SVGPathData } from 'svg-pathdata'; import arcToBezier from 'svg-arc-to-cubic-bezier'; import { createLogger } from '@/lib/logger'; const log = createLogger('SvgPathParser'); const typeMap = { 1: 'Z', 2: 'M', 4: 'H', 8: 'V', 16: 'L', 32: 'C', 64: 'S', 128: 'Q', 256: 'T', 512: 'A', }; /** * 简单解析SVG路径 * @param d SVG path d属性 */ export const parseSvgPath = (d: string) => { const pathData = new SVGPathData(d); const ret = pathData.commands.map((item) => { return { ...item, type: typeMap[item.type] }; }); return ret; }; export type SvgPath = ReturnType; /** * 解析SVG路径,并将圆弧(A)类型的路径转为三次贝塞尔(C)类型的路径 * @param d SVG path d属性 * * Returns an empty array if the path is malformed (e.g. unrecognised commands). * Mirrors the defensive behaviour of {@link getSvgPathRange}: a single bad path * (often produced by upstream LLM hallucinations) shouldn't take down the whole * PPTX export. */ export const toPoints = (d: string) => { let pathData: SVGPathData; try { pathData = new SVGPathData(d); } catch (err) { log.warn(`Failed to parse SVG path "${d}":`, err); return []; } const points = []; for (const item of pathData.commands) { const type = typeMap[item.type]; if (item.type === 2 || item.type === 16) { points.push({ x: item.x, y: item.y, relative: item.relative, type, }); } if (item.type === 32) { points.push({ x: item.x, y: item.y, curve: { type: 'cubic', x1: item.x1, y1: item.y1, x2: item.x2, y2: item.y2, }, relative: item.relative, type, }); } else if (item.type === 128) { points.push({ x: item.x, y: item.y, curve: { type: 'quadratic', x1: item.x1, y1: item.y1, }, relative: item.relative, type, }); } else if (item.type === 512) { const lastPoint = points[points.length - 1]; // An arc may appear before any anchor point (e.g. a path that starts with // "A", or one whose leading commands push no point). Without `lastPoint` // there is nothing to arc from, so skip it instead of throwing — this keeps // the documented "malformed path returns []" contract. if (!lastPoint || !['M', 'L', 'Q', 'C'].includes(lastPoint.type)) continue; const cubicBezierPoints = arcToBezier({ px: lastPoint.x as number, py: lastPoint.y as number, cx: item.x, cy: item.y, rx: item.rX, ry: item.rY, xAxisRotation: item.xRot, largeArcFlag: item.lArcFlag, sweepFlag: item.sweepFlag, }); for (const cbPoint of cubicBezierPoints) { points.push({ x: cbPoint.x, y: cbPoint.y, curve: { type: 'cubic', x1: cbPoint.x1, y1: cbPoint.y1, x2: cbPoint.x2, y2: cbPoint.y2, }, relative: false, type: 'C', }); } } else if (item.type === 1) { points.push({ close: true, type }); } else continue; } return points; }; const ZERO_RANGE = { minX: 0, minY: 0, maxX: 0, maxY: 0 } as const; export const getSvgPathRange = (path: string) => { try { // Delegate to svg-pathdata's bounds, which correctly account for relative // commands, H/V/Z (which have no x/y), and arc bulge. The previous hand-rolled // version defaulted missing coordinates to 0 — injecting a spurious (0,0) for // Z, treating relative deltas as absolute, and ignoring arc extent. const { minX, minY, maxX, maxY } = new SVGPathData(path).getBounds(); if ([minX, minY, maxX, maxY].some((v) => v == null || !Number.isFinite(v))) { return { ...ZERO_RANGE }; } return { minX, minY, maxX, maxY }; } catch { return { ...ZERO_RANGE }; } }; export type SvgPoints = ReturnType;