Technical Article

Извличане на текст от PDF файлове с PDFium VCL в Delphi

Извличането на текст от PDF изглежда лесно, докато не се сблъскате с документ, при който текстовият слой липсва, повреден е или е разделен на десетки малки части от символи без логичен ред. PDFium VCL ви предоставя две входни точки: масива 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 върне False, трябва да затворите и отворите отново документа с правилната парола.

Извличане страница по страница с 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, който групира символите по техните ограничителни рамки в логически единици за четене, но не може да гарантира пълна точност.

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] е пълната му ограничителна рамка (bounding box). Те са градивните елементи за всичко извън обикновения текст: откриване на граници на колони, групиране на символи в редове чрез сравняване на координатите Y в рамките на определен толеранс, или изграждане на карта на съвпаденията за селекция на текст в четец. Ако трябва да откриете кой символ се намира под кликване на мишката, CharacterIndexAtPos(X, Y, ToleranceX, ToleranceY) прави това директно, без да се налага да обхождате правоъгълници.

Настройка на DLL файловете

PDFium VCL делегира цялото анализиране на 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 (current transformation matrix) на страницата, така че документ, при който авторът е мащабирал цялата страница, ще докладва пропорционално променени размери. Ако сравнявате размери между страници с различни размери, нормализирайте спрямо височината на MediaBox на всяка страница, преди да вземате решения за праговите стойности.

Записване на извлечения текст във файл

Класът TStringList на Delphi работи безпроблемно с UTF-8 изход от версия XE насам. Задайте WriteBOM := False, ако имате нужда от файл без 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 в цикъла за страници, вместо първо да натрупвате всичко в списък.

Използване на TPdfView във VCL приложение

Памет и производителност при пакетна обработка

При големи архиви най-важното нещо, което трябва да следите, е бюджетът на паметта. Всяко извикване на Bitmap[] заделя нов TBitmap в купчината (heap), което при сканирана страница с 300 DPI е лесно 25 MB необработени пикселни данни преди всякакво кодиране. Ако обработвате страниците в затворен цикъл без освобождаване на паметта между итерациите, обемът на заетата памет ще нараства линейно с броя на изображенията. Правилният подход винаги е: извличате един bitmap, извършвате необходимите операции, освобождавате го и извличате следващия. Ако трябва да пазите препратки към няколко растерни изображения едновременно за сравнение, първо ги пребройте с BitmapCount, разпределете съответно контейнера си и след това освобождавайте всяко едно веднага щом приключите с него, вместо да отлагате почистването за края на документа. При документ от 500 сканирани страници тази разлика може да бъде между 25 MB и 12 GB пикова заетост на паметта (peak RSS).

Компонентът TPdfView предлага същите свойства BitmapCount и Bitmap[], но страницата, от която чете, е текущо показаната страница на изгледа, а не TPdf.PageNumber. Двата указателя за страници са независими; промяната на единия не премества другия. Във VCL приложение с преглед на живо можете да извикате Pdf.PageNumber := N, за да управлявате извличането чрез TPdf, докато изгледът остава на страницата, която потребителят последно е прелистил. Това разделение е умишлено и запазва състоянието на дисплея на четеца чисто, докато извличането се изпълнява във фонов режим.

Приложните програмни интерфейси (API) за Character[], CharacterCount, CharacterOrigin[], CharacterRectangle[], ReadablePageContent и CharacterIndexAtPos, показани тук, са част от компонента PDFium VCL Component за Delphi и C++Builder.