Tehnički članak

Uklonite lažne PDF tablice iz justified teksta u Delphiju

PDFium Component verzija 3.117.0 prestaje prijavljivati obostrano poravnate odlomke kao whitespace-poravnate tablice tako da zahtijeva da svaka granica stupca bude okomiti koridor bez teksta u svakom retku koji razdvaja, preskače riječi koje je crtana mreža već prisvojila, i sastavlja tekst ćelije po okomitom preklapanju umjesto po udaljenosti središta glyph boxova. Sve tri promjene žive unutar ExtractTables i ExtractDocumentTables i ne trebaju nikakvu opciju

Prijava koja je pokrenula ovo bila je neatraktivna. Stranica priopćenja na kojoj nema nikakve tablice vratila se iz ExtractTables s whitespace tablicom 5x4, pouzdanosti udobno iznad zadanog MinConfidence od 0.5, a ćelije su držale dijelove običnog teksta tijela. Prijavni obrazac učinio je isto sa svojim esejističkim odlomcima i proizveo 3x4 i 5x3. Oba dokumenta bila su obostrano poravnata. Očiti odgovor je podesiti pragove, a korisna lekcija iz ovog izdanja je da podešavanje to ne može popraviti, jer je pravilo koje se podešava postavljalo pogrešno pitanje

uses
  PDFium;

// Regresijska provjera: izlistaj svaku whitespace tablicu u dokumentu da se
// stranica za koju znate da je samo proza može potvrditi čistom
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;

Zašto obostrano poravnat tekst izgleda kao tablica?

Obostrano poravnat odlomak izgleda kao tablica zato što je obostrano poravnat redak niz riječi razdvojenih prazninama koje je layout engine razvukao, a kada razvučena praznina dosegne MinColumnGap, detektor nema načina unutar jednog retka da je razlikuje od razdjelnika stupaca. Strategija whitespacea u PDFium Componentu grupira word boxove u vizualne retke, dijeli svaki redak u grupe riječi gdje god je vodoravna udaljenost od prethodne riječi najmanje MinColumnGap (12 točaka po defaultu), i prihvaća tablicu kada barem dva uzastopna retka ponavljaju barem MinColumns lijevo poravnatih sidara grupa unutar AlignmentTolerance, što je 3 točke. To je pravilo opisano u pregledu detekcije tablica, i za pravu poravnatu tablicu ono je točno pravo

Primijenite to sada na dvadeset redaka obostrano poravnate proze od 10 točaka. Svaki je redak razvučen do iste desne margine, pa redak koji završava dugom riječi otvara svoje unutarnje razmake, a u odlomku s nekoliko kratkih redaka neki od tih razmaka prijeđu 12 točaka. Dva uzastopna retka trebaju samo po jednu razvučenu prazninu, koja slijeće unutar 3 točke iste X pozicije, da tvore kandidata s dva retka i dva stupca. Kroz dovoljno redaka to nije loša sreća; to je vjerojatnost koja se približava sigurnosti, a 5x4 na priopćenju bio je jednostavno niz u kojem su se četiri takve praznine poravnale na pet redaka

PDFium Component dijagram zašto je obostrano poravnata proza prošla kao tablica: svaki je redak razvučen do iste margine pa pojedine praznine prelaze MinColumnGap na različitom X u svakom retku, a dvije uzastopne praznine unutar AlignmentTolerance izgradile su lažne kandidate koje test koridora sada odbija
Prava tablica ponavlja svoja sidra stupaca u svakom retku, dok obostrano poravnat odlomak razvlači različit razmak u svakom retku, zato podešavanje na razini retka samo nije moglo razdvojiti to dvoje

Svaki prag razmjenjuje jednu klasu dokumenata za drugu. Podizanje MinColumnGap na 20 točaka gubi kompaktne stupce gustih financijskih izvještaja, što je upravo slučaj zbog kojeg je zadana vrijednost već bila snižena. Podizanje MinRows na 3 odbacuje prave tablice s dva retka i samo snižava vjerojatnost za duge odlomke. Stezanje AlignmentTolerance ispod 3 točke lomi word boxove dobivene OCR-om, čiji lijevi rubovi podrhtavaju više od toga. Signal na razini retka doista je dvosmislen, pa popravak mora doći iz signala koji reci sami po sebi ne nose

Što granicu stupca čini stvarnom?

Stvarna granica stupca je okomita traka stranice koja ostaje prazna u svakom retku koji razdvaja. Tablica je ima između svakog para stupaca po konstrukciji, jer su ćelije položene prema zajedničkim X pozicijama. Obostrano poravnat odlomak razvlači svoje razmake među riječima na različitim vodoravnim pozicijama u svakom retku, pa nijedna traka ne preživi presjek više od retka ili dva. PDFium Component sada testira upravo to: nakon što su grupe riječi kandidata dodijeljene sidrenim stupcima, za svaki par susjednih stupaca uzima, u svakom retku koji ima sadržaj u objema ćelijama, interval od desnog ruba riječi lijeve ćelije do lijevog ruba riječi desne ćelije, presijeca te intervale kroz retke, i odbija cijelog kandidata ako je presjek uži od MinColumnGap puta 0.5, što je 6 točaka uz zadane postavke

PDFium Component dijagram testa koridora bez teksta iza ExtractTables: svaki redak daje interval od desnog ruba svoje lijeve ćelije do lijevog ruba desne ćelije, presjek ostaje širi od polovice MinColumnGap u pravoj tablici, a u obostrano poravnatom tekstu pada na ništa
Prava granica stupca prazna je u svakom retku koji razdvaja, pa presjek praznina po retku ostavlja zajedničku traku za tablicu, a za razvučenu prozu nikakvu traku

Dva detalja imaju značenje. Reci u kojima je bilo koja ćelija prazna ne glasaju, pa tablica s praznom ćelijom, ili zaglavlje koje premošćuje manje stupaca od tijela, i dalje prolazi. A širina koridora izvodi se iz MinColumnGap, a ne izlaže kao zasebna opcija, jer oboje opisuje istu fizičku stvar: prazninu koju dizajner ostavlja između stupaca. Logika je dovoljno mala da se reproducira ako gradite na sirovim word boxovima umjesto na table API-ju, a uzorak ispod zrcali provjeru unutar komponente:

uses
  Math, PDFium;

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

// Vraća False kada bilo kojem paru susjednih stupaca nedostaje okomiti
// koridor bez teksta širok barem MinColumnGap / 2 kroz retke koji ga koriste
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;                             // prazne ćelije ne glasaju
      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;

Zašto su crtane tablice izvučene dvaput?

Crtane tablice izvučene su dvaput zato što je whitespace prolaz nekad vidio svaku riječ na stranici, uključujući riječi koje je prolaz za crtane mreže već smjestio u mrežu, a čista crtana tablica je po konstrukciji i savršeno poravnata whitespace tablica. Provjera preklapanja već je odbijala whitespace kandidata čije su granice pokrivale više od polovice postojeće tablice, ali kandidat koji je spojio donje retke tablice s nekoliko poravnatih redaka teksta ispod nje mogao je pasti ispod tog omjera i preživjeti kao druga, nešto veća tablica koja se prelijevala u susjednu. ExtractTables sada uklanja te riječi prije nego se whitespace prolaz izvrši. Riječ se odbacuje kada njeno središte leži unutar granica bilo koje tablice koju je prolaz za crtane mreže proizveo; koristi se središte, a ne potpuno sadržavanje, da riječ koja prelazi obrub za djelić točke slijedi tablicu kojoj vizualno pripada. Strategija whitespacea zatim radi samo na slobodnim riječima, što znači i da se mala tablica bez crta koja sjedi neposredno pod crtanom detektira po vlastitim zaslugama, umjesto da se stopi s mrežom iznad sebe

Zašto je "Purpose of Request:" izašlo kao "of Purpose Request:"?

Riječi su izašle presložene zato što su word boxovi koje PDFium Component gradi unije bounding boxova glyphova, a "of" nema descender dok "Purpose" i "Request:" imaju. FPDFText_GetCharBox vraća tijesan box tinte glypha u prostoru stranice, a ne box dopunjen do ascenta i descenta fonta, i word box je unija boxova njegovih znakova. Riječ bez descendera stoga je kraća i njeno okomito središte sjedi više, za 2 do 3 točke na dotičnom obrascu. Stara rutina za tekst ćelije sortirala je riječi prvo po središtu Y, s tolerancijom od 1 točke za "isti redak", a zatim po lijevom rubu; "of" je prešao toleranciju, sortirao se kao vlastiti redak iznad ostalih, i bio emitiran prvi

To nije toliko PDFiumova posebnost koliko posljedica načina na koji PDF pozicionira tekst. ISO 32000-1 §9.2.2 i §9.4.4 definiraju smještaj glyphova kao vodoravni pomak duž baselinea u tekstualnom prostoru, a jedine okomite metrike koje datoteka nosi su po fontu: unosi Ascent, Descent i FontBBox font descriptora u §9.8.1. Ništa u datoteci ne kaže da dva glypha dijele redak; to se mora zaključiti iz geometrije, a tijesni glyph boxovi koji čine da isticanje odabira izgleda ispravno, kako je opisano u odabiru tekstualnih redaka uz PDFium char boxove, pogrešan su ulaz za usporedbu udaljenosti središta

Popravak u verziji 3.117.0 mijenja pitanje s "koliko su središta udaljena" na "koliko se boxovi okomito preklapaju". Tekst ćelije sastavlja se tako da se riječi ćelije prvo grupiraju u vizualne retke, gdje se riječ pridružuje retku kada je njeno okomito preklapanje s tekućim granicama retka najmanje 25 posto manje od dviju visina, zatim se svaki redak sortira umetanjem po lijevom rubu, i na kraju se reci spajaju prijelomom retka. "Purpose" i "of" preklapaju se preko cijele x-visine, što je znatno više od 25 posto kraćeg boxa, pa slijeću u isti redak i sortiraju se po X kako je i zamišljeno

PDFium Component dijagram popravka poretka Purpose of Request: tijesni glyph boxovi iz FPDFText_GetCharBox daju riječi of bez descendera više središte koje je stara tolerancija od 1 pt po središtu Y sortirala kao vlastiti redak, dok pravilo preklapanja od 25 posto drži riječ na baselini i vraća redoslijed riječi
Središte Y pomiče se sa svakim ascenderom i descenderom koje tinta slučajno nosi, dok se dva boxa na istoj baselini preklapaju preko zajedničke x-visine bez obzira na svoje visine

Grupirajte tekstualne retke po preklapanju, a ne po udaljenosti središta

Pravilo koje vrijedi ponijeti iz ovog buga je općenito: svaki PDF kod za layout teksta koji odlučuje "isti redak" usporedbom okomitih središta prema fiksnoj toleranciji pasti će na stvarnim fontovima, a pad je tih: ništa ne diže grešku, riječi jednostavno izlaze u pogrešnom poretku. Miješani descenderi najblaži su okidač. Podebljana oznaka od 12 točaka uz vrijednosti od 10 točaka, marker fusnote kao superscript, simbol valute iscrtan iz fallback fonta, i OCR word boxovi sa šumom u visini po riječi svi pomiču središta više od bilo koje tolerancije koja još razdvaja susjedne retke teksta od 10 točaka s proredom od 12 točaka. Omjer preklapanja ne ovisi o veličini: dva boxa na istoj baselini preklapaju se preko svoje zajedničke x-visine bez obzira na ascent i descent, a dva boxa u susjednim recima ne preklapaju se nikako

Isto je pravilo lako primijeniti i izvan izvlačenja tablica. TPdf.PageWordBoxes vraća svaku riječ na aktivnoj stranici s njenim pravokutnikom u prostoru stranice, pa je grupiranje stranice u vizualne retke kratka petlja:

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>;   // tekuća unija po retku
  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;
  // sortiraj svaki redak po Rect.Left prije čitanja; PageWordBoxes vraća
  // riječi u redoslijedu content streama, koji nije zajamčeno vizualan
end;

Što se mijenja za postojeće pozivatelje, i gdje su granice

Poanta tog isječka je predikat, a ne petlja; za sve osim brzog dumpa počnite od modela strukturiranog teksta, koji već nosi blokove, retke i izvor redoslijeda čitanja, kako je obrađeno u strukturiranom izvlačenju PDF teksta s redoslijedom čitanja. Postojeći pozivatelji tablica dobivaju sve tri ispravke bez diranja svojih opcija. Prag koridora fiksiran je na polovicu MinColumnGap, strategija whitespacea zadržava svoj pod od dva retka čak i kada je MinRows postavljen na 1 (što strategija crtanih mreža sada prihvaća), a filtriranje riječi nakon crtane mreže bezuvjetno je kad god su obje strategije omogućene. Na skupu uzoraka od 13 dokumenata korištenom za izdanje, whitespace prolaz prije je vraćao 34 fragmenta i lažna pozitiva uz 9 crtanih tablica; nakon izdanja ne vraća nijedan, a broj crtanih tablica porastao je na 41, iako većina tog porasta dolazi od toga što je isto izdanje naučilo detektor crtanih mreža da čita obrube nacrtane kao ispunjene pravokutnike, što je zasebna priča

Iskrene granice: test koridora treba barem jedan redak sa sadržajem na objema stranama granice da bi išta odbio, pa kandidat s dva retka čije dvije razvučene praznine slučajno padnu unutar 6 točaka jedna od druge i dalje prolazi. To je uska slučajnost, a ne gotovo sigurnost kakva je bila prije, ali dokumenti prepuni proze bez pravih tablica s dva retka mogu to zatvoriti postavljanjem MinRows na 3. Lijevo poravnat neujednačen tekst nikad nije bio problem i nije pogođen. A PDF i dalje nema objekt tablice; ISO 32000-1 §14.8.4.3 definira strukturni element Table, ali ga nosi samo Tagged PDF, pa za sve ostalo mreža ostaje zaključak iz geometrije, a vrijednost pouzdanosti na svakom TPdfTable postoji zato što zaključivanje zaslužuje ocjenu

Izvlačenje tablica, strukturirani tekst i word boxovi čitaju iz istog modela stranice u Delphiju, C++Builderu i Lazarusu; kompletan API, uključujući TPdfTableExtractionOptions i demo TableExtractionLab koji se isporučuje uz njega, opisan je na stranici PDFium Component za Delphi