Задача звучала прямолинейно: пользователь выделяет прямоугольник на отсканированном PDF, нажимает кнопку, и поверх оригинала появляется редактируемый текстовый слой — точно там, где был нечитаемый или ошибочный текст. Никакого сервера, никакой загрузки файлов. Вот как это собрано.
Архитектурный вызов: OCR в браузере без бэкенда
Первый вопрос при проектировании: что тяжелее — гонять изображения на сервер для OCR или тащить движок в браузер?
Серверный OCR решает проблему вычислений, но создаёт другую: файл с чувствительными данными улетает на чужую машину, появляются задержки на round-trip, нужна инфраструктура. Для инструмента, где конфиденциальность — главный посыл, это не подходит.
Tesseract.js 5.x — WebAssembly-компиляция классического OCR-движка Tesseract. Он запускается прямо в браузере через Web Workers, не блокирует UI-поток и не требует никакого сервера. Языковые пакеты (~2,5 МБ каждый) скачиваются один раз и кэшируются. Это не нейросеть последнего поколения — Tesseract работает на основе классических алгоритмов сегментации, LSTM и обученных языковых моделей. Он хорошо справляется с качественными сканами и плохо — с рукописным текстом или сильно сжатыми JPEG.
Ленивая загрузка: не грузить то, что не нужно
Tesseract.js весит несколько мегабайт. Грузить его при открытии сайта — неоправданно, большинство пользователей никогда не откроет OCR. Поэтому загрузка отложена:
async function fabStartOCR(areaRect = null) {
if (!window.Tesseract) {
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = 'https://unpkg.com/tesseract.js@5/dist/tesseract.min.js';
s.onload = res;
// При сбое сети — удаляем битый тег из DOM, иначе window.Tesseract
// останется undefined, а при повторном вызове скрипт продублируется.
s.onerror = (e) => { s.remove(); rej(e); };
document.head.appendChild(s);
});
}
// ...
}
Первый вызов fabStartOCR() — скрипт подгружается, браузер кэширует. Второй вызов — работает мгновенно.
Тонкий момент — обработка ошибки. Если s.onerror сработал (у пользователя проблемы с сетью), промис отклоняется, но сам тег <script> остаётся в <head>. При следующем вызове функции window.Tesseract всё ещё undefined — условие if (!window.Tesseract) снова истинно, и в DOM добавляется второй битый тег, потом третий. Поэтому в onerror мы сначала удаляем элемент (s.remove()), и только затем отклоняем промис: каждая новая попытка стартует с чистого <head>.
Downscale до 1500px: почему это не маркетинг, а необходимость
Отсканированные документы часто имеют разрешение 300 DPI и выше. Лист A4 при 300 DPI — это 2480×3508 пикселей. Обработка такого изображения Tesseract.js в браузере требует от 300 до 600 МБ RAM только на декодирование и создание внутренних буферов движка.
На десктопах это ещё терпимо. На мобильных устройствах с 2–4 ГБ общей памяти — гарантированный краш или зависание вкладки.
Поэтому перед передачей в Tesseract изображение уменьшается:
const MAX_SIDE = 1500;
const scale = Math.min(1, MAX_SIDE / Math.max(imgW, imgH));
const scaledW = Math.floor(imgW * scale);
const scaledH = Math.floor(imgH * scale);
1500px — эмпирически подобранный порог, при котором Tesseract стабильно работает на iPhone 12 и Android-устройствах с 3 ГБ RAM. При уменьшении качество распознавания деградирует незначительно для большинства сканов: Tesseract сам нормализует изображение перед обработкой.
Языковые модели и совмещённый режим
Пользователи pdfredX часто работают с документами, где смешаны русский и английский текст: договоры с латинскими аббревиатурами, медицинские заключения с латинскими терминами, финансовые отчёты.
Монолингвальный режим в таких случаях ломается на переходах между языками. Поэтому по умолчанию используется комбинированная модель:
const lang = currentLang === 'de' ? 'deu+eng' : 'rus+eng';
const { data } = await Tesseract.recognize(croppedCanvas, lang, { ... });
currentLang — это язык интерфейса сайта, который пользователь выбрал кнопками RU/EN/DE. Немецкие пользователи получают deu+eng, остальные — rus+eng. Это не идеально (англоязычный пользователь с русским документом получит rus+eng только если переключится), но покрывает 90% реальных кейсов.
Область распознавания: координаты в двух системах отсчёта
Это самое тонкое место в реализации. У нас два масштабных пространства:
1. Canvas-холст Fabric.js — рабочий размер, масштабируется под экран 2. Нативное изображение — полное разрешение оригинальной страницы
Пользователь рисует прямоугольник в пространстве Fabric.js. OCR должен получить координаты в пространстве оригинала:
// Условие корректности: areaRect (fabric.Rect) создан с originX:'left' и
// originY:'top', а areaRect.x/y — координаты его левого верхнего угла в
// глобальном пространстве Viewport холста (не относительно родителя/группы).
const scNX = fabNativeW / fabBaseW; // коэффициент X
const scNY = fabNativeH / fabBaseH; // коэффициент Y
ocrOffX = Math.floor(areaRect.x * scNX);
ocrOffY = Math.floor(areaRect.y * scNY);
ocrW = Math.min(Math.ceil(areaRect.w * scNX), fabNativeW - ocrOffX);
ocrH = Math.min(Math.ceil(areaRect.h * scNY), fabNativeH - ocrOffY);
fabNativeW/H — размер оригинала. fabBaseW/H — размер Canvas-холста. Кроп делается из оригинального изображения, а не из масштабированного — поэтому качество распознавания не зависит от размера окна браузера.
Важное условие: формула верна только если areaRect создан с originX: 'left' и originY: 'top' (точка отсчёта объекта — левый верхний угол), а areaRect.x/y берутся в глобальном пространстве Viewport холста. Если точку отсчёта сместить в центр (originX: 'center') или считать координаты относительно группы/родителя — ocrOffX/ocrOffY уедут на полширины-полвысоты, и кроп уйдёт мимо выделенной области.
Слой замазки + редактируемый текст: два объекта Fabric на строку
Tesseract возвращает структуру data.lines — массив строк с координатами bbox и распознанным текстом. Для каждой строки создаётся пара объектов:
data.lines.forEach(line => {
// Масштабируем bbox обратно в пространство Canvas
const x = (line.bbox.x0 / scNX) + areaRect.x;
const y = (line.bbox.y0 / scNY) + areaRect.y;
const w = (line.bbox.x1 - line.bbox.x0) / scNX;
const h = (line.bbox.y1 - line.bbox.y0) / scNY;
// Замазка — перекрывает оригинальный текст
const rect = new fabric.Rect({
left: x, top: y, width: w, height: h,
fill: '#ffffff', selectable: true, evented: true,
});
// Редактируемый текст поверх
const itext = new fabric.IText(line.text.trim(), {
left: x, top: y,
fontSize: Math.round(h * 0.8),
fill: '#000000',
fontFamily: 'Arial, sans-serif',
selectable: true, editable: true,
});
fabCanvas.add(rect);
fabCanvas.add(itext);
});
fabCanvas.renderAll(); // принудительный рендер: фиксируем все объекты ДО снимка
fabPushUndo(); // весь блок — одно состояние в стеке отмены
Порядок здесь важен. fabPushUndo() делает моментальный снимок состояния холста (через toJSON). Если вызвать его сразу после синхронного добавления объектов в цикле, Fabric.js может ещё не отрисовать новые rect и itext — он откладывает перерисовку до следующего кадра. Поэтому перед снимком мы форсируем fabCanvas.renderAll(): он синхронно прогоняет рендер, и в Undo-стек попадает уже актуальное состояние со всеми добавленными строками.
Белый цвет замазки — дефолт. На документах с цветным фоном пользователь может поменять цвет через цветовую панель, а потом снова запустить OCR на том же участке.
Ограничения, с которыми мы живём
Точность позиционирования bbox. Tesseract не всегда точно определяет границы строк, особенно в документах с нестандартным межстрочным интервалом. Иногда bbox немного смещён — приходится двигать текстовый блок вручную в режиме Select.
Таблицы. Tesseract в режиме PAGE_SEG_MODE_AUTO часто неправильно сегментирует строки внутри таблиц. Для таблиц лучше работает постолбцовое выделение и ручная корректировка.
Рукопись. Не распознаётся. Точка.
Производительность на длинных документах. OCR запускается только для выделенной области, а не для всего документа. Это принципиальное решение — обрабатывать страницу целиком в браузере нереально без значительных задержек.
Попробовать Area OCR в браузере — pdfredx.com, без регистрации, без загрузки файлов на сервер.