path.js 8.2 KB

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