Articol tehnic

Tabele PDF continuate între pagini după conținut în Delphi

PDFium Component versiunea 3.117.0 leagă un tabel care se rupe la o graniță de pagină atunci când fie ambele fragmente ating marginile paginii, fie nu există text de corp sub primul fragment și deasupra celui de-al doilea, cu anteturile și subsolurile curente ignorate. ExtractDocumentTables aplică acel test bazat pe conținut ca alternativă la testul mai vechi pe marginile paginii, refuză un fragment de pe pagina următoare a cărui primă linie este o singură celulă de titlu pe toată lățimea și păstrează un singur rând care se revarsă pe pagina următoare ca parte din lanțul lui de continuare

Articolul despre detectarea și extragerea tabelelor prezenta continuarea ca patru porți stricte și trata „atinge marginea paginii” ca una dintre ele. Descrierea era exactă pentru versiunea pe care o acoperea și era, de asemenea, greșită pentru majoritatea tabelelor pe care oamenii le dau efectiv componentei. Acest articol este corecția: ce documente nu poate trata testul pe margini, ce l-a înlocuit și cele două cazuri secundare pe care reparația le-a adus cu ea

De ce eșuează testul pe marginile paginii la exporturile din Word?

Testul pe marginile paginii eșuează pentru că un procesor de text oprește așezarea rândurilor la marginea de jos, nu la muchia hârtiei. Cu ContinuationMargin implicit de 36 de puncte, regula originală cerea ca marginea de jos a fragmentului anterior să se afle în 36 de puncte de baza paginii, iar marginea de sus a fragmentului următor în 36 de puncte de partea de sus a paginii. Un document exportat din Word cu marginile lui implicite de un inch pune ultimul rând la cel puțin 72 de puncte deasupra bazei paginii, mai mult dacă există un subsol, așa că condiția nu se îndeplinea niciodată. Fiecare tabel lung dintr-un astfel de document revenea ca fragmente independente cu ContinuationGroup la zero, iar apelantul se întorcea la cusutul manual. Testul are în continuare sens pentru ce a fost gândit: rapoarte generate de motoare de așezare în pagină care umplu o pagină până la o casetă de conținut fixă și încep pagina următoare direct de la partea de sus. Nu este o regulă rea, este una incompletă, motiv pentru care versiunea 3.117.0 a păstrat-o și a adăugat o a doua cale în loc să o înlocuiască

Ce verifică în schimb testul bazat pe conținut?

Testul bazat pe conținut verifică dacă altceva decât tabelul ocupă spațiul dintre cele două fragmente, folosind casetele de cuvinte ale fiecărei pagini, nu geometria paginii. În timp ce ExtractDocumentTables parcurge documentul, înregistrează pentru fiecare pagină cea mai de jos margine inferioară a oricărui cuvânt al cărui vârf se află deasupra benzii de subsol și cea mai de sus margine superioară a oricărui cuvânt a cărui bază se află sub banda de antet. Ambele benzi au adâncimea de ContinuationMargin puncte, așa că aceeași opțiune are acum dublu rol: marja de manevră la muchia paginii și înălțimea zonelor de antet și subsol curente. O pereche de fragmente trece când marginea de jos a celui anterior este la sau sub cel mai de jos text de corp de pe pagina lui, iar marginea de sus a celui următor este la sau peste cel mai de sus text de corp de pe pagina următoare, fiecare în limita AlignmentTolerance. Pe scurt: tabelul era ultimul lucru de pe pagina N și primul de pe pagina N+1, iar un număr de pagină sau un titlu de document din banda de margine nu contează. Acea excludere nu este arbitrară. ISO 32000-1 §14.8.2.2 clasifică anteturile și subsolurile curente ca artefacte de paginare, conținut care există din cauza ruperii de pagină, nu în ciuda ei, iar aceeași idee care permite unui cititor cu tag-uri să le sară este ce permite unui tabel să continue dincolo de ele. Articolul despre conținutul marcat acoperă cum fișierele cu tag-uri declară explicit acele artefacte; aici clasificarea este dedusă din poziție, pentru că majoritatea tabelelor exportate nu poartă niciun tag

De ce are nevoie continuarea tabelelor în PDFium Component de două teste: cu marginile de un inch din Word, testul pe marginile paginii cere marginile fragmentelor în ferestre de 36 pt pe care așezarea în pagină nu le atinge niciodată, în timp ce testul bazat pe conținut compară casetele de cuvinte și leagă atunci când tabelul este ultimul conținut de corp de pe pagina N și primul de pe pagina N+1, ignorând benzile de antet și subsol curente
Oricare dintre teste deschide poarta și abia apoi rulează verificările rămase: pagini adiacente, nicio linie de titlu pe toată lățimea pe fragmentul următor și limite de coloane care se potrivesc în limita a două ori AlignmentTolerance

Cele două teste se combină cu OR. Un raport de motor de așezare în pagină ale cărui tabele ajung la muchia hârtiei trece de primul; un export din Word ale cărui tabele se opresc la margine trece de al doilea; un document care face ambele trece de două ori. Abia după ce unul dintre ele reușește rulează porțile rămase, și rulează într-o ordine fixă: numerele de pagină trebuie să fie adiacente, fragmentul următor nu trebuie să înceapă cu o linie de titlu, iar limitele de coloane trebuie să se potrivească în limita a două AlignmentTolerance, adică 6 puncte la valorile implicite. Enumerarea este TPdfTableContinuation cu valorile ptcNone, ptcStart, ptcMiddle și ptcEnd. Un fragment care a fost marcat ptcEnd și apoi se leagă mai departe de încă o pagină este promovat la ptcMiddle, așa că un tabel de trei pagini se citește start, middle, end în ordinea paginilor. Numerele de grup pornesc de la 1, iar 0 înseamnă nelegat, iar ToJson emite aceeași informație ca membrii continuation și continuationGroup, care este forma de preferat dacă un serviciu din aval face cusutul

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;

    Options := TPdfTableExtractionOptions.Default;
    Options.DetectContinuations := True;     // implicit; arătat pentru claritate
    Options.ContinuationMargin := 54;        // subsol de două linii, ~50 pt adâncime

    Tables := Pdf.ExtractDocumentTables(Options);
    for I := 0 to High(Tables) do
      case Tables[I].Continuation of
        ptcStart:
          Writeln(Format('group %d starts on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
        ptcMiddle, ptcEnd:
          Writeln(Format('group %d continues on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
      else
        Writeln(Format('standalone table on page %d (%d rows)',
          [Tables[I].PageNumber, Tables[I].RowCount]));
      end;
  finally
    Pdf.Free;
  end;
end;

Cum oprește o linie de titlu fuziunea a două tabele?

Un fragment de pe pagina următoare a cărui primă linie este o celulă care se întinde peste toate coloanele este tratat ca tabel nou, niciodată ca restul celui anterior. Regula există pentru că testul bazat pe conținut, singur, leagă prea avid. Cazul care a expus-o era un formular de tip transcriere: un tabel se termină spre baza paginii 1, un al doilea tabel cu lățimi de coloane identice începe spre partea de sus a paginii 2, între ele nu stă decât subsolul, iar coloanele se potrivesc la punct. Sub testul pe margini cele două nu se întâlneau niciodată, pentru că niciunul nu atingea o muchie; sub testul pe conținut s-au legat imediat, iar un formular cu secțiuni a devenit o singură grilă incoerentă. Ce le separă se vede în structura celulelor. Al doilea tabel începe cu un titlu de secțiune precum „RECIPIENT INFORMATION”, așezat ca o singură celulă îmbinată pe toată lățimea, iar o continuare autentică nu face niciodată asta, pentru că titlul aparține tabelului care a început deja pe pagina anterioară. TableStartsWithCaptionRow codifică exact asta: fragmentul are cel puțin două coloane și conține o celulă cu RowIndex = 0, ColumnIndex = 0 și ColumnSpan = ColumnCount. Verificarea rulează doar pe fragmentul următor, așa că un tabel a cărui linie de titlu se află pe prima lui pagină nu este afectat; titlul este pe pagina N, iar doar fragmentul de pe pagina N+1 este inspectat

Poarta liniei de titlu în PDFium Component: o continuare autentică începe cu celule de date și se alătură aceluiași ContinuationGroup, în timp ce un fragment următor a cărui linie zero conține o singură celulă îmbinată cu RowIndex 0, ColumnIndex 0 și ColumnSpan egal cu ColumnCount este refuzat ca continuare și raportat ca tabel nou
Inspecția atinge doar fragmentul următor, așa că un tabel a cărui linie de titlu se află pe prima lui pagină nu este afectat, iar poarta rulează după ce unul dintre cele două teste de muchie a legat deja perechea

Comparația de coloane care urmează, TablesHaveMatchingColumns, este mai strictă decât „același număr de coloane”. Reconstruiește pozițiile limitelor fiecărui fragment din dreptunghiurile celulelor, interpolează limitele pe care celulele îmbinate le ascund și respinge perechea când vreo limită deviază cu mai mult decât toleranța. Două tabele cu patru coloane și proporții diferite rămân astfel separate chiar și atunci când tot restul se aliniază

Ce se întâmplă cu un singur rând care se revarsă pe pagina următoare?

O grilă cu linii care duce un rând pe pagina următoare este acum detectată și legată, cu condiția să ajungă într-un lanț de continuare; de una singură este aruncată. MinRows implicit de 2 există ca o pereche rătăcită de linii să nu fie raportată ca tabel, dar un ultim rând împins peste ruptură este un rând real pe care un prag fix de 2 îl elimina în tăcere, iar restul tabelului părea complet când nu era. Scanarea la nivel de document îl tratează în trei pași. Când DetectContinuations și DetectRuledTables sunt ambele setate, trecerea per pagină rulează detectorul de linii cu pragul de rânduri coborât temporar la 1, motiv pentru care ExtractTables acceptă acum MinRows de 1 pentru grilele cu linii, în timp ce detectarea pe spații albe păstrează un prag intern de 2. Continuările sunt marcate pe rezultatul complet. Apoi fiecare tabel mai scurt decât MinRows al apelantului și care nu face parte din niciun lanț este eliminat. Fragmentul de un singur rând supraviețuiește doar pentru că a fost legat, iar o grilă de un rând în mijlocul unei pagini altfel obișnuite este filtrată exact ca înainte

Cum păstrează PDFium Component un rând cu linii care se revarsă peste o rupere de pagină: trecerea per pagină pentru grile cu linii rulează cu pragul de rânduri la unu când DetectContinuations și DetectRuledTables sunt setate, continuările sunt marcate pe rezultatul complet, iar doar fragmentele mai scurte decât MinRows care stau în afara oricărui lanț sunt eliminate
Rândul revărsat supraviețuiește pentru că lanțul lui îl leagă, în timp ce o grilă de un rând de sine stătătoare pe o pagină obișnuită este filtrată exact ca înainte, iar tabelele detectate pe spații albe își păstrează pragul de două rânduri fără nicio derogare
// Reconstruiește fiecare lanț ca un singur CSV, eliminând rândurile de antet repetate
// de pe fragmentele de continuare
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
  I, R: Integer;
  Lines: TStringList;
  Csv: TStringList;
begin
  Csv := TStringList.Create;
  Lines := TStringList.Create;
  try
    for I := 0 to High(Tables) do
    begin
      if Tables[I].Continuation in [ptcNone, ptcStart] then
        Csv.Clear;
      Lines.Text := string(Tables[I].ToCsv);
      if (Tables[I].Continuation in [ptcMiddle, ptcEnd]) and
         (Lines.Count > 1) and (Tables[I].RowCount > 1) then
        Lines.Delete(0);            // antet repetat de procesorul de text
      for R := 0 to Lines.Count - 1 do
        Csv.Add(Lines[R]);
      if Tables[I].Continuation in [ptcNone, ptcEnd] then
        Csv.SaveToFile(Format('%s\page%d-group%d.csv',
          [Folder, Tables[I].PageNumber, Tables[I].ContinuationGroup]));
    end;
  finally
    Lines.Free;
    Csv.Free;
  end;
end;

Două detalii din acea rutină sunt deliberate. Rândul revărsat nu este niciodată eliminat, pentru că protecția pe RowCount îl păstrează, iar un procesor de text care repetă linia de antet pe fiecare pagină produce un fragment a cărui primă linie este din nou antetul, așa că eliminarea liniei zero pe fragmentele din mijloc și de la final este corectă pentru acel caz și greșită pentru un generator care nu repetă anteturile. Verificați un document înainte de a lăsa rutina slobodă pe un dosar întreg

Unde se opresc totuși regulile

Testul bazat pe conținut valorează exact cât stratul de text pe care îl citește. Pe o pagină scanată fără niciun text, extremele înregistrate ale textului de corp cad pe limitele paginii, condiția „nimic între” este îndeplinită în mod vid, iar singurele care rămân sunt porțile de titlu și de coloane; o grilă cu linii de pe o astfel de pagină este totuși găsită ca un schelet gol, așa că lanțul se poate lega corect, dar nimic din textul din jur nu a fost verificat efectiv. Adăugați mai întâi un strat de text dacă asta contează. Subsolurile randate ca imagini, nu ca text, sunt invizibile pentru logica benzilor și inofensive din același motiv

Benzile sunt un singur număr. Un subsol mai adânc decât ContinuationMargin lasă liniile lui de jos în interiorul zonei de corp, ceea ce face ca fragmentul anterior să pară urmat de text și blochează legătura; ridicați opțiunea la adâncimea reală a benzii, cum face primul exemplu. Ridicați-o prea mult și un paragraf scurt de încheiere de lângă baza paginii alunecă în bandă și este ignorat, ceea ce leagă tabelul de orice urmează după el. Regula titlului are un eșec în oglindă: un generator care scrie un banner îmbinat de tip „continued” ca primă linie a fiecărui fragment de continuare va vedea acele fragmente respinse ca tabele noi, iar singurul remediu de azi este să cusați singuri după ContinuationGroup, fără să slăbiți nimic, pentru că regula nu are niciun comutator

Tabelele detectate pe spații albe nu primesc nicio derogare pentru rândul unic. Strategia pe spații albe are nevoie de două rânduri aliniate ca să vadă deloc un tabel, așa că un tabel fără linii care revarsă un rând este în continuare raportat cu acel rând lipsă. Când dați peste asta, casetele de cuvinte din spatele blocurilor de text structurat și ordinii de citire vă dau pozițiile brute cu care să îl recuperați. Pe setul de eșantioane care a stat la baza acestei munci, treisprezece exporturi de procesoare de text și de browsere, cele cinci documente cu tabele autentice pe mai multe pagini s-au legat toate în lanțuri unice, iar formularul de transcriere care se fuziona înainte a rămas separat, ceea ce este ștacheta față de care a fost măsurată versiunea, nu o promisiune despre orice așezare în pagină

Marcarea continuărilor, regula titlului și trecerea pentru rândul unic trăiesc toate în calea la nivel de document partajată de build-urile Delphi, C++Builder și Lazarus; API-ul complet de extragere a tabelelor este descris pe pagina PDFium Component pentru Delphi