Artykuł techniczny

Wyodrębnianie tabel ze stron PDF w Delphi za pomocą PDFium

PDFium Component wykrywa tabele na stronie PDF i zwraca je jako siatkę komórek z zakresami wierszy i kolumn, wierszami nagłówka i wartością pewności, za pomocą ExtractTables dla jednej strony i ExtractDocumentTables dla całego dokumentu. Każda tabela konwertuje się do CSV albo JSON jednym wywołaniem, a tabele kontynuujące się przez podział strony można połączyć w łańcuch kontynuacji

PDF nie ma obiektu tabeli. Tabela w PDF to zbiór fragmentów tekstu rozmieszczonych tak, że człowiek odczytuje je jako siatkę, czasem z narysowanymi wokół nich liniami, a często bez nich. Odtworzenie siatki oznacza rekonstrukcję intencji, której plik nigdy nie zarejestrował, dlatego każde narzędzie ekstrakcji daje nieco inne wyniki i dlatego narzędzie, które podaje swoją pewność, jest bardziej użyteczne niż to, które tego nie robi

Dwa tryby wykrywania dla dwóch rodzajów tabel

Wykrywanie liniowane korzysta z narysowanych linii. Każdy odcinek obrysowanej ścieżki jest transformowany do współrzędnych strony przez macierz obiektu strony, linie poziome i pionowe są przecinane, a punkty przecięcia tworzą spójne składowe. Każda składowa staje się własną, posortowaną siatką pozycji X i Y, co zapobiega scaleniu dwóch osobnych tabel na tej samej stronie w jedną bezsensowną siatkę

Wykrywanie na podstawie białych znaków obsługuje tabele rysowane za pomocą wyrównania zamiast linii. Ramki słów są grupowane w wiersze wizualne, przerwy wewnątrz wiersza dzielą go na kandydujące kolumny, a tabela jest akceptowana tylko wtedy, gdy co najmniej MinRows wierszy powtarza co najmniej MinColumns kotwic wyrównanych do lewej w zakresie AlignmentTolerance. Współczynnik odstępu wierszy domyślnie wynosi 3, co obejmuje typowy dla tekstu 12-punktowego odstęp linii bazowej wynoszący około 30 punktów, nie pozwalając przy tym pojedynczej linii zawierającej kilka fragmentów tekstu podszywać się pod tabelę

Diagram potoku wykrywania tabel PDFium Component w Delphi, w którym przecięcia liniowanych linii i wyrównane wiersze słów zasilają jeden oceniony rekord tabeli z eksportem do CSV i JSON
Wykrywanie liniowane przecina narysowane obrysy, podczas gdy wykrywanie na podstawie białych znaków zlicza wyrównane wiersze ramek słów; kandydaci przekraczający MinRows i MinColumns pojawiają się z dołączoną oceną pewności i DetectionMode
uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'annual-report.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 12;                    // numeracja od 1

    Options := TPdfTableExtractionOptions.Default;
    Options.DetectRuledTables := True;
    Options.DetectWhitespaceTables := True;
    Options.MinConfidence := 0.6;            // domyślnie 0,5
    Options.HeaderRowCount := 1;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
      Writeln(Format('table %d: %dx%d cells, confidence %.2f, mode %d',
        [I, Tables[I].RowCount, Tables[I].ColumnCount,
         Tables[I].Confidence, Ord(Tables[I].DetectionMode)]));

    if Length(Tables) > 0 then
      SaveText('page12-table0.csv', Tables[0].ToCsv);
  finally
    Pdf.Free;
  end;
end;

Jak odzyskiwane są scalone komórki?

To jest ta część, którą naiwne ekstraktory robią źle. Scalonej komórki nie da się zidentyfikować z samej globalnej siatki, ponieważ siatka jest wyprowadzana ze wszystkich linii na stronie, a scalony region po prostu nie ma wewnętrznej linii, która by go rozdzieliła

Zastosowana tu reguła jest lokalna: dwie sąsiednie komórki bazowe są scalane, gdy żadna linia graniczna nie pokrywa przedziału między nimi. Union-find je łączy, wynikowe prostokątne składowe stają się wartościami RowSpan i ColumnSpan, a tekst jest przypisywany do komórki bazowej na podstawie jej punktu środkowego, a następnie podąża za tą komórką do jej korzenia scalenia. Zrobienie tego w ten sposób utrzymuje też koszt liniowy względem liczby słów plus komórek, zamiast kwadratowego przeszukiwania, jakie dostajesz, testując każde słowo względem każdej komórki

Diagram odzyskiwania scalonych komórek w ekstrakcji tabel PDFium dla Delphi, gdzie union-find łączy sąsiednie komórki bazowe zawsze, gdy żadna linia graniczna nie pokrywa wspólnego przedziału między nimi, dając RowSpan i ColumnSpan
Union-find scala sąsiadujące komórki bazowe, których wspólny przedział nie niesie narysowanej granicy, więc scalony nagłówek wraca jako jedna komórka z ustawionym ColumnSpan, a nie jedna wypełniona komórka otoczona pustymi

Praktycznym efektem jest to, że tabela finansowa ze scalonym nagłówkiem „Total” obejmującym trzy kolumny wychodzi jako jedna komórka o zakresie trzy, a nie jedna wypełniona komórka i dwie tajemnicze puste

Kontynuacja między stronami

Długie tabele łamią się między stronami, a traktowanie fragmentu z każdej strony jako niezależnej tabeli zmusza wywołującego do ich zszywania. ExtractDocumentTables może zamiast tego je połączyć, ale tylko przy ścisłych warunkach: fragment musi być najniższą tabelą na wcześniejszej stronie, kolejny musi być najwyższą tabelą na następnej stronie, numery stron muszą być sąsiednie, a granice kolumn muszą się zgadzać

To właśnie wszystkie cztery warunki razem zapobiegają oczywistemu błędowi, jakim byłoby połączenie każdej czterokolumnowej tabeli w dokumencie w jedną urojoną megatabelę tylko dlatego, że akurat mają tę samą liczbę kolumn. Gdy warunki są spełnione, tabele współdzielą identyfikator grupy kontynuacji i niosą metadane kontynuacji; gdy nie są, dostajesz osobne tabele i możesz zdecydować sam

Diagram kontynuacji tabel między stronami PDF w Delphi, gdzie cztery ścisłe bramki decydują, czy najniższy fragment na jednej stronie łączy się z najwyższym fragmentem na następnej
Ekstrakcja na poziomie dokumentu łączy fragmenty tylko wtedy, gdy wszystkie cztery bramki są spełnione, co zapobiega połączeniu niepowiązanych czterokolumnowych tabel w jedną urojoną megatabelę

Ekstrakcja na poziomie dokumentu współdzieli limity MaxCells i MaxTables między stronami, zamiast resetować je dla każdej strony, i przywraca aktywną stronę w bloku finally, więc uruchomienie ekstrakcji w przeglądarce pozostawia użytkownika patrzącego na stronę, na której był

Eksport bez uszkadzania danych

Oba eksportery są celowe w kwestii escapowania. CSV zawsze cytuje pola i podwaja wewnętrzne cudzysłowy, co unika klasycznego błędu, w którym komórka zawierająca przecinek po cichu zamienia się w dwie kolumny. Dla scalonych komórek treść jest emitowana tylko w kotwicy górnej-lewej, więc zapis i odczyt CSV nie duplikują nagłówka obejmującego zakres na kolumnach, które obejmuje

JSON zachowuje Unicode zamiast escapować go do ASCII, escapuje znaki sterujące i zawiera metadane potrzebne odbiorcy do oceny jakości: tryb wykrywania, pewność, granice, wartości zakresów, flagi nagłówka i informacje o kontynuacji. Jeśli przekazujesz wyodrębnione tabele do systemu niżej w potoku, preferuj JSON, ponieważ wiersz CSV nie powie ci, że tabela, z której pochodzi, uzyskała pewność 0,51:

// Ekstrakcja obejmująca cały dokument, zachowująca tylko tabele warte zaufania
Tables := Pdf.ExtractDocumentTables(Options);
for I := 0 to High(Tables) do
begin
  if Tables[I].Confidence < 0.75 then
  begin
    Log(Format('page %d table needs review (%.2f)',
      [Tables[I].PageNumber, Tables[I].Confidence]));
    Continue;
  end;
  if Tables[I].ContinuationGroup > 0 then
    AppendToGroup(Tables[I].ContinuationGroup, Tables[I].ToJson)
  else
    EmitStandalone(Tables[I].ToJson);
end;

Strojenie i wiedza, kiedy przestać

Trzy ustawienia mają większe znaczenie niż reszta. MinConfidence to bramka jakości, a 0,5 jest celowo permisywne; podnieś ją dla zautomatyzowanego wczytywania i obniż dla interfejsu przeglądowego, w którym człowiek potwierdza każdy wynik. MinColumnGap decyduje, co liczy się jako granica kolumny w trybie białych znaków, a ciasno ustawione tabele w gęstych raportach mogą wymagać zmniejszenia jej z domyślnych 12 punktów. MaxRowGapFactor decyduje, kiedy odległość pionowa kończy tabelę, co ma znaczenie dla tabel z okazjonalnymi pustymi wierszami

Bądź szczery co do ograniczeń. Tabele liniowane wyodrębniają się niezawodnie. Czysto wyrównane tabele oparte na białych znakach wyodrębniają się dobrze. Tabele z obróconym tekstem, tabele zagnieżdżone albo komórki, których treść zawija się w coś wyglądającego jak kolejny wiersz, będą wymagać przeglądu bez względu na ustawienie parametrów. Dla nich model tekstu strukturalnego daje surowy materiał do zbudowania czytnika dla konkretnej domeny, opisany w blokach tekstu strukturalnego i kolejności czytania

Jedno przydatne połączenie: gdy zeskanowany dokument w ogóle nie ma tekstu, wykrywanie tabel nie ma z czym pracować, dopóki nie istnieje warstwa tekstowa. Dodaj ją najpierw, jak opisano w dodawaniu przeszukiwalnej warstwy tekstowej do zeskanowanych PDF, a dopiero potem wyodrębniaj. Ramki słów zwracane przez dostawcę OCR to dokładnie te dane wejściowe, których potrzebuje wykrywanie na podstawie białych znaków

Ekstrakcja tabel, tekst strukturalny i reflow czytają z tego samego modelu strony w Delphi, C++Builder i Lazarus; pełne API opisano na stronie PDFium Component dla Delphi