Artykuł techniczny

Strukturalna ekstrakcja tekstu PDF w Delphi z PDFium VCL

PDFiumPas zwraca tekst strony jako strukturę zamiast łańcucha znaków. GetStructuredText tworzy TPdfStructuredTextPage zawierającą bloki, z których każdy trzyma wiersze, z których każdy trzyma stylizowane fragmenty, z granicami w przestrzeni strony na każdym poziomie oraz zachowanymi indeksami znaków źródłowych, dzięki czemu każdy fragment można odwzorować z powrotem na leżącą pod spodem stronę tekstu

Płaska ekstrakcja tekstu, od której zaczyna większość kodu, wciąż tam jest i wciąż jest poprawna do swojego celu. Przestaje wystarczać w chwili, gdy musisz wiedzieć, które słowa były nagłówkiem, które należały do lewej kolumny, albo gdzie na stronie faktycznie znajduje się dopasowanie

Dlaczego płaski łańcuch znaków to zła odpowiedź dla większości zadań?

Ponieważ pytania, które ludzie zadają wyodrębnionemu tekstowi, niemal nigdy nie brzmią „jakie znaki są na tej stronie”. Brzmią „jaki jest tytuł”, „czy to tabela”, „czy ten akapit należy do sekcji 4”, „gdzie narysować podświetlenie”. Pojedynczy łańcuch znaków nie odpowiada na żadne z nich, a każda odpowiedź, którą z niego odtwarzasz, to heurystyka, którą teraz sam utrzymujesz

Układy dwukolumnowe uwidaczniają to najlepiej. Wyodrębnij dwukolumnowy artykuł jako łańcuch znaków, a w zależności od tego, jak producent napisał strumień treści, możesz dostać kolumnę pierwszą, a po niej kolumnę drugą, albo możesz dostać wiersz pierwszy kolumny pierwszej, wiersz pierwszy kolumny drugiej, wiersz drugi kolumny pierwszej i tak dalej w dół strony. Oba warianty wychodzą z zgodnego pliku PDF. Żaden nie jest błędny na poziomie formatu, ponieważ PDF opisuje ślady na stronie, nie zarys dokumentu. Model oparty na blokach pozwala ekstraktorowi jawnie podjąć decyzję o kolejności i powiedzieć ci, którą decyzję podjął

Kolejność treści czy fizyczny układ?

TPdfStructuredTextOptions.ReadingOrder wybiera między roContentOrder a roPhysicalLayout, a właściwa odpowiedź zależy od tego, czemu ufasz bardziej: producentowi czy geometrii

Kolejność treści zwraca tekst w sekwencji, w jakiej strumień treści go rysuje. To szybkie i dla dokumentów wygenerowanych przez dobrze zachowującego się producenta zwykle zamierzona kolejność czytania. Fizyczny układ ignoruje sekwencję strumienia i odtwarza kolejność na podstawie tego, gdzie znaki faktycznie leżą, grupując je w wiersze, a potem w kolumny. Tego chcesz dla stron zeskanowanych i przepuszczonych przez OCR, dla wyjścia z narzędzi, które emitują tekst w kolejności czcionek zamiast kolejności czytania, oraz dla wszystkiego, gdzie wynik wizualny jest jedynym sygnałem, na którym możesz polegać

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfStructuredTextOptions;
  Page: TPdfStructuredTextPage;
  B, L: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'article.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // liczone od 1

    Options := TPdfStructuredTextOptions.Default;
    Options.ReadingOrder := roPhysicalLayout;
    Options.IncludeFontInfo := True;
    Options.IncludeSemantics := True;
    Options.MaxCharacters := 200000;         // budżet fail-closed

    Page := Pdf.GetStructuredText(Options);

    for B := 0 to High(Page.Blocks) do
    begin
      if Page.Blocks[B].Kind = cfHeading then
        Emit(Format('H%d: %s',
          [Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
      else
        for L := 0 to High(Page.Blocks[B].Lines) do
          Emit(Page.Blocks[B].Lines[L].Text);
    end;
  finally
    Pdf.Free;
  end;
end;

Co dodaje tagowanie, czego nie może dać geometria?

Intencję. Przy włączonym IncludeSemantics bloki z otagowanego pliku PDF niosą Kind pobrany z drzewa struktury, więc nagłówek jest nagłówkiem, ponieważ powiedział tak producent, a nie dlatego, że jego czcionka była większa niż przeciętna. Rodzaje obejmują kształty, które mają znaczenie dla ponownego wykorzystania: cfParagraph, cfHeading z HeadingLevel, cfListItem, cfTableCell, cfCaption, cfFigure oraz nieoznaczoną wartość zapasową cfPlain

Pole Source zapisuje, skąd pochodzi każda klasyfikacja, rosStructure dla drzewa struktury i rosHeuristic dla wnioskowania, czyli pole, które warto logować, decydując, jak dalece ufać potokowi ekstrakcji w całym zbiorze dokumentów. Figury to szczególny przypadek wart poznania: dla bloku cfFigure tekst pochodzi z opisu alternatywnego, a nie z żadnych glifów, ponieważ figura nie ma własnych znaków. Niedopasowany tekst alternatywny jest wciąż reprezentowany zamiast być odrzucany, co pozwala audytowi dostępności zobaczyć, że opis istnieje, nawet gdy nic na stronie go nie rysuje. Sam model tagowania jest omówiony w walidacji drzewa struktury PDF/UA

Fragmenty niosą stylizację i pochodzenie

Każdy TPdfStructuredTextSpan trzyma swój tekst, granice w przestrzeni strony, FontName, FontSize, FontWeight i Angle, plus SourceStartIndex i SourceCharacterCount. Fragmenty przerywają się tam, gdzie zmienia się stylizacja, więc zdanie z trzema pogrubionymi słowami staje się trzema fragmentami, a odtworzenie wyróżnienia w HTML albo Markdown to kwestia odczytania właściwości, a nie zgadywania na podstawie nazw czcionek

Oba pola indeksu źródłowego są tym, co zamienia ekstrakcję w funkcję, a nie tylko raport. Wskazują z powrotem na sekwencję znaków strony, co oznacza, że blok, który dopasowałeś w wyszukiwaniu, można przekształcić w geometrię zaznaczenia na poziomie znaku albo prostokąt podświetlenia bez drugiego, inaczej uporządkowanego przebiegu po tekście; mechanika jest opisana w wizualnym zaznaczaniu linii tekstu za pomocą ramek znaków. Pole Angle ma większe znaczenie, niż się wydaje: obrócony tekst w pieczątce albo znaku wodnym trafia do tej samej przestrzeni współrzędnych co tekst główny, a potok, który ignoruje kąt, chętnie scali ukośny napis „DRAFT” ze środkiem akapitu

Budżet i dwa liczniki jakości

MaxCharacters to budżet fail-closed, nie ustawienie obcinania: strona przekraczająca go zatrzymuje się, zamiast po cichu zwrócić część treści. Na niezaufanej ścieżce przyjmowania plików to zachowanie, którego chcesz, ponieważ strona z milionem znaków to albo maszynowo wygenerowany potwór, albo próba zamienienia twojego ekstraktora w najwolniejszą część systemu

Dwa liczniki na zwróconej stronie opisują jakość ekstrakcji wprost. UnmappedCharacterCount liczy znaki bez użytecznego mapowania Unicode, co jest klasycznym objawem czcionki podzbioru osadzonej bez mapy CMap /ToUnicode; taki tekst renderuje się idealnie i wyodrębnia jako nic użytecznego. GeometryFailureCount liczy znaki, których prostokąta granicznego nie udało się ustalić, co pogarsza porządkowanie fizycznego układu. Loguj oba. Zbiór dokumentów, gdzie te liczby są konsekwentnie bliskie zeru, można indeksować z zaufaniem, a taki, gdzie nie są, mówi ci, że niektórzy producenci w twoim potoku potrzebują uwagi, zanim jakikolwiek dalszy wynik będzie wiarygodny

var
  Page: TPdfStructuredTextPage;
  B, S, L: Integer;
  Emphasised: Boolean;
begin
  Page := Pdf.GetStructuredText(Options);

  if Page.UnmappedCharacterCount > 0 then
    Log(Format('page %d: %d characters without a Unicode mapping',
      [Page.PageNumber, Page.UnmappedCharacterCount]));
  if Page.GeometryFailureCount > 0 then
    Log(Format('page %d: %d characters without geometry',
      [Page.PageNumber, Page.GeometryFailureCount]));

  for B := 0 to High(Page.Blocks) do
    for L := 0 to High(Page.Blocks[B].Lines) do
      for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
      begin
        Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
        AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
          Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
      end;
end;

Wydajność na prawdziwych stronach

Ekstrakcja fizycznego układu to tryb kosztowny, a implementacja jest zbudowana pod strony, które są naprawdę duże: porządkowanie znaków działa w O(n log n) zamiast przez powtarzane skanowanie, bufory wierszy i fragmentów rosną geometrycznie zamiast realokować się per znak, tekst Unicode jest budowany w buforach zamiast przez konkatenację łańcuchów, a wyszukiwania czcionek dla sąsiadujących obiektów tekstowych są buforowane. Ta kombinacja utrzymuje gęstą stronę o 5000 znakach przewidywalną zamiast kwadratową

Dla zadania obciążonego liczbą stron wciąż warto wybierać tańszy tryb tam, gdzie się da. Używaj roContentOrder z włączoną semantyką dla otagowanych dokumentów, którym ufasz, i zarezerwuj roPhysicalLayout dla materiałów zeskanowanych i przestarzałych, gdzie geometria jest jedynym sygnałem. Jeśli potrzebujesz tylko zwykłego łańcucha znaków, prostsze API opisane w wyodrębnianiu tekstu z dokumentów PDF pozostaje szybszą ścieżką, a gdy musisz śledzić tekst z powrotem do identyfikatorów treści oznaczonej, odczyt i zapis treści oznaczonej BDC i MCID obejmuje tę warstwę

Model blokowy mapuje się też czysto na to, czego chcą potoki wyszukiwania: nagłówek z jego akapitami to fragment z tytułem, a granice pozwalają, aby cytat wskazywał na lokalizację na stronie zamiast na cały dokument. PDFiumPas to komponent Delphi i Lazarus wokół silnika PDFium, udokumentowany z przykładami na stronie komponentu PDFium dla Delphi