Благодарности и огромная благодарность Янну Армелину за его замечательную работу по этой теме.

Нет ничего лучше векторной графики. И диагонали, и кривые выглядят хрустящими и плохо очерченными с растровой графикой, но с векторной графикой они несравненно гладкие и четкие в любом масштабе. А векторные файлы легкие, как перышко, особенно по сравнению с JPEG, поэтому время загрузки незначительно, а производительность высокая.

В сегодняшнем сообщении блога мы собираемся углубиться в задачу, с которой я столкнулся при личной работе с векторной графикой: синтаксический анализ и проверка дескрипторов путей. Я думаю, это поможет глубже понять движущиеся части SVG, потому что затрагивает очень многие из них.

Предыстория: один тег для управления всеми

Большинство файлов изображений - это файлы растровой графики - по сути, это просто огромные массивы пикселей. Векторная графика, с другой стороны, определяет точки на декартовой (двумерной) плоскости со стандартизированным синтаксисом. Эта концепция была создана в 50-х годах для компьютеров, используемых военно-воздушными силами, а затем и в гражданской авиадиспетчерской службе, и теперь стала универсальной для потребительских компьютеров.

Основополагающему стандарту векторной графики в Интернете, Масштабируемая векторная графика (SVG), около 20 лет. Вы можете открыть .svg файл в любом текстовом редакторе, и если вы это сделаете, вы увидите синтаксис, внешне похожий на HTML:

<?xml version="1.0" encoding="utf-8"?>
   <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
   <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="522px" height="522px" viewBox="0 0 522 522" enable-background="new 0 0 522 522" xml:space="preserve">
...

На самом деле это просто XML, двоюродный брат HTML. Правильно: это просто разметка текста. Это упрощает управление им с помощью JavaScript, потому что формы представлены как знакомые теги.

Путь праведника

В библиотеке SVG есть несколько основных форм - базовые <line> сегменты, формы, состоящие из линейных сегментов, таких как <polyline>s, <rect>angles и<polygon>s, и изогнутые формы, такие как <circle>s и<ellipse>s. Но сегодня мы сосредоточимся на самом важном элементе SVG: <path>. Он настолько мощный , что может заменить все остальные элементы; большинство файлов SVG, созданных с помощью типичного редактора векторной графики, такого как Adobe Illustrator, на 90% <path> состоят из тегов.

Все теги SVG обладают большим разнообразием атрибутов стиля. Некоторые из них говорят сами за себя, такие как stroke= и fill=, но важные из них относятся к каждому тегу и определяют базовый внешний вид формы. Единственный атрибут, который требует тега <path>, - это атрибут дескриптор, определенный с помощью d=:

<path d="M 0 397.833 c 9.688 1.337 17.864 1.417 27.229 -0.735 c 7.506 -1.725 15.587 0.872 23.494 -0.734 c 13.812 -2.807 16.568 -19.019 29.683 -23.269..." />

Дескриптор пути - это последовательность команд (букв), которые могут быть абсолютными (заглавная) или относительными (строчные), за которыми следует один или несколько параметров (чисел) в стандартном формате. Параметры абсолютной команды представляют координаты относительно окна просмотра; параметры относительной команды представляют собой координаты относительно предыдущей точки фигуры. Это означает, что два тега ниже будут рисовать одну и ту же форму:

<path d="M 25 25 L 75 25 L 75 75 L 25 75 Z" /> // absolute commands
<path d="m 25,25 l 50,0 l 0,50 l -50,0 z" /> // relative commands

Смешивание абсолютных и относительных команд разрешено, но не рекомендуется.

Шесть типов команд пути:

  • ход: M, m
  • строка: L, l, H, h, V, v
  • кривая куба: C, c, S, s
  • четырехугольная кривая: Q, q, T, t
  • дуговая кривая: A, a
  • закрыть: Z, z

Допустим, вы пишете векторный редактор или игру с большим количеством векторных изображений. Если да, то вам, скорее всего, понадобится быстрый и простой способ получить целые числа, представляющие координаты, определяющие ваши SVG-фигуры, чтобы они отображались и сталкивались правильно. Фактически это именно то, что делают такие большие пакеты, как Snap и SVG.js - но вместо использования пакета давайте по-настоящему испачкаем руки и сами закодируем часть этой логики.

Один подход: проверка путей SVG с помощью регулярного выражения

Мои подходы будут использовать регулярное выражение, которое я обсуждал ранее в этом блоге, подчеркивая каждый раз, когда я пишу его, примерно так же весело, как протыкать фурункул. Я изо всех сил пытался вызвать эти проклятые порождения ада для проверки команд и выдергивать их из строки:

const validCommand = /([ml](\s?-?((\d+(\.\d+)?)|(\.\d+)))[,\s]?(-?((\d+(\.\d+)?)|(\.\d+))))|([hv](\s?-?((\d+(\.\d+)?)|(\.\d+))))|(c(\s?-?((\d+(\.\d+)?)|(\.\d+)))([,\s]?(-?((\d+(\.\d+)?)|(\.\d+)))){5})|(q(\s?-?((\d+(\.\d+)?)|(\.\d+)))([,\s]?(-?((\d+(\.\d+)?)|(\.\d+)))){3}(\s?t?(\s?-?((\d+(\.\d+)?)|(\.\d+)))[,\s]?(-?((\d+(\.\d+)?)|(\.\d+))))*)|(a(\s?-?((\d+(\.\d+)?)|(\.\d+)))([,\s]?(-?((\d+(\.\d+)?)|(\.\d+)))){2}[,\s]?[01][,\s]+[01][,\s]+([,\s]?(-?((\d+(\.\d+)?)|(\.\d+)))){2})|(s(\s?-?((\d+(\.\d+)?)|(\.\d+)))([,\s]?(-?((\d+(\.\d+)?)|(\.\d+)))){3})|z/ig;
const isValidDescriptor = /^m(\s?-?((\d+(\.\d+)?)|(\.\d+)))[,\s]?(-?((\d+(\.\d+)?)|(\.\d+)))([,\s]?(([ml](\s?-?((\d+(\.\d+)?)|(\.\d+)))[,\s]?(-?((\d+(\.\d+)?)|(\.\d+))))|([hv](\s?-?((\d+(\.\d+)?)|(\.\d+))))|(c(\s?-?((\d+(\.\d+)?)|(\.\d+)))([,\s]?(-?((\d+(\.\d+)?)|(\.\d+)))){5})|(q(\s?-?((\d+(\.\d+)?)|(\.\d+)))([,\s]?(-?((\d+(\.\d+)?)|(\.\d+)))){3}(\s?t?(\s?-?((\d+(\.\d+)?)|(\.\d+)))[,\s]?(-?((\d+(\.\d+)?)|(\.\d+))))*)|(a(\s?-?((\d+(\.\d+)?)|(\.\d+)))([,\s]?(-?((\d+(\.\d+)?)|(\.\d+)))){2}[,\s]?[01][,\s]+[01][,\s]+([,\s]?(-?((\d+(\.\d+)?)|(\.\d+)))){2}))[,\s]?)+z/ig;

Полное раскрытие информации: в обоих случаях проскальзывают некоторые крайние случаи, но они ловят очень много правильных путей и были достаточно хороши для некоторого развлечения в 2 часа ночи на localhost.

Оба эти выражения выглядят как пылающая детская больница, потому что допустимые пути SVG принимают очень много форм: параметры команды пути обычно должны быть разделены пробелами, но при определенных обстоятельствах могут быть разделены запятыми, отрицательными / десятичными символами или вообще ничем. Очевидно, ветвление в Regex некрасиво; здесь это делается со списком () групп захвата, каждая из которых инициирует совпадение благодаря метасимволу OR| регулярного выражения, например: ()|()|()|... Команды пути обрабатываются отдельно в зависимости от того, сколько параметров они должны принять.

Ограничения и побочные эффекты этого подхода

Было бы неплохо умение .match() validCommands ...

const shape1 = "m 150,150 a 25,25 0 1,1 50,0 a 25,25 0 1,1 -50,0 z";
const shape2 = "m 40 254 s 35 -27 30 -69 s 33 -49 75 -25 z";
const wrongShape = "m l 250 a -400, -350 .";
isValidDescriptor.test( shape2 )
   -> true
isValidDescriptor.test( wrongShape )
   -> false
shape1.match( validCommand )
   -> [ 'm 150,150', 'a 25,25 0 1,1 50,0', 'a 25,25 0 1,1 -50,0', 'z' ]
shape2.match( validCommand ).map( command => command.split( /[\s,]/ ).map( parameter => parseInt( parameter ) || parameter ) );
   -> [ [ 'm', 40, 254 ], [ 's', 35, -27, 30, -69 ], [ 's', 33, -49, 75, -25 ], [ 'z' ] ]

Но эта синтаксическая простота и ясность в JavaScript достигается за счет абсолютной помойки в регулярном выражении. Регулярные выражения также обычно выполняются с экспоненциальным временем, поэтому они уязвимы для уникального вида атак - так называемого злого регулярного выражения (как будто регулярное выражение еще не было достаточно злым).

Я не думаю, что это будет хорошо масштабироваться или окажется надежным. И в сочетании со случайным ложным совпадением, как я упоминал выше, я решил, что оно того не стоит только из-за возможности использовать .match() и .test(), оба уже медленных метода. Так что я не стал использовать регулярное выражение прямо и вместо этого выбрал гибридный подход ...

Другой подход: разделение / нарезка с помощью регулярного выражения, проверка с помощью JavaScript

Вместо того, чтобы приглашать зло в нашу жизнь, давайте определим какое-нибудь меньшее регулярное выражение, соответствующее основным компонентам команды - буквам команды, цифрам (параметры, флаги или координаты) и разделители (запятые, пробелы и отрицательные / десятичные обозначения - все необязательно, но некоторые требуются в некоторых случаях).

const validFlagEx = /^[01]/;
const commaEx = /^(([\t\n\f\r\s]+,?[\t\n\f\r\s]*)|(,[\t\n\f\r\s]*))/;
const validCommandEx = /^[\t\n\f\r\s]*([achlmqstvz])[\t\n\f\r\s]*/i;
const validCoordinateEx = /^[+-]?((\d*\.\d+)|(\d+\.)|(\d+))(e[+-]?\d+)?/i;

Давайте вкратце разберем их:

  • Обратите внимание, что эти выражения больше не содержат /global flag, а вместо этого все начинаются с метасимвола start ^! Это будет важно позже.
  • Допустимый флаг - это один ноль или единица [01].
  • Допустимый разделитель - это либо одиночный пробел перед необязательной запятой ,? и необязательными пробелами, либо запятая перед любым количеством необязательных пробелов.
  • Допустимая буква команды [achlmqstvz] может иметь любое количество необязательных пробелов до или после нее.
  • Допустимый номер имеет необязательный отрицательный знак [+-]? и должен состоять либо из нуля или более цифр, либо из нуля или более цифр с десятичной точкой, либо из нуля или более цифр, за которыми следует десятичная точка и одна или несколько цифр. (Еще есть раздражающий крайний регистр в конце электронной записи.)

Затем давайте создадим грамматику - объект для хранения правильного формирования синтаксиса для каждой команды, как в начальной школе:

const pathGrammar = {
   z: [],
   h: [ validCoordinateEx ],
   v: [ validCoordinateEx ],
   m: [ validCoordinateEx, validCoordinateEx ],
   l: [ validCoordinateEx, validCoordinateEx ],
   t: [ validCoordinateEx, validCoordinateEx ],
   s: [ validCoordinateEx, validCoordinateEx, validCoordinateEx, validCoordinateEx ],
   q: [ validCoordinateEx, validCoordinateEx, validCoordinateEx, validCoordinateEx ],
   c: [ validCoordinateEx, validCoordinateEx, validCoordinateEx, validCoordinateEx, validCoordinateEx, validCoordinateEx ],
   a: [ validCoordinateEx, validCoordinateEx, validCoordinateEx, validFlagEx, validFlagEx, validCoordinateEx, validCoordinateEx ],
};

Теперь, когда у нас есть грамматика и некоторые правила, давайте создадим эти переменные static и определим класс для их хранения и структурирования того, как их использовать.

class PathParser {
static validCommand = /^[\t\n\f\r\s]*([achlmqstvz])[\t\n\f\r\s]*/i;
   static validFlag = /^[01]/;
   static validCoordinate = /^[+-]?((\d*\.\d+)|(\d+\.)|(\d+))(e[+-]?\d+)?/i;
   static validComma = /^(([\t\n\f\r\s]+,?[\t\n\f\r\s]*)|(,[\t\n\f\r\s]*))/;
static pathGrammar = {
      z: [],
      h: [ validCoordinateEx ],
      v: [ validCoordinateEx ],
      m: [ validCoordinateEx, validCoordinateEx ],
      l: [ validCoordinateEx, validCoordinateEx ],
      t: [ validCoordinateEx, validCoordinateEx ],
      s: [ validCoordinateEx, validCoordinateEx, validCoordinateEx, validCoordinateEx ],
      q: [ validCoordinateEx, validCoordinateEx, validCoordinateEx, validCoordinateEx ],
      c: [ validCoordinateEx, validCoordinateEx, validCoordinateEx, validCoordinateEx, validCoordinateEx, validCoordinateEx ],
      a: [ validCoordinateEx, validCoordinateEx, validCoordinateEx, validFlagEx, validFlagEx, validCoordinateEx, validCoordinateEx ],
   };
static parseRaw( path ) {}
static parseComponents( type, path, cursor ) {}
}

У нашего класса будет один главный метод для parseRaw() всего пути до необработанных примитивов, который использует вспомогательный метод для parseComponents(). parse() постоянно откусывает path, откусывая по одному куски размером cursor в начале строки - отсюда и начальные ^ метасимволы!

static parseRaw( path ) {
   let cursor = 0, parsedComponents = [];
   while ( cursor < path.length ) {
const match = path.slice( cursor ).match( this.validCommand );
      if ( match !== null ) {
         const command = match[ 1 ];
         cursor += match[ 0 ].length;
         const componentList = PathParser.parseComponents( command, path, cursor );
         cursor = componentList[ 0 ];
         parsedComponents = [ ...parsedComponents, ...componentList[1] ];
      } else {
throw new Error(  `Invalid path: first error at char ${ cursor }`  );
      }
   }
   return parsedComponents;
}
static parseComponents( type, path, cursor ) {
   const expectedCommands = this.pathGrammar[ type.toLowerCase() ];
   const components = [];
   while ( cursor <= path.length ) {
      const component = [ type ];
      for ( const regex of expectedCommands ) {
         const match = path.slice( cursor ).match( regex );
         if ( match !== null ) {
            component.push( parseInt( match[ 0 ] ) );
            cursor += match[ 0 ].length;
            const nextSlice = path.slice( cursor ).match( this.validComma );
            if ( nextSlice !== null ) cursor += nextSlice[ 0 ].length;
         } else if ( component.length === 1 ) {
            return [ cursor, components ];
         } else {
            throw new Error( `Invalid path: first error at char ${ cursor }` );
         }
      }
      components.push( component );
      if ( expectedCommands.length === 0 ) return [ cursor, components ];
      if ( type === 'm' ) type = 'l';
      if ( type === 'M' ) type = 'L';
   }
   throw new Error( `Invalid path: first error at char ${ cursor }` );
}

parseRaw() разбивает путь на компоненты и передает их методу parseComponents() для поиска match() в соответствии с правилами построения expectedCommand из нашего pathGrammar, а также любых validComma. Обратите внимание, что parseComponents() returns массив с позицией команды cursor (начальный индекс) и недавно проанализированным command.

По мере того, как parseComponents() проходит через path, который мы анализируем, он проверяет совпадение, push() анализирует его как целое число с массивом результатов components, возвращает, если анализируемая в данный момент команда находится в конце строки path, и throw поднимает руки в Error(), если что-то пойдет не так.

1000 точек света

Наш PathParser решает две важные проблемы. Конечно, он хорошо разбирает и форматирует строки пути SVG, но он также throw выдает Error(), когда его заставляют анализировать недопустимый путь, что упрощает проверку с помощью блоков try {} catch {} finally {}.

const shape = "m 150,150 a 25,25 0 1,1 50,0 a 25,25 0 1,1 -50,0 z";
const parsedShape = PathParser.parseRaw( shape )
   -> [ [ 'm', 50, 50 ], [ 'l', 100, 0 ], [ 'l', 0, 100 ], [ 'l', -100, 0 ], [ 'z' ] ]

Это также поможет нам сопоставить проанализированные точки для создания <path> тегов для DOM. Но помните, пути могут быть определены как в абсолютных, так и в относительных терминах , и разные команды имеют разные правила. Таким образом, вычисление координат окна просмотра будет задачей другого метода с грамматикой - на этот раз pointGrammar:

static pointGrammar = {
   z: () => [],
   Z: () => [],
   m: ( point, command ) => [ point[ 0 ] + command[ 1 ], point[ 1 ] + command[ 2 ] ],
   M: ( point, command ) => command.slice( 1 ),
   h: ( point, command ) => [ point[ 0 ] + command[ 1 ], point[ 1 ] ],
   H: ( point, command ) => [ command[ 1 ], point[ 1 ] ],
   v: ( point, command ) => [ point[ 0 ], point[ 1 ] + command[ 1 ] ],
   V: ( point, command ) => [ point[ 0 ], command[ 1 ] ],
   l: ( point, command ) => [ point[ 0 ] + command[ 1 ], point[ 1 ] + command[ 2 ] ],
   L: ( point, command ) => command.slice( 1 ),
   a: ( point, command ) => [ point[ 0 ] + command[ 6 ], point[ 1 ] + command[ 7 ] ],
   A: ( point, command ) => command.slice( 6 ),
   c: ( point, command ) => [ point[ 0 ] + command[ 5 ], point[ 1 ] + command[ 6 ] ],
   C: ( point, command ) => command.slice( 5 ),
   t: ( point, command ) => [ point[ 0 ] + command[ 1 ], point[ 1 ] + command[ 2 ] ],
   T: ( point, command ) => command.slice( 1 ),
   q: ( point, command ) => [ point[ 0 ] + command[ 3 ], point[ 1 ] + command[ 4 ] ],
   Q: ( point, command ) => command.slice( 3 ),
   s: ( point, command ) => [ point[ 0 ] + command[ 3 ], point[ 1 ] + command[ 4 ] ],
   S: ( point, command ) => command.slice( 3 ),
};

Клавиши pointGrammar на этот раз указывают не на регулярное выражение, а на чистые функции, которые берут текущую точку и команду и выводят следующую точку. Конечно, абсолютные (заглавные) команды намного проще, поскольку в этом случае мне не нужно ничего знать о текущей точке, чтобы найти положение следующей точки.

После этого мой PathParser готов анализировать координаты из пути SVG:

static points( path ) {
   let currentPoint = [ 0, 0 ];
   return PathParser.parseRaw( path ).reduce( ( result, command ) => {
      currentPoint = this.pointGrammar[ command[ 0 ] ]( currentPoint, command );
      return [ ...result, currentPoint ];
   }, [] );
}

Этот метод использует pointGrammar и currentPoint буфер для reduce() этих parsed команд в массив координат окна просмотра:

const sameShape = [
   "m 25,25 l 50,0 l 0,50 l -50,0 z",
   "M 25 25 L 75 25 L 75 75 L 25 75 Z"
];
PathParser.parseRaw( sameShape[ 0 ] )
   -> [ [ 25, 25 ], [ 75, 25 ], [ 75, 75 ], [ 25, 75 ], [] ]
PathParser.parseRaw( sameShape[ 1 ] )
   -> [ [ 25, 25 ], [ 75, 25 ], [ 75, 75 ], [ 25, 75 ], [] ]

Вывод: путь наименьшего сопротивления

Мы объединили возможности регулярного выражения для сопоставления текста с гибкостью JavaScript и можем преобразовывать необработанные текстовые пути в массивы координат точек. Одного этого достаточно, чтобы дать пользователям полный контроль над <svg> <path> в DOM. И совсем несложно добавить PathParser методов для расчета дескрипторов кривых и многое другое, что упрощает воспроизведение параметров / функций из фирменных графических редакторов, таких как Adobe Illustrator.

Мораль этой истории? Регулярное выражение само по себе плохо, плохо, плохо для синтаксического анализа технического синтаксиса; они обычно слишком вложены. Эта ветка лучше всего объясняет, почему.

Больше контента на plainenglish.io