Как папка SVG становится шрифтом
Привет!
Я написал icofon — инструмент, который берёт папку с SVG-иконками и собирает из
неё иконочный шрифт вместе со стилями и страницей предпросмотра.
Дальше — про то, как он устроен внутри: что происходит с картинкой на пути от SVG до глифа, где icofon упирается в ограничения формата и что пришлось придумать с именами и кодовыми позициями, чтобы шрифт можно было пересобирать, не ломая уже опубликованные страницы. Если вам нужен только инструмент, то установка, первый пример и команды — сразу ниже; остальное можно не читать.
Установка
icofon написан на Rust, лицензия MIT. Homebrew ставит готовый бинарник, компилировать нечего:
brew install jwo1f/tap/icofon
Или через cargo, если так привычнее:
cargo install icofon
Исходники — https://github.com/JWo1F/icofon.
Первый запуск
icofon build ./icons -o dist
16 icons
dist/icons.woff2 3.1 KB
dist/icons.woff 3.5 KB
dist/icons.ttf 5.1 KB
dist/icons.css 1.3 KB
dist/index.html 35.4 KB
icons/icofon.json 1.4 KB
На странице нужны две строчки:
<link rel="stylesheet" href="icons.css">
<span class="icon-check"></span>
Для браузера глиф — это текст: он наследует font-size окружения, а одноцветный
ещё и color. Значит, размер иконке задавать не нужно, а одноцветной — и цвет;
цветные тут ведут себя иначе, о них ниже.
Ещё три команды
icofon check ./icons # всё сконвертировать, ничего не записать
icofon watch ./icons -o dist # пересобирать при изменениях
icofon init ./icons # написать icofon.toml
check — команда для CI. Она конвертирует все иконки в глифы и падает, если хоть
одна из них глифом не стала. Не записывает при этом ничего — ни шрифта, ни
стилей, ни страницы, ни icofon.json.
Всё, что иначе пришлось бы набирать каждый раз, живёт в icofon.toml. icofon
ищет его вверх по дереву от каталога, где запустили команду, а пути внутри
считает относительно самого файла, так что сборка ведёт себя одинаково из любой
точки проекта.
source = "icons"
out = "dist"
name = "icons"
formats = [
"woff2",
"woff",
"ttf",
]
prefix = "icon"
В шрифте есть только заливка
Глиф — это контур, залитый по правилу non-zero winding.
Обводки в шрифте не бывает. Иконка, нарисованная через stroke="currentColor"
без заливки — а это большая часть Feather, Lucide и Tabler, — не даёт ни одной
фигуры, которую можно было бы перенести: сначала каждый штрих надо обвести
собственным контуром, сохранив толщину и то, как он загибается на концах и
стыках.
Работу от файла до TTF делят четыре библиотеки. usvg 0.48 разбирает файл и
превращает примитивы в пути; tiny-skia-path, который идёт с ним в комплекте,
обводит штрих контуром; kurbo считает всю остальную геометрию; write-fonts
собирает таблицы шрифта. Сводит пути в плоский список уже сам icofon; он же
переводит кубические кривые в квадратичные — саму аппроксимацию делает kurbo, —
потому что других кривых TrueType не хранит.
Ещё в SVG есть fill-rule="evenodd", которым многие иконки делают дырки, — а
шрифты, как уже сказано, знают только non-zero winding. Перенести такие контуры
как есть — значит наглухо залить дырки, намотанные в ту же сторону, что и внешний
контур. Лечится переориентацией каждого контура по глубине вложенности:
направление обхода берёт на себя работу правила заливки.
И метрики. Высота viewBox ложится на один em, а em здесь — тысяча единиц: от 200
ниже базовой линии до 800 выше. Иконка, нарисованная во весь viewBox, выходит
ровно 1em высотой и встаёт вровень со строкой текста, без подпорок через
vertical-align. Ширина масштабируется с тем же коэффициентом, так что
неквадратная иконка сохраняет пропорции и бывает заметно шире одного em.
Белое — это не цвет
У глифа нет ни цвета, ни прозрачности — только контур и заливка внутри него. Поэтому две вещи из SVG нельзя перенести как есть.
Первая — белая фигура. В папке examples/icons лежит badge-colour.svg: синий
круг, а поверх него белая галочка обводкой. Перенести их буквально — значит
получить залитый круг: галочка станет краской ровно там, где рисунок просил,
чтобы её не было.
Вторая — бледная заливка. Рядом, в shapes/, лежит tinted-ring.svg: это кольцо
со значком внутри и подложка того же цвета с opacity="0.2". Прозрачности у
глифа нет, так что подложка залилась бы в полную силу и поглотила бы всё, что
нарисовано поверх неё.
Обходятся обе одинаково. Фигуры берутся в порядке отрисовки, а что значит каждая из них, решает то, что уже лежит под ней. Поверх краски — выворотка: контур наматывается в обратную сторону и становится дыркой. Поверх пустоты — фон, и он выбрасывается. Вычесть можно только вложенную фигуру — правда, вложенность проверяется по габаритным прямоугольникам, и это грубо.
Порог бледности — 0.5, порог белизны — 0xF0 по каждому каналу. Оба числа я взял
из головы, и оба пока держатся.
У правила есть исключение, без которого оно ломает нормальные картинки. Иконка, нарисованная целиком белым или целиком бледной заливкой, — это просто светлая иконка, и рисовать её надо. Поэтому правило включается только там, где в файле есть хоть одна фигура в полную силу.
Когда цвет нельзя выбросить
Правило из прошлого раздела отрабатывает и на самом badge-colour.svg: синий
круг с белой галочкой сводится к кругу с дыркой вместо залитого пятна. Для
одноцветного глифа это лучшее, что тут возможно, — но синего в нём уже нет.
Поэтому иконки, в которых больше одного цвета, выводятся слоями COLR/CPAL — это
механизм OpenType ровно для такого случая — и рендерятся как нарисованы.
Сплющенный контур с этой дыркой никуда не девается: он остаётся базовым глифом,
тем самым, что увидит рендерер без поддержки COLR. Правила про белое и бледное в
слоях уже не работают: там есть настоящие цвета, так что галочка остаётся белой
на синем круге.
Слои, закрашенные currentColor, получают индекс палитры 0xFFFF — значение,
которое OpenType резервирует под цвет текста. Так в одной иконке уживаются
намертво заданный цвет и тот, что слушается вашего CSS.
Решать пришлось другое: когда так не делать. Иконка одного плоского цвета —
currentColor или захардкоженный тёмно-серый — остаётся обычным глифом. Смысл
цвета — в отношении между красками, а у одной краски отношений нет, сохранять
нечего. Зафиксировать её — значит только отнять у иконки способность
перекрашиваться и заморозить её в том единственном цвете, что оказался в файле,
даже если на тёмном фоне она пропадает.
Что не станет глифом
Некоторые SVG не могут стать глифами вообще — таких случаев три, и молча пропускать их нельзя.
Первый — растр, завёрнутый в SVG. PNG, вставленный из дизайн-редактора, приезжает
либо как <image>, где обводить просто нечего, либо как заливка через
<pattern> — и такая заливка обвелась бы по собственному контуру, то есть обычно
по прямоугольнику, и вышла бы сплошным чёрным пятном. Второй — рисунок,
оформленный через <mask>: маска решает попиксельно, сколько от каждой фигуры
выживет, а контур такого выразить не может. И третий — файл, в котором нечего
рисовать вовсе. Сборка падает и называет всех нарушителей разом, так что большой
набор чинится за один проход; --on-error skip собирает шрифт без них.
А вот чего сборка не ловит: градиент схлопывается в свой первый цвет, потому что слой — это одна плоская краска. Заливка слабее половинной непрозрачности считается фоном — по правилу из раздела о белом, с той же оговоркой про фигуру в полную силу. Та, что плотнее, рисуется сплошной. Текст, не переведённый в кривые, выпадает так же тихо: контура он не даёт, а если рядом есть фигуры, проверка на пустой глиф его не поймает. Файл конвертируется успешно, просто выглядит не так, как в редакторе.
Мой любимый сбой этого рода я уже починил. usvg опознаёт только точное
написание currentColor и в остальных случаях выбрасывает краску. Ключевые слова
SVG регистронезависимы, дизайн-редакторы охотно пишут currentcolor, и иконка с
таким написанием теряла все обводки и собиралась в пустой глиф. Ни ошибки, ни
предупреждения, просто пустое место. Сейчас вход нормализуется перед разбором, а
файл, который всё-таки собрался в пустоту, упрётся в проверку на пустой глиф —
она приехала тем же коммитом. Но это одно ключевое слово, пойманное руками;
целого класса оно не закрывает.
Имена, пути и кодовые позиции
Самая скучная часть. От неё зависит, можно ли пересобрать шрифт и ничего не сломать.
Имя файла становится CSS-классом: нижний регистр, слаг из имени, префикс из
подпапок. arrows/left.svg — это .icon-arrows-left, и две папки могут спокойно
держать по своему left.svg.
Иконки получают символы в Private Use Area начиная с U+E900. Наивный подход
раздаёт их в порядке имён на каждой сборке, и это прекрасно работает, пока
кто-нибудь не добавит aardvark.svg. Он сортируется первым, забирает U+E900, и
каждая иконка после него сдвигается на одну позицию. Шрифт по-прежнему
собирается, CSS по-прежнему валиден, а браузер, у которого в кэше остался старый
CSS, соединяет его с новым шрифтом и показывает не те иконки, ни на что не
жалуясь.
Поэтому рядом с иконками лежит icofon.json, и его надо коммитить. Ключ в нём —
путь файла: имя иконки может измениться от того, что рядом появился сосед, а путь
останется прежним. Позиция никогда не выдаётся повторно, даже если иконку
удалили: иначе закэшированный CSS однажды тихо покажет другую картинку.
Позицию можно прибить руками, префиксом в имени файла — uE9F0-star.svg даёт
.icon-star на \e9f0. А map-pin.svg и map_pin.svg сводятся к одному слагу
— такие дубликаты роняют сборку, и она перечисляет все столкновения сразу;
--on-duplicate number нумерует их и привязывает номер к самому файлу, в
icofon.json. Раньше сборка нумеровала их сама, и выбора не было, — что
оказалось ловушкой: третий конфликтующий файл, который сортировался первым,
переименовывал остальные, а вместе с именами уезжали и позиции.
Отдельно про пересборку: шрифт собирается воспроизводимо. В полях head.created
и head.modified, где TrueType хранит даты, стоит фиксированное число. До этого
туда шло текущее время, поэтому одни и те же иконки давали разные байты, и файл,
закоммиченный рядом с исходниками, числился изменённым после каждого запуска.
Три контейнера
WOFF2 сжат brotli, его понимает любой браузер начиная с 2016 года, и на практике только он и скачивается. WOFF сжат deflate — запасной вариант для всего, что старше. TTF не сжат вовсе и нужен настольным приложениям и инструментам вне веба. Все три оборачивают одни и те же контуры, ничего не перерисовывается; CSS перечисляет их от меньшего к большему, и браузер останавливается на первом контейнере, который понимает.
Префикс забирает себе все имена
По умолчанию стили цепляются к любому классу, который начинается с префикса:
[class^="icon-"],
[class*=" icon-"] {
font-family: 'icons' !important;
…
}
Ничего не надо помнить: добавил иконку — она работает. Цена в том, что всё
пространство icon-* теперь принадлежит шрифту: ваш собственный .icon-button
или класс-утилита из фреймворка получает иконочный шрифт, причём через
!important, так что обычным правилом его не перебить.
--base-class работает иначе. Префикс становится самостоятельным классом, каждое
правило требует его, разметка выходит многословнее — class="icon icon-check", —
зато по имени больше ничего не захватывается. !important при этом остаётся:
меняется только то, что он накрывает. Так и надо делать, когда вы кладёте шрифт в
кодовую базу, которую контролируете не целиком.
Страница предпросмотра
Сборка пишет index.html. Задумывалась эта страница как разовая проверка, но
заглядываю я в неё чаще, чем во всё остальное, что лежит рядом.

На карточке — имя, селектор, кодовая позиция и, если есть что сказать, отметка о
ширине или о цвете. Поиск работает по имени, классу, папке и хексу позиции — без
U+, — а заодно по словам состояния — тому же fixed, о котором ниже. Клик по
строке с классом кладёт в буфер сам класс — то, что надо вписать в class.
Подпапки становятся разделами.

Кроме поиска, фильтров два, и работают все три вместе. Папка сужает набор до подпапки, а цвет делит его по тому, насколько CSS может на иконку повлиять: целиком, частично или никак. В этом наборе встречаются только первое и последнее. Выше в той же панели стоят образцы цвета и выбор любого другого. Переключатель темы — ещё выше, в строке заголовка. Он нужен, потому что иконки рисуют на белом, а ставят куда придётся.
Любое из трёх сужает не только сетку: группы, из которых ничего не осталось, сворачиваются вместе с иконками.

Выбранный цвет применяется только к глифам, чтобы подписи оставались читаемыми:

Пятнадцать иконок стали красными. badge-colour осталась синей, потому что этот
синий живёт в шрифте, а не в вашем CSS, — и её белая галочка осталась белой по
той же причине. На карточке написано fixed. Фильтр «Fixed» показывает такие
иконки за один клик.
Дальше я вернул чёрный и переключил тему. Выбирать цвет заново не пришлось: чёрный сам стал белым — эти двое меняются местами при переключении. Любой другой выбранный цвет остался бы как есть, красный в том числе.

Две вещи на этой странице я недооценил. Карточке приходится знать пропорции своей
иконки. В примерах самая широкая — wide-bar, вдвое шире своей высоты, но
логотип-надпись бывает шире в разы и просто уезжает за край, если его не ужать.
Липкая панель обязана держать одну высоту. Стоит ей измениться, как она сдвигает контент под собой, а браузер компенсирует сдвиг привязкой прокрутки (scroll anchoring). Дальше они дерутся, и на мелкой прокрутке у самой границы залипания это видно.
Одно место мне тут не нравится. Страница подключает сгенерированный CSS, то есть рендерится ровно теми стилями, которые будут на сайте, — но собственная её вёрстка тянет Tailwind и веб-шрифты с чужих CDN. Без сети она выглядит криво; шрифта и стилей это не касается.
Чего это стоит
Цена иконочного шрифта — доступность. Глиф не картинка, и от скринридеров его
надо прятать через aria-hidden. А если шрифт не загрузился, символ из Private
Use Area достаётся запасной гарнитуре, и та обычно рисует квадратик: на месте
иконки оказывается мусор. Поэтому всё, что несёт смысл, лучше оставить картинкой
с alt. А для интерфейсной обвязки — шеврон в выпадающем списке, крестик закрытия
— я на этот размен согласен.
Если попробуете и что-нибудь сломается — issue или pull request на https://github.com/JWo1F/icofon будет кстати. Особенно интересны SVG, которые конвертируются без ошибки, но неправильно: такие случаи я нахожу только глазами.
Комментарии 0
Комментариев пока нет.