Этой весной мне понадобился лист QR‑кодов с подписями: по коду на каждую позицию, чтобы скачать PDF, распечатать и разрезать. Выяснилось, что онлайн‑генераторы делают коды по одному, просят подписку за пакетную генерацию или прогоняют ссылку через свой редирект, который умрет вместе с триалом.

В итоге я написал свой сервис, бесплатный, до 1500 кодов за запуск, на выходе PDF, SVG или ZIP с PNG. Это моя разработка, так что дисклеймер: дальше реклама, но техническая.

Продуктовую историю я уже рассказывал на vc, повторять не буду. Здесь про то, что интереснее разработчику: как устроено ядро, почему оно детерминированное, и пять граблей, на которые я наступил. Стек скучный и рабочий: PHP 8.2, chillerlan/php‑qrcode, TCPDF, OpenSpout, GD.

Ядро: чистая функция от входа до SVG

Генератор устроен как чистое ядро и тонкая обвязка. Класс ядра принимает провалидированный вход (список строк, размеры, цвета, логотип) и отдает SVG: превью, лист по номеру, поштучный код. Он не знает про HTTP, CMS и файловую систему. Экспортеры PDF и ZIP потребляют интерфейс «источник листов» и тоже ничего не знают про то, откуда пришли данные.

В SVG нет ни таймстампов, ни случайности, ни идентификаторов запуска. Одинаковый вход дает байт‑в-байт одинаковый файл. Сначала это было просто опрятностью, а потом стало главным инструментом рефакторинга.

Когда сервису понадобились штрих‑коды EAN-13 (о них ниже), ядро пришлось обобщать: вместо «генератора QR» появился «генератор символики», где QR стал частным случаем.

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

Тот же прием работает после деплоя: генерирую файл на проде и локально из одинакового входа, сравниваю байты. Одна оговорка: PNG так сравнивать можно только между одинаковыми сборками GD и libpng. Прод и локальный Docker дают разные байты при одинаковых пикселях, поэтому для PNG сравнение честное только попиксельное.

Грабля 1: кириллические домены

Хотелось, чтобы строка «пример.рф/меню» работала без ручного ввода схемы, а камера при сканировании показывала человеку кириллицу. Тут выяснилось, что у URL в этой задаче две формы.

Для валидации нужен ASCII: FILTER_VALIDATE_URL юникода не понимает. Поэтому URL переводится в ASCII‑представление только на время проверки: хост через idn_to_ascii (на сервере без intl выручает symfony/polyfill‑intl‑idn), а path, query и fragment через посимвольный percent‑encoding всего, что выходит за печатный ASCII. Дальше обычные filter_var и parse_url со сверкой схемы и хоста.

В сам QR уходит исходная юникодная форма. Камера телефона показывает «пример.рф/меню», а не «xn--e1afmkfd.xn--p1ai». Если бы в код записывался punycode, человек перед переходом видел бы абракадабру и не пошел по ссылке: для таблички на столике кафе это фатально.

Грабля 2: TCPDF молча теряет кириллицу в SVG

Лист в PDF вставляется методом ImageSVG: вектор остается вектором, типография довольна. Но подписи под кодами из SVG‑текста в PDF приезжали вопросиками: ImageSVG не рендерит кириллицу в элементах text. Растровые image внутри SVG у него тоже ведут себя ненадежно, так что логотипы под ту же раздачу.

Решение: ядро умеет отдавать лист без оверлеев, а подписи и логотипы рисуются поверх средствами самого TCPDF. SetFont('dejavusans'), Cell с центрированием по тем же координатам, что использовал SVG‑рендерер, логотип отдельным вызовом Image.

Координаты приходят из единственного расчета сетки в сантиметрах, пересчет в пункты: 28,3465 pt на см. Дублирования логики раскладки нет, есть два потребителя одного расчета.

Побочный бонус: у EAN-13 цифры под штрихами в PDF рисуются тем же механизмом, и кегль пришлось выводить из метрик шрифта. Группа из 6 цифр должна лечь в 40 модулей с зазором до защитных штрихов; при ширине цифры DejaVu Sans около 0,636 em это дает кегль около 10,5 модуля. Такие константы лежат в коде с комментарием, откуда взялось число, иначе через полгода их страшно трогать.

Грабля 3: Excel превращает артикулы в 4.6E+12

Импорт списка принимает XLSX. Телефон или штрих‑код в числовой ячейке OpenSpout отдает как float, и наивное приведение к строке дает экспоненту: 4600000000008 становится 4.6E+12. Человек этого числа не вводил и в напечатанном коде его не ждет.

Лечится форматированием с запасом точности и обрезкой хвоста: sprintf('%.10F', $value), затем срезать нули и точку. Заодно всплыла вторая особенность: Excel хранит «пустые» строки после данных, если у них осталось форматирование. Ридер запоминает последнюю непустую строку и отрезает хвост, иначе список из 30 позиций приходил как 500 строк, где 470 пустые.

Грабля 4: прозрачный PNG, который зеленый в тестах и маджентовый в жизни

Фича «прозрачный фон» для PNG. Первая версия делала прозрачность одним tRNS‑чанком: фон заливается техническим цветом‑ключом, ключ объявляется прозрачным. Все проверки зеленые: jsQR код читает, imagecolortransparent ключ видит.

Потом я прогнал файл через пару реальных конвейеров и посмотрел на результат глазами. Везде, где tRNS не читают, а таких мест много (тот же GD теряет его при imagecopyresampled), вместо прозрачности вылезал ядовито‑маджентовый фон #fe00fe. Пользователь вставил бы такой «прозрачный» код в макет и получил сюрприз.

Теперь цвет‑ключ остался внутренней технологией, а итоговый PNG всегда переносится на канву с честным альфа‑каналом: imagecopy пропускает пиксели ключа, на новой канве остается только код. Ключ выбирается из трех кандидатов, чтобы не совпасть с цветом самого кода, который настраивается пользователем.

С логотипом тонкость отдельная: у PNG‑логотипа полупрозрачные края, и класть его на цвет‑ключ нельзя, края смешаются с ключом и дадут цветную кайму по контуру.

Урок из этих граблей сформулирую жестко: прозрачность и цвет проверяются глазами на темной подложке. Числовые проверки смотрели на файл и дефекта не видели.

Грабля 5: getSize() считает не то, что вы думаете

Для расчетов печати нужен размер QR‑матрицы в модулях: от него зависит физический размер модуля и рекомендации «код такой длины при 2 см уже не читается». У chillerlan/php‑qrcode после getQRMatrix() метод getSize() возвращает сторону вместе с тихой зоной: quietzoneSize уже прибавлен с двух сторон.

Если этого не знать, все расчеты уезжают на 8 модулей, а рекомендации начинают врать в безопасную, но бесполезную сторону. Правильный источник: номер версии, сторона матрицы равна 17 + 4 × версия.

В тестах я в итоге читаю сторону с готовой картинки, первым темным пикселем на диагонали: подбор версии по размеру файла неоднозначен, 21 и 33 модуля при разном масштабе дают одинаковую сторону в пикселях.

EAN-13 без библиотеки, но с оракулом

Пользователи попросили штрих‑коды листами. Тащить зависимость ради EAN-13 не хотелось: весь стандарт это 3 таблицы по 10 семибитных паттернов (наборы L, G, R), схема четности левой половины, которую выбирает первая цифра, и защитные штрихи 101, 01010, 101. Итого 95 модулей.

Контрольная цифра: веса 1 и 3 по 12 цифрам, сумма дополняется до ближайшего кратного 10.

Самописный кодер штрих‑кодов - это то место, где обязательно ошибешься в таблице. От этого спасает оракул: в тестах те же номера кодирует TCPDFBarcode, и битовые последовательности сравниваются на 25 номерах, включая граничные.

В проде TCPDF для штрих‑кодов не используется: нужен свой SVG с цифрами под штрихами и точными пропорциями GS1 (ширина символа с тихими зонами от 2,9 до 7,5 см, стандартные 100 процентов это 3,7 см).

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

Мелочи, которые дешевле сделать сразу

Логотип в центре кода принимается только растром, PNG или JPEG. SVG‑логотип пришлось бы инлайнить в превью, а инлайн чужого вектора в своей странице это готовая XSS‑дыра.

Сам логотип ограничен четвертью стороны кода: вместе с подложкой это около 9 процентов площади, а ECC H выдерживает потерю до 30, поэтому сломать код загрузкой картинки не выйдет.

Цвета кода и фона настраиваются, но перед генерацией считается контраст по WCAG. Светло‑желтый код на белом фоне сервис не выпустит: камера его не прочитает, а пользователь узнал бы об этом после печати тиража.

Все проверки входа отвечают номерами строк: «Строка #47: не похоже на ссылку». Когда строк 1500, ошибка без номера бесполезна. Из той же серии подсказки на типовые случаи: перепутанный разделитель колонок, подпись перед ссылкой, неверная контрольная цифра EAN с показом верной.

Что в итоге

Ядро покрыто 650 с лишним юнит-тестами, которые гоняются PHPUnit в Docker без поднятия CMS: автозагрузка классов своя, окружение сервису для генерации не нужно. Golden-тесты ловят изменения байтов, оракул проверяет таблицы кодирования, а цвет и прозрачность проверяю глазами: тесты этого не видят.

Сервис живет на qqkod.ru, бесплатный и без регистрации: генерация дешевая, а нишевому инструменту пользователи важнее ранней монетизации. Если у вас есть сценарий тиражной печати кодов, под который не хватает возможностей, напишите в комментариях: роадмап живой, и EAN-13 в нем появился ровно так.

Комментарии (2)


  1. Astus
    24.08.2026 15:02

    Офлайн вариант не планируется?


    1. SorokinSergei Автор
      24.08.2026 15:02

      На данный момент нет. Сервис задуман как онлайн-инструмент: открыл страницу, вставил список, получил документ в нужном формате. Офлайн-версия означает отдельное приложение с установкой и обновлениями, пока что нет желания с этим заморачиваться.

      Если вопрос про данные: содержимое кодов на сервере не сохраняется.

      А какой у вас сценарий: закрытый контур без интернета или именно нежелание отправлять данные наружу?