Руководство по интеграцииAPI-справочникДемо виджета

Сканер QR кассового чека — код для вашего фронтенда

Если чек принимает ваш собственный интерфейс, а не наш виджет, вам нужна та часть, которая читает QR с камеры и с фотографии. Мы вырезали её из боевого виджета fnfd отдельным файлом — без интерфейса, без стилей и без обращений к нашему API. Берите как есть или как образец.

Скрипт кладите к себе: адрес выше — наш прод, но зависеть от чужого домена в критичном месте приёма чека не стоит.

Содержание
  1. Быстрый старт
  2. Что умеет модуль
  3. Если чек не читается, а приложение ФНС его берёт
  4. Что важно знать до того, как это попадёт к пользователям
  5. Лицензия

1. Быстрый старт

<video id="cam" playsinline muted style="width:100%;max-width:420px"></video>
<script src="/js/fnfd-qr-scanner.js"></script>
<script>
  let stopScan = null;

  async function startScan() {
    const video = document.getElementById("cam");
    stopScan = await FnfdQrScanner.scanVideo(video, (text) => {
      if (!FnfdQrScanner.isFiscalQr(text)) return false;  // не чек — сканируем дальше
      sendReceipt(text);                                  // ваш вызов POST /api/receipts/qr
      return true;                                        // true — камера гаснет
    });
  }

  // stopScan() вызывайте всегда: при закрытии окна сканирования, уходе со страницы,
  // по таймауту. Иначе камера продолжит гореть.
</script>

Фото или скриншот чека:

const text = await FnfdQrScanner.decodeFile(file);   // File / Blob
if (text && FnfdQrScanner.isFiscalQr(text)) {
  sendReceipt(text);             // лёгкая строка вместо мегабайтной картинки
} else {
  sendPhoto(file);               // QR не читается — пусть распознаёт бэкенд
}

Эта развилка экономит и трафик пользователя, и время ответа: строка проверяется в ФНС сразу, фото уходит в очередь распознавания.


2. Что умеет модуль

МетодЧто делает
scanVideo(video, onText, opts)Включает камеру и читает кадры, пока onText не вернёт true. Резолвится функцией остановки.
decodeFile(file, opts)Читает QR с картинки. Резолвится строкой или null — «не нашли» это не ошибка.
isFiscalQr(text, country)Похоже ли на фискальный QR: "ru" — ФНС, "uz" — ОФД ГНК, без аргумента — любой из двух.
torch(video, on)Подсветка, если камера умеет. Резолвится false, если нет.
focus(video)Толчок автофокуса. Повесьте на кнопку и на тап по превью.
listCameras()Все камеры устройства: {deviceId, label, facing}. Метки доступны только после выданного разрешения.
backCameras(list)Из списка выше — только тыловые. Камеру с неизвестной стороной тыловой не считает.
selfTest()Прогон движков по эталонному QR: {nativePresent, native, jsqr}.
decodeOnServer(blob, {url, apiKey})Отдать кадр на разбор серверу (zbar), когда браузер не справился.
locateCode(src, w, h)Найти «глаза» QR и вернуть его прямоугольник. Внутренний, но полезен для отладки.
hasNativeDetector()Есть ли в браузере BarcodeDetector (диагностика, в логи).

opts для scanVideo:

ОпцияПо умолчаниюЧто делает
deviceId—конкретный объектив; без него берётся запомненный удачный, иначе решает браузер
autoLenstrueне нашли за lensTryMs — переключиться на следующий тыловой объектив
lensTryMs6000сколько ищем одним объективом
rememberLenstrueзапоминать удачный объектив в localStorage
countryоба"ru" / "uz" — какой формат считать чеком
onDiag(d)—разрешение, движок, объектив, кадры/сек, попытки, резкость, яркость, снимки
onHint(h)—подсказки пользователю по времени: кадрирование, тап-фокус, «снимите фото», смена объектива
onStill(blob)—полноразмерный снимок, который сканер сделал сам
serverDecode—{url, apiKey} — разбор кадра на сервере (zbar), когда браузер не справился
stilltrueделать полноразмерные снимки через ImageCapture
zoomfalseперебирать кратность 1× → 2× → 3×; по умолчанию не трогаем
locatetrueискать «глаза» QR своим локатором (третья попытка по кадру)
roi0.6доля кадра под центральный кроп
forceEngine—"native" / "zxing" / "jsqr" — для замеров
jsqrUrl, zxingUrlнаш продсвои адреса вендорных движков

3. Если чек не читается, а приложение ФНС его берёт

Разбор живой жалобы 09.09.2026 дал три причины, и ни одна из них не «слабый декодер вообще».

Движок может быть слепым. BarcodeDetector в браузере может присутствовать и не работать: на Android он делегирует распознавание модулю Play Services, и если модуль не установлен, detect() молча возвращает пустой список на каждом кадре. Сканер при этом честно «работает» — вечно. Поэтому движок проверяется эталонным QR (FnfdQrScanner.selfTest()), и если он слеп, не используется вовсе.

Нельзя ужимать весь кадр. Это ошибка, которую я допустил, разгоняя сканер: кадр ужимался до 900 px целиком. Когда чек держат далеко и код занимает 8-10% ширины кадра, после ужатия от него остаётся 40-50 px — не читает никто. Замер на кадрах 1080×1920: код в 30% и 15% ширины ужатый кадр берёт, код в 10% и 8% — уже нет, а центральный кроп в исходном разрешении берёт все четыре. Поэтому масштабы чередуются: весь кадр ужатый (код крупный), центр 60% и центр 35% — в нативном разрешении. Полноразмерный снимок разбирается плитками 3×3 с перекрытием, тоже без общего ужатия.

Дело было в поиске кода, а не в его чтении. Библиотечный поиск в быстром режиме рассчитан на код, занимающий заметную долю кадра: мелкий он просто не находит — до декодирования дело не доходит. Включённый tryHarder меняет именно это: он ищет код многомасштабно. Замер на кадрах 1080×1920 (ZXing):

Доля ширины кадра900 px, быстрый режимвесь кадр + tryHarderцентр 40% + tryHarder
20%дадада
12%дадада
10%нетдада
8%нетдада
6%нетнетда
10%, код у краянетданет

Цена вопроса: 6 мс на попытку против 3 мс (в живом цикле кадр ограничен 1080 px — 3,5 мс, читаемость та же) — то есть «экономия» на быстром режиме была ложной. Поэтому основная попытка теперь — весь кадр в исходном разрешении с tryHarder, а через кадр — центр 40% для совсем мелкого кода. Дополнительно работает свой локатор: он ищет «глаза» QR (профиль 1:1:3:1:1) и, если находит, отдаёт декодеру плотный вырез — это ускоряет обычные случаи, но на мелком коде помогает не всегда, поэтому он идёт третьей попыткой, а не вместо основной.

Частота попыток тоже важна — но не ценой масштаба. Попытка идёт на каждый кадр видео (requestVideoFrameCallback), а не по таймеру: так сканер ловит те доли секунды, когда автофокус успел навестись. Дорогие вещи вынесены из горячего пути — локатор работает раз в три кадра, оценка резкости раз в восемь.

Фокус. Чек подносят вплотную, ближе минимальной дистанции фокусировки — камера физически не наводится, и в кадре мыло при любом разрешении. Поэтому: непрерывный автофокус, толчок автофокуса по таймеру и по тапу на превью, наведение на найденный код через pointsOfInterest, подсказки по времени («QR должен занимать примерно треть кадра», «нажмите, чтобы навести фокус», «снимите фото») и оценка резкости кадра, которую можно показать пользователю.

Объектив решает больше, чем движок

Замер на живом устройстве (Samsung, четыре камеры — две тыловые, две фронтальные), один и тот же чек, одна и та же сборка сканера:

ОбъективДиапазон фокусаПодсветкаРезультат
camera 0, facing back0.10–4.05 местьраспознан за 3 кадра, 1 секунда
camera 2, facing back0–0.79 мнет169 попыток за 37 секунд — не распознан

facingMode: environment отдаёт одну камеру на усмотрение браузера, и это не обязательно та, что сфокусируется на чеке в руке. Поэтому сканер теперь: пробует объектив 6 секунд, не вышло — переключается на следующий тыловой; удачный запоминает в localStorage и в следующий раз начинает с него; при тёмном кадре сам включает подсветку, если объектив её отдаёт. Опции: deviceId, autoLens: false, lensTryMs, rememberLens: false.

Фронтальные камеры в переборе не участвуют. На телефоне их обычно столько же, сколько тыловых (у устройства из замера — четыре камеры: две и две), и переключение на селфи-камеру выглядит как поломка. Сторона определяется через InputDeviceInfo.getCapabilities().facingMode, а если устройство молчит — по метке (facing back, rear, задняя…). Камеру с неизвестной стороной тыловой не считаем: лучше остаться на текущей, чем показать пользователю его же лицо. Если тыловых меньше двух — перебор просто не запускается. Фронтальная камера не запоминается как удачная, даже если каким-то образом код прочитан именно ею.

Чего у браузера нет по сравнению с нативным приложением

Часть разрыва закрывается кодом, часть — нет. Честный список:

ЧтоНативное приложениеБраузер
Выбор объективаберёт любой физический модуль: ультраширик фокусируется с 3-5 см, теле лучше видит мелкое издалекаможно — через deviceId из enumerateDevices(); facingMode: environment всегда даёт только основную камеру
Фокус по областиMeteringRectangle: «наводись сюда»pointsOfInterest есть в спецификации, реализован не везде — пробуем best-effort
Подсветкавсегдатолько если камера отдала torch в capabilities; на части устройств не отдаёт
Кадры для анализаYUV прямо с сенсора, отдельный поток 12 Мп для снимкаодин поток, RGBA через канвас, копия на каждый кадр
ДетекторML Kit — обученная модель, находит код смазанным и частично перекрытымклассическое зрение (ZXing) или своя эвристика
Экспозициякороткая выдержка, чтобы не смазывалоexposureTime/exposureCompensation — редко доступны
Сцена «штрихкод»CONTROL_SCENE_MODE_BARCODE в Camera2нет

Из этого списка мы теперь используем всё, что доступно: перебор объективов (метод listCameras(), опция deviceId), наведение на найденный код через pointsOfInterest, подсветку, zoom, непрерывный автофокус и его толчок. Недоступное принципиально — ML-детектор и сцена «штрихкод»; отчасти это компенсирует tryHarder и собственный локатор.

Движки и цифры

ДвижокЧто этоКогда используется
BarcodeDetectorнативный, через Play Servicesтолько если прошёл самопроверку
ZXing C++ (wasm)тот же класс декодера, что в нативных сканерахосновной; ~1 МБ, грузится лениво
jsQRлёгкий JS-декодерпока грузится wasm и если он не загрузился

Замер в браузере на девяти намеренно испорченных снимках (2–4 px на модуль, размытие, бледная печать, наклон, jpeg 60): jsQR 3/9, ZXing 5/9, нативный 5/9. Скорость одной попытки по живому кадру 900 px: jsQR 11 мс, ZXing 2 мс — то есть 88 против 585 попыток в секунду. Четыре снимка с размытием 0.9–2.4 px не берёт ни один движок: это и есть случай «камера не навелась», который лечится дистанцией и фокусом, а не декодером.

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

Запасной разбор на сервере. Кадр, который не взяли браузерные движки, можно отправить на POST /api/qr (multipart photo) — там zbar в несколько проходов; ответ содержит qrString. В модуле это опция serverDecode: { url, apiKey }.

Проверить на конкретном телефоне: fnfd.ru/qr-test.html — самопроверка движков, реальное разрешение потока, поддержка фокуса и zoom, кадры в секунду, резкость кадра, превью снятого кадра и кто в итоге разобрал: браузер или сервер.

Разбор фотографии — многопроходный и на клиенте, и на сервере: целиком, центральным кропом, кропом с укрупнением. Серверный декодер на двенадцати испорченных снимках за один проход берёт 7, многопроходный — 10.


4. Что важно знать до того, как это попадёт к пользователям

Камера работает только в secure context — https:// или localhost. По http:// браузер не покажет даже запрос доступа: scanVideo сразу отклонится. Это не наше ограничение и обойти его нельзя, поэтому ручной ввод реквизитов должен быть в интерфейсе всегда, а не «на всякий случай».

Два движка, потому что нативного не хватает. BarcodeDetector есть в Chrome и на Android — там не грузится ничего лишнего. В Safari и Firefox его нет, и модуль лениво подтягивает jsQR (~250 КБ) в момент первого сканирования, а не при загрузке страницы. Проверить, какой путь сработал у пользователя, можно через hasNativeDetector().

Гасите поток при закрытии. Между «пользователь нажал сканировать» и «браузер выдал доступ» проходит время, за которое окно успевают закрыть. Поток при этом всё равно выдаётся, и если его не остановить — камера остаётся включённой; на iOS это видно индикатором. В модуле это учтено (stop() до получения потока тоже сработает), но если пишете своё — не забудьте.

willReadFrequently: true у канваса. Без него getImageData на каждом кадре выбивает канвас с GPU-пути, и на недорогих телефонах сканирование заметно дёргается.

Фильтруйте не-чеки на клиенте. Камера ловит любой QR — с упаковки, ценника, плаката. Без isFiscalQr вы будете слать это на сервер и показывать пользователю отказы вместо подсказки «наведите на QR внизу чека».

Ручной ввод — по реквизитам, а не по строке QR. Проверено на промо-аудитории: строку QR обычный человек взять неоткуда, а ФН, ФД, ФП, дату и сумму он перепишет с бумажного чека. Как из них собрать стандартную строку — в руководстве, раздел про приём чека; готовая форма есть в боевом виджете.


5. Лицензия

Откуда берётся wasm. Модуль грузит zxing-reader.iife.js обычным <script>, а zxing_reader.wasm — из той же папки, что и этот скрипт (по умолчанию https://fnfd.ru/assets/js/vendor/zxing/, при своём zxingUrl — рядом с ним), а не с fastly.jsdelivr.net, куда его по умолчанию отправляет сама сборка zxing-wasm. Размещая копию у себя, кладите zxing_reader.wasm рядом с zxing-reader.iife.js; если скрипт и страница на разных доменах, wasm должен отдаваться с Access-Control-Allow-Origin и Content-Type: application/wasm.

Сам модуль — наш код, берите и правьте. Запасной движок jsQR распространяется по Apache-2.0 (github.com/cozmo/jsQR): размещая его копию у себя, положите рядом текст лицензии. Наш файл assets/js/vendor/jsQR.min.js — сборка upstream без изменений.