Articol tehnic

Fără tabele false din text aliniat în PDF, în Delphi

PDFium Component versiunea 3.117.0 nu mai raportează paragrafele aliniate ca tabele aliniate pe spații albe, cerând ca fiecare limită de coloană să fie un culoar vertical fără text pe orice rând pe care îl separă, sărind peste cuvintele deja revendicate de o grilă cu linii și asamblând textul celulelor după suprapunerea verticală în loc de distanța dintre centrele casetelor de glifă. Toate cele trei modificări trăiesc în ExtractTables și ExtractDocumentTables și nu cer nicio opțiune

Raportul care a pornit toate astea era lipsit de farmec. O pagină de comunicat de presă pe care nu exista niciun tabel revenea din ExtractTables cu un tabel pe spații albe de 5x4, cu o încredere confortabil peste MinConfidence implicit de 0.5, iar celulele conțineau fragmente de text de corp obișnuit. Un formular de înscriere a făcut la fel cu paragrafele lui eseistice și a produs un 3x4 și un 5x3. Ambele documente erau aliniate. Răspunsul evident este să reglezi pragurile, iar lecția utilă din această versiune este că reglarea nu poate rezolva problema, pentru că regula reglată punea întrebarea greșită

uses
  PDFium;

// Verificare de regresie: listează fiecare tabel pe spații albe dintr-un document, ca o pagină
// despre care știi că e doar proză să poată fi confirmată curată
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;

De ce arată textul aliniat ca un tabel?

Un paragraf aliniat arată ca un tabel pentru că o linie aliniată este un rând de cuvinte separate de goluri pe care motorul de așezare în pagină le-a întins, iar odată ce un gol întins ajunge la MinColumnGap, detectorul nu are nicio cale locală la rând să îl distingă de un separator de coloană. Strategia pe spații albe din PDFium Component grupează casetele de cuvinte în rânduri vizuale, împarte fiecare rând în grupuri de cuvinte oriunde distanța orizontală față de cuvântul anterior este de cel puțin MinColumnGap (12 puncte implicit) și acceptă un tabel când cel puțin două rânduri consecutive repetă cel puțin MinColumns ancore de grup aliniate la stânga în limita AlignmentTolerance, adică 3 puncte. Aceasta este regula descrisă în prezentarea generală a detectării tabelelor și, pentru un tabel cu adevărat aliniat, este exact corectă

Aplicați-o acum pe douăzeci de linii de proză aliniată de 10 puncte. Fiecare linie este întinsă până la aceeași margine dreaptă, așa că o linie care se termină cu un cuvânt lung își deschide spațiile interioare, iar într-un paragraf cu câteva linii scurte unele dintre acele spații depășesc 12 puncte. Două linii consecutive au nevoie doar de câte un gol întins fiecare, care să cadă în 3 puncte de aceeași poziție X, ca să formeze un candidat de două rânduri și două coloane. Pe destule linii asta nu este ghinion; este o probabilitate care se apropie de certitudine, iar 5x4-ul din comunicatul de presă a fost pur și simplu șirul în care patru astfel de goluri s-au aliniat pe cinci linii

Diagramă PDFium Component despre de ce proza aliniată a fost notată ca tabel: fiecare linie este întinsă până la aceeași margine, așa că golurile singulare depășesc MinColumnGap la alt X pe fiecare linie, iar două goluri consecutive în limita AlignmentTolerance au construit candidații falși pe care testul de culoar îi respinge acum
Un tabel real își repetă ancorele de coloană pe fiecare rând, în timp ce un paragraf aliniat întinde alt spațiu pe fiecare linie, motiv pentru care reglarea doar la nivel de rând nu putea să le separe

Fiecare prag face schimb între o clasă de documente și alta. Ridicarea lui MinColumnGap la 20 de puncte pierde coloanele compacte ale rapoartelor financiare dense, exact cazul pentru care valoarea implicită fusese deja coborâtă. Ridicarea lui MinRows la 3 aruncă tabele reale de două rânduri și doar scade șansele pentru paragrafele lungi. Strângerea lui AlignmentTolerance sub 3 puncte rupe casetele de cuvinte provenite din OCR, ale căror margini din stânga tremură cu mai mult de atât. Semnalul la nivel de rând este cu adevărat ambiguu, așa că reparația trebuie să vină dintr-un semnal pe care rândurile nu îl cară de la ele

Ce face ca o limită de coloană să fie reală?

O limită de coloană reală este o fâșie verticală a paginii care rămâne goală pe fiecare rând pe care îl separă. Un tabel are una între fiecare pereche de coloane prin construcție, pentru că celulele au fost așezate față de poziții X comune. Un paragraf aliniat își întinde spațiile dintre cuvinte la poziții orizontale diferite pe fiecare linie, așa că nicio fâșie nu supraviețuiește intersecției a mai mult de o linie sau două. PDFium Component testează exact asta: după ce grupurile de cuvinte ale candidatului au fost atribuite coloanelor de ancoră, pentru fiecare pereche de coloane adiacente ia, pe fiecare rând care are conținut în ambele celule, intervalul de la muchia cea mai din dreapta a cuvintelor celulei din stânga până la muchia cea mai din stânga a cuvintelor celulei din dreapta, intersectează acele intervale peste rânduri și respinge tot candidatul dacă intersecția este mai îngustă decât MinColumnGap înmulțit cu 0.5, adică 6 puncte la valorile implicite

Diagramă PDFium Component a testului de culoar fără text din spatele ExtractTables: fiecare rând donează intervalul de la muchia dreaptă a celulei lui din stânga până la muchia stângă a celei din dreapta, iar intersecția rămâne mai lată de jumătate din MinColumnGap într-un tabel real și se colapsează la nimic în textul aliniat
O limită de coloană autentică este goală pe fiecare rând pe care îl separă, așa că intersectarea golurilor per rând lasă o fâșie comună pentru un tabel și nicio fâșie pentru proza întinsă

Două detalii contează. Rândurile în care oricare dintre celule este goală nu votează, așa că un tabel cu o celulă goală, sau cu un antet care acoperă mai puține coloane decât corpul, trece în continuare. Iar lățimea culoarului este derivată din MinColumnGap, nu expusă ca opțiune separată, pentru că cele două descriu același lucru fizic: spațiul pe care un designer îl lasă între coloane. Logica este destul de mică pentru a fi reprodusă dacă lucrați pe casete de cuvinte brute, nu prin API-ul de tabele, iar exemplul de mai jos oglindește verificarea din interiorul componentei:

uses
  Math, PDFium;

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

// Întoarce False când vreo pereche de coloane adiacente nu are un culoar vertical
// fără text lat de cel puțin MinColumnGap / 2 pe rândurile care îl folosesc
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;                             // celulele goale nu votează
      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;

De ce au fost extrase de două ori tabelele cu linii?

Tabelele cu linii erau extrase de două ori pentru că trecerea pe spații albe obișnuia să vadă fiecare cuvânt de pe pagină, inclusiv cuvintele pe care trecerea pe linii le plasase deja într-o grilă, iar un tabel curat cu linii este prin construcție și un tabel perfect aliniat pe spații albe. O verificare de suprapunere respingea deja un candidat pe spații albe ale cărui limite acopereau mai mult de jumătate dintr-un tabel existent, dar un candidat care combina rândurile de jos ale tabelului cu câteva linii aliniate de text de sub el putea cădea sub acel raport și supraviețuia ca un al doilea tabel, ceva mai mare, care se revărsa în vecinul lui. ExtractTables elimină acum acele cuvinte înainte ca trecerea pe spații albe să ruleze. Un cuvânt este eliminat când punctul lui central se află în interiorul limitelor oricărui tabel produs de trecerea pe linii; se folosește centrul, nu conținerea completă, ca un cuvânt care trece o margine cu o fracțiune de punct să urmeze tabelul căruia îi aparține vizual. Strategia pe spații albe lucrează apoi doar pe cuvintele libere, ceea ce înseamnă și că un tabel mic fără linii aflat direct sub unul cu linii este detectat pe meritele lui, în loc să fie fuzionat cu grila de deasupra

De ce a ieșit „Purpose of Request:” ca „of Purpose Request:”?

Cuvintele au ieșit reordonate pentru că casetele de cuvinte pe care le construiește PDFium Component sunt uniuni de casete de încadrare ale glifelor, iar „of” nu are coborâtor, în timp ce „Purpose” și „Request:” au. FPDFText_GetCharBox întoarce caseta strânsă a cernelii glifei în spațiul paginii, nu o casetă umplută până la ascendentul și descendentul fontului, iar caseta cuvântului este uniunea casetelor caracterelor lui. Un cuvânt fără coborâtoare este așadar mai scund, iar centrul lui vertical stă mai sus, cu 2 până la 3 puncte pe formularul în cauză. Vechea rutină de text al celulei sorta cuvintele mai întâi după centrul Y, cu o toleranță de 1 punct pentru „același rând”, apoi după muchia din stânga; „of” depășea toleranța, se sorta ca propriul lui rând deasupra celorlalte și era emis primul

Asta nu este atât o particularitate a PDFium, cât o consecință a felului în care PDF-ul poziționează textul. ISO 32000-1 §9.2.2 și §9.4.4 definesc plasarea glifelor ca deplasare orizontală de-a lungul liniei de bază în spațiul textului, iar singurele metrice verticale pe care le cară fișierul sunt per font: intrările Ascent, Descent și FontBBox ale descriptorului de font din §9.8.1. Nimic din fișier nu spune că două glife împart o linie; asta trebuie dedus din geometrie, iar casetele strânse de glife care fac evidențierea selecției să arate bine, cum este descris în selectarea liniilor de text cu casete de caractere PDFium, sunt intrarea greșită pentru o comparație de distanță între centre

Reparația din versiunea 3.117.0 schimbă întrebarea din „cât de departe sunt centrele” în „cât se suprapun vertical casetele”. Textul celulei este asamblat grupând mai întâi cuvintele celulei în linii vizuale, unde un cuvânt se alătură unei linii când suprapunerea lui verticală cu limitele curente ale liniei este de cel puțin 25 la sută din cea mai mică dintre cele două înălțimi, apoi sortând prin inserție fiecare linie după muchia din stânga și apoi unind liniile cu un sfârșit de linie. „Purpose” și „of” se suprapun pe toată înălțimea x, ceea ce este mult mai mult de 25 la sută din caseta mai scurtă, așa că ajung pe aceeași linie și se sortează după X cum trebuie

Diagramă PDFium Component a reparației de reordonare Purpose of Request: casetele strânse de glife din FPDFText_GetCharBox dau lui of, fără coborâtoare, un centru mai sus pe care vechea toleranță de 1 pt pe centrul Y îl sorta ca linie separată, în timp ce o regulă de suprapunere verticală de 25 la sută îl ține pe linia de bază și restaurează ordinea cuvintelor
Centrul Y se mișcă în funcție de ascendenții și descendenții pe care îi cară cerneala, în timp ce două casete pe aceeași linie de bază se suprapun pe înălțimea x comună, orice ar face înălțimile lor

Grupați liniile de text după suprapunere, nu după distanța dintre centre

Regula care merită reținută din acest bug este generală: orice cod de așezare a textului PDF care decide „același rând” comparând centrele verticale cu o toleranță fixă va eșua pe fonturi reale, iar eșecul este tăcut: nimic nu dă eroare, cuvintele pur și simplu ies în ordinea greșită. Coborâtoarele amestecate sunt declanșatorul cel mai blând. O etichetă îngroșată de 12 puncte lângă valori de 10 puncte, un marcaj de notă de subsol în superscript, un simbol de monedă desenat cu un font de rezervă și casetele de cuvinte din OCR cu zgomot de înălțime per cuvânt mută toate centrele cu mai mult decât orice toleranță care încă separă linii adiacente de text de 10 puncte la un interliniu de 12 puncte. Raportul de suprapunere este invariant la dimensiune: două casete pe aceeași linie de bază se suprapun pe înălțimea x comună, orice ar face ascendenții și descendenții lor, iar două casete pe linii adiacente nu se suprapun deloc

Aceeași regulă este ușor de aplicat și în afara extragerii de tabele. TPdf.PageWordBoxes întoarce fiecare cuvânt de pe pagina activă cu dreptunghiul lui în spațiul paginii, așa că gruparea unei pagini în linii vizuale este o buclă scurtă:

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>;   // uniune curentă per linie
  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;
  // sortează fiecare linie după Rect.Left înainte de a o citi; PageWordBoxes întoarce
  // cuvintele în ordinea fluxului de conținut, care nu este garantat vizuală
end;

Ce se schimbă pentru apelanții existenți și unde sunt limitele

Rostul acelui fragment este predicatul, nu bucla; pentru orice depășește un dump rapid, porniți de la modelul de text structurat, care cară deja blocuri, linii și o sursă de ordine de citire, cum este acoperit în extragerea textului structurat din PDF cu ordine de citire. Apelanții existenți ai extragerii de tabele primesc toate cele trei corecții fără să își atingă opțiunile. Pragul de culoar este fixat la jumătate din MinColumnGap, strategia pe spații albe își păstrează pragul de două rânduri chiar și când MinRows este setat la 1 (ceea ce strategia pe linii acceptă acum), iar filtrarea cuvintelor după trecerea pe linii este necondiționată ori de câte ori ambele strategii sunt active. Pe setul de 13 documente eșantion folosit pentru versiune, trecerea pe spații albe întorsese anterior 34 de fragmente și fals pozitive alături de 9 tabele pe linii; după versiune nu mai întoarce niciunul, iar numărul de tabele pe linii a crescut la 41, deși cea mai mare parte a creșterii vine din faptul că aceeași versiune a învățat detectorul de linii să citească bordurile desenate ca dreptunghiuri umplute, ceea ce este o poveste separată

Limitele oneste: testul de culoar are nevoie de cel puțin un rând cu conținut pe ambele laturi ale unei limite ca să respingă ceva, așa că un candidat de două rânduri ale cărui două goluri întinse se nimerește să cadă în 6 puncte unul de altul trece în continuare. Este o coincidență îngustă, nu aproape-certitudinea de dinainte, dar documentele pline de proză care nu au tabele autentice de două rânduri pot închide problema setând MinRows la 3. Textul nealiniat, cu marginea din dreapta neregulată, nu a fost niciodată problema și nu este afectat. Iar PDF nu are încă un obiect de tabel; ISO 32000-1 §14.8.4.3 definește un element de structură Table, dar doar Tagged PDF îl cară, așa că pentru tot restul grila rămâne o deducție din geometrie, iar valoarea de încredere de pe fiecare TPdfTable există pentru că deducția merită un scor

Extragerea de tabele, textul structurat și casetele de cuvinte citesc toate din același model de pagină în Delphi, C++Builder și Lazarus; API-ul complet, inclusiv TPdfTableExtractionOptions și demo-ul TableExtractionLab care vine alături, este descris pe pagina PDFium Component pentru Delphi