Artykuł techniczny

Kontynuacja tabel świadoma treści na stronach PDF w Delphi

PDFium Component w wersji 3.117.0 spina tabelę łamiącą się na granicy stron, gdy albo oba fragmenty dotykają krawędzi strony, albo pod pierwszym fragmentem i nad drugim nie leży żaden tekst główny — przy czym nagłówki i stopki stron są ignorowane. ExtractDocumentTables stosuje ten test świadomy treści jako alternatywę dla starszego testu marginesów strony, odrzuca fragment z następnej strony, którego pierwszy wiersz to jedna komórka podpisu na całą szerokość, i zachowuje pojedynczy wiersz przelewający się na kolejną stronę jako część jego łańcucha kontynuacji

Artykuł o wykrywaniu i wyciąganiu tabel przedstawiał kontynuację jako cztery ścisłe bramki i jedną z nich było „dotyka krawędzi strony”. Ten opis był trafny dla wydania, którego dotyczył, i jednocześnie błędny dla większości tabel, które ludzie naprawdę podają komponentowi. Ten artykuł jest sprostowaniem: których dokumentów test marginesów nie obsłuży, co go zastąpiło i jakie dwa przypadki brzegowe pociągnęła za sobą ta poprawka

Dlaczego test marginesów strony zawodzi na eksportach z Worda?

Test marginesów strony zawodzi, bo edytor tekstu przestaje układać wiersze na dolnym marginesie, a nie na krawędzi papieru. Przy domyślnym ContinuationMargin wynoszącym 36 punktów pierwotna reguła wymagała, by dolna krawędź wcześniejszego fragmentu leżała w granicach 36 punktów od dołu strony, a górna krawędź późniejszego fragmentu w granicach 36 punktów od góry strony. Dokument wyeksportowany z Worda z domyślnymi marginesami jednego cala stawia ostatni wiersz co najmniej 72 punkty nad dolną krawędzią strony, a przy stopce jeszcze wyżej, więc warunek nigdy nie zachodził. Każda długa tabela w takim dokumencie wracała jako niezależne fragmenty z ContinuationGroup równym zero, a wołający wracał do zszywania ręcznego. Test wciąż ma sens dla tego, pod co powstał: raportów generowanych przez silniki składu, które wypełniają stronę po stały obszar treści i zaczynają następną stronę równo u góry. To nie jest zła reguła, to reguła niepełna — dlatego wersja 3.117.0 zachowała ją i dodała drugą ścieżkę, zamiast ją zastąpić

Co sprawdza zamiast tego test świadomy treści?

Test świadomy treści sprawdza, czy przestrzeń między dwoma fragmentami zajmuje coś poza tabelą, posługując się ramkami słów na każdej stronie, a nie geometrią strony. Gdy ExtractDocumentTables przechodzi dokument, zapisuje dla każdej strony najniższą dolną krawędź spośród słów, których górna krawędź leży nad pasem stopki, oraz najwyższą górną krawędź spośród słów, których dolna krawędź leży pod pasem nagłówka. Oba pasy mają głębokość ContinuationMargin punktów, więc ta sama opcja pełni teraz podwójną rolę: zapasu przy krawędzi strony i wysokości stref nagłówka oraz stopki. Para fragmentów przechodzi, gdy dolna krawędź wcześniejszego leży na poziomie najniższego tekstu głównego na jego stronie albo poniżej, a górna krawędź późniejszego leży na poziomie najwyższego tekstu głównego na następnej stronie albo powyżej, każde z dokładnością do AlignmentTolerance. Mówiąc wprost: tabela była ostatnią rzeczą na stronie N i pierwszą na stronie N+1, a numer strony czy tytuł dokumentu w pasie marginesu się nie liczy. To wykluczenie nie jest arbitralne. ISO 32000-1 §14.8.2.2 klasyfikuje nagłówki i stopki stron jako artefakty paginacji, treść, która istnieje z powodu złamania strony, a nie mimo niego, i ta sama idea, która pozwala czytnikowi tagów je pomijać, pozwala tabeli przechodzić obok nich. Artykuł o treści oznaczonej omawia, jak pliki z tagami deklarują te artefakty jawnie; tutaj klasyfikacja jest wywnioskowana z położenia, bo większość eksportowanych tabel nie niesie żadnych tagów

Dlaczego kontynuacja tabel w PDFium Component potrzebuje dwóch testów: przy marginesach jednego cala w Wordzie test marginesów strony wymaga krawędzi fragmentu wewnątrz 36-punktowych okien, których układ nigdy nie osiąga, natomiast test świadomy treści porównuje ramki słów i spina tabelę, gdy jest ona ostatnią treścią główną na stronie N i pierwszą na stronie N+1, ignorując pasy nagłówka i stopki
Każdy z testów otwiera bramkę i dopiero wtedy działają pozostałe sprawdzenia: sąsiednie strony, brak wiersza podpisu na pełną szerokość w późniejszym fragmencie i zgodność granic kolumn w granicach dwukrotności AlignmentTolerance

Oba testy łączą się przez OR. Raport z silnika składu, którego tabele dochodzą do krawędzi papieru, przechodzi pierwszy; eksport z Worda, którego tabele kończą się na marginesie, przechodzi drugi; dokument, który spełnia oba, przechodzi dwa razy. Dopiero gdy jeden z nich się powiedzie, działają pozostałe bramki i działają w stałej kolejności: numery stron muszą być sąsiednie, późniejszy fragment nie może zaczynać się wierszem podpisu, a granice kolumn muszą się zgadzać w granicach dwukrotności AlignmentTolerance, czyli domyślnie 6 punktów. Wyliczenie to TPdfTableContinuation z wartościami ptcNone, ptcStart, ptcMiddle i ptcEnd. Fragment oznaczony jako ptcEnd, który potem spina się z jeszcze inną stroną, jest awansowany na ptcMiddle, więc trzystronicowa tabela czyta się jako start, środek, koniec w kolejności stron. Numery grup zaczynają się od 1, a 0 oznacza brak powiązania, zaś ToJson emituje te same informacje jako składowe continuation i continuationGroup — to forma, którą warto preferować, jeśli zszywanie robi usługa po stronie odbiorcy

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;

    Options := TPdfTableExtractionOptions.Default;
    Options.DetectContinuations := True;     // domyślne; pokazane dla jasności
    Options.ContinuationMargin := 54;        // stopka na dwie linie, głęboka na ~50 pt

    Tables := Pdf.ExtractDocumentTables(Options);
    for I := 0 to High(Tables) do
      case Tables[I].Continuation of
        ptcStart:
          Writeln(Format('group %d starts on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
        ptcMiddle, ptcEnd:
          Writeln(Format('group %d continues on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
      else
        Writeln(Format('standalone table on page %d (%d rows)',
          [Tables[I].PageNumber, Tables[I].RowCount]));
      end;
  finally
    Pdf.Free;
  end;
end;

Jak wiersz podpisu powstrzymuje dwie tabele przed zlaniem się?

Fragment z następnej strony, którego pierwszy wiersz to jedna komórka obejmująca wszystkie kolumny, jest traktowany jako nowa tabela, nigdy jako ciąg dalszy poprzedniej. Ta reguła istnieje, bo test świadomy treści sam z siebie spina zbyt ochoczo. Przypadkiem, który to ujawnił, był formularz w stylu protokołu: tabela kończy się blisko dołu strony 1, druga tabela o identycznych szerokościach kolumn zaczyna się blisko góry strony 2, między nimi nie ma nic poza stopką, a kolumny zgadzają się co do punktu. Pod testem marginesów te dwie nigdy się nie spotkały, bo żadna nie dotykała krawędzi; pod testem treści spięły się natychmiast i formularz z sekcjami stał się jedną niespójną siatką. To, co je rozdziela, widać w strukturze komórek. Druga tabela otwiera się podpisem sekcji w rodzaju „RECIPIENT INFORMATION” rozłożonym jako jedna scalona komórka na całą szerokość, a prawdziwa kontynuacja nigdy tego nie robi, bo podpis należy do tabeli, która zaczęła się już na poprzedniej stronie. TableStartsWithCaptionRow koduje dokładnie to: fragment ma co najmniej dwie kolumny i zawiera komórkę z RowIndex = 0, ColumnIndex = 0 i ColumnSpan = ColumnCount. Sprawdzenie działa tylko na późniejszym fragmencie, więc tabela, której własny wiersz podpisu siedzi na jej pierwszej stronie, jest tym niedotknięta; podpis jest na stronie N, a badany jest tylko fragment ze strony N+1

Bramka wiersza podpisu w PDFium Component: prawdziwa kontynuacja zaczyna się komórkami danych i dołącza do tej samej ContinuationGroup, natomiast późniejszy fragment, którego wiersz zerowy trzyma jedną scaloną komórkę z RowIndex 0, ColumnIndex 0 i ColumnSpan równym ColumnCount, jest odrzucany jako kontynuacja i raportowany jako nowa tabela
Sprawdzenie dotyka tylko późniejszego fragmentu, więc tabela, której własny wiersz podpisu siedzi na jej pierwszej stronie, jest tym niedotknięta, a bramka działa dopiero po tym, jak jeden z dwóch testów krawędzi już spiął parę

Porównanie kolumn, które następuje potem — TablesHaveMatchingColumns — jest ostrzejsze niż „ta sama liczba kolumn”. Odbudowuje położenia granic każdego fragmentu z prostokątów komórek, interpoluje granice ukryte przez scalone komórki i odrzuca parę, gdy którakolwiek granica odjechała bardziej niż tolerancja. Dwie czterokolumnowe tabele o różnych proporcjach zostają więc osobno, nawet gdy wszystko inne się zgadza

Co dzieje się z pojedynczym wierszem, który przelewa się na następną stronę?

Posiatkowana tabela, która przenosi jeden wiersz na następną stronę, jest teraz wykrywana i spięta, o ile trafi do jakiegoś łańcucha kontynuacji; sama w sobie jest odrzucana. Domyślne MinRows równe 2 istnieje po to, by przypadkowa para linii nie była raportowana jako tabela, ale ostatni wiersz wypchnięty za złamanie strony to prawdziwy wiersz, który twardy próg 2 po cichu wyrzucał, a reszta tabeli wyglądała na kompletną, choć nią nie była. Przejście na poziomie dokumentu radzi sobie z tym w trzech krokach. Gdy DetectContinuations i DetectRuledTables są ustawione razem, przebieg po stronie uruchamia detektor siatek z progiem wierszy tymczasowo obniżonym do 1 — dlatego ExtractTables przyjmuje teraz MinRows równe 1 dla siatek, podczas gdy wykrywanie po białych znakach trzyma wewnętrzny próg 2. Kontynuacje są oznaczane na pełnym wyniku. Potem usuwana jest każda tabela krótsza niż MinRows wołającego i niebędąca częścią żadnego łańcucha. Jednowierszowy fragment przeżywa tylko dlatego, że został spięty, a jednowierszowa siatka pośrodku zwyczajnej zresztą strony jest odfiltrowywana dokładnie jak wcześniej

Jak PDFium Component zachowuje wiersz siatki przelewający się przez złamanie strony: przebieg po stronie dla siatek działa z progiem wierszy równym jeden, gdy ustawione są DetectContinuations i DetectRuledTables, kontynuacje są oznaczane na pełnym wyniku, a usuwane są tylko fragmenty krótsze niż MinRows i leżące poza każdym łańcuchem
Przelany wiersz przeżywa, bo jego łańcuch go spina, natomiast samodzielna jednowierszowa siatka na zwyczajnej stronie jest filtrowana jak wcześniej, a tabele wykryte po białych znakach zachowują próg dwóch wierszy bez takiej ulgi
// Odbuduj każdy łańcuch jako jeden CSV, wyrzucając powtórzone wiersze nagłówka
// na fragmentach kontynuacji
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
  I, R: Integer;
  Lines: TStringList;
  Csv: TStringList;
begin
  Csv := TStringList.Create;
  Lines := TStringList.Create;
  try
    for I := 0 to High(Tables) do
    begin
      if Tables[I].Continuation in [ptcNone, ptcStart] then
        Csv.Clear;
      Lines.Text := string(Tables[I].ToCsv);
      if (Tables[I].Continuation in [ptcMiddle, ptcEnd]) and
         (Lines.Count > 1) and (Tables[I].RowCount > 1) then
        Lines.Delete(0);            // nagłówek powtórzony przez edytor tekstu
      for R := 0 to Lines.Count - 1 do
        Csv.Add(Lines[R]);
      if Tables[I].Continuation in [ptcNone, ptcEnd] then
        Csv.SaveToFile(Format('%s\page%d-group%d.csv',
          [Folder, Tables[I].PageNumber, Tables[I].ContinuationGroup]));
    end;
  finally
    Lines.Free;
    Csv.Free;
  end;
end;

Dwa szczegóły w tej procedurze są celowe. Jednowierszowy przelew nigdy nie jest usuwany, bo pilnuje go warunek na RowCount, a edytor tekstu, który powtarza wiersz nagłówka na każdej stronie, produkuje fragment, którego pierwsza linia jest znów nagłówkiem — więc wyrzucanie linii zerowej na fragmentach środkowych i końcowych jest słuszne w tym przypadku, a błędne dla generatora, który nagłówków nie powtarza. Sprawdź jeden dokument, zanim puścisz tę procedurę luźno na cały katalog

Gdzie reguły wciąż się zatrzymują

Test świadomy treści jest tyle wart, ile warstwa tekstowa, którą czyta. Na zeskanowanej stronie bez żadnego tekstu zapisane skrajne wartości tekstu głównego spadają do granic strony, warunek „nic pomiędzy” jest spełniony trywialnie i zostają tylko bramki wiersza podpisu oraz kolumn; posiatkowana tabela na takiej stronie wciąż zostanie znaleziona jako pusty szkielet, więc łańcuch może spiąć się poprawnie, ale o otaczającym tekście niczego tak naprawdę nie zweryfikowano. Dodaj najpierw warstwę tekstową, jeśli to ma znaczenie. Stopki wyrenderowane jako obrazy, a nie tekst, są niewidoczne dla logiki pasów i z tego samego powodu nieszkodliwe

Pasy to jedna liczba. Stopka głębsza niż ContinuationMargin zostawia swoje dolne linie wewnątrz strefy tekstu głównego, przez co wcześniejszy fragment wygląda, jakby następował po nim tekst, i spięcie jest przez to blokowane; podnieś opcję do prawdziwej głębokości pasa, tak jak robi to pierwszy przykład. Podnieś ją za bardzo, a krótki akapit zamykający blisko dołu strony wśliźnie się do pasa i zostanie zignorowany, co spina tabelę z tym, co po niej następuje. Reguła podpisu ma lustrzaną wadę: generator, który zapisuje scalony baner „ciąg dalszy” jako pierwszy wiersz każdego fragmentu kontynuacji, doprowadzi do tego, że te fragmenty zostaną odrzucone jako nowe tabele, a jedynym dzisiejszym lekarstwem jest zszycie samodzielnie po ContinuationGroup, bez luzowania czegokolwiek, bo reguła nie ma przełącznika

Tabele wykrywane po białych znakach nie dostają żadnej ulgi dla pojedynczego wiersza. Strategia białych znaków potrzebuje dwóch wyrównanych wierszy, żeby w ogóle zobaczyć tabelę, więc nieposiatkowana tabela, której jeden wiersz się przelewa, jest wciąż raportowana o ten wiersz za krótka. Gdy na to trafisz, ramki słów stojące za blokami tekstu strukturalnego i kolejnością czytania dadzą ci surowe pozycje, by go odzyskać. Na zestawie próbek, który napędzał tę pracę — trzynaście eksportów edytorów tekstu i przeglądarek — pięć dokumentów z prawdziwymi wielostronicowymi tabelami spięło się w pojedyncze łańcuchy, a formularz-protokół, który wcześniej się zlewał, został osobno; to poprzeczka, względem której mierzono wydanie, a nie obietnica dotycząca każdego układu

Oznaczanie kontynuacji, reguła podpisu i przebieg dla pojedynczego wiersza żyją w ścieżce na poziomie dokumentu współdzielonej przez kompilacje dla Delphi, C++Buildera i Lazarusa; pełne API wyciągania tabel jest opisane na stronie PDFium Component dla Delphi