QR версии 8-H: внешне обычная матрица и ошибочное число блоков, 5 вместо 6
QR версии 8-H: внешне обычная матрица и ошибочное число блоков, 5 вместо 6

Одна строка, два QR одинакового размера. Белое поле, три привычных квадрата по углам. Один возвращает исходные данные. Из второго два декодера не смогли извлечь ничего.

Если сравнивать маленькие квадраты по одному, различия видны. Но какое из двух изображений исправно? Ни один из них не выглядит сломанным.

Ошибка нашлась в таблице: для версии 8 с уровнем коррекции H мы указали пять блоков вместо шести. При подготовке этой статьи мы вернули опечатку в изолированную копию кодировщика и повторили эксперимент. А затем проверили и сам способ проверки: достаточно ли того, что QR удалось прочитать?

Рис. 1. Одна входная строка, версия 8-H, матрица 49 × 49 модулей и маска № 2. Белое поле входит в изображение. Слева восстановлен ошибочный вариант, справа текущая реализация. Проверяли исходный растр, до вставки в статью.
Рис. 1. Одна входная строка, версия 8-H, матрица 49 × 49 модулей и маска № 2. Белое поле входит в изображение. Слева восстановлен ошибочный вариант, справа текущая реализация. Проверяли исходный растр, до вставки в статью.

Почему у нас вообще появился свой кодировщик

Серверу требовалось отдавать QR в SVG. В той сборке в образ попадали готовый клиент, серверный файл и служебные скрипты. Каталога node_modules там не было: установленная в проекте библиотека генерации штрихкодов в серверном окружении оказывалась недоступна.

Мы добавили небольшой кодировщик прямо в поставляемый файл: байтовый режим, версии с 1 по 40, четыре уровня коррекции, выбор маски и вывод SVG. Эпизод с пятью блоками зафиксирован в комментарии к тесту и в коммите от 6 августа 2026 года.

Готовую библиотеку можно было включить в сборку: у Nayuki есть реализация без внешних зависимостей. Этот вариант стоило рассмотреть до написания своего кодировщика.

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

Пять блоков аккуратно помещались в квадрат

Для начала нужны три термина. Модуль — маленький тёмный или светлый квадрат. Кодовое слово в этом разборе — восемь бит. Блок — часть потока данных, для которой отдельно рассчитываются слова коррекции Рида — Соломона.

У версии 8 сторона равна 49 модулям. После выделения служебных областей остаётся место для 242 кодовых слов. Уровень H определяет, как это место делится между данными и коррекцией. Нужная строка таблицы ZXing задаёт шесть блоков: четыре по 14 слов данных и два по 15. К каждому добавляется по 26 слов коррекции.

Получается:

Данные:       4 × 14 + 2 × 15 = 86 слов
Коррекция:    6 × 26          = 156 слов
Всего:        86 + 156        = 242 слова

В нашей реализации ёмкость вычисляется из двух таблиц: количества блоков и числа слов коррекции на блок. С пятёркой арифметика продолжала сходиться:

Коррекция:    5 × 26          = 130 слов
Под данные:   242 − 130       = 112 слов
Всего:        112 + 130       = 242 слова

Ни отрицательного размера, ни выхода за массив. Все 242 слова можно уложить в ту же матрицу. Функция возвращает результат. Рисунок остаётся убедительным.

Рис. 2. Полосы показывают слова данных и коррекции в каждом блоке. Ошибочное разбиение: 22, 22, 22, 23, 23 слова данных. Правильное: 14, 14, 14, 14, 15, 15. Итоговая длина в обоих случаях равна 242 словам.
Рис. 2. Полосы показывают слова данных и коррекции в каждом блоке. Ошибочное разбиение: 22, 22, 22, 23, 23 слова данных. Правильное: 14, 14, 14, 14, 15, 15. Итоговая длина в обоих случаях равна 242 словам.

Есть ещё одна неприятная деталь. 86 слов данных не означают 86 байт пользовательской строки. В нашем байтовом режиме для версии 8 нужны четыре бита указателя режима и восемь бит длины. При нагрузке в 84 байта остаются четыре бита на терминатор:

4 + 8 + 84 × 8 + 4 = 688 бит = 86 слов

Поэтому правильная предельная нагрузка здесь — 84 байта. Ошибочная таблица обещала 110. Кодировщик считал, что у него появилось 26 дополнительных байт, хотя размер QR не изменился.

Это влияло и на выбор версии. В нашем повторном эксперименте строка из 85 байт переходила в версию 9 при правильной таблице, но оставалась в версии 8 при ошибочной. Для 110 байт расхождение доходило до версии 10 против версии 8.

Пятёрка одновременно портила структуру кода и делала его на вид более компактным.

Почему коррекция ошибок не помогла

Блоки не записываются в QR целиком один за другим. Сначала берётся первое слово данных каждого блока, затем второе и так далее. Если в коротком блоке слова закончились, его пропускают. После данных так же перемежаются слова коррекции.

Декодер видит версию 8 и уровень H. Отдельного поля «автор решил использовать пять блоков» в QR нет. Читатель берёт положенное разбиение из своей таблицы и восстанавливает шесть блоков. Ему приходится разбирать поток, собранный по другой схеме. К тому же из-за изменившихся границ блоков различаются сами слова коррекции.

Рис. 3. Буквы обозначают блоки, индексы — позиции слов внутри них. Это схема порядка, а не реальные значения байтов. Для 8-H декодер ожидает шестой блок; ошибочный кодировщик уже переходит ко второму слову первого.
Рис. 3. Буквы обозначают блоки, индексы — позиции слов внутри них. Это схема порядка, а не реальные значения байтов. Для 8-H декодер ожидает шестой блок; ошибочный кодировщик уже переходит ко второму слову первого.

Коррекция Рида — Соломона работает с определённой структурой блоков и ограниченным числом ошибок внутри них. DENSO описывает её возможности через кодовые слова, а не как обещание восстановить любую испорченную площадь картинки. Здесь ещё до чтения неверно сформирована сама последовательность слов.

В нашем случае jsQR не вернул результат. @zxing/library на трёх проверенных масштабах завершился ChecksumException. Получить исходную строку из ошибочного варианта не удалось ни одной библиотеке.

Убираем из опыта всё, что не нужно

Для воспроизведения мы взяли строку из 84 семёрок. Она заполняет полезную ёмкость правильной версии 8-H и при этом помещается в ошибочно рассчитанную ёмкость той же версии. Так можно сравнить две матрицы одного размера.

Почему цифры, а режим байтовый? Потому что исследуемый кодировщик всегда использует байтовый режим. Автоматического переключения в более экономный числовой режим в нём нет. Для другого генератора строка из тех же цифр может дать меньший QR, и это будет корректное поведение.

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

import { readFile } from "node:fs/promises";
import * as fixed from "./encoder-snapshot.mjs";

const source = await readFile(
  new URL("./encoder-snapshot.mjs", import.meta.url), "utf8"
);
const broken = await import("data:text/javascript;base64," +
  Buffer.from(source + "\nQR_BLOCKS.H[8] = 5;\n").toString("base64"));

const payload = "7".repeat(84);
const before = broken.qrMatrix(payload, "H");
const after = fixed.qrMatrix(payload, "H");

Это реконструкция конкретной ошибки на сохранённой версии кода.

Матрицу разворачиваем прямо в чёрно-белый растр. С каждой стороны добавляем белое поле шириной в четыре модуля, каждому модулю отводим целое число пикселей. Для jsQR пробуем масштабы 3, 4, 6 пикселей на модуль, для ZXing — 4, 3, 6. Каждый декодер останавливаем после первого успеха; при отказе пробуем следующий масштаб. SVG, браузерный рендеринг и сглаживание в этом опыте не участвуют.

Успехом считаем точное совпадение прочитанной строки с исходной. Сам по себе найденный на изображении QR ещё не означает, что данные восстановлены.

Параметр

Пять блоков

Шесть блоков

Входная строка

84 байта

84 байта

Версия и уровень

8-H

8-H

Сторона матрицы

49 модулей

49 модулей

Выбранная маска

2

2

Тёмные модули

1286

1250

jsQR 1.4.0

Не вернул строку

Вернул исходную строку

@zxing/library 0.21.3

Не вернула строку

Вернула исходную строку

В двух матрицах различаются 736 модулей из 2401. Числа тёмных модулей отличаются всего на 36: часть квадратов стала тёмной, часть светлой. Подсчёт чёрных точек тоже не дал бы понятного признака поломки.

Рис. 4. Оранжевый означает несовпадение, серый — одинаковый тёмный модуль. Это карта сравнения, она не предназначена для сканирования. 736 — число различий между двумя конкретными результатами кодирования, а не размер повреждения, который QR должен уметь исправить.
Рис. 4. Оранжевый означает несовпадение, серый — одинаковый тёмный модуль. Это карта сравнения, она не предназначена для сканирования. 736 — число различий между двумя конкретными результатами кодирования, а не размер повреждения, который QR должен уметь исправить.

Проверить все версии оказалось только началом

После ошибки в одной ячейке логично перестать выбирать для тестов только несколько удобных размеров. В QR Model 2 есть 40 версий, от 21 × 21 до 177 × 177 модулей, и четыре уровня коррекции. Получается 160 сочетаний.

Для каждого сочетания тест берёт строку из семёрок ровно на пределе байтовой ёмкости. Дополнительно проверяет, что кодировщик выбрал ожидаемую версию. Это существенно: без такой проверки тест «версии 8» мог бы незаметно сгенерировать версию 9 и благополучно её прочитать.

При подготовке статьи мы запускали оба декодера для каждого сочетания, даже когда первый уже справился. В 158 случаях строку вернули оба, ещё два дали расхождения. «Прочитал» здесь означает успех хотя бы на одном масштабе: библиотеки могли справиться при разных размерах модуля. Случаев, в которых не справился никто, не было.

Рис. 5. В каждой ячейке проверена одна строка на пределе ёмкости. Оба декодера вызваны независимо. Необычные результаты выделены цветом и подписаны: 23-L — ZXing, 36-L — jsQR.
Рис. 5. В каждой ячейке проверена одна строка на пределе ёмкости. Оба декодера вызваны независимо. Необычные результаты выделены цветом и подписаны: 23-L — ZXing, 36-L — jsQR.

Случай 23-L прочитала только @zxing/library. У jsQR 1.4.0 нашлась ещё одна табличная ошибка: для версии 23 одна из координат выравнивающих узоров равна 74 вместо 78, при отсчёте с нуля. Эта таблица участвует в определении служебных областей при чтении кодовых слов. Расхождение видно в исходниках jsQR и при сравнении с таблицами ZXing и Nayuki.

Случай 36-L прочитал только jsQR. На всех трёх масштабах @zxing/library вернула NotFoundException. Причину мы пока не выяснили. По одному этому результату нельзя судить ни о библиотеке в целом, ни о её алгоритме коррекции.

Наш регрессионный тест пропускает результат, если хотя бы один декодер восстановил строку. Для быстрого обнаружения явных поломок это полезно. Для утверждения «генератор работает без ошибок» — слишком слабое основание.

Декодер может скрыть ошибку, выполнив свою работу

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

Поэтому к чтению мы добавили сравнение с другим кодировщиком: Nayuki 1.8.0. Сравнивать две автоматически сгенерированные картинки напрямую нельзя. Один и тот же текст допускает разные режимы, версии, уровни коррекции и маски.

В опыте мы выровняли условия: один байтовый сегмент, одна версия, один уровень и одна маска. Автоматическое повышение уровня коррекции в Nayuki отключили. Маску брали ту, которую выбрал наш кодировщик: этот тест проверяет построение матрицы при выбранной маске, но не качество самого выбора.

Проверили четыре вида входа на пределе ёмкости: повторяющуюся цифру, чередование aZ, последовательность печатных ASCII-символов и псевдослучайную ASCII-строку с фиксированным начальным состоянием. Это 640 сравнений. Затем для каждого из 160 сочетаний версии и уровня добавили ещё две строки из последовательности печатных ASCII-символов: на один и два байта короче предельной ёмкости. Эти дополнительные 320 сравнений затрагивают заполнение свободного места словами 0xEC и 0x11.

Все 960 матриц совпали с Nayuki модуль в модуль. Отдельно совпали 160 пар табличных параметров: число блоков и слова коррекции на блок. Правильный вариант из начала статьи тоже совпал полностью. Ошибочный отличался от Nayuki на те же 736 модулей.

Здесь независимый кодировщик помогает ответить на вопрос, которого не слышит декодер: не только «можно ли восстановить строку», но и «построили ли мы ту же матрицу при тех же условиях».

И всё же 960 — размер нашего набора, а не число всех возможных QR. Мы не перебрали каждую длину строки, каждую комбинацию байтов и каждую маску. Полного доказательства соответствия стандарту такой опыт не даёт.

Что теперь стоит проверять вместе

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

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

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

Отдельный следующий слой — проверка SVG после рендеринга, печати и камеры. Наш опыт с чистым растром не измеряет влияние бумаги, размера модуля, размытия, бликов и наклона. Утверждать что-либо о них по приведённым результатам нельзя. Для такой статьи потребуются напечатанные образцы и отдельный протокол съёмки.

Пять блоков поместились в квадрат так же аккуратно, как шесть. Ошибку выдали лишние 26 байт ёмкости, 736 изменившихся модулей и отказ обоих декодеров.

Но и успешное чтение не закрывает проверку: декодер умеет исправлять ошибки. Поэтому теперь рядом с вопросом «прочиталось?» у нас есть второй: «а что именно мы записали?»


К статье приложен код эксперимента с протоколом и зафиксированными зависимостями. После распаковки: npm ci --omit=dev, затем npm test. Проверка использует Node.js; приведённые результаты получены на Node.js 24.15.0, Windows x64. В архив входят сохранённый кодировщик, Nayuki 1.8.0 с лицензией, контрольные суммы и полный results.json. Ошибка с пятью блоками воспроизводится в памяти. Новый запуск записывает latest-run.json, сохраняя протокол статьи без изменений.