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

Витяг тексту з PDF-файлів за допомогою компонента PDFium у Delphi

Витяг тексту з PDF виглядає простою задачею, доки ви не натрапите на документ, де текстовий шар відсутній, пошкоджений або розбитий на десятки дрібних груп символів без жодного змістовного порядку. Компонент PDFium надає вам дві точки входу: масив Character[] для прямого доступу до кожного гліфа на сторінці за індексом, та ReadablePageContent для структурованого подання, що відновлює абзаци та заголовки з дерева тегів PDF або за допомогою евристичного аналізу. Жоден з них не є універсально правильним вибором, тому важливо розуміти, що саме надає кожен підхід

Відкриття документа та пастка тихого збою

TPdf відкриває файл шляхом встановлення FileName і перемикання Active := True. Важлива деталь: Active := True ніколи не викликає виключень. Якщо файл відсутній, захищений паролем або пошкоджений, PDFium перехоплює помилку внутрішньо, а Active просто залишається False. Це означає, що кожен цикл вилучення тексту повинен передбачати таку перевірку:

Pdf := TPdf.Create(nil);
try
  Pdf.FileName := 'report.pdf';
  Pdf.Active := True;
  if not Pdf.Active then
  begin
    ShowMessage('Could not open PDF (damaged or wrong password)');
    Exit;
  end;
  // extraction follows here
finally
  Pdf.Active := False;
  Pdf.Free;
end;

Для файлів, захищених паролем, потрібно встановити Pdf.Password := '...' перед викликом Active := True. Другого шансу немає: якщо Active не вдалося виконати, ви закриваєте і відкриваєте знову з правильним паролем

Посторінковий витяг за допомогою Character[]

Найнижчий рівень підходу передбачає обхід кожного символу на кожній сторінці. Встановіть Pdf.PageNumber, щоб завантажити текстовий шар для цієї сторінки, а потім переберіть CharacterCount записів за допомогою властивості Character[]. Варто перевіряти два прапорці на кожному записі: CharacterGenerated[i] позначає синтетичні гліфи, вставлені рендером (наприклад, м'які перенесення при розриві рядка), які не мають справжнього значення Unicode, а CharacterMapError[i] сигналізує, що PDFium не зміг зіставити гліф із кодовою точкою, що трапляється при кодуванні шрифтів, у яких відсутня таблиця ToUnicode

procedure ExtractAllText(Pdf: TPdf; Output: TStrings);
var
  Page, I: Integer;
  Line: string;
  Ch: WideChar;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := Page;
    Line := '';
    for I := 0 to Pdf.CharacterCount - 1 do
    begin
      if Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I] then
        Continue;
      Ch := Pdf.Character[I];
      if Ch = #13 then
        Ch := #10;   // normalize CR to LF
      Line := Line + Ch;
    end;
    Output.Add(Line);
  end;
end;

Результатом є плоский рядок кодових точок Unicode у тому порядку, в якому PDFium перелічує їх — це порядок, у якому вони з'являються в потоці вмісту, а не обов'язково порядок читання зліва направо. Для більшості документів на основі латиниці, створених стандартними офісними інструментами, це цілком підходить. Але для відсканованих PDF, розпізнаних (OCR) з незвичними послідовностями гліфів, або для тексту, що читається справа наліво, порядок може бути хибним. Саме тоді ReadablePageContent стає у нагоді

Структурований витяг за допомогою ReadablePageContent

ReadablePageContent працює на вищому рівні: він повертає запис TPdfReadableContent, чий масив Fragments містить фрагменти тегованого контенту, кожен з яких має Kind, що ідентифікує абзаци, заголовки, елементи списку, комірки таблиці тощо. Коли PDF містить дерево структури (перевірте Pdf.IsTagged), джерелом є rosStructure, і порядок читання є достовірним. Для нетегованих файлів PDFium повертається до rosHeuristic, який групує символи за їхніми обмежувальними рамками (bounding boxes) у правдоподібні одиниці для читання, але не може гарантувати точність

procedure ExtractStructured(Pdf: TPdf; Output: TStrings);
var
  Page: Integer;
  Content: TPdfReadableContent;
  Fragment: TPdfContentFragment;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Content := Pdf.ReadablePageContent(Page);
    for Fragment in Content.Fragments do
    begin
      case Fragment.Kind of
        cfHeading   : Output.Add('# ' + Fragment.Text);
        cfParagraph : Output.Add(Fragment.Text);
        cfListItem  : Output.Add('- ' + Fragment.Text);
      else
        Output.Add(Fragment.Text);
      end;
    end;
  end;
end;

Якщо Content.Source = rosHeuristic, а ваш результат виглядає спотвореним, скоріше за все, текстовий шар документа не створювався з урахуванням порядку читання. На цьому етапі єдиним надійним виправленням є повторний експорт із вихідної програми з належним тегуванням або запуск кроку постобробки, який сортує джерела символів спочатку за Y, а потім за X

Що дають CharacterOrigin та CharacterRectangle

Обидві властивості повертають позицію символу в просторі сторінки (в пунктах, початок координат у лівому нижньому куті, Y зростає вгору). CharacterOrigin[i] — це базова точка прив'язки гліфа; CharacterRectangle[i] — це повна обмежувальна рамка. Це будівельні блоки для всього, що виходить за межі простого тексту: визначення меж стовпців, групування символів у рядки шляхом порівняння координат Y у межах допуску, або створення карти для перевірки влучань (hit-test) для виділення тексту в переглядачі. Якщо вам потрібно дізнатися, який символ знаходиться під місцем кліку миші, CharacterIndexAtPos(X, Y, ToleranceX, ToleranceY) виконує цей пошук напряму без необхідності перебирати всі рамки

Встановлення DLL

Компонент PDFium делегує весь синтаксичний аналіз PDF нативній DLL: або pdfium32.dll, або pdfium64.dll, залежно від вашої цільової платформи. З компонентом постачається скрипт CopyDlls.bat, який копіює потрібний файл до системного каталогу Windows. Запустити його від імені адміністратора один раз на машині розробника цілком достатньо; для розгортання ви копіюєте DLL разом із виконуваним файлом програми. Варіанти з підтримкою V8 (pdfium32v8.dll, pdfium64v8.dll) значно більші і потрібні лише в тому разі, якщо ваші PDF містять JavaScript, який повинен виконуватись. Для суто вилучення тексту найкращим вибором є стандартна збірка

Якщо DLL відсутня під час виконання, Active := True завершиться тихою помилкою, так само як і для відсутнього файлу, оскільки компонент перехоплює помилку завантаження внутрішньо. Завжди тестуйте на "чистій" машині перед випуском продукту

Використання FontSize[] разом із Character[] для аналізу макета

Крім простого тексту, API рівня символів надає FontSize[i], що повертає розмір у пунктах для кожного відрендереного гліфа. Разом із CharacterOrigin[i] та CharacterRectangle[i] це дозволяє відрізняти основний текст від заголовків без покладання на дерево структури. Група символів, де розмір шрифту стрибає вище певного порогу, майже напевно є заголовком у нетегованому документі. Такий самий метод застосовується для виявлення підписів (дрібний текст під обмежувальною рамкою зображення) або виносок (дрібний текст біля нижнього краю сторінки). Ніщо з цього не вимагає рендерингу; всі три властивості зчитуються напряму з текстового шару, який PDFium створює під час Active := True

Один нюанс: FontSize[i] відображає розмір після застосування поточної матриці трансформації (CTM) сторінки, тому в документі, де автор масштабував усю сторінку, розміри будуть відповідно змінені. Якщо ви порівнюєте розміри на різних сторінках із різними розмірами сторінки, нормалізуйте їх відносно висоти MediaBox кожної сторінки перед прийняттям порогових рішень

Запис результату у файл

TStringList у Delphi починаючи з версії XE бездоганно обробляє виведення в UTF-8. Встановіть WriteBOM := False, якщо вам потрібен файл без BOM (багато подальших споживачів мають проблеми з початковим BOM):

var
  Lines: TStringList;
begin
  Lines := TStringList.Create;
  try
    ExtractAllText(Pdf, Lines);
    Lines.WriteBOM := False;
    Lines.SaveToFile('output.txt', TEncoding.UTF8);
  finally
    Lines.Free;
  end;
end;

Для дуже великих документів, де використання пам'яті є критичним, пишіть напряму в TStreamWriter за допомогою TEncoding.UTF8 всередині циклу сторінок замість попереднього накопичення всього у список

Показані тут API Character[], CharacterCount, CharacterOrigin[], CharacterRectangle[], ReadablePageContent та CharacterIndexAtPos є частиною компонента PDFium для Delphi та C++Builder