Tekninen artikkeli

Tasatun tekstin väärien taulukkohavaintojen korjaus

PDFium Component -versio 3.117.0 lakkaa raportoimasta tasattuja kappaleita tyhjän tilan mukaan kohdistettuina taulukoina vaatimalla, että jokainen sarakeraja on pystysuuntainen käytävä, jossa ei ole tekstiä yhdelläkään sen erottamalla rivillä, ohittamalla sanat, jotka viivaruudukko on jo vallannut, ja kokoamalla solun tekstin pystysuuntaisen päällekkäisyyden eikä glyfilaatikoiden keskietäisyyden perusteella. Kaikki kolme muutosta elävät ExtractTables- ja ExtractDocumentTables -funktioiden sisällä eivätkä tarvitse asetusta

Tämän liikkeelle pannut raportti oli vaatimaton. Lehdistötiedotteen sivu, jolla ei ole taulukkoa, palasi ExtractTables-kutsusta 5x4-tyhjän tilan taulukkona, luottamus selvästi oletusarvoisen MinConfidence-arvon 0.5 yläpuolella, ja solut sisälsivät tavallisen leipätekstin palasia. Ilmoittautumislomake teki saman esseekappaleillaan ja tuotti 3x4- ja 5x3-taulukon. Molemmat asiakirjat oli aseteltu tasatuiksi. Ilmeinen vastaus on virittää kynnysarvoja, ja tämän julkaisun hyödyllinen oppi on, ettei virittäminen voi korjata sitä, koska viritettävä sääntö kysyi väärää kysymystä

uses
  PDFium;

// Regressiotarkistus: luettele jokainen tyhjän tilan taulukko asiakirjassa, jotta sivu
// jonka tiedät pelkäksi proosaksi, voidaan todeta puhtaaksi
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;

Miksi tasattu teksti näyttää taulukolta?

Tasattu kappale näyttää taulukolta, koska tasattu rivi on rivi sanoja, joita erottavat raot, jotka asettelumoottori venytti, ja kun venytetty rako yltää MinColumnGap-arvoon, ilmaisimella ei ole rivikohtaista keinoa erottaa sitä sarake-erottimesta. PDFium Componentin tyhjän tilan strategia ryhmittelee sanalaatikot visuaalisiksi riveiksi, jakaa jokaisen rivin sanaryhmiin aina kun vaakasuuntainen etäisyys edelliseen sanaan on vähintään MinColumnGap (oletuksena 12 pistettä), ja hyväksyy taulukon kun vähintään kaksi peräkkäistä riviä toistaa vähintään MinColumns vasemmalle kohdistettua ryhmäankkuria AlignmentTolerance-arvon sisällä, joka on 3 pistettä. Se on sääntö, jota kuvataan taulukontunnistuksen yleiskatsauksessa, ja aidolle kohdistetulle taulukolle se on täsmälleen oikein

Sovella sitä nyt kahteenkymmeneen riviin tasattua 10 pisteen proosaa. Jokainen rivi venytetään samaan oikeaan marginaaliin, joten pitkällä sanalla päättyvä rivi avaa sisäiset välilyöntinsä, ja kappaleessa, jossa on muutama lyhyt rivi, jotkin noista välilyönneistä ylittävät 12 pistettä. Kahden peräkkäisen rivin tarvitsee vain yksi venytetty rako kummassakin, osuen 3 pisteen sisällä samaan X-sijaintiin, muodostaakseen kahden rivin ja kahden sarakkeen ehdokkaan. Tarpeeksi monella rivillä tämä ei ole huonoa onnea; se on todennäköisyys, joka lähestyy varmuutta, ja lehdistötiedotteen 5x4 oli yksinkertaisesti se jakso, jossa neljä sellaista rakoa osui kohdakkain viidellä rivillä

PDFium Componentin kaavio siitä, miksi tasattu proosa pisteyttyi taulukoksi: jokainen rivi venytetään samaan marginaaliin, joten yksittäiset raot ylittävät MinColumnGap-arvon eri X-kohdassa kullakin rivillä, ja kaksi peräkkäistä AlignmentTolerancen sisällä olevaa rakoa rakensivat ne väärät ehdokkaat jotka käytävätarkistus nyt hylkää
Aito taulukko toistaa sarakeankkurinsa jokaisella rivillä, kun taas tasattu kappale venyttää eri välilyöntiä kullakin rivillä, minkä takia pelkkä rivitason virittäminen ei pystynyt erottamaan näitä kahta

Jokainen kynnysarvo vaihtaa yhden asiakirjaluokan toiseen. MinColumnGap-arvon nostaminen 20 pisteeseen menettää tiheiden talousraporttien kapeat sarakkeet, mikä on täsmälleen se tapaus, jonka takia oletusta jo laskettiin. MinRows-arvon nostaminen 3:een hylkää aidot kaksiriviset taulukot ja ainoastaan laskee pitkien kappaleiden todennäköisyyttä. AlignmentTolerance-arvon kiristäminen alle 3 pisteen rikkoo OCR:stä johdetut sanalaatikot, joiden vasemmat reunat värisevät enemmän kuin sen verran. Rivitason signaali on aidosti monitulkainen, joten korjauksen on tultava signaalista, jota rivit eivät kanna yksinään

Mikä tekee sarakerajasta aidon?

Aito sarakeraja on sivun pystysuuntainen kaista, joka pysyy tyhjänä jokaisella erottamallaan rivillä. Taulukossa sellainen on jokaisen sarakeparin välissä rakenteensa puolesta, koska solut aseteltiin yhteisiä X-sijainteja vasten. Tasattu kappale venyttää sanavälilyöntejään eri vaakasijainneissa kullakin rivillä, joten mikään kaista ei selviä yhden tai kahden rivin leikkauksesta pidemmälle. PDFium Component testaa nyt täsmälleen tuota: kun ehdokkaan sanaryhmät on sijoitettu ankkurisarakkeisiin, se ottaa jokaiselle vierekkäisten sarakkeiden parille jokaisella rivillä, jolla on sisältöä molemmissa soluissa, välin vasemman solun sanojen oikeimmasta reunasta oikean solun sanojen vasempaan reunaan, leikkaa nuo välit rivien yli ja hylkää koko ehdokkaan jos leikkaus on kapeampi kuin MinColumnGap kertaa 0.5, mikä oletuksella on 6 pistettä

PDFium Componentin kaavio ExtractTables-funktion takana olevasta tekstistä vapaasta käytävätarkistuksesta: jokainen rivi lahjoittaa välin vasemman solunsa oikeasta reunasta oikean solunsa vasempaan reunaan, leikkaus pysyy yli puolena MinColumnGap-arvosta aidossa taulukossa ja luhistuu olemattomiin tasatussa tekstissä
Aito sarakeraja on tyhjä jokaisella erottamallaan rivillä, joten rivikohtaisten rakojen leikkaus jättää taulukolle yhteisen kaistan eikä venytetty proosa jätä kaistaa lainkaan

Kaksi yksityiskohtaa on merkityksellisiä. Rivit, joilla jompikumpi solu on tyhjä, eivät äänestä, joten taulukko, jossa on tyhjä solu tai ylätunniste joka kattaa vähemmän sarakkeita kuin runko, menee silti läpi. Ja käytävän leveys johdetaan MinColumnGap-arvosta sen sijaan, että se paljastettaisiin omana asetuksenaan, koska ne kaksi kuvaavat samaa fyysistä asiaa: rakoa, jonka suunnittelija jättää sarakkeiden väliin. Logiikka on tarpeeksi pieni toistettavaksi, jos rakennat raakojen sanalaatikoiden varaan taulukko-API:n sijaan, ja alla oleva esimerkki peilaa komponentin sisäistä tarkistusta:

uses
  Math, PDFium;

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

// Palauttaa False kun miltä tahansa vierekkäiseltä sarakeparilta puuttuu tekstistä vapaa
// pystykäytävä, joka on vähintään MinColumnGap / 2 leveä sitä käyttävien rivien yli
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;                             // tyhjät solut eivät äänestä
      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;

Miksi viivapohjaiset taulukot poimittiin kahdesti?

Viivapohjaiset taulukot poimittiin kahdesti, koska tyhjän tilan kierros näki aiemmin jokaisen sivun sanan, mukaan lukien ne sanat, jotka viivakierros oli jo sijoittanut ruudukkoon, ja puhdas viivataulukko on rakenteensa puolesta myös täydellisesti kohdistettu tyhjän tilan taulukko. Päällekkäisyystarkistus hylkäsi jo tyhjän tilan ehdokkaan, jonka rajat kattoivat yli puolet olemassa olevasta taulukosta, mutta ehdokas, joka yhdisti taulukon alemmat rivit muutamaan kohdistettuun tekstiriviin sen alapuolelta, saattoi jäädä tuon suhteen alle ja selviytyä toisena, hieman suurempana taulukkona, joka vuoti naapuriinsa. ExtractTables poistaa nyt nuo sanat ennen kuin tyhjän tilan kierros ajetaan. Sana pudotetaan kun sen keskipiste on minkä tahansa viivakierroksen tuottaman taulukon rajojen sisällä; keskipistettä käytetään täyden sisältyvyyden sijaan, jotta reunaa murtoluvun verran sivuava sana seuraa taulukkoa, johon se visuaalisesti kuuluu. Tyhjän tilan strategia toimii sitten vain vapaiden sanojen varassa, mikä tarkoittaa myös, että pieni viivaton taulukko suoraan viivatun taulukon alla havaitaan omilla ansioillaan sen sijaan että se sulautettaisiin yläpuoliseen ruudukkoon

Miksi "Purpose of Request:" tuli ulos muodossa "of Purpose Request:"?

Sanat tulivat uudelleenjärjestettyinä, koska PDFium Componentin rakentamat sanalaatikot ovat glyfien rajaavien laatikoiden yhteisjoukkoja, eikä sanassa "of" ole alapidennystä, kun taas sanoissa "Purpose" ja "Request:" on. FPDFText_GetCharBox palauttaa glyfin musteen tiiviin laatikon sivua varuudessa, ei laatikkoa, joka on täytetty fontin nousun ja laskun mukaan, ja sanalaatikko on sen merkkien laatikoiden yhteisjoukko. Ilman alapidennyksiä oleva sana on siksi lyhyempi ja sen pystykeskipiste istuu korkeammalla, kyseessä olevassa lomakkeessa 2–3 pistettä. Vanha solutekstirutiini lajitteli sanat ensin keskimmäisen Y:n mukaan yhden pisteen toleranssilla samalle riville ja sitten vasemman reunan mukaan; "of" ylitti toleranssin, lajittui omaksi rivikseen muiden yläpuolelle ja tulostui ensimmäisenä

Tämä ei ole niinkään PDFiumin omituisuus kuin seuraus siitä, miten PDF asettaa tekstin. ISO 32000-1 §9.2.2 ja §9.4.4 määrittelevät glyfin sijoittelun vaakasuuntaiseksi siirtymäksi perusviivaa pitkin tekstiavaruudessa, ja ainoat pystysuuntaiset mittatiedot, joita tiedosto kantaa, ovat fonttikohtaisia: fonttikuvaajan Ascent-, Descent- ja FontBBox-merkinnät §9.8.1:ssä. Mikään tiedostossa ei sano, että kaksi glyfiä jakaa rivin; se on pääteltävä geometriasta, ja tiiviit glyfilaatikot, jotka saavat valinnan korostuksen näyttämään oikealta, kuten kuvataan artikkelissa tekstirivien valinnasta PDFiumin merkkipaikoilla, ovat väärä syöte keskietäisyysvertailulle

Version 3.117.0 korjaus muuttaa kysymyksen siitä, kuinka kaukana keskipisteet ovat toisistaan, siihen, kuinka paljon laatikot menevät pystysuunnassa päällekkäin. Solun teksti kootaan ryhmittelemällä ensin solun sanat visuaalisiksi riveiksi, missä sana liittyy riviin kun sen pystysuuntainen päällekkäisyys rivin juoksevien rajojen kanssa on vähintään 25 prosenttia kahdesta korkeudesta pienemmästä, lajittelemalla sitten jokainen rivi lisäyslajittelulla vasemman reunan mukaan ja yhdistämällä rivit rivinvaihdolla. "Purpose" ja "of" menevät päällekkäin koko x-korkeuden yli, mikä on selvästi enemmän kuin 25 prosenttia lyhyemmästä laatikosta, joten ne päätyvät samalle riville ja lajittuvat X:n mukaan kuten pitikin

PDFium Componentin kaavio Purpose of Request -uudelleenjärjestyksen korjauksesta: FPDFText_GetCharBox-funktion tiiviit glyfilaatikot antavat ilman alapidennystä olevalle of-sanalle korkeamman keskipisteen, jonka vanha 1 pt:n keski-Y-toleranssi lajitteli omaksi rivikseen, kun taas 25 prosentin pystysuuntainen päällekkäisyyssääntö pitää sen perusviivalla ja palauttaa sanajärjestyksen
Keski-Y liikkuu sen mukaan, mitä ylä- ja alapidennyksiä muste sattuu kantamaan, kun taas kaksi samalla perusviivalla olevaa laatikkoa menevät päällekkäin yhteisen x-korkeuden yli riippumatta niiden korkeuksista

Ryhmittele tekstirivit päällekkäisyyden eikä keskietäisyyden mukaan

Tästä bugista kannattaa viedä mukanaan yleinen sääntö: mikä tahansa PDF-tekstiasettelukoodi, joka päättää samasta rivistä vertaamalla pystykeskipisteitä kiinteään toleranssiin, epäonnistuu oikeilla fonteilla, ja epäonnistuminen on hiljaista: mikään ei virheile, sanat vain tulevat ulos väärässä järjestyksessä. Sekalaiset alapidennykset ovat lievin laukaisin. Lihavoitu 12 pisteen tunniste 10 pisteen arvojen vieressä, yläindeksimerkintä alaviitteessä, varafontista piirretty valuuttamerkki ja OCR:n sanalaatikot sanakohtaisella korkeusvaihtelulla siirtävät kaikki keskipisteitä enemmän kuin mikään toleranssi, joka vielä erottaa 10 pisteen tekstin vierekkäiset rivit 12 pisteen rivivälillä. Päällekkäisyyssuhde on koosta riippumaton: kaksi samalla perusviivalla olevaa laatikkoa menevät päällekkäin yhteisen x-korkeutensa yli riippumatta siitä, mitä niiden ylä- ja alapidennykset tekevät, ja kaksi vierekkäisillä riveillä olevaa laatikkoa eivät mene päällekkäin lainkaan

Sama sääntö on helppo soveltaa taulukonpoiminnan ulkopuolella. TPdf.PageWordBoxes palauttaa jokaisen aktiivisen sivun sanan sen sivua-avaruuden suorakaiteen kanssa, joten sivun ryhmittely visuaalisiksi riveiksi on lyhyt silmukka:

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>;   // rivin juokseva yhteisjoukko
  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;
  // lajittele jokainen rivi Rect.Left-arvon mukaan ennen lukemista; PageWordBoxes palauttaa
  // sanat sisältövirran järjestyksessä, mikä ei ole taattu visuaaliseksi
end;

Mikä muuttuu nykyisille kutsujille ja missä rajat ovat

Tuon katkelman pointti on predikaatti, ei silmukka; kaikkeen pikaraapaisua suurempaan kannattaa aloittaa jäsennellystä tekstimallista, joka kantaa jo lohkot, rivit ja lukujärjestyksen lähteen, kuten käsitellään artikkelissa jäsennellystä PDF-tekstinpoiminnasta ja lukujärjestyksestä. Nykyiset taulukon kutsujat saavat kaikki kolme korjausta koskematta asetuksiinsa. Käytäväkynnys on kiinteästi puolet MinColumnGap-arvosta, tyhjän tilan strategia pitää kaksirivisen alarajansa myös kun MinRows on asetettu 1:een (minkä viivastrategia hyväksyy nyt), ja viiva-ensin-sanansuodatus on ehdoton aina kun molemmat strategiat ovat käytössä. Julkaisua varten käytetyllä 13 asiakirjan näytejoukolla tyhjän tilan kierros oli aiemmin palauttanut 34 palaa ja väärää positiivista 9 viivataulukon rinnalla; julkaisun jälkeen se ei palauta yhtään, ja viivataulukoiden määrä nousi 41:een, joskin suurin osa tuosta noususta tulee siitä, että sama julkaisu opetti viivailmaisimen lukemaan täytettyinä suorakaiteina piirrettyjä reunuksia, mikä on eri tarina

Rehelliset rajat: käytävätesti tarvitsee vähintään yhden rivin, jolla on sisältöä rajan molemmin puolin, hylätäkseen mitään, joten kaksirivinen ehdokas, jonka kaksi venytettyä rakoa sattuvat 6 pisteen sisälle toisistaan, menee yhä läpi. Se on kapea yhteensattuma eikä se lähes-varmuus, joka se oli ennen, mutta proosavaltaiset asiakirjat, joissa ei ole aitoja kaksirivisiä taulukoita, voivat sulkea sen asettamalla MinRows-arvoksi 3. Vasemmalle kohdistettu rosoinen teksti ei ollut koskaan ongelma eikä siihen vaikuta mikään. Eikä PDF:ssä ole yhä taulukko-objektia; ISO 32000-1 §14.8.4.3 määrittelee Table-rakenne-elementin, mutta vain Tagged PDF kantaa sitä, joten kaiken muun osalta ruudukko on yhä geometriasta tehty päätelmä, ja jokaisen TPdfTable-rakenteen luottamusarvo on olemassa siksi, että päätelmä ansaitsee pisteytyksen

Taulukonpoiminta, jäsennelty teksti ja sanalaatikot lukevat kaikki samasta sivumallista Delphissä, C++Builderissa ja Lazarusissa; täysi API, mukaan lukien TPdfTableExtractionOptions ja sen rinnalla toimitettava TableExtractionLab-demo, kuvataan PDFium Component for Delphi -sivulla