UNPKG

8.78 kBJavaScriptView Raw
1'use strict';
2
3const { removeLeadingZero, toFixed } = require('./svgo/tools');
4
5/**
6 * @typedef {import('./types').PathDataItem} PathDataItem
7 * @typedef {import('./types').PathDataCommand} PathDataCommand
8 */
9
10// Based on https://www.w3.org/TR/SVG11/paths.html#PathDataBNF
11
12const argsCountPerCommand = {
13 M: 2,
14 m: 2,
15 Z: 0,
16 z: 0,
17 L: 2,
18 l: 2,
19 H: 1,
20 h: 1,
21 V: 1,
22 v: 1,
23 C: 6,
24 c: 6,
25 S: 4,
26 s: 4,
27 Q: 4,
28 q: 4,
29 T: 2,
30 t: 2,
31 A: 7,
32 a: 7,
33};
34
35/**
36 * @type {(c: string) => c is PathDataCommand}
37 */
38const isCommand = (c) => {
39 return c in argsCountPerCommand;
40};
41
42/**
43 * @type {(c: string) => boolean}
44 */
45const isWsp = (c) => {
46 const codePoint = c.codePointAt(0);
47 return (
48 codePoint === 0x20 ||
49 codePoint === 0x9 ||
50 codePoint === 0xd ||
51 codePoint === 0xa
52 );
53};
54
55/**
56 * @type {(c: string) => boolean}
57 */
58const isDigit = (c) => {
59 const codePoint = c.codePointAt(0);
60 if (codePoint == null) {
61 return false;
62 }
63 return 48 <= codePoint && codePoint <= 57;
64};
65
66/**
67 * @typedef {'none' | 'sign' | 'whole' | 'decimal_point' | 'decimal' | 'e' | 'exponent_sign' | 'exponent'} ReadNumberState
68 */
69
70/**
71 * @type {(string: string, cursor: number) => [number, ?number]}
72 */
73const readNumber = (string, cursor) => {
74 let i = cursor;
75 let value = '';
76 let state = /** @type {ReadNumberState} */ ('none');
77 for (; i < string.length; i += 1) {
78 const c = string[i];
79 if (c === '+' || c === '-') {
80 if (state === 'none') {
81 state = 'sign';
82 value += c;
83 continue;
84 }
85 if (state === 'e') {
86 state = 'exponent_sign';
87 value += c;
88 continue;
89 }
90 }
91 if (isDigit(c)) {
92 if (state === 'none' || state === 'sign' || state === 'whole') {
93 state = 'whole';
94 value += c;
95 continue;
96 }
97 if (state === 'decimal_point' || state === 'decimal') {
98 state = 'decimal';
99 value += c;
100 continue;
101 }
102 if (state === 'e' || state === 'exponent_sign' || state === 'exponent') {
103 state = 'exponent';
104 value += c;
105 continue;
106 }
107 }
108 if (c === '.') {
109 if (state === 'none' || state === 'sign' || state === 'whole') {
110 state = 'decimal_point';
111 value += c;
112 continue;
113 }
114 }
115 if (c === 'E' || c == 'e') {
116 if (
117 state === 'whole' ||
118 state === 'decimal_point' ||
119 state === 'decimal'
120 ) {
121 state = 'e';
122 value += c;
123 continue;
124 }
125 }
126 break;
127 }
128 const number = Number.parseFloat(value);
129 if (Number.isNaN(number)) {
130 return [cursor, null];
131 } else {
132 // step back to delegate iteration to parent loop
133 return [i - 1, number];
134 }
135};
136
137/**
138 * @type {(string: string) => PathDataItem[]}
139 */
140const parsePathData = (string) => {
141 /**
142 * @type {PathDataItem[]}
143 */
144 const pathData = [];
145 /**
146 * @type {?PathDataCommand}
147 */
148 let command = null;
149 let args = /** @type {number[]} */ ([]);
150 let argsCount = 0;
151 let canHaveComma = false;
152 let hadComma = false;
153 for (let i = 0; i < string.length; i += 1) {
154 const c = string.charAt(i);
155 if (isWsp(c)) {
156 continue;
157 }
158 // allow comma only between arguments
159 if (canHaveComma && c === ',') {
160 if (hadComma) {
161 break;
162 }
163 hadComma = true;
164 continue;
165 }
166 if (isCommand(c)) {
167 if (hadComma) {
168 return pathData;
169 }
170 if (command == null) {
171 // moveto should be leading command
172 if (c !== 'M' && c !== 'm') {
173 return pathData;
174 }
175 } else {
176 // stop if previous command arguments are not flushed
177 if (args.length !== 0) {
178 return pathData;
179 }
180 }
181 command = c;
182 args = [];
183 argsCount = argsCountPerCommand[command];
184 canHaveComma = false;
185 // flush command without arguments
186 if (argsCount === 0) {
187 pathData.push({ command, args });
188 }
189 continue;
190 }
191 // avoid parsing arguments if no command detected
192 if (command == null) {
193 return pathData;
194 }
195 // read next argument
196 let newCursor = i;
197 let number = null;
198 if (command === 'A' || command === 'a') {
199 const position = args.length;
200 if (position === 0 || position === 1) {
201 // allow only positive number without sign as first two arguments
202 if (c !== '+' && c !== '-') {
203 [newCursor, number] = readNumber(string, i);
204 }
205 }
206 if (position === 2 || position === 5 || position === 6) {
207 [newCursor, number] = readNumber(string, i);
208 }
209 if (position === 3 || position === 4) {
210 // read flags
211 if (c === '0') {
212 number = 0;
213 }
214 if (c === '1') {
215 number = 1;
216 }
217 }
218 } else {
219 [newCursor, number] = readNumber(string, i);
220 }
221 if (number == null) {
222 return pathData;
223 }
224 args.push(number);
225 canHaveComma = true;
226 hadComma = false;
227 i = newCursor;
228 // flush arguments when necessary count is reached
229 if (args.length === argsCount) {
230 pathData.push({ command, args });
231 // subsequent moveto coordinates are treated as implicit lineto commands
232 if (command === 'M') {
233 command = 'L';
234 }
235 if (command === 'm') {
236 command = 'l';
237 }
238 args = [];
239 }
240 }
241 return pathData;
242};
243exports.parsePathData = parsePathData;
244
245/**
246 * @type {(number: number, precision?: number) => {
247 * roundedStr: string,
248 * rounded: number
249 * }}
250 */
251const roundAndStringify = (number, precision) => {
252 if (precision != null) {
253 number = toFixed(number, precision);
254 }
255
256 return {
257 roundedStr: removeLeadingZero(number),
258 rounded: number,
259 };
260};
261
262/**
263 * Elliptical arc large-arc and sweep flags are rendered with spaces
264 * because many non-browser environments are not able to parse such paths
265 *
266 * @type {(
267 * command: string,
268 * args: number[],
269 * precision?: number,
270 * disableSpaceAfterFlags?: boolean
271 * ) => string}
272 */
273const stringifyArgs = (command, args, precision, disableSpaceAfterFlags) => {
274 let result = '';
275 let previous;
276
277 for (let i = 0; i < args.length; i++) {
278 const { roundedStr, rounded } = roundAndStringify(args[i], precision);
279 if (
280 disableSpaceAfterFlags &&
281 (command === 'A' || command === 'a') &&
282 // consider combined arcs
283 (i % 7 === 4 || i % 7 === 5)
284 ) {
285 result += roundedStr;
286 } else if (i === 0 || rounded < 0) {
287 // avoid space before first and negative numbers
288 result += roundedStr;
289 } else if (
290 !Number.isInteger(previous) &&
291 rounded != 0 &&
292 rounded < 1 &&
293 rounded > -1
294 ) {
295 // remove space before decimal with zero whole
296 // only when previous number is also decimal
297 result += roundedStr;
298 } else {
299 result += ` ${roundedStr}`;
300 }
301 previous = rounded;
302 }
303
304 return result;
305};
306
307/**
308 * @typedef {{
309 * pathData: PathDataItem[];
310 * precision?: number;
311 * disableSpaceAfterFlags?: boolean;
312 * }} StringifyPathDataOptions
313 */
314
315/**
316 * @param {StringifyPathDataOptions} options
317 * @returns {string}
318 */
319const stringifyPathData = ({ pathData, precision, disableSpaceAfterFlags }) => {
320 if (pathData.length === 1) {
321 const { command, args } = pathData[0];
322 return (
323 command + stringifyArgs(command, args, precision, disableSpaceAfterFlags)
324 );
325 }
326
327 let result = '';
328 let prev = { ...pathData[0] };
329
330 // match leading moveto with following lineto
331 if (pathData[1].command === 'L') {
332 prev.command = 'M';
333 } else if (pathData[1].command === 'l') {
334 prev.command = 'm';
335 }
336
337 for (let i = 1; i < pathData.length; i++) {
338 const { command, args } = pathData[i];
339 if (
340 (prev.command === command &&
341 prev.command !== 'M' &&
342 prev.command !== 'm') ||
343 // combine matching moveto and lineto sequences
344 (prev.command === 'M' && command === 'L') ||
345 (prev.command === 'm' && command === 'l')
346 ) {
347 prev.args = [...prev.args, ...args];
348 if (i === pathData.length - 1) {
349 result +=
350 prev.command +
351 stringifyArgs(
352 prev.command,
353 prev.args,
354 precision,
355 disableSpaceAfterFlags,
356 );
357 }
358 } else {
359 result +=
360 prev.command +
361 stringifyArgs(
362 prev.command,
363 prev.args,
364 precision,
365 disableSpaceAfterFlags,
366 );
367
368 if (i === pathData.length - 1) {
369 result +=
370 command +
371 stringifyArgs(command, args, precision, disableSpaceAfterFlags);
372 } else {
373 prev = { command, args };
374 }
375 }
376 }
377
378 return result;
379};
380exports.stringifyPathData = stringifyPathData;