Artykuł techniczny

Fałszywe tabele w PDF z tekstu wyjustowanego w Delphi

PDFium Component w wersji 3.117.0 przestaje raportować wyjustowane akapity jako tabele wyrównane po białych znakach: wymaga teraz, by każda granica kolumny była pionowym korytarzem bez tekstu w żadnym wierszu, który rozdziela, pomija słowa zajęte już przez siatkę i składa tekst komórek po pionowym nakładaniu, a nie po odległości środków ramek glifów. Wszystkie trzy zmiany żyją w ExtractTables i ExtractDocumentTables i nie wymagają żadnej opcji

Zgłoszenie, które to zapoczątkowało, było mało spektakularne. Strona komunikatu prasowego, na której nie ma żadnej tabeli, wracała z ExtractTables z tabelą 5x4 po białych znakach, z pewnością wygodnie powyżej domyślnego MinConfidence równego 0,5, a komórki trzymały fragmenty zwykłego tekstu głównego. Formularz zgłoszeniowy zrobił to samo ze swoimi akapitami eseju i wyprodukował tabelę 3x4 oraz 5x3. Oba dokumenty były wyjustowane. Oczywistą reakcją jest strojenie progów, a pożyteczny wniosek z tego wydania jest taki, że strojenie tego nie naprawi, bo reguła, którą się stroiło, zadawała niewłaściwe pytanie

uses
  PDFium;

// Sprawdzenie regresyjne: wypisz każdą tabelę po białych znakach, żeby stronę
// o której wiesz, że to sama proza, dało się potwierdzić jako czystą
procedure ReportWhitespaceTables(Pdf: TPdf);
var
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Options := TPdfTableExtractionOptions.Default;   // MinColumnGap 12pt
  Tables := Pdf.ExtractDocumentTables(Options);
  for I := 0 to High(Tables) do
    if Tables[I].DetectionMode = ptdmWhitespace then
      Writeln(Format('page %d: %dx%d whitespace table, confidence %.2f, ' +
        'first cell "%s"',
        [Tables[I].PageNumber, Tables[I].RowCount, Tables[I].ColumnCount,
         Tables[I].Confidence, Tables[I].Cells[0].Text]));
end;

Dlaczego wyjustowany tekst wygląda jak tabela?

Wyjustowany akapit wygląda jak tabela, bo wyjustowany wiersz to rząd słów rozdzielonych odstępami, które silnik składu rozciągnął, a gdy rozciągnięty odstęp osiągnie MinColumnGap, detektor nie ma w obrębie wiersza sposobu, by odróżnić go od separatora kolumn. Strategia białych znaków w PDFium Component grupuje ramki słów w wiersze wizualne, dzieli każdy wiersz na grupy słów wszędzie tam, gdzie odległość pozioma od poprzedniego słowa wynosi co najmniej MinColumnGap (domyślnie 12 punktów), i przyjmuje tabelę, gdy co najmniej dwa kolejne wiersze powtarzają co najmniej MinColumns lewostronnie wyrównanych punktów odniesienia grup w granicach AlignmentTolerance, czyli 3 punktów. To ta reguła, którą opisuje przegląd wykrywania tabel, i dla prawdziwej wyrównanej tabeli jest dokładnie słuszna

Zastosuj ją teraz do dwudziestu wierszy wyjustowanej prozy o stopniu 10. Każdy wiersz jest rozciągany do tego samego prawego marginesu, więc wiersz kończący się długim słowem otwiera swoje wewnętrzne odstępy, a w akapicie z kilkoma krótkimi wierszami część tych odstępów przekracza 12 punktów. Dwa kolejne wiersze potrzebują tylko po jednym rozciągniętym odstępie, trafiającym w granicach 3 punktów w tę samą pozycję X, by powstał kandydat na dwuwierszową, dwukolumnową tabelę. Przy odpowiedniej liczbie wierszy to nie pech; to prawdopodobieństwo zmierzające do pewności, a tabela 5x4 na komunikacie prasowym była po prostu tym przebiegiem, w którym cztery takie odstępy ustawiły się w pięciu wierszach

Diagram PDFium Component wyjaśniający, dlaczego wyjustowana proza liczyła się jako tabela: każdy wiersz jest rozciągany do tego samego marginesu, więc pojedyncze odstępy przekraczają MinColumnGap w innej pozycji X w każdym wierszu, a dwa kolejne odstępy w granicach AlignmentTolerance budowały fałszywych kandydatów, których test korytarza teraz odrzuca
Prawdziwa tabela powtarza swoje punkty odniesienia kolumn w każdym wierszu, a wyjustowany akapit rozciąga inny odstęp w każdym wierszu — dlatego samo strojenie na poziomie wiersza nie mogło rozdzielić tych dwóch przypadków

Każdy próg wymienia jedną klasę dokumentów na inną. Podniesienie MinColumnGap do 20 punktów gubi zwarte kolumny gęstych raportów finansowych, czyli dokładnie ten przypadek, dla którego wartość domyślną już obniżono. Podniesienie MinRows do 3 odrzuca prawdziwe dwuwierszowe tabele i tylko zmniejsza szanse przy długich akapitach. Zacieśnienie AlignmentTolerance poniżej 3 punktów psuje ramki słów pochodzące z OCR, których lewe krawędzie drgają bardziej. Sygnał na poziomie wiersza jest naprawdę niejednoznaczny, więc poprawka musi przyjść z sygnału, którego pojedyncze wiersze same nie niosą

Co czyni granicę kolumny prawdziwą?

Prawdziwa granica kolumny to pionowy pas strony, który pozostaje pusty we wszystkich wierszach, które rozdziela. Tabela ma taki pas między każdą parą kolumn z założenia, bo komórki układano względem wspólnych pozycji X. Wyjustowany akapit rozciąga swoje odstępy między słowami w różnych miejscach poziomych w każdym wierszu, więc żaden pas nie przetrwa przecięcia więcej niż jednego czy dwóch wierszy. PDFium Component sprawdza teraz dokładnie to: gdy grupy słów kandydata zostaną przypisane do kolumn odniesienia, dla każdej pary sąsiednich kolumn bierze w każdym wierszu, który ma treść w obu komórkach, przedział od najbardziej prawej krawędzi słów lewej komórki do najbardziej lewej krawędzi słów prawej komórki, przecina te przedziały przez wiersze i odrzuca całego kandydata, jeśli przecięcie jest węższe niż MinColumnGap razy 0,5, czyli 6 punktów przy wartościach domyślnych

Diagram PDFium Component z testem korytarza wolnego od tekstu stojącym za ExtractTables: każdy wiersz oddaje przedział od prawej krawędzi swojej lewej komórki do lewej krawędzi prawej komórki, przecięcie pozostaje szersze niż połowa MinColumnGap w prawdziwej tabeli, a w wyjustowanym tekście zapada się do zera
Prawdziwa granica kolumny jest pusta w każdym wierszu, który rozdziela, więc przecięcie odstępów z poszczególnych wierszy zostawia wspólny pas dla tabeli i żadnego pasa dla rozciągniętej prozy

Znaczące są dwa szczegóły. Wiersze, w których którakolwiek komórka jest pusta, nie głosują, więc tabela z pustą komórką albo nagłówek obejmujący mniej kolumn niż ciało tabeli nadal przechodzi. A szerokość korytarza jest wyprowadzana z MinColumnGap, a nie wystawiona jako osobna opcja, bo obie rzeczy opisują to samo fizyczne zjawisko: odstęp, który projektant zostawia między kolumnami. Ta logika jest dość mała, by ją odtworzyć, jeśli budujesz na surowych ramkach słów, a nie na API tabel, a próbka poniżej odwzorowuje to sprawdzenie z wnętrza komponentu:

uses
  Math, PDFium;

type
  TIndexList = array of Integer;
  TCellIndexes = array of TIndexList;   // Row * ColumnCount + Column

// Zwraca False, gdy dowolna para sąsiednich kolumn nie ma pionowego
// korytarza wolnego od tekstu o szerokości co najmniej MinColumnGap / 2
function HasTextFreeCorridors(const Words: TPdfWordBoxes;
  const Cells: TCellIndexes; RowCount, ColumnCount: Integer;
  MinColumnGap: Double): Boolean;
var
  Col, Row, I, LeftCell, RightCell, Supported: Integer;
  CorridorLeft, CorridorRight, RowLeft, RowRight: Double;
begin
  for Col := 0 to ColumnCount - 2 do
  begin
    CorridorLeft := -MaxDouble;
    CorridorRight := MaxDouble;
    Supported := 0;
    for Row := 0 to RowCount - 1 do
    begin
      LeftCell := Row * ColumnCount + Col;
      RightCell := LeftCell + 1;
      if (Length(Cells[LeftCell]) = 0) or (Length(Cells[RightCell]) = 0) then
        Continue;                             // puste komórki nie głosują
      RowLeft := -MaxDouble;
      RowRight := MaxDouble;
      for I in Cells[LeftCell] do
        RowLeft := Max(RowLeft, Words[I].Rect.Right);
      for I in Cells[RightCell] do
        RowRight := Min(RowRight, Words[I].Rect.Left);
      CorridorLeft := Max(CorridorLeft, RowLeft);
      CorridorRight := Min(CorridorRight, RowRight);
      Inc(Supported);
    end;
    if (Supported > 0) and
       (CorridorRight - CorridorLeft < MinColumnGap * 0.5) then
      Exit(False);
  end;
  Result := True;
end;

Dlaczego tabele siatkowe były wyciągane dwa razy?

Tabele siatkowe były wyciągane dwa razy, bo przebieg po białych znakach widział kiedyś każde słowo na stronie, w tym słowa, które przebieg siatkowy umieścił już w siatce — a czysta tabela siatkowa jest z założenia także doskonale wyrównaną tabelą po białych znakach. Sprawdzenie nakładania odrzucało już kandydata po białych znakach, którego granice obejmowały więcej niż połowę istniejącej tabeli, ale kandydat łączący dolne wiersze tabeli z kilkoma wyrównanymi liniami tekstu pod nią mógł spaść poniżej tego stosunku i przetrwać jako druga, nieco większa tabela nachodząca na sąsiada. ExtractTables usuwa teraz te słowa, zanim ruszy przebieg po białych znakach. Słowo jest wyrzucane, gdy jego punkt środkowy leży wewnątrz granic którejkolwiek tabeli wyprodukowanej przez przebieg siatkowy; używany jest środek, a nie pełne zawieranie, żeby słowo wystające poza obramowanie o ułamek punktu poszło za tabelą, do której wizualnie należy. Strategia białych znaków pracuje potem tylko na wolnych słowach, co oznacza też, że mała tabela bez obramowań leżąca bezpośrednio pod siatkową jest wykrywana na własnych prawach, zamiast zlewać się z siatką nad sobą

Dlaczego „Purpose of Request:” wyszło jako „of Purpose Request:”?

Słowa wyszły w zmienionej kolejności, bo ramki słów, które buduje PDFium Component, są sumami prostokątów otaczających glify, a „of” nie ma wydłużenia dolnego, podczas gdy „Purpose” i „Request:” mają. FPDFText_GetCharBox zwraca ciasny prostokąt tuszu glifu w przestrzeni strony, a nie prostokąt dopełniony do wyniesienia i wydłużenia czcionki, a ramka słowa to suma prostokątów jego znaków. Słowo bez wydłużeń dolnych jest więc krótsze, a jego pionowy środek siedzi wyżej — o 2 do 3 punktów w tym formularzu. Stara procedura tekstu komórki sortowała słowa najpierw po środku Y, z 1-punktową tolerancją na „ten sam wiersz”, a potem po lewej krawędzi; „of” przechodziło tę tolerancję, sortowało się jako własny wiersz nad pozostałymi i było emitowane pierwsze

To nie tyle kaprys PDFium, ile konsekwencja tego, jak PDF pozycjonuje tekst. ISO 32000-1 §9.2.2 i §9.4.4 definiują umieszczanie glifów jako przesunięcie poziome wzdłuż linii bazowej w przestrzeni tekstu, a jedyne metryki pionowe, jakie niesie plik, są przypisane do czcionki: wpisy Ascent, Descent i FontBBox deskryptora czcionki z §9.8.1. Nic w pliku nie mówi, że dwa glify dzielą wiersz; trzeba to wywnioskować z geometrii, a ciasne prostokąty glifów, które sprawiają, że podświetlanie zaznaczenia wygląda dobrze — jak opisuje zaznaczanie wierszy tekstu ramkami znaków w PDFium — są niewłaściwym wejściem dla porównywania odległości środków

Poprawka w wersji 3.117.0 zmienia pytanie z „jak daleko są od siebie środki” na „jak bardzo prostokąty nakładają się pionowo”. Tekst komórki jest składany tak: najpierw słowa komórki grupuje się w wiersze wizualne, gdzie słowo dołącza do wiersza, gdy jego pionowe nakładanie z bieżącymi granicami wiersza wynosi co najmniej 25 procent mniejszej z dwóch wysokości, potem każdy wiersz sortuje się wstawianiem po lewej krawędzi, a na końcu wiersze łączy się znakiem przejścia do nowego wiersza. „Purpose” i „of” nakładają się na całej wysokości x, a to znacznie więcej niż 25 procent krótszego prostokąta, więc lądują w tym samym wierszu i sortują się po X, jak powinny

Diagram PDFium Component z poprawką kolejności Purpose of Request: ciasne prostokąty glifów z FPDFText_GetCharBox dają słowu of bez wydłużenia dolnego wyższy środek, który stara 1-punktowa tolerancja środka Y sortowała jako osobny wiersz, natomiast reguła 25 procent pionowego nakładania trzyma je na linii bazowej i przywraca kolejność słów
Środek Y przesuwa się razem z tym, jakie wyniesienia i wydłużenia dolne niesie tusz, a dwa prostokąty na jednej linii bazowej nakładają się na wspólnej wysokości x niezależnie od swoich wysokości

Grupuj wiersze tekstu po nakładaniu, nie po odległości środków

Reguła, którą warto wynieść z tego błędu, jest ogólna: każdy kod składu tekstu PDF, który rozstrzyga „ten sam wiersz”, porównując pionowe środki z ustaloną tolerancją, polegnie na prawdziwych czcionkach, i to po cichu: nic nie zgłasza błędu, słowa po prostu wychodzą w złej kolejności. Wymieszane wydłużenia dolne to najłagodniejszy wyzwalacz. Pogrubiona etykieta o stopniu 12 obok wartości o stopniu 10, znacznik przypisu w indeksie górnym, symbol waluty rysowany z czcionki zastępczej i ramki słów z OCR z szumem wysokości na słowo — wszystko to przesuwa środki bardziej niż jakakolwiek tolerancja, która wciąż rozdziela sąsiednie wiersze tekstu o stopniu 10 przy interlinii 12. Stosunek nakładania nie zależy od rozmiaru: dwa prostokąty na jednej linii bazowej nakładają się na wspólnej wysokości x niezależnie od swoich wyniesień i wydłużeń dolnych, a dwa prostokąty w sąsiednich wierszach nie nakładają się wcale

Tę samą regułę łatwo zastosować poza wyciąganiem tabel. TPdf.PageWordBoxes zwraca każde słowo na aktywnej stronie wraz z jego prostokątem w przestrzeni strony, więc zgrupowanie strony w wiersze wizualne to krótka pętla:

uses
  Math, PDFium;

function SameVisualLine(const A, B: TPdfRectangle): Boolean;
var
  Overlap, MinHeight: Double;
begin
  Overlap := Min(A.Top, B.Top) - Max(A.Bottom, B.Bottom);
  MinHeight := Min(A.Top - A.Bottom, B.Top - B.Bottom);
  Result := (MinHeight > 0) and (Overlap >= MinHeight * 0.25);
end;

procedure GroupPageIntoLines(Pdf: TPdf; out Lines: TArray<TPdfWordBoxes>);
var
  Words: TPdfWordBoxes;
  Bounds: TArray<TPdfRectangle>;   // suma bieżąca na wiersz
  I, J, Found: Integer;
begin
  Words := Pdf.PageWordBoxes;
  Lines := nil;
  Bounds := nil;
  for I := 0 to High(Words) do
  begin
    Found := -1;
    for J := High(Lines) downto 0 do
      if SameVisualLine(Bounds[J], Words[I].Rect) then
      begin
        Found := J;
        Break;
      end;
    if Found < 0 then
    begin
      SetLength(Lines, Length(Lines) + 1);
      SetLength(Bounds, Length(Bounds) + 1);
      Found := High(Lines);
      Bounds[Found] := Words[I].Rect;
    end;
    SetLength(Lines[Found], Length(Lines[Found]) + 1);
    Lines[Found][High(Lines[Found])] := Words[I];
    Bounds[Found].Left := Min(Bounds[Found].Left, Words[I].Rect.Left);
    Bounds[Found].Right := Max(Bounds[Found].Right, Words[I].Rect.Right);
    Bounds[Found].Top := Max(Bounds[Found].Top, Words[I].Rect.Top);
    Bounds[Found].Bottom := Min(Bounds[Found].Bottom, Words[I].Rect.Bottom);
  end;
  // posortuj każdy wiersz po Rect.Left przed czytaniem; PageWordBoxes zwraca
  // słowa w kolejności strumienia treści, która nie musi być wizualna
end;

Co się zmienia dla istniejących wywołań i gdzie są granice

W tym fragmencie kodu chodzi o predykat, a nie o pętlę; do czegokolwiek więcej niż szybki zrzut zacznij od modelu tekstu strukturalnego, który niesie już bloki, wiersze i źródło kolejności czytania, jak opisuje strukturalne wyciąganie tekstu PDF z kolejnością czytania. Istniejące wywołania tabel dostają wszystkie trzy poprawki bez dotykania swoich opcji. Próg korytarza jest ustalony na połowę MinColumnGap, strategia białych znaków zachowuje swój próg dwóch wierszy nawet przy MinRows ustawionym na 1 (co strategia siatkowa teraz przyjmuje), a filtrowanie słów zajętych przez siatkę działa bezwarunkowo, gdy obie strategie są włączone. Na zestawie 13 dokumentów użytym do wydania przebieg po białych znakach zwracał wcześniej 34 fragmenty i fałszywe trafienia obok 9 tabel siatkowych; po wydaniu nie zwraca żadnego, a liczba tabel siatkowych wzrosła do 41, choć większość tego wzrostu pochodzi z tego samego wydania, które nauczyło detektor siatek czytać obramowania rysowane jako wypełnione prostokąty — a to osobna historia

Uczciwe granice: test korytarza potrzebuje co najmniej jednego wiersza z treścią po obu stronach granicy, żeby cokolwiek odrzucić, więc dwuwierszowy kandydat, którego dwa rozciągnięte odstępy przypadkiem wypadną w granicach 6 punktów od siebie, wciąż przechodzi. To wąski zbieg okoliczności, a nie prawie pewność jak wcześniej, ale dokumenty pełne prozy, w których nie ma prawdziwych dwuwierszowych tabel, mogą tę lukę zamknąć, ustawiając MinRows na 3. Tekst wyrównany do lewej z poszarpanym prawym brzegiem nigdy nie był problemem i nie jest tym dotknięty. A PDF wciąż nie ma obiektu tabeli; ISO 32000-1 §14.8.4.3 definiuje element struktury Table, ale niesie go tylko Tagged PDF, więc dla całej reszty siatka pozostaje wnioskiem z geometrii, a wartość pewności na każdym TPdfTable jest tam dlatego, że wnioskowanie zasługuje na ocenę

Wyciąganie tabel, tekst strukturalny i ramki słów czytają z tego samego modelu strony w Delphi, C++Builderze i Lazarusie; pełne API, wraz z TPdfTableExtractionOptions i dostarczanym obok demem TableExtractionLab, jest opisane na stronie PDFium Component dla Delphi