Технічна стаття

Об'єднання відсканованих зображень в один PDF за допомогою PDFium Component у Delphi

Команда з обробки претензій мала тридцятирічний архів паперових справ, які пропускали через потоковий сканер. Сканер зберігав кожну сторінку як окремий JPEG у папку з назвами 0001.jpg, 0002.jpg тощо. Натомість архів потребував єдиного PDF-файлу для кожної справи з правильним порядком сторінок, щоб рецензент міг відкрити один документ замість того, щоб переглядати сотні мініатюр. Саме цей останній крок, перетворення пронумерованої купи сканів у єдиний впорядкований PDF, ми і розглянемо

PDFium Component вирішує цю задачу безпосередньо. Окрім рендерингу та вилучення тексту, компонент дозволяє створювати PDF з нуля: створити порожній документ, додати чисту сторінку довільного розміру, розмістити зображення за координатами простору користувача та зберегти результат. Увесь цей конвеєр реалізовано в компоненті TPdf, тому пакетний конвертер зводиться до циклу за іменами файлів та кількох викликів методів

Пакетний конвеєр Delphi використовує виклики PDFium Component AddPage, PageNumber, AddImage і SaveAs, щоб перетворити каталог нумерованих сканів в один впорядкований PDF
Кожен скан стає однією сторінкою, створеною AddPage і націленою PageNumber до того, як AddImage її намалює; SaveAs записує готовий документ один раз

Загальна схема конвертації

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

Єдина деталь, яка часто збиває з пантелику - це система координат. Простір користувача у форматі PDF розміщує початок координат у лівому нижньому куті сторінки, причому вісь Y зростає вгору, що є протилежністю до екранних координат, до яких розробники Delphi звикли на рефлекторному рівні. Значення X, Y, які ви передаєте в AddImage, є координатами лівого нижнього кута прямокутника зображення, а Width, Height - це розмір малювання в пунктах, а не в пікселях вихідного файлу. Якщо ви переплутаєте ці поняття, ваші скани опиняться за межами сторінки або будуть перевернуті

Створення документа та сторінки для кожного скану

Почніть з порожнього документа. Метод CreateDocument виділяє пам'ять під новий PDF і залишає компонент в активному стані, тому окремого кроку відкриття не потрібно. Далі ви проходите списком відсканованих файлів і для кожного з них додаєте сторінку, робите її поточною та розміщуєте зображення. Для прикладу ми використовуємо розмір сторінки А4 в пунктах (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, звичайне рядкове сортування поставить десяту сторінку перед дев'ятою. Завжди сортуйте список явно перед циклом і налаштовуйте сканер на додавання провідних нулів у назви файлів, щоб лексичний порядок збігався з фізичним. Неправильний порядок сторінок - це перше, що помітить користувач, і найпростіша помилка, якій можна запобігти

Розміщення скану та збереження його пропорцій

Пропорції скану рідко ідеально збігаються з пропорціями сторінки. Якщо розтягнути його на весь аркуш, ви спотворите текст, а якщо вставити в оригінальному розмірі в пікселях - він вийде за межі. Правильне рішення - це масштабування за меншим із двох співвідношень (за шириною або висотою) та центрування залишку. Оскільки початок координат лежить у лівому нижньому куті, центрування означає рівномірний розподіл вільного простору та додавання його до координат X та Y

Діаграма сторінки A4 показує, як PDFium Component AddImage розміщує масштабований скан всередині полів з кодом 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 спочатку розкодовує його в растр, а під час збереження PDFium знову стискає його. Це два непотрібних цикли перетворення з втратою якості. Метод 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 - це функція, яка повертає логічне значення, тому в робочому коді завжди перевіряйте її результат замість того, щоб сліпо сподіватися на успішний запис. Інакше нестача місця на диску або заблокований файл призведуть до тихої помилки. Після успішного завершення циклу та запису файлу ви отримаєте саме те, що потрібно для архіву: єдиний впорядкований PDF для кожної справи з правильно відмасштабованими сторінками, готовими до перегляду в будь-якій програмі

Ці ж самі базові блоки чудово підходять і для суміжних завдань. Змініть логіку обчислення розміру сторінки - і ви отримаєте фотокнигу з одним зображенням на аркуш. Залиште цикл, але зчитуйте сторінки з багатосторінкового TIFF - і матимете конвертер факсових архівів. Якщо ви хочете детальніше розібратися у програмному створенні PDF, ознайомтеся зі статтею про створення PDF-документів з нуля за допомогою PDFium Component. А щоб дізнатися, як пізніше відрендерити результат назад на екран, перегляньте матеріал про перетворення сторінок PDF у зображення JPEG за допомогою PDFium Component

PDFium Component від loslab.com містить усі необхідні API для створення документів, рендерингу та роботи з текстом, які використовувалися в цій серії статей