Постановка задачи
На одной из планёрок перед нами встала задача: разработать инструмент в 1С, который мог бы получать на входе многостраничный PDF-файл, разбивать его постранично на отдельные изображения, а затем распознавать штрихкод с каждой страницы. По считанному коду система должна автоматически найти документ в базе 1С и прикрепить к нему соответствующий скан-лист.
К тому моменту наш отдел по работе с рекламациями накопил существенный объём бумажных документов. Это сильно тормозило обработку претензий и взаимодействие с поставщиками: было банально невозможно оперативно понять, пришёл ли ответ от партнёра или он затерялся в архивных кучах бумаги на полках.
Попытки быстро найти в сети готовое решение, которое удовлетворяло бы всем условиям задачи «из коробки», не увенчались успехом. Поэтому, немного погрустив, пришлось засучить рукава и взяться за разработку самостоятельно.
Решение "в лоб"
Для начала я посмотрел доступные внешние компоненты DLL и типовые механизмы. Первой мыслью было подсмотреть логику в «1С:Документооборот», но мы быстро упёрлись в ограничения платформы. В типовом решении (на тот момент) была работа преимущественно с кодами формата EAN-13, в то время как наши документы маркировались штрихкодами логистического формата EAN-128 (GS1-128). Дальше по списку были уже коммерческие решения, в нашем случае не вариант.
Попробовали "ветерана" ZBar. Выяснилось, что ZBar хорошо читал чистые векторные PDF, где штрихкод был зашит текстовыми шрифтами. Но в нашем случае на вход поступали сканы актов за целый день работы — то есть тяжёлые, неконтрастные картинки, просто упакованные в PDF. Я честно пытался завести связку ZBar + ImageMagick, чтобы предварительно подготавливать эти сканы, но добиться от неё хоть какого-нибудь результат мне так и не удалось.
Я протестировал ещё несколько утилит, но детально останавливаться на них нет смысла — результат везде крутился около нуля. Методом последовательного перебора я дошёл до библиотеки ZXing

И вот тут появилась надежда...
PowerShell и общая архитектура решения
Почему PowerShell?
Когда библиотека ZXing была выбрана, возник закономерный вопрос: как красиво подружить её с 1С? Платформа «1С:Предприятие» не умеет напрямую (без создания сложных COM-обёрток) загружать управляемый .NET-код. Поскольку PowerShell является родной частью экосистемы Windows и базируется на той же CLR (Common Language Runtime), для него загрузка сторонней DLL — это базовая операция Add-Type. Часть корабля, часть команды — всё работает бесшовно.
При таком подходе мы получаем:
В итоге PowerShell выступил в роли (условно) бесплатного моста между 1С и .NET. Сама 1С в этой схеме просто формирует параметры и вызывает скрипт через стандартную команду ЗапуститьПриложение(), а PowerShell берёт на себя всю работу с графикой и консольной утилитой MuPDF (на неё я, кстати, наткнулся на StackOverflow).
Рисунок 1 — Принципиальная схема обработки PDF-файла.
Алгоритм работы системы по этапам
1. Инициация процесса: Оператор в 1С открывает обработку и указывает путь к PDF-файлу
2. Работа PowerShell: Платформа запускает PowerShell-скрипт, передавая ему параметры. Скрипт последовательно выполняет следующую цепочку действий:
-
Вызывает утилиту mutool.exe которая разбивает исходный PDF на отдельные PNG-картинки.
-
Пробегается по каждому полученному PNG-файлу, находит штрихкод и распознает его с помощью библиотеки ZXing.
-
Складывает итоговые результаты распознавания (имя файла и считанный код) в CSV-файл, а картинки во временные файлы.

Рисунок 2 — Вид CSV-файла
3. Разбор результата 1С: после завершения работы скрипта 1С считывает созданный CSV-файл. Пробегаясь по строкам, система находит в базе нужные документы по распознанным штрихкодам и автоматически привязывает к ним файлы страниц по путям, указанным в этом же файле.
4. Контроль: Пользователь в интерфейсе 1С видит финальный статус и если система не справилась, то, оператор может вручную сопоставить картинку с нужным документом прямо в форме обработки.
ZXing не распознаёт
Это, конечно, крутая библиотека, которую можно просто скачать бесплатно, но первые тесты показали, что ШК не всегда надлежащего качества и для распознавания ШК изображение нужно готовить.
Тут собственно и пригодятся встроенные .NET классы для работы с изображениями.
При дальнейшем изучении ZXing.net, стало понятно, что её встроенные полезные фичи (адаптивная блочная бинаризация, многоуровневое сканирование строк и т.д.) труднодоступны. Чтобы их активировать, нужно настраивать параметры декодирования через дженерик-словари. Что-то типа этого...
$hints = New-Object 'System.Collections.Generic.Dictionary[ZXing.DecodeHintType,System.Object]'
Скажу честно: я сходу не осилил. Разбираться в документации библиотеки и выяснять, как правильно конструировать эти .NET-конструкции внутри PowerShell, мне не хотелось да и задача подгорала. Решил оставить это всё на потом.
Поэтому я решил пойти по пути, который мне был понятен: использовать ZXing как есть. А всю работу по подготовке, очистке и масштабированию изображения реализовать на уровне скрипта с помощью стандартных .NET-классов для работы с графикой. В итоге результат выглядит так, что я не ошибся.
Методы улучшения изображения
Этап 1
Применяем два простых решения:
- Ограничение области поиска (Crop): как видно на примере ниже, штрихкод на наших документах всегда расположен в определенном месте — в правом верхнем углу листа. С помощью встроенных средств .NET мы отрезаем (кропаем) лишние ~80% изображения, оставляя для анализа только целевой прямоугольник.

Рисунок 3 — Вид ШК на сканированном листе.
- Явное указание типа ШК: По умолчанию ZXing пытается применить к картинке все известные ему форматы (QR, DataMatrix, EAN13, Code39 и т.д.). Мы сразу указали, что ищем EAN-128.
Ограничение области поиска и явное указание типа штрихкода дали ощутимый прирост в распознавании ШК но не достаточный для прода.
Первое же промежуточное тестирование на реальных пачках документов (от 20 листов и более) показало статистику: процент успешных распознаваний плавал в районе 55-65%
С одной стороны, результат уже был заметен — больше половины документов система обрабатывала сама. С другой стороны, отправлять это в таком виде в прод было нельзя: работы в рукопашную ещё оставалось прилично. Нужно было внедрять более результативные методы улучшения картинки.

Рисунок 4 — Вид обрезанного сканированного листа.
Что нужно, чтобы ZXing прочитал EAN-128 (теория)
Чтобы библиотека ZXing успешно распознала штрихкод EAN-128 (GS1-128), изображение должно строго удовлетворять трем фундаментальным критериям:
1. Наличие хотя бы одной «идеальной» линии сканирования
Алгоритм ZXing не анализирует штрихкод целиком как картинку — он виртуально «чертит» горизонтальные и диагональные линии поперек изображения. Штрихкод будет считан, если на пути хотя бы одной такой линии соблюдены следующие условия:
- Сквозное пересечение: Линия без разрывов проходит от левого белого поля до правого белого поля сквозь абсолютно все штрихи кода.
- Стартовый маркер: На этой линии четко считывается комбинация знаков
Start A (или B, C) и спецсимвол FNC1. Именно FNC1 говорит библиотеке, что перед ней не просто Code 128, а стандарт EAN-128.
- Стоп-маркер: Линия сканирования доходит до финального символа
Stop.
2. Математическая точность пропорций (Правило 11 модулей)
Поскольку алгоритм работает на чистой математике (без нейросетей), критически важна геометрия:
- Каждая буква или цифра в коде состоит из 3 штрихов и 3 пробелов, которые в сумме должны давать ровно 11 условных единиц ширины (модулей).
- Разрешение и фокус: Самый узкий штрих (в 1 модуль) на матрице камеры или скане должен занимать минимум 1.5–2 пикселя. Если картинка размыта, пропорции искажаются, ZXing не сможет поделить ширину знака на 11 и выдаст ошибку декодирования.
3. Обязательные свободные зоны (Quiet Zones)
- Слева и справа от крайних штрихов кода должно быть абсолютно чистое светлое пространство.
- Ширина этих полей должна быть минимум в 10 раз больше ширины самого узкого штриха штрихкода. Без этого алгоритм локализации ZXing просто не поймет, где на картинке начинаются и заканчиваются границы кода.
Этап 2
При низком разрешении сканирования штрихам не хватает пикселей. И мы внедрили второй шаг — принудительное масштабирование. Мы просто растягиваем поля картинки в 2.5 раза (это число было определено экспериментально). При чём, GoogleAI подкинул идею использовать HighQualityBicubic который встроен в .NET На тот момент это казалось хорошим решением: при «умном» растягивании по идее часть мелких артефактов и рваных краев со штрихкода должна уйти (хотя, как выяснилось позже, решение оказалось весьма спорным)

Рисунок 5 — Сравнение размеров изображений (сверху увеличенное)
Краткое описание High Quality Bicubic (теория)
HighQualityBicubic — это режим интерполяции в GDI+ (используется в .NET, например в Windows Forms), который обеспечивает максимально возможное качество при масштабировании изображений как при увеличении, так и при уменьшении. learn.microsoft.comlearn.microsoft.commsdn-whiteknight.github.io
При масштабировании алгоритм должен вычислить цвет и прозрачность каждого пикселя в новом изображении на основе исходных данных. При изменении размера алгоритм вычисляет цвет и прозрачность каждого пикселя в новом изображении на основе исходных данных. В отличие от простых режимов (вроде NearestNeighbor или Bilinear), HighQualityBicubic использует сложный и ресурсоёмкий алгоритм на основе кубических сплайнов, который учитывает значения не только ближайших, но и всех соседних пикселей. Это позволяет добиться очень плавных цветовых переходов и минимизировать появление ступенчатых артефактов («лесенок») на границах объектов
Этап 3 + тесты
Как показала практика, для большинства документов первых двух шагов хватает за глаза. Но периодически в пачке попадается по-настоящему «сложный пациент» (грязный скан, блёклый картридж или следы дырокола), на котором происходит затык. На этот случай у нас припасён третий этап конвейера — бинаризация изображения.

Рисунок 6 — Пример с нераспознанным ШК (без бинаризации)
На текущий момент наш конвейер обработки выглядит следующим образом (в процессе эксплуатации мы его ещё доработаем и улучшим, но на старте схема была такой):
Этап 1 (Кроп): Ограничиваем область поиска (ROI), пытаемся прочитать штрихкод «в лоб». Если успешно — отдаем результат. Если код не определён — передаем картинку на следующий этап.
Этап 2 (Увеличение): Растягиваем вырезанную область в 2.5 раза с помощью режима интерполяции HighQualityBicubic. Пытаемся распознать. Если мимо — идём дальше.
Этап 3 (Бинаризация): Применяем к увеличенной картинке жесткий фильтр перевода в чёрно-белый спектр и делаем финальную попытку распознавания.
На рисунке 6 приведены реальные логи работы этой схемы на предприятии — это живой пример из повседневной практики.
- Страницы 1, 4, 5, 6, 7, 8 и 9 — это нормальные, качественные сканы. Они мгновенно распознались на первом же этапе ("Кроп") всего за 2 мс каждый.
- Страницы 3 и 10 — оказались проблемными. Первый этап их пропустил, но на втором этапе ("Увеличение") алгоритм их успешно разобрал. Времени на них ушло ощутимо больше — 144 мс и 147 мс соответственно, но для пользователя это всё равно доли секунды.
- Страница 2 — выдала "NOT_FOUND". И тут самое время попробовать бинаризацию
Если объяснять принцип работы этого фильтра совсем простыми словами, то мы избавляемся от всех промежуточных полутонов и градаций серого цвета, оставляя картинку строго двухцветной.
В цифровом изображении градация цвета пикселя обычно идёт в диапазоне от 0 до 255 (где 0 — это абсолютно чёрный цвет, а 255 — идеально белый). Алгоритм бинаризации берёт заданный нами порог яркости (например, значение 200) и начинает сравнивать с ним каждый пиксель:
-
Всё, что больше 200 (светло-серый, блёклые шумы, грязь бумаги) — принудительно закрашивается в чистый белый цвет (255).
-
Всё, что меньше 200 (тёмные элементы, сами полосы кода) — превращается в глубокий чёрный (0).
В итоге мы получаем высоко контрастное изображение: есть только белый фон и чёткие чёрные штрихи, без какого-либо постороннего «визуального мусора». Именно такая картинка подходит лучше всего для сканирования ZXing.

Рисунок 7 — сложный для распознавания ШК до бинаризации

Рисунок 8 — Результат распознавания ШК с бинаризацией

Рисунок 9 — Наглядное сравнение ШК после/до бинаризации
На рисунке 7 хорошо виден тот самый "сложный пациент" до обработки: штрихкод блёклый, границы размыты, алгоритм растягивания (апскейл) превратил края в серую кашу, и движок ZXing его не читает.
Чтобы заставить систему увидеть эти полосы, в коде была выделена переменная порога яркости "threshold" (в скрипте она задаётся вручную). Экспериментальным путём для наших документов был выбран порог 190-200.
Посмотрите на рисунок 9 — верхняя половина показывает результат работы бинаризации с правильным порогом. Вся серая грязь и промежуточные полутона исчезли. Остались только идеально контрастные, рубленые чёрные линии на чистом белом фоне. Такую картинку виртуальная линия сканирования ZXing распознаёт.
Промежуточные выводы
Посмотрим на обновлённые логи консоли на рисунке 8:
Страница 2, которая раньше выдавала "NOT_FOUND", теперь успешно распознана. Но время обработки: 4672 мс, можно сказать 5 сек на штрихкод (это без учёта работы прошлых методов).
Бинаризация на уровне скрипта — это ресурсоёмкий и тяжёлый алгоритм, так как процессору приходится попиксельно перелопачивать всю картинку высокого разрешения.
Именно этот замер времени наглядно подтверждает: запускать бинаризацию сразу для каждого штрихкода на первой же стадии — плохая затея. Если бы скрипт не имел каскадной структуры, обычная пачка документов заставила бы пользователя сидеть и ждать перед монитором. Благодаря конвейеру, лёгкие страницы пролетают за 2 миллисекунды, а алгоритмы потяжелее включается точечно и только тогда, когда это действительно необходимо для спасения процесса распознавания.
Внезапный инсайт
И можно было бы закончить на этом, но в процессе написания этого материала у меня появилась мысль, которая заставила меня по-новому взглянуть на реализованный подход: а что если запустить бинаризацию не на увеличенной картинке, а сразу на том изображении, которое после кропа?
По идее, это должно работать в разы быстрее, так как объем обрабатываемых пикселей меньше.
Вспомним параметры разрешений наших картинок из предыдущего шага:
Исходный фрагмент: 1487 * 631 = 0.93 млн пикселей.
Увеличенный фрагмент: 3818 *1638 = 6.25 млн пикселей.
Поскольку бинаризация — это прямолинейный попиксельный алгоритм (он в цикле перебирает каждую точку и сравнивает её с порогом "threshold"), скорость напрямую зависит от площади картинки.
В первом случае нашему PowerShell-скрипту нужно перелопатить меньше миллиона пикселей, а во втором — более 6 млн. Разница в объёме данных ~6.6 раз Поэтому бинаризация на раздутом изображении заняла 4.6 секунды. Если обрабатывать "маленький" оригинал, мы должны получить прирост к скорости.

Рисунок 10 -- Результат изменения конвейера обработки изображения.
Гипотеза подтвердилась: время обработки проблемной страницы 2 упало с 4672 мс до 833 мс. Только за счет оптимизации логики. Но, я ожидал результата лучше. Да, и забыл упомянуть, что от бикубика (HighQualityBicubic) тоже можно отказаться, без него работает не хуже. Просто растянуть методом по умолчанию.
Финальный аккорд: Помощь AI и C#
Захотелось ещё улучшить производительность, но я не знал как. Поэтому пошел за помощью к AI. AI тут же выдал код на C# внутри PowerShell, с чем я не разобрался ранее самостоятельно.
Результаты работы этого тандема смотрите сами на рисунке ниже:

Рисунок 11 — Результат доработки алгоритма бинаризации AI.
Результаты получились хорошими и не в пользу человека:
- Страница 2 (сложный пациент) Время обработки сократилось с исходных 4672 мс и промежуточных 833 мс до 50 мс! Ускорение относительно первоначального варианта — почти в 100 раз... (!!!)
- Страницы 3 и 10 Которые раньше на чистом увеличении требовали по 145 мс, теперь пролетели через связку "Кроп + C#-бинаризация" всего за 54 мс и 49 мс
При этом все штрихкоды распознались. В итоге на выходе получился достойный инструмент: лёгкие страницы мгновенно распознаются за 2-3 мс на первом этапе, а на сложных страницах включается бинаризация на C#, написанная AI, которая быстро проворачивает сложные случаи всего за 50 мс. И эта пачка документов распознается за секунду.
Отладочный скрипит PowerShell с выводом в консоль
param (
[string]$pdfFile = "C:\pdf\file.pdf", # Путь к тестируемому PDF
[string]$tempDir = "C:\pdf\tmp", # Временная папка для страниц
[string]$zxingDll = "C:\pdf\zxing\zxing.dll", # Путь к библиотеке ZXing
[string]$muTool = "C:\pdf\mupdf\mutool.exe", # Путь к утилите MuPDF
[string]$debugDir = "C:\pdf\debug" # Папка для сохранения отладочных картинок
)
Add-Type -AssemblyName System.Drawing
Add-Type -Path $zxingDll
# --- МЕТОД 1: Медленная бинаризация силами чистого PowerShell ---
function ConvertToBinary([System.Drawing.Bitmap]$bmp, [byte]$threshold) {
$rect = New-Object System.Drawing.Rectangle(0, 0, $bmp.Width, $bmp.Height)
$bmpData = $bmp.LockBits($rect, [System.Drawing.Imaging.ImageLockMode]::ReadWrite, [System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
$stride = $bmpData.Stride
$totalBytes = [Math]::Abs($stride) * $bmp.Height
$rgbValues = New-Object byte[] $totalBytes
[System.Runtime.InteropServices.Marshal]::Copy($bmpData.Scan0, $rgbValues, 0, $totalBytes)
for ($y = 0; $y -lt $bmp.Height; $y++) {
for ($x = 0; $x -lt $bmp.Width; $x++) {
$i = ($y * $stride) + ($x * 3)
if ($i + 2 -lt $totalBytes) {
# Формула люминесценции для перевода в оттенки серого
$grayValue = [byte]($rgbValues[$i] * 0.11 + $rgbValues[$i+1] * 0.59 + $rgbValues[$i+2] * 0.3)
$bwValue = if ($grayValue -lt $threshold) { 0 } else { 255 }
$rgbValues[$i] = $rgbValues[$i+1] = $rgbValues[$i+2] = [byte]$bwValue
}
}
}
[System.Runtime.InteropServices.Marshal]::Copy($rgbValues, 0, $bmpData.Scan0, $totalBytes)
$bmp.UnlockBits($bmpData)
}
# --- МЕТОД 2: Реактивная бинаризация на C# (написано с помощью AI) ---
function ConvertToBinaryFast([System.Drawing.Bitmap]$bmp, [byte]$threshold) {
# Компилируем C# код прямо в памяти текущей PowerShell-сессии
$Asm = Add-Type -TypeDefinition @"
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
public class ImageProcessor {
public static void Binarize(Bitmap bmp, byte threshold) {
Rectangle rect = new Rectangle(0, 0, bmp.Width, bmp.Height);
BitmapData bmpData = bmp.LockBits(rect, ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb);
int stride = bmpData.Stride;
int totalBytes = Math.Abs(stride) * bmp.Height;
byte[] rgbValues = new byte[totalBytes];
Marshal.Copy(bmpData.Scan0, rgbValues, 0, totalBytes);
for (int y = 0; y < bmp.Height; y++) {
for (int x = 0; x < bmp.Width; x++) {
int i = (y * stride) + (x * 3);
if (i + 2 < totalBytes) {
byte gray = (byte)(rgbValues[i] * 0.11 + rgbValues[i+1] * 0.59 + rgbValues[i+2] * 0.3);
byte bw = (gray < threshold) ? (byte)0 : (byte)255;
rgbValues[i] = rgbValues[i+1] = rgbValues[i+2] = bw;
}
}
}
Marshal.Copy(rgbValues, 0, bmpData.Scan0, totalBytes);
bmp.UnlockBits(bmpData);
}
}
"@ -ReferencedAssemblies "System.Drawing" -PassThru
# Вызываем скомпилированный метод
[ImageProcessor]::Binarize($bmp, $threshold)
}
# 1. Подготовка и очистка папок
if (-not (Test-Path $tempDir)) { New-Item -Path $tempDir -ItemType Directory | Out-Null }
else { Remove-Item "$tempDir\*.png" -ErrorAction SilentlyContinue }
if (-not (Test-Path $debugDir)) { New-Item -Path $debugDir -ItemType Directory | Out-Null }
else { Remove-Item "$debugDir\*.jpg" -ErrorAction SilentlyContinue }
# Настройка ридера ZXing
$reader = New-Object ZXing.BarcodeReader
$reader.Options.PossibleFormats = [System.Collections.Generic.List[ZXing.BarcodeFormat]]::new()
$reader.Options.PossibleFormats.Add([ZXing.BarcodeFormat]::CODE_128)
$reader.Options.TryHarder = $true
$reader.AutoRotate = $true
# Получаем количество страниц в документе
$info = & $muTool info $pdfFile
$totalPages = [int](($info | Select-String "Pages:") -split ':')[-1].Trim()
$results = New-Object System.Collections.Generic.List[PSObject]
Write-Host "Обработка PDF ($totalPages страниц) с анализом времени..." -ForegroundColor Cyan
$sw = New-Object System.Diagnostics.Stopwatch
# 2. Основной конвейер обработки страниц
1..$totalPages | ForEach-Object {
$pageNum = $_
$tmpImg = Join-Path $tempDir ("page-" + $pageNum + ".png")
$bmp = $null; $cropped = $null; $scaled = $null; $decoded = $null
$methodUsed = "NOT_FOUND"
$elapsedMs = 0
try {
# Рендерим страницу во временный PNG файл
& $muTool draw -r 300 -o $tmpImg $pdfFile $pageNum 2>&1 | Out-Null
$bmp = [System.Drawing.Bitmap]::FromFile($tmpImg)
# ЭТАП 0: Пробуем распознать штрихкод на чистом оригинале страницы
$decoded = $reader.Decode($bmp)
if ($null -ne $decoded) {
$methodUsed = " Этап 0 | Оригинал|"
}
# Задаем координаты зоны штрихкода (правый верхний угол)
$startX = [int]($bmp.Width * 0.4)
$cropW = [int]($bmp.Width - $startX - 1)
$cropH = [int]($bmp.Height * 0.18)
$rect = New-Object System.Drawing.Rectangle($startX, 0, $cropW, $cropH)
$cropped = $bmp.Clone($rect, $bmp.PixelFormat)
# --- СТАРТ ЗАМЕРА ВРЕМЕНИ ДЕКОДИРОВАНИЯ МОДИФИЦИРОВАННЫХ ФРАГМЕНТОВ ---
$sw.Restart()
# ЭТАП 1: Быстрый тест на «чистом» кропе
$reader.Options.TryInverted = $false
$reader.Options.PureBarcode = $false
$decoded = $reader.Decode($cropped)
if ($null -ne $decoded) {
$methodUsed = " Этап 1 | Кроп|"
}
# ЭТАП 2: Новая идея — бинаризация МИНИАТЮРЫ (сразу после кропа)
if ($null -eq $decoded) {
# Для переключения на сверхбыстрый метод от AI — замените комментирование строк ниже:
ConvertToBinary $cropped 200
# ConvertToBinaryFast $cropped 200
$decoded = $reader.Decode($cropped)
$methodUsed = " Этап 2 |Кроп + бинаризация|"
}
# ЭТАП 3 и 4: Старая тяжелая схема (апскейл + бинаризация), если предыдущие шаги не помогли
if ($null -eq $decoded) {
# Бэкапим исходный кроп для истории
$origCropPath = Join-Path $debugDir ("page-" + $pageNum + "-ORIG-cropped.jpg")
$cropped.Save($origCropPath, [System.Drawing.Imaging.ImageFormat]::Jpeg)
# Масштабируем картинку в 2.5 раза
$scaled = New-Object System.Drawing.Bitmap([int]($cropped.Width * 2.5), [int]($cropped.Height * 2.5))
$g = [System.Drawing.Graphics]::FromImage($scaled)
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.DrawImage($cropped, 0, 0, $scaled.Width, $scaled.Height)
$g.Dispose()
# Добавление белых полей (Padding)
$paddingX = 50
$paddingY = 30
$newWidth = $scaled.Width + ($paddingX * 2)
$newHeight = $scaled.Height + ($paddingY * 2)
$paddedBefore = New-Object System.Drawing.Bitmap($newWidth, $newHeight, [System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
$gBefore = [System.Drawing.Graphics]::FromImage($paddedBefore)
$gBefore.Clear([System.Drawing.Color]::White)
$gBefore.DrawImage($scaled, $paddingX, $paddingY)
$gBefore.Dispose()
$paddedBefore.Save((Join-Path $debugDir ("page-" + $pageNum + "-BEFORE-binary.jpg")), [System.Drawing.Imaging.ImageFormat]::Jpeg)
$paddedBefore.Dispose()
# Пробуем декодировать чистый апскейл
$decoded = $reader.Decode($scaled)
if ($null -ne $decoded) {
$methodUsed = " Этап 2 |Увеличение|"
}
if ($null -eq $decoded) {
# Если не помогло — запускаем тяжелую бинаризацию по большой площади
ConvertToBinary $scaled 190
# ConvertToBinaryFast $scaled 120
$paddedAfter = New-Object System.Drawing.Bitmap($newWidth, $newHeight, [System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
$gAfter = [System.Drawing.Graphics]::FromImage($paddedAfter)
$gAfter.Clear([System.Drawing.Color]::White)
$gAfter.DrawImage($scaled, $paddingX, $paddingY)
$gAfter.Dispose()
$paddedAfter.Save((Join-Path $debugDir ("page-" + $pageNum + "-AFTER-binary.jpg")), [System.Drawing.Imaging.ImageFormat]::Jpeg)
$decoded = $reader.Decode($paddedAfter)
$paddedAfter.Dispose()
if ($null -ne $decoded) {
$methodUsed = " Этап 3 |Увеличение + Бинаризация|"
}
# Самый крайний случай — инверсия цветов
if ($null -eq $decoded) {
$reader.Options.TryInverted = $true
$decoded = $reader.Decode($scaled)
if ($null -ne $decoded) {
$methodUsed = "Этап 5 |Бинаризация + Инверсия|"
}
}
}
}
# --- СТОП ЗАМЕРА ВРЕМЕНИ ---
$sw.Stop()
$elapsedMs = $sw.ElapsedMilliseconds
# Фиксируем результаты
$foundText = "NOT_FOUND"
if ($null -ne $decoded -and $null -ne $decoded.Text) {
$foundText = $decoded.Text.ToString()
} else {
$methodUsed = "NOT_FOUND"
}
$results.Add([PSCustomObject]@{
Page = $pageNum
Barcode = $foundText
Method = $methodUsed
TimeMs = $elapsedMs
ImagePath = $tmpImg
})
# Логирование в консоль
if ($foundText -ne "NOT_FOUND") {
Write-Host ("{0} Стр {1}: {2} | Время: {3} мс | Метод: {4}" -f "[OK]", $pageNum, $foundText, $elapsedMs, $methodUsed) -ForegroundColor Green
} else {
Write-Host ("{0} Стр {1}: NOT_FOUND" -f "[--]", $pageNum) -ForegroundColor Yellow
}
}
catch {
Write-Host ("Ошибка на странице {0}: {1}" -f $pageNum, $_.Exception.Message) -ForegroundColor Red
}
finally {
# Своевременно освобождаем неуправляемые ресурсы GDI+
if ($null -ne $scaled) { $scaled.Dispose() }
if ($null -ne $cropped) { $cropped.Dispose() }
if ($null -ne $bmp) { $bmp.Dispose() }
}
}
# 3. Экспорт результатов в CSV
$csvPath = $pdfFile -replace "\.pdf$", ".csv"
$results | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 -Delimiter ";"
Write-Host ("`nГотово! Результаты сохранены: {0}" -f $csvPath) -ForegroundColor Magenta
Write-Host ("Отладочные сканы лежат в: {0}" -f $debugDir) -ForegroundColor Gray
Ниже проверенный временем скрипт, который пашет уже ~4 месяца.
Жаль, что я так и не нашел фото заваленного стола бумагами, чтобы показать, какие кучи бумаги удалось разгрести с помощью этой разработки.
Важное примечание: Описанный выше вариант с быстрой бинаризацией на C# от AI прямо сейчас находится на стадии тестирования. Как только он докажет свою пригодность, мы накатим этот апгрейд. Но, если вам нужно внедрить решение "здесь и сейчас" можете брать этот проверенный вариант. В отладочном скрипте есть все алгоритмы, если что их можно просто скопировать в скрипт для обработки ниже.
Скрипт, который вызывается из обработки 1С
param (
[string]$pdfFile = "C:\PARSE\parse_pdf\file.pdf",# нужно путь прописать самому
[string]$tempDir = "C:\pdf\tmp", # нужно путь прописать самому
[string]$zxingDll = "C:\PARSE\parse_pdf\zxing\zxing.dll", # нужно путь прописать самому
[string]$muTool = "C:\PARSE\parse_pdf\mupdf\mutool.exe" # нужно путь прописать самому
)
Add-Type -AssemblyName System.Drawing
Add-Type -Path $zxingDll
function ConvertToBinary([System.Drawing.Bitmap]$bmp, [byte]$threshold) {
$rect = New-Object System.Drawing.Rectangle(0, 0, $bmp.Width, $bmp.Height)
$bmpData = $bmp.LockBits($rect, [System.Drawing.Imaging.ImageLockMode]::ReadWrite, [System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
$stride = $bmpData.Stride
$totalBytes = [Math]::Abs($stride) * $bmp.Height
$rgbValues = New-Object byte[] $totalBytes
[System.Runtime.InteropServices.Marshal]::Copy($bmpData.Scan0, $rgbValues, 0, $totalBytes)
for ($y = 0; $y -lt $bmp.Height; $y++) {
for ($x = 0; $x -lt $bmp.Width; $x++) {
$i = ($y * $stride) + ($x * 3)
if ($i + 2 -lt $totalBytes) {
$grayValue = [byte]($rgbValues[$i] * 0.11 + $rgbValues[$i+1] * 0.59 + $rgbValues[$i+2] * 0.3)
$bwValue = if ($grayValue -lt $threshold) { 0 } else { 255 }
$rgbValues[$i] = $rgbValues[$i+1] = $rgbValues[$i+2] = [byte]$bwValue
}
}
}
[System.Runtime.InteropServices.Marshal]::Copy($rgbValues, 0, $bmpData.Scan0, $totalBytes)
$bmp.UnlockBits($bmpData)
}
# 1. Подготовка
if (-not (Test-Path $tempDir)) { New-Item -Path $tempDir -ItemType Directory | Out-Null }
else { Remove-Item "$tempDir\*.png" -ErrorAction SilentlyContinue }
$reader = New-Object ZXing.BarcodeReader
$reader.Options.PossibleFormats = [System.Collections.Generic.List[ZXing.BarcodeFormat]]::new()
$reader.Options.PossibleFormats.Add([ZXing.BarcodeFormat]::CODE_128)
$reader.Options.TryHarder = $true
$reader.AutoRotate = $true
# Получаем количество страниц
$info = & $muTool info $pdfFile
$totalPages = [int](($info | Select-String "Pages:") -split ':')[-1].Trim()
$results = New-Object System.Collections.Generic.List[PSObject]
Write-Host "Обработка PDF ($totalPages страниц)..." -ForegroundColor Cyan
# 2. Основной цикл
1..$totalPages | ForEach-Object {
$pageNum = $_
$tmpImg = Join-Path $tempDir ("page-" + $pageNum + ".png")
$bmp = $null; $cropped = $null; $scaled = $null; $decoded = $null
try {
# Извлекаем страницу (DPI 300 для гарантии, раз мы теперь пропускаем бинаризацию)
& $muTool draw -r 300 -o $tmpImg $pdfFile $pageNum 2>&1 | Out-Null
$bmp = [System.Drawing.Bitmap]::FromFile($tmpImg)
$startX = [int]($bmp.Width * 0.4)
$cropW = [int]($bmp.Width - $startX - 1)
$cropH = [int]($bmp.Height * 0.18)
$rect = New-Object System.Drawing.Rectangle($startX, 0, $cropW, $cropH)
$cropped = $bmp.Clone($rect, $bmp.PixelFormat)
# ШАГ 1: БЫСТРЫЙ ТЕСТ
$reader.Options.TryInverted = $false
$reader.Options.PureBarcode = $false
$decoded = $reader.Decode($cropped)
# ШАГ 2: ЕСЛИ НЕ НАШЛИ
if ($null -eq $decoded) {
$scaled = New-Object System.Drawing.Bitmap([int]($cropped.Width * 2.5), [int]($cropped.Height * 2.5))
$g = [System.Drawing.Graphics]::FromImage($scaled)
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.DrawImage($cropped, 0, 0, $scaled.Width, $scaled.Height)
$g.Dispose()
$decoded = $reader.Decode($scaled)
if ($null -eq $decoded) {
ConvertToBinary $scaled 185
$decoded = $reader.Decode($scaled)
if ($null -eq $decoded) {
$reader.Options.TryInverted = $true
$decoded = $reader.Decode($scaled)
}
}
}
# --- СОХРАНЕНИЕ РЕЗУЛЬТАТА (внутри цикла!) ---
$foundText = "NOT_FOUND"
if ($null -ne $decoded -and $null -ne $decoded.Text) {
$foundText = $decoded.Text.ToString()
}
$results.Add([PSCustomObject]@{
Page = $pageNum
Barcode = $foundText
ImagePath = $tmpImg
})
}
catch {
# Write-Host ("Ошибка на странице {0}: {1}" -f $pageNum, $_.Exception.Message) -ForegroundColor Red
}
finally {
# Освобождаем ресурсы
if ($null -ne $scaled) { $scaled.Dispose() }
if ($null -ne $cropped) { $cropped.Dispose() }
if ($null -ne $bmp) { $bmp.Dispose() }
}
} # КОНЕЦ ЦИКЛА ForEach-Object
# 3. Сохранение итогов
$csvPath = $pdfFile -replace "\.pdf$", ".csv"
$results | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 -Delimiter ";"
Write-Host ("`nГотово! Результаты: {0}" -f $csvPath) -ForegroundColor Magenta
&НаКлиенте
Процедура ПутьКФайлуНачалоВыбора(Элемент, ДанныеВыбора, ВыборДобавлением, СтандартнаяОбработка)
Диалог = Новый ДиалогВыбораФайла(РежимДиалогаВыбораФайла.Открытие);
Диалог.Заголовок = "Выберите файл PDF";
Диалог.Фильтр = "PDF файлы(*.pdf;*.pdf)|*.pdf;*.pdf";
Диалог.МножественныйВыбор = Ложь;
Если Диалог.Выбрать() Тогда
ПутьКФайлу = Диалог.ПолноеИмяФайла;
КонецЕсли;
КонецПроцедуры
&НаКлиенте
Процедура ОбработатьФайл(Команда)
АдресВХ = "";
ПоместитьФайл(АдресВХ, ПутьКФайлу,, Ложь, ЭтаФорма.УникальныйИдентификатор);
ОбработатьФайлНаСервере(АдресВХ);
КонецПроцедуры
&НаСервере
Процедура ОбработатьФайлНаСервере(АдресВХ)
Результаты.Очистить();
// Брем файл из временного хранилища и пишем на диск сервера
ДвоичныеДанные = ПолучитьИзВременногоХранилища(АдресВХ);
ИмяВременногоФайла = ПолучитьИмяВременногоФайла("pdf");
ДвоичныеДанные.Записать(ИмяВременногоФайла);
ВыбФайл = Новый Файл(ИмяВременногоФайла);
НовоеИмяФайла = ИмяВременногоФайла;
КаталогФайла = ВыбФайл.Путь;
// для PowerShell важно, чтобы в пути к папке не было финального слэша
ДлинаСтроки = СтрДлина(КаталогФайла);
КаталогФайла = Лев(КаталогФайла, ДлинаСтроки - 1);
// Параметры запуска для подавления окон и ускорения инициализации:
// -WindowStyle Hidden: скрывает окно консоли
// -NonInteractive: отключает интерактивные запросы к пользователю
// -NoProfile: ускоряет запуск (не загружает профиль пользователя)
ПутьКСкрипту = "C:\PARSE\parse_pdf\conv_pdf.ps1";
ПапкаPNG = КаталогФайла; // Сюда скрипт нарежет PNG-страницы
ФайлPDF = НовоеИмяФайла;
ШаблонКоманды = "powershell.exe -ExecutionPolicy Bypass -File ""%1"" -pdfFile ""%2"" -tempDir ""%3""";
Команда = СтрШаблон(ШаблонКоманды, ПутьКСкрипту, ФайлPDF, ПапкаPNG);
КодЗавершения = Неопределено;
Сообщить("Запущено распознавание ШК... " + ТекущаяДата());
// Запускаем приложение в синхронном режиме (третий параметр = Истина)
ЗапуститьПриложение(Команда, , Истина, КодЗавершения);
Сообщить("Завершено распознавание ШК..." + ТекущаяДата());
Если КодЗавершения < 1 Тогда
// Скрипт отработал штатно, парсим CSV-карту результатов
ТаблицаДанных = ПрочитатьФайлCSV(НовоеИмяФайла);
ВыполнитьДействияВСистемеПоТаблицеДанных(ТаблицаДанных);
УдалениеФайлов(НовоеИмяФайла, ТаблицаДанных);
ЗаполнитьИнформациюОРезультатахОбработки(ТаблицаДанных);
Иначе
Сообщить("Скрипт завершил работу с ошибкой. Код ошибки: " + КодЗавершения);
КонецЕсли;
КонецПроцедуры
&НаСервере
Процедура ЗаполнитьИнформациюОРезультатахОбработки(ТаблицаДанных)
нпп = 1;
Для Каждого СтрокаДанных Из ТаблицаДанных Цикл
НоваяСтрока = Результаты.Добавить();
НоваяСтрока.Рекламация = СтрокаДанных.Рекламация;
НоваяСтрока.ПутьКФайлу = СтрокаДанных.НовыйПутьФайла;
НоваяСтрока.Статус = СтрокаДанных.Статус;
НоваяСтрока.Нпп = нпп;
нпп = нпп + 1;
КонецЦикла;
КонецПроцедуры
&НаСервере
Функция ПрочитатьФайлCSV(Знач ИмяФайла)
ВыбФайл = Новый Файл(ИмяФайла);
КаталогФайла = ВыбФайл.Путь;
ИмяБезРасширения = ВыбФайл.ИмяБезРасширения;
Таблица = Новый ТаблицаЗначений();
Таблица.Колонки.Добавить("ЗначениеШК");
Таблица.Колонки.Добавить("ПутьКФайлу");
Таблица.Колонки.Добавить("Статус");
Таблица.Колонки.Добавить("Рекламация");
Таблица.Колонки.Добавить("НовыйПутьФайла");
ИмяФайлаCSV = КаталогФайла + ИмяБезРасширения + ".csv";
ФайлCSV = Новый Файл(ИмяФайлаCSV);
Если ФайлCSV.Существует() Тогда
ЗагружаемыйФайл = Новый ТекстовыйДокумент;
ЗагружаемыйФайл.Прочитать(ИмяФайлаCSV, КодировкаТекста.UTF8);
ВсегоСтрок = ЗагружаемыйФайл.КоличествоСтрок();
// Начинаем со 2-й строки, так как первая — это заголовки CSV
Для к = 2 по ВсегоСтрок Цикл
НоваяСтрока = Таблица.Добавить();
МассивЗначений = СтроковыеФункцииКлиентСервер.РазложитьСтрокуВМассивПодстрок(ЗагружаемыйФайл.ПолучитьСтроку(к), ";", Истина);
// Очищаем кавычки, которые Export-Csv вешает на значения
НоваяСтрока.ЗначениеШК = СтрЗаменить(МассивЗначений[1], """", "");
НоваяСтрока.ПутьКФайлу = СтрЗаменить(МассивЗначений[2], """", "");
НоваяСтрока.Статус = "Не распознано";
КонецЦикла;
Иначе
Сообщить("Скрипт не отработал! CSV файл не был создан.");
КонецЕсли;
Возврат Таблица;
КонецФункции
Процедура ВыполнитьДействияВСистемеПоТаблицеДанных(ТаблицаДанных)
Для Каждого СтрокаДанных Из ТаблицаДанных Цикл
НайтиДокументИПереместитьФайл(СтрокаДанных);
КонецЦикла;
КонецПроцедуры
Процедура НайтиДокументИПереместитьФайл(СтрокаДанных)
// Тут ваша логика поиска документов по ШК и привязки сканов
ШК = СтрокаДанных.ЗначениеШК;
// ДокументСсылка = ....;
КонецПроцедуры
Процедура УдалениеФайлов(ИмяФайлаPDF, ТаблицаДанных)
// Чистим временные PNG-картинки страниц
Для Каждого Строка Из ТаблицаДанных Цикл
Попытка
УдалитьФайлы(Строка.ПутьКФайлу);
Исключение
Сообщить(ОписаниеОшибки());
КонецПопытки;
КонецЦикла;
ИмяФайлаCSV = СтрЗаменить(ИмяФайлаPDF, ".pdf", ".csv");
// Чистим сам темповый PDF и CSV-карту
Попытка
УдалитьФайлы(ИмяФайлаCSV);
УдалитьФайлы(ИмяФайлаPDF);
Исключение
Сообщить(ОписаниеОшибки());
КонецПопытки;
КонецПроцедуры
&НаКлиенте
Процедура РезультатыПутьКФайлуОткрытие(Элемент, СтандартнаяОбработка)
СтандартнаяОбработка = Ложь;
ТекущиеДанные = Элементы.Результаты.ТекущиеДанные;
Если ТекущиеДанные <> Неопределено Тогда
ЗапуститьПриложение(ТекущиеДанные.ПутьКФайлу);
КонецЕсли;
КонецПроцедуры
Вместо заключения
Давайте смотреть на это всё без иллюзий. Описанное мной решение — по сути "костыль". Пусть рабочий, в какой-то мере даже изящный, но всё же это обходной путь. Это классическое "решение для бедных". Оно создано для ситуаций, когда задачу автоматизации нужно закрыть «еще вчера», ресурсов на покупку платного софта нет, а оставлять пользователей страдать на ручном вводе — вариант, но не вариант.
Правильный путь
Если развивать эту систему по всем канонам ИТ-архитектуры, то двигаться нужно в сторону интеграции DLL и 1С.
В идеале нужно полностью избавиться от лишних промежуточных звеньев:
-
Исключить вызовы командной строки PowerShell.
-
Уйти от рендеринга временных картинок на диск через MuPDF.
-
Написать полноценную внешнюю компоненту (Native API) для 1С или подключить .NET DLL напрямую, чтобы весь конвейер (чтение PDF, улучшение изображения, распознавание ZXing) крутился исключительно в оперативной памяти самого сервера «1С:Предприятие».Но у этого «костыля» есть один жирный плюс: он работает. И работает исправно.

Рисунок 12 — Бывает и такое.

Рисунок 13 — Вид обработки из 1С.
Системные требования и окружение:
- ОС: Решение стабильно работает на Windows Server (2016, 2019, 2022) и обычных десктопных Windows 10/11.
- PowerShell:стандартной версии Windows PowerShell 5.1, которая встроена в систему "из коробки" (ничего дополнительно скачивать и обновлять не нужно)
- Регистрации DLL: Не требуется.
- Использовалась платформа 1С: 8.3.25.1546