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