Техническая статья

Объединение отсканированных изображений в PDF с PDFium

Прогон листового сканера оставляет вам папку JPEG-файлов, по одному на страницу, с именами 0001.jpg, 0002.jpg и так далее. Никто не хочет ревьюить дело, щёлкая через сотню миниатюр; архиву нужен один PDF на дело со страницами по порядку. Вот этот последний шаг — превратить нумерованную кучу сканов в единый упорядоченный PDF — и есть работа, о которой статья

PDFium Component делает это напрямую. Помимо рендеринга и извлечения текста, компонент умеет собирать PDF с нуля: создать пустой документ, добавить чистую страницу любого нужного размера, положить изображение на эту страницу в координатах пользовательского пространства, затем сохранить. Весь pipeline живёт на компоненте TPdf, так что пакетный конвертер — это цикл по именам файлов плюс горстка вызовов

Пакетный конвейер Delphi использует вызовы PDFium Component AddPage, PageNumber, AddImage и SaveAs, чтобы превратить папку нумерованных сканов в один упорядоченный PDF
Каждый скан становится одной страницей, созданной AddPage и нацеленной PageNumber прежде, чем AddImage её нарисует; SaveAs записывает готовый документ один раз

Форма преобразования

Для каждого сканирования должны произойти три вещи. Вы определяете размер страницы, размещаете изображение на странице, оставляя поле, и переходите к следующей странице. Компонент PDFium предоставляет по одному методу для каждого действия: AddPage создает пустую страницу заданного размера, AddImage (или AddPicture, если у вас уже есть TPicture) рисует растровое изображение на текущей странице, а PageNumber сообщает компоненту, на какую страницу нацелены последующие вызовы отрисовки

Единственная деталь, на которой люди спотыкаются, — это система координат. Пользовательское пространство PDF помещает начало координат в левый нижний угол страницы, при этом ось Y направлена вверх, что противоположно экранным координатам, к которым разработчики Delphi привыкли рефлекторно. Координаты X, Y, которые вы передаете в AddImage, — это нижний левый угол прямоугольника изображения, а Width, Height — это размер размещения в пунктах, а не размер исходного файла в пикселях. Если вы перепутаете это, ваши отсканированные изображения окажутся за пределами страницы или будут перевернуты по отношению к тому месту, где вы ожидали их увидеть

Создание документа и по странице на каждый скан

Начните с пустого документа. CreateDocument выделяет свежий PDF-файл и оставляет компонент активным, поэтому отдельного шага открытия нет. Отсюда вы просматриваете список отсканированных файлов и для каждого из них добавляете страницу, делаете ее текущей и размещаете изображение. Размеры страницы здесь — A4 в пунктах (595 × 842 книжная ориентация), стандартный размер листа для архивной корреспонденции

procedure TArchiveForm.ScansToPdf(const Files: TStrings; const OutputPath: string);
const
  PageW = 595.0;   // ширина A4 в пунктах
  PageH = 842.0;   // высота A4 в пунктах
  Margin = 36.0;   // полу-дюймовая рамка вокруг каждого скана
var
  I: Integer;
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;                       // новый, пустой, уже активный
    for I := 0 to Files.Count - 1 do
    begin
      Pdf.AddPage(I + 1, PageW, PageH);       // индекс страницы, начиная с 1
      Pdf.PageNumber := I + 1;                // сделать новую страницу текущей
      PlaceScan(Pdf, Files[I], PageW, PageH, Margin);
    end;
    Pdf.SaveAs(OutputPath);
  finally
    Pdf.Free;
  end;
end;

Каждая итерация создает страницу и сразу же устанавливает для нее PageNumber. Эта вторая строка имеет значение: AddPage вставляет страницу, но методы рисования действуют на ту страницу, которая является текущей, поэтому установка PageNumber — это то, что направляет AddImage на страницу, которую вы только что создали. Пропустите этот шаг, и ваши изображения наложатся на любую страницу, которая была загружена ранее

В этом цикле скрывается одно предположение: порядок Files. Сканер называет страницы от 0001.jpg до 0100.jpg, но перечисление каталога не всегда возвращает их отсортированными, и в тот момент, когда вы столкнетесь с page9.jpg рядом с page10.jpg, простая строковая сортировка поместит страницу 10 перед страницей 9. Явно отсортируйте список перед циклом и предпочитайте имена с нулями в начале во время сканирования, чтобы лексический порядок совпадал с порядком страниц. Последовательность страниц — это то, что рецензент замечает сразу, и это самая дешевая ошибка, которую можно предотвратить

Размещение отсканированного изображения и сохранение его пропорций

Отсканированное изображение редко имеет ту же форму, что и страница. Если вы растянете его на весь лист, вы исказите текст; если вы разместите его в полном размере в пикселях, оно выйдет за края. Решение состоит в том, чтобы масштабировать по меньшему из двух соотношений: по ширине или по высоте, и центрировать то, что осталось. Поскольку начало координат находится в левом нижнем углу, центрирование означает разделение оставшегося пространства поровну и добавление его как к X, так и к Y

Диаграмма страницы A4: как AddImage в PDFium Component размещает масштабированный скан внутри полей с помощью кода Delphi и начала координат внизу слева
AddImage берёт левый нижний угол прямоугольника размещения, поэтому подгонка и центрирование вычисляются в пунктах страницы от начала координат
procedure TArchiveForm.PlaceScan(Pdf: TPdf; const FileName: string;
  PageW, PageH, Margin: Double);
var
  Pic: TPicture;
  AvailW, AvailH, Scale, DrawW, DrawH, X, Y: Double;
begin
  Pic := TPicture.Create;
  try
    Pic.LoadFromFile(FileName);              // BMP, JPG, PNG и т. д. через графические модули VCL

    AvailW := PageW - 2 * Margin;
    AvailH := PageH - 2 * Margin;

    // Вписываем в поля без искажения скана.
    Scale := Min(AvailW / Pic.Width, AvailH / Pic.Height);
    DrawW := Pic.Width * Scale;
    DrawH := Pic.Height * Scale;

    // Центрируем: оставшееся место делится поровну. Y отсчитывается от низа страницы.
    X := (PageW - DrawW) / 2;
    Y := (PageH - DrawH) / 2;

    Pdf.AddImage(FileName, X, Y, DrawW, DrawH);
  finally
    Pic.Free;
  end;
end;

Это загружает файл один раз, чтобы прочитать его размеры в пикселях, вычисляет единый однородный масштаб и передает прямоугольник размещения в AddImage. AddImage напрямую принимает путь к файлу и направляет его через тот же конвейер изображений, что и AddPicture, поэтому любой формат, который распознают графические модули VCL, работает без особых случаев. Если изображение уже декодировано в TPicture из панели предварительного просмотра, вызовите AddPicture(Pic, X, Y, DrawW, DrawH) с тем же прямоугольником и пропустите второе чтение файла

Пропуск декодирования для отсканированных JPEG

Сканеры почти всегда выдают JPEG. Загрузка JPEG в TPicture декодирует его в полный bitmap, и именно этот bitmap уходит в PDF. Декодирование не стоит качества, и PDFium хранит пиксели без потерь, так что страница выглядит так же; платите вы размером и временем, потому что скан, пришедший компактным JPEG, записывается как сырые пиксели со сжатием без потерь — обычно в разы больше исходного файла. AddJpegImage встраивает оригинальные сжатые байты прямо на страницу из потока, так что вывод остаётся близким к размеру сканов, а пакетный прогон вообще пропускает работу по декодированию

AddJpegImage встраивает оригинальные байты JPEG на страницу PDFium Component, тогда как AddImage декодирует, а SaveAs перекодирует пиксели в Delphi
AddJpegImage вставляет сжатые байты сканера как есть, минуя проходы декодирования и перекодирования, которые выполняют AddImage и SaveAs
var
  Stream: TFileStream;
begin
  // ... после AddPage + PageNumber для текущей страницы ...
  Stream := TFileStream.Create(FileName, fmOpenRead);
  try
    // Встраивает байты JPEG как есть; без цикла декодирования/перекодирования.
    Pdf.AddJpegImage(Stream, X, Y, DrawW, DrawH);
  finally
    Stream.Free;
  end;
end;

Вы по-прежнему вычисляете X, Y, DrawW и DrawH тем же способом, поскольку вам нужны размеры в пикселях для масштабирования. Прочитайте их из файла или с помощью быстрого разбора заголовка, затем передайте сырой поток в AddJpegImage. Для сканирований PNG или TIFF правильным путем будет AddImage; приберегите ярлык JPEG для формата, к которому он фактически применим

Маркировка каждой страницы

Архивные сканирования легче проверять, когда на каждой странице указано имя исходного файла. AddText рисует строку в координатах пространства пользователя, поэтому подпись располагается прямо под изображением. Помните о перевернутой оси Y: чтобы поместить метку под сканированным изображением, нужно вычитать из нижнего края изображения, а не прибавлять к нему

// Подпись под сканом: Y уменьшается к низу страницы.
Pdf.AddText('File: ' + ExtractFileName(FileName), 'Helvetica', 9,
  X, Y - 14, clGray);

Последнее замечание о сохранении. SaveAs возвращает Boolean, и False значит, что документ не удалось сериализовать, так что проверяйте результат, а не надейтесь, что запись прошла. Неполадки файловой системы идут другим маршрутом: отсутствующая папка вывода или заблокированный целевой файл поднимают исключение из файлового слоя, поэтому пакетному заданию нужны и проверка результата, и обработчик, логирующий, какое именно дело не удалось. Когда цикл завершён и файл записан, у вас ровно то, что требовалось архиву: один упорядоченный PDF на дело, страницы смасштабированы под лист, читать можно в любом viewer

Те же строительные блоки покрывают смежные задачи. Поменяйте правило подгонки размера страницы — получите фотокнигу с одним изображением на лист; оставьте цикл, но читайте из многостраничного TIFF — получится конвертер факс-архива. Если нужен более общий взгляд на программную сборку PDF, смотрите создание PDF-документов с нуля с PDFium Component; а чтобы позже отрисовать результат на экран, смотрите конвертацию страниц PDF в изображения JPEG с PDFium Component

Компонент PDFium от loslab.com объединяет API-интерфейсы создания документов, рендеринга и работы с текстом, используемые в этой серии статей