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ę
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
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
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