[6a3a178] | 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;
|
---|