Artykuł techniczny

Wypełnione prostokąty jako linie tabel w PDFium Delphi

Wyciąganie tabel w PDFium Component od wersji 3.117.0 traktuje cienki wypełniony prostokąt jako linię tabeli. Przy włączonym DetectFilledRulings, co jest ustawieniem domyślnym, wypełniony prostokąt wyrównany do osi, nie grubszy niż MaxRulingThickness (3 punkty), staje się jedną linią wzdłuż swojej długiej osi, większy wypełniony prostokąt wnosi swoje cztery krawędzie, a każda współrzędna linii jest przyciągana w granicach RulingSnapTolerance (4 punkty), zanim siatka zostanie złożona. Tabele eksportowane z Worda, Google Docs i przeglądarek trafiają więc do detektora siatek jako kompletne siatki, zamiast spadać do wykrywania po białych znakach jako fragmenty

Wcześniejszy artykuł o wykrywaniu i wyciąganiu tabel twierdził, że wykrywanie siatek korzysta z narysowanych linii i że każdy segment obrysowanej ścieżki jest przekształcany na współrzędne strony. To zdanie było prawdziwe i niepełne. Policzenie obiektów ścieżek w zestawie 13 prawdziwych dokumentów próbnych pokazało, że 9 z nich nie zawiera żadnej obrysowanej ścieżki, a jednak każda ich strona niesie setki wypełnionych prostokątów o grubości od 0,5 do 1 punktu. Detektor patrzący tylko na obrys nie widział nic, każda strona spadała do wykrywania po białych znakach, a wynikiem był rozsypany zbiór drobnych fragmentów zamiast tabel. Preset zwartych kolumn dodany w 3.116.4 złagodził to na poziomie fragmentów; przyczyną źródłową było to, że detektor czytał niewłaściwy operator malowania

Dlaczego tabela wyeksportowana z Worda nie ma obrysowanych linii?

Edytor tekstu nie myśli o obramowaniu jako o linii; myśli o nim jako o prostokącie o pewnej szerokości i maluje ten prostokąt wypełnieniem. ISO 32000-1 §8.5.2.1 definiuje operator re jako dopisujący podścieżkę prostokąta, a §8.5.3 rozdziela operatory malowania: S obrysowuje ścieżkę bieżącą szerokością linii, f wypełnia jej wnętrze. Obramowanie komórki o grubości 0,5 punktu wychodzi jako x y w 0.5 re f, a cała maszyneria obrysu — szerokość linii, połączenia i wzór kreskowania — nigdy się nie uruchamia. Cieniowanie komórek to ta sama konstrukcja z większym prostokątem. Obrysowana siatka rysowana przez m, l i S to jest to, czego oczekiwał pierwotny detektor, i to jest to, czego nie produkuje prawie nic eksportowane z aplikacji biurowej:

% jedno obramowanie komórki z eksportu edytora tekstu: wypełniony prostokąt wysoki na 0.5 pt
72 700 468 0.5 re f
% cieniowanie komórki: wypełniony prostokąt rozmiaru komórki
72 676 117 24 re f
% obrysowana linia siatki, pod którą pisano pierwotny detektor
72 700 m 540 700 l S

Dla detektora, który pyta FPDFPath_GetDrawMode tylko o to, czy flaga obrysu jest ustawiona, oba wypełnione prostokąty są niewidzialne. Słowa wewnątrz komórek trafiają wtedy do wykrywania po białych znakach, gdzie kolumny rozdzielone 6-punktowym odstępem leżą poniżej domyślnego MinColumnGap równego 12 punktów, a wraca dowolny podzbiór wierszy, który przypadkiem wyrówna się dość dobrze, by przejść MinRows. To właśnie jest zachowanie fragmentowe i żadne strojenie parametrów nie zamieni go w siatkę, którą narysował autor

Jak PDFium Component zamienia wypełniony prostokąt w linię tabeli?

TableCollectObjectRulings bada każdy obiekt ścieżki podścieżka po podścieżce. Tryb malowania pochodzi z FPDFPath_GetDrawMode; ścieżka liczy się jako wypełniona, gdy DetectFilledRulings jest włączone, a tryb wypełnienia nie jest pusty. Każdy punkt jest przekształcany przez macierz obiektu i zbierany, do MaxSubpathPoints (8) na podścieżkę, a każdy segment krzywej oznacza podścieżkę jako krzywą. Gdy podścieżka się zamyka albo zaczyna się nowe MoveTo, FlushSubpath rozstrzyga, czym była: krzywa podścieżka jest odrzucana, podobnie jak każdy zamknięty wielokąt, którego punkty nie leżą wszystkie w granicach PointTolerance (0,05 punktu) od krawędzi prostokąta otaczającego na co najmniej jednej osi. Trójkąt, szewron albo zaokrąglona zakładka nigdy nie staje się linią tabeli, i to właśnie trzyma dekoracyjną grafikę poza siatką

Diagram PDFium Component pokazujący, jak TableCollectObjectRulings zamienia zamknięte podścieżki w linie tabel w Delphi: FlushSubpath odrzuca krzywe kontury i wielokąty odległe od krawędzi prostokąta otaczającego, MaxRulingThickness dzieli cienkie prostokąty na jedną linię na długą oś, zacieniowane komórki dają cztery krawędzie, a DetectFilledRulings trzyma małe kwadraty z dala
Zamknięta podścieżka przeżywa tylko wtedy, gdy jest wyrównana do osi, a prostokąt otaczający rozstrzyga potem, czy jest jedną linią, czterema krawędziami zacieniowanej komórki, czy niczym

To, co przeżyje, to prostokąt wyrównany do osi, klasyfikowany po swoim prostokącie otaczającym. Szerokość na poziomie MaxRulingThickness albo poniżej przy większej wysokości daje jedną pionową linię w połowie szerokości, obejmującą prostokąt od dołu do góry; przypadek lustrzany daje jedną linię poziomą. Oba wymiary powyżej progu oznaczają zacieniowaną komórkę, a prostokąt wnosi cztery linie, po jednej na krawędź. Oba wymiary na progu albo poniżej nie wnoszą nic, więc dwupunktowy kwadratowy punkt listy nie jest brany za linię. Obrysowana ścieżka idzie starszą trasą przez AddLine, po jednej linii na segment wyrównany do osi, więc siatka rysowana przez S jest obsługiwana dokładnie jak wcześniej, a ścieżka malowana i wypełnieniem, i obrysem produkuje nakładające się kawałki, które przebieg scalania zwija:

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  Mode: string;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // liczone od 1

    Options := TPdfTableExtractionOptions.Default;
    // to są wartości domyślne z 3.117.0, wypisane dla jasności
    Options.DetectFilledRulings := True;     // cienkie wypełnione prostokąty stają się liniami
    Options.MaxRulingThickness := 3.0;       // punkty; grubsze prostokąty liczą się jako cieniowanie
    Options.RulingSnapTolerance := 4.0;      // punkty; 0 wyłącza przyciąganie
    Options.IncludeFormXObjects := True;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
    begin
      if Tables[I].DetectionMode = ptdmRuled then
        Mode := 'ruled'
      else
        Mode := 'whitespace';
      Writeln(Format('%dx%d %s, confidence %.2f',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence]));
    end;
  finally
    Pdf.Free;
  end;
end;

Co robi RulingSnapTolerance w tabelach z zacieniowanymi komórkami?

RulingSnapTolerance to to, co sprawia, że tabela zbudowana z samego cieniowania spina się w jedną siatkę. Niektóre eksporty nie rysują żadnego obramowania: każda komórka to wypełniony prostokąt w swoim kolorze, a sąsiednie prostokąty dzieli odstęp bieli od 1 do 3 punktów. Każdy prostokąt daje cztery krawędziowe linie, ale prawa krawędź jednej komórki i lewa krawędź następnej siedzą 2 punkty od siebie, a test spójności używa RulingTolerance, które domyślnie wynosi 1 punkt. Bez przyciągania każda komórka tworzy własną spójną składową z czterech linii, żadna składowa nie osiąga MinRows i strona nie raportuje nic. TableSnapRulings zbiera każdą współrzędną X w grze (położenie każdej pionowej linii plus początek i koniec każdej poziomej) i analogicznie każdą współrzędną Y, sortuje każdą listę, grupuje ją, łańcuchując wartości, których sąsiad różni się nie bardziej niż tolerancja, zastępuje każdą grupę jej średnią, a potem przesuwa każdą pozycję, początek i koniec do najbliższego środka grupy. Dwie strony odstępu stają się tą samą linią i spójność się trzyma

Diagram PDFium Component pokazujący, jak RulingSnapTolerance spina tabelę z zacieniowanych komórek w Delphi: sąsiednie komórki zostawiają 2-punktowy odstęp, ich krawędziowe linie leżą poza 1-punktowym RulingTolerance, a TableSnapRulings łańcuchuje dwie wartości X w jedną średnią grupy, więc test spójności widzi wreszcie wspólną linię siatki
Przyciąganie działa przed scalaniem i przed detektorem siatek, więc dwie strony odstępu bieli stają się jedną linią, a każda komórka przestaje być wyspą z czterech linii

Przyciąganie działa przed TableMergeRulings, które sortuje linie i łączy współliniowe kawałki stykające się albo nakładające w granicach RulingTolerance, a oba przebiegi idą, zanim TableDetectRuled w ogóle zobaczy dane, więc test spójności par jest proporcjonalny do liczby linii siatki, a nie do liczby fragmentów na komórkę. Na obrysowanej siatce te przebiegi są nieszkodliwe, bo współrzędne, które były już identyczne, przyciągają się do siebie samych. Trzeba tylko pamiętać, że grupowanie łańcuchowe nie ma własnego limitu szerokości: ciąg współrzędnych oddalonych od siebie o 3 punkty zwija się do jednego środka. Przy domyślnych 4 punktach dotyczy to tylko kolumn węższych niż znak, ale jeśli dokument ma prawdziwe 3-punktowe odstępy, które muszą zostać osobno, zmniejsz tolerancję albo ustaw ją na 0, żeby wyłączyć przyciąganie:

// Wyizoluj strategię siatek i porównaj, co każde ustawienie widzi na jednej stronie
function CountRuledTables(Pdf: TPdf; FilledRulings: Boolean;
  SnapTolerance: Double): Integer;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.DetectWhitespaceTables := False;
  Options.DetectFilledRulings := FilledRulings;
  Options.RulingSnapTolerance := SnapTolerance;
  Result := Length(Pdf.ExtractTables(Options));
end;

// Eksport z Worda raportuje typowo 0, N, a potem mniej niż N:
// sam obrys nie widzi nic, przyciąganie spina zacieniowane komórki,
// a wyłączenie przyciągania zostawia każdą zacieniowaną komórkę jako osobną wyspę
Writeln(CountRuledTables(Pdf, False, 4.0));
Writeln(CountRuledTables(Pdf, True, 4.0));
Writeln(CountRuledTables(Pdf, True, 0.0));

Linie tabeli wewnątrz form XObjects

Narzędzia składu strony często pakują tabelę, albo całe ciało strony, w form XObject i malują go operatorem Do. ISO 32000-1 §8.10.1 mówi, że macierz formularza jest łączona z bieżącą macierzą przekształcenia w chwili malowania formularza, więc prostokąt wewnątrz formularza żyje w przestrzeni formularza i ląduje na stronie dopiero po dwóch lub więcej przekształceniach. TableCollectObjectRulings wchodzi rekurencyjnie do obiektów formularza, gdy ustawione jest IncludeFormXObjects: czyta macierz obiektu, łączy ją z macierzą rodzica przez TableMultiplyMatrix, którego kolejność argumentów znaczy „przekształć przez pierwszą macierz, potem przez drugą”, i wylicza dzieci przez FPDFFormObj_CountObjects i FPDFFormObj_GetObject, przekazując połączoną macierz w dół. Zagnieżdżenie głębsze niż MaxFormDepth (8) jest po cichu pomijane, co jest zabezpieczeniem przed patologicznymi plikami, a nie limitem, do którego zbliża się jakikolwiek prawdziwy eksport. Powód, dla którego kolejność mnożenia ma znaczenie, jest ten sam, o którym mówi poprzedzanie kontra dodawanie macierzy: zamiana operandów przesuwa składnik translacji, a linia, która powinna wylądować u góry strony, ląduje w początku układu

Diagram PDFium Component pokazujący linie tabeli wewnątrz form XObject w Delphi: cienki prostokąt zapisany jako 72 700 468 0.5 re f żyje w przestrzeni formularza i ląduje na stronie dopiero po tym, jak TableMultiplyMatrix połączy CTM rodzica z macierzą formularza, schodząc rekurencyjnie przez FPDFFormObj_CountObjects do MaxFormDepth
Prostokąt jest zapisany w przestrzeni formularza i trafia na górę strony dopiero wtedy, gdy macierze zostaną pomnożone w kolejności, która trzyma składnik translacji tam, gdzie jego miejsce

Dlaczego budżet linii wzrósł czterokrotnie?

Domyślne MaxRulingSegments wzrosło z 4096 do 16384 w 3.117.0, bo obramowania pojedynczych komórek przychodzą w znacznie większej liczbie niż obrysowane linie siatki. Obrysowana tabela o 30 wierszach i 6 kolumnach to 38 segmentów linii. Ta sama tabela wyeksportowana jako wypełnione prostokąty to do czterech obramowań na komórkę, 720 kawałków przed scaleniem, a formularz z zacieniowanymi komórkami podwaja tę liczbę. Dwie takie tabele na stronie wyczerpałyby stary budżet. Budżet jest egzekwowany w TableAppendRuling przez Check, który zgłasza EPdfError z komunikatem „Table ruling-segment budget exceeded”; nie ma wyniku zdegenerowanego, nie ma częściowej siatki, a przebieg po białych znakach też się nie uruchamia. Jeśli ustawiasz własny, ciaśniejszy budżet dla niezaufanego wejścia, złap wyjątek i zdecyduj, zamiast czytać pusty wynik jako „brak tabel”:

Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := 2048;        // celowo ciasny dla niezaufanego wejścia
try
  Tables := Pdf.ExtractTables(Options);
except
  on E: EPdfError do
  begin
    Log(E.Message);                       // 'Table ruling-segment budget exceeded'
    Options.MaxRulingSegments := 16384;   // domyślna wartość z 3.117.0
    Tables := Pdf.ExtractTables(Options);
  end;
end;

Zmierzony wynik i gdzie to podejście się zatrzymuje

Na tych samych 13 dokumentach próbnych wyciąganie przeszło z 43 tabel — 9 siatkowych i 34 fragmentów po białych znakach albo fałszywych trafień — na 41 tabel siatkowych i zero fałszywych trafień z białych znaków. Część tego porządku należy do dwóch towarzyszących zmian w 3.117.0: słowa zajęte już przez siatkę są usuwane, zanim ruszy wykrywanie po białych znakach, więc tabela nigdy nie jest raportowana dwa razy, a granica kolumny w wykrywaniu po białych znakach musi być teraz wolnym od tekstu korytarzem przez każdy wiersz, który rozdziela — i to właśnie powstrzymało wyjustowane akapity przed liczeniem się jako tabele 5x4. To czytnik wypełnionych prostokątów przesunął same tabele z kolumny fragmentów do kolumny siatek

Granice warto powiedzieć wprost. Strona bez warstwy tekstowej wciąż daje szkielet siatki, z każdą komórką pustą, bo linie pochodzą z geometrii, a tekst ze strony tekstowej; strony zeskanowane wymagają najpierw OCR. Wypełnione kształty z krzywymi, zaokrąglonymi narożnikami albo nieprostokątnymi konturami są odrzucane w całości, więc tabela, której obramowania narysowano jako kontury prostokątów o zaokrąglonych narożnikach, potrzebuje wykrywania po białych znakach jak wcześniej. Tabela bez obramowań i bez cieniowania nie zmienia się od żadnej z tych zmian i pozostaje domeną strategii białych znaków opisanej w artykule o wyciąganiu tabel; gdy i ona nie wystarcza, ramki słów i bloki z tekstu strukturalnego i kolejności czytania są surowcem dla czytnika dziedzinowego. Demo TableExtractionLab dostarczane z komponentem wystawia DetectFilledRulings w swoim panelu opcji, co jest najszybszym sposobem, by zobaczyć, jak wygląda dany eksport z tą opcją i bez niej; pełne API jest opisane na stronie PDFium Component dla Delphi