Artykuł techniczny

Typowane tabele PDF w Delphi przez podziały stron

HotPDF odzyskuje tabele z istniejącego PDF-a przez ExtractLoadedTypedTables, API Delphi, które scala fragmenty wierszy utworzone przez przebieg układu, buduje jedną kanoniczną siatkę kolumn dla każdej tabeli, kontynuuje tabelę przez podział strony, gdy pozwala na to geometria, i zwraca każdą komórkę jako wartość typowaną z pochodzeniem strony, rozpiętością kolumn i granicami. ExportLoadedTypedTables zapisuje ten sam wynik bezpośrednio do CSV albo JSON. Scenariusz uzasadniający budowę tej funkcji jest zwyczajny i bardzo częsty. Czterdziestostronicowy rejestr faktur, logicznie jedna tabela, został wydrukowany z nagłówkiem powtarzanym u góry każdej strony. Uruchom na nim naiwny przebieg kolejności czytania, a otrzymasz czterdzieści tabel, trzydzieści dziewięć fałszywych wierszy nagłówkowych i kolumnę walutową przesuniętą o jedną pozycję w lewo w każdym wierszu, w którym środkowa komórka była pusta. Porządkowanie tego downstream, w aplikacji wywołującej, jest miejscem, w którym umierają projekty importu dokumentów

Dlaczego strona PDF przekazuje fragmenty zamiast tabeli?

Bo strona PDF nie ma żadnej semantyki tabeli, chyba że dokument jest otagowany. Strumień treści zawiera operatory wyświetlania tekstu i macierze pozycjonowania (ISO 32000-1 §9.4.3) i nic więcej; obramowane pole widoczne na ekranie jest niezależnym malowaniem ścieżki, którego żaden ekstraktor nie musi korelować z tekstem. Typy elementów struktury Table, TR, TH i TD istnieją wyłącznie w hierarchii struktury logicznej otagowanego PDF-a (ISO 32000-1 §14.8.4), a przytłaczająca większość krążących dokumentów biznesowych nie ma tagów. Wszystko, co opisano poniżej, jest odzyskiwaniem geometrycznym, a nie parsowaniem, i warto powiedzieć to głośno, zanim ktoś zbuduje na tym raport uzgodnieniowy

Dlatego HotPDF najpierw wykonuje semantyczną analizę układu na wyodrębnionych glifach, w tym samym przebiegu, który zasila ekstrakcję tekstu w kolejności struktury z załadowanego PDF-a oraz eksporty ustrukturyzowanego HTML i XML. Ten przebieg grupuje linie bazowe w odcinki, których komórki są wyrównane w pionie, i kontynuuje odcinek tylko dopóty, dopóki kolejne wiersze mają tę samą liczbę komórek. Dla silnika układu ta zasada jest poprawna i tania. Dla wywołującego ma niewłaściwy kształt: pojedynczy wiersz z pustą komórką wewnętrzną dzieli jedną wizualną tabelę na dwie tabele źródłowe. Warstwa typowanych tabel znajduje się nad tym przebiegiem właśnie po to, by złożyć elementy z powrotem

Kanoniczne siatki kolumn i pokrętło ColumnTolerance

ExtractLoadedTypedTables najpierw scala fragmenty z tej samej strony, a dopiero potem robi cokolwiek innego, i scala je na podstawie geometrii kolumn, a nie tekstu wierszy. Dwie sąsiednie tabele źródłowe na jednej stronie zostają połączone, gdy obie mają co najmniej dwie kolumny, pionowa przerwa między ostatnim wierszem pierwszej i pierwszym wierszem drugiej pozostaje w paśmie tolerancji, a początki kolumn są wyrównane. Początki kolumn oddalone od siebie o nie więcej niż ColumnTolerance zapadają się do jednej kolumny kanonicznej i są uśredniane podczas scalania. Domyślna tolerancja wynosi 12 jednostek przestrzeni użytkownika, co pasuje do zwykłej typografii biznesowej, ale dla szerokiego trackingu albo głęboko wciętych układów warto ją podnieść

Najważniejsza jest sytuacja wiersza z brakującą wartością wewnętrzną. HotPDF przypina każdą komórkę do najbliższego kanonicznego początku kolumny, a następnie ustawia ColumnSpan jako odległość od tej kolumny do następnej zajętej, zamiast przesuwać pozostałe komórki w lewo. Wiersz z trzema komórkami w siatce pięciokolumnowej zachowuje wartości pod właściwymi nagłówkami i dokładnie zapisuje miejsca przerw. To różnica między tabelą, którą można uzgodnić, a tabelą, która po cichu przypisuje pieniądze do niewłaściwych kolumn

var
  Pdf: THotPDF;
  Options: THPDFTypedTableExtractionOptions;
  Tables: THPDFTypedTables;
  Info: THPDFTypedTableExtractionInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('register.pdf', '') <= 0 then
      Exit;
    Options := THPDFTypedTableExtractionOptions.Default;
    Options.ColumnTolerance := 12;           // jednostki przestrzeni użytkownika
    Options.MinimumTableConfidence := 0.55;  // poniżej tego tabele są odrzucane
    Options.DateOrder := ttdoDMY;            // 03/04/2026 to 3 kwietnia
    Options.DecimalSeparator := ',';
    Options.ThousandsSeparator := '.';
    if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
      // Info.TableCount kontra Info.SourceTableCount pokazuje, ile scalono
      ProcessTables(Tables)
    else if Info.Status = ttesBudgetExceeded then
      Log(string(Info.Diagnostic));
  finally
    Pdf.Free;
  end;
end;

Co naprawdę gwarantuje scalanie przez strony?

Gwarantuje konserwatywność, i to celowo. HotPDF łączy dwie tabele przez granicę strony tylko wtedy, gdy MergeAcrossPages jest włączone, druga tabela zaczyna się dokładnie na indeksie strony następującym po końcu pierwszej, obie mają co najmniej dwie kolumny, a co najmniej dwa kanoniczne początki kolumn są wyrównane w ramach ColumnTolerance. Warunek kolejnych stron jest kluczowy. Wywołujący przekazuje PageIndices jako tablicę otwartą w dowolnej kolejności, a bez tego sprawdzenia żądanie dla stron 3, 9 i 14 mogłoby zespawać trzy niezwiązane tabele w jeden wynik wyglądający całkowicie wiarygodnie. Koszt polega na tym, że prawdziwa kontynuacja z pominiętą stroną, przeplatanym dodatkiem albo skanem dwustronnym z pustym odwrociem wraca jako dwie tabele i żadna opcja tego nie rozluźnia. Ponowne połączenie jest decyzją polityczną, którą może podjąć tylko aplikacja wywołująca, więc API udostępnia FirstPageIndex, LastPageIndex, SourceTableCount oraz PageIndex dla każdego wiersza i pozostawia decyzję tam, gdzie należy

Powtarzane nagłówki są oznaczane, nigdy usuwane

ExtractLoadedTypedTables nigdy nie usuwa powtarzanego wiersza nagłówkowego z wyniku. Gdy scalanie przez strony znajdzie, że napływająca tabela zaczyna się tekstem nagłówka identycznym z nagłówkiem tabeli już zgromadzonej, po przycięciu i złożeniu wielkości liter, oznacza te wiersze jako IsHeader i IsRepeatedHeader, a mimo to dołącza je w kolejności źródłowej. Usunięcie jest decyzją stratną i nieodwracalną, a różni konsumenci chcą różnych odpowiedzi: import CSV chce usunięcia powtórzeń, ślad audytowy chce ich obecności z numerami stron, a narzędzie diff chce zachowania kolejności źródłowej bajt po bajcie. Biblioteka raportuje więc stan, a decyzję podejmuje wywołujący

var
  T, R, C: Integer;
  Row: THPDFTypedTableRow;
  Total: Double;
begin
  Total := 0;
  for T := 0 to High(Tables) do
    for R := 0 to High(Tables[T].Rows) do
    begin
      Row := Tables[T].Rows[R];
      if Row.IsRepeatedHeader then
        Continue;                    // zachowaj tylko pierwszy blok nagłówka
      for C := 0 to High(Row.Cells) do
        if Row.Cells[C].ValueKind = ttvkCurrency then
          Total := Total + Row.Cells[C].NumberValue;
    end;
end;

Wartości typowane i separatory, które musisz podać

Wnioskowanie typu działa w stałej kolejności, która rozwiązuje niejednoznaczności w jedynym rozsądnym kierunku: najpierw wartość logiczna, potem data, procent, waluta, zwykła liczba, a wszystko niedopasowane pozostaje łańcuchem. To kolejność sprawia, że 2026 w kolumnie dat nie zostanie rozstrzygnięte przez parser liczb, zanim parser dat zobaczy wartość. Waluta jest rozpoznawana po początkowym $, £, ¥ albo lub po trzyznakowym kodzie ISO 4217 następującym przed spacją, a kod jest zachowywany w CurrencyCode. Co ważne, HotPDF nie zgaduje twojej lokalizacji. DecimalSeparator, ThousandsSeparator i DateOrder pochodzą z opcji, ponieważ 1.234 może oznaczać jedną liczbę albo tysiąc dwieście trzydzieści cztery, zależnie od faktu, którego PDF nie zawiera. Surowy Unicode Text jest zachowywany w każdej komórce obok wartości typowanej, więc błędne zgadywanie zawsze można odzyskać bez drugiej ekstrakcji

var
  Stream: TFileStream;
  Info: THPDFTypedTableExtractionInfo;
begin
  Stream := TFileStream.Create('tables.json', fmCreate);
  try
    if not Pdf.ExportLoadedTypedTables([0, 1, 2], ttefJSON,
      Stream, Options, Info) then
      case Info.Status of
        ttesInvalidOptions:   ReportBadConfiguration;
        ttesBudgetExceeded:   ReportOversizedDocument;
        ttesCancelled:        ReportUserCancelled;
        ttesWriteFailed:      ReportDestinationProblem;
      else
        ReportExtractionFailure;
      end;
  finally
    Stream.Free;
  end;
end;

Dwa formaty eksportu odpowiadają na różne pytania i celowo nie są równoważne. CSV zapisuje kolumny kontynuacji scalonego zakresu jako puste pola, czego oczekuje arkusz albo loader wsadowy. JSON zachowuje wszystko, co wiedziała ekstrakcja: wartość typowaną pod własnym rodzajem, columnSpan, pewność dla każdej komórki i wiersza, granice komórek oraz pochodzenie strony i tabeli źródłowej. Oba formaty najpierw układają cały dokument w ograniczonym buforze pamięci, a dopiero potem publikują go w docelowym strumieniu, przywracając oryginalne bajty, długość i pozycję, jeśli zapis nie powiedzie się w połowie, więc nieudany eksport nigdy nie pozostawia częściowo zapisanego pliku. Budżety stron, glifów na stronę, tabel, wierszy, komórek, znaków i bajtów wyjściowych są rozliczane osobno, a wiersze są zliczane przed alokacją, ponieważ SetLength dla każdego wiersza degraduje się do kopiowania kwadratowego na długo przed domyślnym limitem miliona wierszy

Gdzie kończy się geometryczne odzyskiwanie tabel?

Jasne opisanie trybów niepowodzenia jest użyteczniejsze niż lista funkcji, bo każdy z tych przypadków jest miejscem, w którym wywołujący potrzebuje własnej polityki, a nie lepszej wartości opcji

  • Scalanie pionowe nie jest odzyskiwane. HotPDF raportuje ColumnSpan dla zakresów poziomych i pozostawia RowSpan równe 1, więc komórka obejmująca trzy wiersze drukowanej tabeli przychodzi jako jedna komórka i dwie przerwy
  • Wykrywanie nagłówka opiera się na danych, a nie na wyglądzie. Blok nagłówka to ciąg wierszy przed pierwszym wierszem zawierającym nietypowaną jako łańcuch wartość, więc tabela, której ciało składa się wyłącznie z tekstu, raportuje HeaderRowCount jako zero niezależnie od stylu
  • Tabele poniżej MinimumTableConfidence są usuwane z wyniku bez błędu. Porównaj Info.TableCount z Info.SourceTableCount, gdy chcesz wiedzieć, że coś odrzucono
  • Odcinek musi mieć co najmniej dwa wiersze i co najmniej dwie kolumny, zanim przebieg układu w ogóle uzna go za tabelę, więc jednowierszowa pseudotabela albo dwukolumnowy układ długiej prozy poprawnie, choć niepraktycznie, nie jest tabelą
  • Skanowane strony nie zawierają operatorów tekstowych, więc nie ma czego odzyskiwać geometrycznie, dopóki na stronie nie istnieje warstwa tekstowa OCR

Jeśli twoje PDF-y pochodzą z własnego stosu raportowego, najtańsza poprawka tego wszystkiego leży wcześniej: emituj otagowane tabele albo zachowuj dane źródłowe i traktuj ekstrakcję jako fallback dla dokumentów, których nie wytworzyłeś. W pozostałych przypadkach warto poznać pipeline w tej kolejności, ponieważ każda warstwa buduje na poprzedniej: zacznij od zwykłej ekstrakcji tekstu z załadowanego PDF-a, przejdź do API typowanych tabel, gdy geometria musi zostać zachowana, i spójrz na renderowanie tabeli danych do nowego PDF-a, gdy jesteś po stronie generowania i możesz zdecydować, jak łatwo odzyskać wynik

ExtractLoadedTypedTables i ExportLoadedTypedTables są częścią natywnego komponentu PDF HotPDF dla Delphi dla Delphi i C++Builder, bez zewnętrznego DLL i zależności runtime; strona produktu zawiera pełny opis opcji, statusów i rekordów API typowanych tabel