все посты
Десятки разрозненных квадратиков, в каждом чёрная иконка, воронкой сходятся слева направо в одну большую карточку с синей галочкой.

Как папка 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. Задумывалась эта страница как разовая проверка, но заглядываю я в неё чаще, чем во всё остальное, что лежит рядом.

Страница предпросмотра icofon в светлой теме: шестнадцать карточек с именем, CSS-селектором и кодовой позицией; разделы — по подпапкам, верхний уровень без заголовка.

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

Панель в верхней части страницы предпросмотра: поле поиска, ряд образцов цвета и два фильтра — по цвету («Recolorable 15», «Fixed 1») и по папке («All folders»).

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

Любое из трёх сужает не только сетку: группы, из которых ничего не осталось, сворачиваются вместе с иконками.

Та же страница с запросом «ai» в поиске: осталось два глифа из шестнадцати, оба из папки «ai».

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

Та же сетка с выбранным красным: все глифы красные, кроме badge-colour.

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

Дальше я вернул чёрный и переключил тему. Выбирать цвет заново не пришлось: чёрный сам стал белым — эти двое меняются местами при переключении. Любой другой выбранный цвет остался бы как есть, красный в том числе.

Страница предпросмотра в тёмной теме: та же сетка глифов, теперь белым по почти чёрному; badge-colour по-прежнему синяя.

Две вещи на этой странице я недооценил. Карточке приходится знать пропорции своей иконки. В примерах самая широкая — wide-bar, вдвое шире своей высоты, но логотип-надпись бывает шире в разы и просто уезжает за край, если его не ужать.

Липкая панель обязана держать одну высоту. Стоит ей измениться, как она сдвигает контент под собой, а браузер компенсирует сдвиг привязкой прокрутки (scroll anchoring). Дальше они дерутся, и на мелкой прокрутке у самой границы залипания это видно.

Одно место мне тут не нравится. Страница подключает сгенерированный CSS, то есть рендерится ровно теми стилями, которые будут на сайте, — но собственная её вёрстка тянет Tailwind и веб-шрифты с чужих CDN. Без сети она выглядит криво; шрифта и стилей это не касается.

Чего это стоит

Цена иконочного шрифта — доступность. Глиф не картинка, и от скринридеров его надо прятать через aria-hidden. А если шрифт не загрузился, символ из Private Use Area достаётся запасной гарнитуре, и та обычно рисует квадратик: на месте иконки оказывается мусор. Поэтому всё, что несёт смысл, лучше оставить картинкой с alt. А для интерфейсной обвязки — шеврон в выпадающем списке, крестик закрытия — я на этот размен согласен.

Если попробуете и что-нибудь сломается — issue или pull request на https://github.com/JWo1F/icofon будет кстати. Особенно интересны SVG, которые конвертируются без ошибки, но неправильно: такие случаи я нахожу только глазами.

Комментарии 0

Комментариев пока нет.