Artykuł techniczny

Wyodrębnianie tekstu z plików PDF za pomocą PDFium Component w Delphi

Wyodrębnianie tekstu z pliku PDF wydaje się proste, dopóki nie natrafisz na dokument, w którym warstwa tekstowa jest nieobecna, uszkodzona lub podzielona na dziesiątki drobnych fragmentów znaków bez logicznego porządku. Komponent PDFium Component oferuje dwa punkty wejścia: tablicę Character[] zapewniającą bezpośredni dostęp do każdego glifu na stronie na podstawie indeksu oraz funkcję ReadablePageContent oferującą ustrukturyzowany widok rekonstruujący akapity i nagłówki z drzewa znaczników PDF lub analizy heurystycznej. Żadne z tych rozwiązań nie jest uniwersalne, dlatego warto rozumieć różnice między nimi

Otwieranie dokumentu i pułapka cichego niepowodzenia

Komponent TPdf otwiera plik poprzez ustawienie właściwości FileName i zmianę stanu na Active := True. Kluczowy szczegół: ustawienie Active := True nigdy nie wywołuje wyjątku. Jeśli plik nie istnieje, jest chroniony hasłem lub uszkodzony, biblioteka PDFium obsłuży ten błąd wewnętrznie, a właściwość Active po prostu pozostanie w stanie False. Oznacza to, że każda pętla wyodrębniania musi zawierać następujące zabezpieczenie:

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;
  // dalsza procedura wyodrębniania
finally
  Pdf.Active := False;
  Pdf.Free;
end;

Dokumenty chronione hasłem wymagają ustawienia Pdf.Password := '...' przed wykonaniem przypisania Active := True. Nie ma tu drugiej szansy: jeśli przypisanie Active się nie powiedzie, należy zamknąć dokument i otworzyć go ponownie z właściwym hasłem

Wyodrębnianie strona po stronie za pomocą Character[]

Najbardziej niskopoziomowe podejście polega na analizie każdego znaku na poszczególnych stronach. Ustaw właściwość Pdf.PageNumber, aby załadować warstwę tekstową dla danej strony, a następnie wykonaj iterację CharacterCount znaków korzystając z właściwości Character[]. Warto sprawdzać dwie flagi dla każdego znaku: CharacterGenerated[i] oznacza syntetyczne glify wstawione przez silnik renderujący (np. miękkie łączniki przy przełamaniu linii), które nie mają odzwierciedlenia w kodowaniu Unicode, natomiast CharacterMapError[i] sygnalizuje, że PDFium nie powiązało glifu z punktem kodowym, co zdarza się przy czcionkach bez tabeli 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;   // znormalizuj znak powrotu karetki (CR) do nowej linii (LF)
      Line := Line + Ch;
    end;
    Output.Add(Line);
  end;
end;

Wynikiem jest prosty ciąg znaków Unicode w kolejności, w jakiej PDFium je wylicza — jest to kolejność ich zapisu w strumieniu zawartości, która nie zawsze odpowiada kolejności czytania od lewej do prawej. Dla większości dokumentów z pismem łacińskim wygenerowanych przez programy biurowe to wystarczające rozwiązanie. W przypadku zeskanowanych plików PDF poddanych procesowi OCR z nietypową kolejnością glifów lub dla tekstów pisanych od prawej do lewej kolejność może być zaburzona. W takich sytuacjach lepiej sprawdza się funkcja ReadablePageContent

Wyodrębnianie strukturalne za pomocą ReadablePageContent

Funkcja ReadablePageContent działa na wyższym poziomie: zwraca rekord TPdfReadableContent, którego tablica Fragments zawiera oznaczone fragmenty zawartości, z parametrem Kind określającym akapity, nagłówki, elementy listy, komórki tabeli itp. Jeśli plik PDF posiada strukturę logiczną (weryfikowaną przez Pdf.IsTagged), źródłem danych jest rosStructure, co gwarantuje poprawną kolejność czytania. Dla plików bez struktury PDFium korzysta z metody heurystycznej rosHeuristic, która grupuje znaki w bloki na podstawie ich współrzędnych, co jednak nie daje pełnej gwarancji dokładności

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;

Jeśli Content.Source = rosHeuristic, a wynikowy tekst jest nieuporządkowany, oznacza to, że warstwa tekstowa dokumentu nie została zapisana z myślą o naturalnej kolejności czytania. W takim przypadku jedynym trwałym rozwiązaniem jest ponowny eksport pliku z aplikacji źródłowej z włączonym oznaczaniem struktury (tagging) lub wdrożenie procedury sortującej pozycje znaków w pionie (Y), a następnie w poziomie (X)

Zastosowanie właściwości CharacterOrigin i CharacterRectangle

Obie te właściwości zwracają pozycję znaku w układzie współrzędnych strony (punkty, z punktem początkowym w lewym dolnym rogu, współrzędna Y rośnie w górę). CharacterOrigin[i] określa punkt bazowy linii pisma glifu, natomiast CharacterRectangle[i] to jego pełny obszar otaczający (bounding box). Informacje te są niezbędne do zaawansowanych analiz: wykrywania kolumn tekstu, grupowania znaków w linie (poprzez porównywanie współrzędnej Y z określoną tolerancją) czy tworzenia mapy zaznaczania tekstu w przeglądarce. Do wyszukiwania znaku pod kursorem myszy służy funkcja CharacterIndexAtPos(X, Y, ToleranceX, ToleranceY), eliminująca potrzebę ręcznego przeszukiwania współrzędnych

Instalacja biblioteki DLL

Komponent PDFium Component deleguje zadania przetwarzania plików PDF do natywnej biblioteki DLL: pdfium32.dll lub pdfium64.dll, zależnie od docelowej platformy sprzętowej. Pakiet zawiera skrypt CopyDlls.bat kopiujący odpowiednie pliki do katalogu systemowego Windows. Jednorazowe uruchomienie go z uprawnieniami administratora na komputerze programisty jest wystarczające; w instalacjach klienckich pliki te kopiuje się bezpośrednio do katalogu z plikiem wykonywalnym aplikacji. Wersje z silnikiem V8 (pdfium32v8.dll, pdfium64v8.dll) are znacznie większe i wymagane wyłącznie wtedy, gdy pliki PDF zawierają skrypty JavaScript. Do samego wyodrębniania tekstu w zupełności wystarcza wersja standardowa

Brak biblioteki DLL w czasie uruchomienia aplikacji spowoduje ciche niepowodzenie przy wywołaniu Active := True (tak samo jak przy braku pliku), ponieważ komponent przechwytuje błąd ładowania wewnętrznie. Przed wdrożeniem oprogramowania u klientów zawsze przeprowadź testy na czystym systemie

Wykorzystanie FontSize[] obok Character[] do analizy układu

Oprócz odczytu znaków, API poziomu znaku udostępnia właściwość FontSize[i] zwracającą stopień powiększenia każdego glifu w punktach. W połączeniu z CharacterOrigin[i] oraz CharacterRectangle[i] pozwala to odróżnić tekst akapitu od nagłówka bez korzystania z drzewa struktury. Ciąg znaków o rozmiarze wyraźnie większym od reszty tekstu w dokumencie bez struktury najpewniej stanowi nagłówek. Ta sama technika służy do wykrywania podpisów pod zdjęciami (mały tekst pod ramką obrazu) oraz przypisów dolnych. Analizy te nie wymagają renderowania graficznego; wszystkie trzy właściwości odczytują dane bezpośrednio z warstwy tekstowej tworzonej przez PDFium przy ustawieniu Active := True

Mały niuans: właściwość FontSize[i] zwraca rozmiar po uwzględnieniu macierzy przekształceń strony (CTM), więc dokument, w którym przeskalowano całą stronę, wykaże odpowiednio zmienione rozmiary pisma. Porównując rozmiary czcionek na stronach o różnych formatach, warto znormalizować te wartości względem wysokości MediaBox każdej strony przed ustaleniem progów podziału

Zapisywanie tekstu do pliku

Klasa TStringList w środowisku Delphi od wersji XE prawidłowo obsługuje kodowanie UTF-8. Ustaw właściwość WriteBOM := False, jeśli chcesz zapisać plik bez znacznika BOM (wiele zewnętrznych parserów zgłasza błędy przy napotkaniu początkowego znacznika 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;

W przypadku bardzo dużych dokumentów, w celu oszczędzania pamięci, zaleca się zapisywanie danych bezpośrednio przez TStreamWriter z kodowaniem TEncoding.UTF8 w pętli przetwarzania stron, zamiast gromadzenia całego tekstu w jednej liście

Opisane tu interfejsy API Character[], CharacterCount, CharacterOrigin[], CharacterRectangle[], ReadablePageContent oraz CharacterIndexAtPos wchodzą w skład PDFium Component dla Delphi i C++Buildera