Skill Tracer
Skills › Archify › archify › svg-path-analysis.mjs

svg-path-analysis.mjs

135 lines · archify/renderers/shared/svg-path-analysis.mjs at a406594

1// 仅分析静态检查器需要的路径子集,不作为通用 SVG 渲染器。
2const NUMBER = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y;
3const COMMANDS = /^[MLHVQZACSTmlhvqzacst]$/;
4const ARITY = { M: 2, L: 2, H: 1, V: 1, Q: 4 };
5
6function failure(tokenOffset, reason, command, unsupported = false) {
7 return { ok: false, error: {
8 code: unsupported ? 'artifact/svg-path-unsupported' : 'artifact/svg-path-malformed',
9 tokenOffset, reason, ...(command ? { command } : {}),
10 } };
13function tokenize(source) {
14 const tokens = [];
15 let offset = 0;
16 let comma = null;
17 while (offset < source.length) {
18 const character = source[offset];
19 if (/[ \t\r\n]/.test(character)) { offset += 1; continue; }
20 if (character === ',') {
21 if (comma !== null || tokens.at(-1)?.kind !== 'number') return failure(offset, 'unexpected comma');
22 comma = offset++;
23 continue;
24 }
25 if (COMMANDS.test(character)) {
26 if (comma !== null) return failure(comma, 'a comma must separate numbers');
27 tokens.push({ kind: 'command', value: character, offset: offset++ });
28 continue;
29 }
30 NUMBER.lastIndex = offset;
31 const number = NUMBER.exec(source);
32 if (!number) return failure(offset, 'expected a number or supported path command');
33 const value = Number(number[0]);
34 if (!Number.isFinite(value)) return failure(offset, 'coordinate is not finite');
35 tokens.push({ kind: 'number', value, offset });
36 offset = NUMBER.lastIndex;
37 comma = null;
38 }
39 return comma === null ? { ok: true, tokens } : failure(comma, 'trailing comma');
42/** 每步消费一个命令或完整参数组;失败不返回部分几何。偏移基于传入的 d 字符串。 */
43export function analyzeSvgPath(source) {
44 if (typeof source !== 'string') return failure(0, 'path data must be a string');
45 const lexed = tokenize(source);
46 if (!lexed.ok) return lexed;
47 const { tokens } = lexed;
48 const subpaths = [];
49 let index = 0;
50 let command = null;
51 let commandOffset = 0;
52 let current = [0, 0];
53 let subpath;
55 function move(point) {
56 current = point;
57 subpath = { points: [point], segments: [], straightSegments: [], closed: false };
58 subpaths.push(subpath);
59 }
60 function line(point) {
61 const segment = { start: current, end: point };
62 subpath.segments.push(segment);
63 subpath.straightSegments.push(segment);
64 subpath.points.push(point);
65 current = point;
66 }
68 while (index < tokens.length) {
69 if (tokens[index].kind === 'command') {
70 command = tokens[index].value;
71 commandOffset = tokens[index++].offset;
72 } else if (!command) {
73 return failure(tokens[index].offset, 'a command is required after closepath');
74 }
75 const upper = command.toUpperCase();
76 if (!subpath && upper !== 'M') return failure(commandOffset, 'the first command must be moveto', command);
77 if (!Object.hasOwn(ARITY, upper) && upper !== 'Z') {
78 return failure(commandOffset, 'path command is not supported by artifact analysis', command, true);
79 }
80 if (upper === 'Z') {
81 line(subpath.points[0]);
82 subpath.closed = true;
83 command = null;
84 continue;
85 }
86 const parameters = [];
87 const parameterOffset = tokens[index]?.offset ?? source.length;
88 for (let count = 0; count < ARITY[upper]; count += 1) {
89 const token = tokens[index];
90 if (!token || token.kind !== 'number') {
91 return failure(token?.offset ?? source.length, `command ${command} requires ${ARITY[upper]} parameters`, command);
92 }
93 parameters.push(token.value);
94 index += 1;
95 }
96 const relative = command !== upper;
97 const point = (x, y) => relative ? [current[0] + x, current[1] + y] : [x, y];
98 let end;
99 let control;
100 if (upper === 'H') end = [relative ? current[0] + parameters[0] : parameters[0], current[1]];
101 else if (upper === 'V') end = [current[0], relative ? current[1] + parameters[0] : parameters[0]];
102 else if (upper === 'Q') {
103 control = point(parameters[0], parameters[1]);
104 end = point(parameters[2], parameters[3]);
105 } else end = point(parameters[0], parameters[1]);
106 if (![...end, ...(control || [])].every(Number.isFinite)) {
107 return failure(parameterOffset, 'computed coordinate is not finite', command);
108 }
109 if (upper === 'M') {
110 move(end);
111 // moveto 后的额外参数组是 lineto,不能变成额外子路径。
112 command = relative ? 'l' : 'L';
113 continue;
114 }
115 if (subpath.closed) move(current);
116 if (upper !== 'Q') { line(end); continue; }
118 const start = current;
119 const cross = (control[0] - start[0]) * (end[1] - start[1]) - (control[1] - start[1]) * (end[0] - start[0]);
120 if (Math.abs(cross) <= 1e-9) subpath.straightSegments.push({ start, end });
121 // 保留既有八步 Q 采样;非共线曲线不冒充结构框的直线边界。
122 for (let step = 1; step <= 8; step += 1) {
123 const amount = step / 8;
124 const remaining = 1 - amount;
125 const next = [0, 1].map(axis => remaining * remaining * start[axis]
126 + 2 * remaining * amount * control[axis] + amount * amount * end[axis]);
127 if (!next.every(Number.isFinite)) return failure(parameterOffset, 'sampled coordinate is not finite', command);
128 subpath.segments.push({ start: current, end: next });
129 subpath.points.push(next);
130 current = next;
131 }
132 current = end;
133 }
134 return { ok: true, subpaths };