Благодарности и огромная благодарность Янну Армелину за его замечательную работу по этой теме.
Нет ничего лучше векторной графики. И диагонали, и кривые выглядят хрустящими и плохо очерченными с растровой графикой, но с векторной графикой они несравненно гладкие и четкие в любом масштабе. А векторные файлы легкие, как перышко, особенно по сравнению с 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()
validCommand
s ...
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;
Давайте вкратце разберем их:
- Обратите внимание, что эти выражения больше не содержат
/g
lobal 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()
return
s массив с позицией команды 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()
этих parse
d команд в массив координат окна просмотра:
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