Teknisk artikel

Indholdsbevidst tabel-fortsættelse over PDF-sider i Delphi

PDFium Component version 3.117.0 forbinder en tabel, der bryder over en sidegrænse, når enten begge fragmenter rører sidekanterne, eller ingen brødtekst ligger under det første fragment og over det andet, idet løbende sidehoveder og sidefødder ignoreres. ExtractDocumentTables anvender det indholdsbevidste tjek som et alternativ til det ældre sidekant-tjek, nægter et næste-side-fragment, hvis første række er én fuldbredde caption-celle, og beholder en enkelt række, der spilder over på den følgende side, som en del af sin fortsættelseskæde

Artiklen om tabeldetektering og -ekstraktion præsenterede fortsættelse som fire strenge porte og behandlede "rører sidekanten" som én af dem. Den beskrivelse var korrekt for den udgivelse, den dækkede, og den var også forkert for de fleste tabeller, folk faktisk giver komponenten. Denne artikel er korrektionen: Hvilke dokumenter sidekant-tjekket ikke kan håndtere, hvad der erstattede det, og de to sidecases, fixet trak med sig

Hvorfor fejler sidekant-tjekket på Word-eksporter?

Sidekant-tjekket fejler, fordi et tekstbehandlingsprogram stopper med at opsætte rækker ved den nederste margin, ikke ved papirkanten. Med default-ContinuationMargin på 36 point krævede den oprindelige regel, at det tidligere fragments nederste kant lå inden for 36 point fra sidebunden, og det senere fragments øverste kant inden for 36 point fra sidetoppen. Et dokument eksporteret fra Word med dets default én-tommes-marginer placerer den sidste række mindst 72 point over sidebunden, længere oppe hvis en sidefod er til stede, så betingelsen holdt aldrig. Hver lang tabel i et sådant dokument kom tilbage som uafhængige fragmenter med ContinuationGroup på nul, og calleren var tilbage til at sy i hånden. Tjekket giver stadig mening for det, det var designet omkring: Rapporter genereret af layout-engines, der fylder en side til en fast indholdsboks og starter næste side flush i toppen. Det er ikke en dårlig regel, det er en ufuldstændig en, hvilket er grunden til, at version 3.117.0 beholdt den og tilføjede en anden vej i stedet for at erstatte den

Hvad tjekker det indholdsbevidste tjek i stedet?

Det indholdsbevidste tjek undersøger, om noget andet end tabellen optager rummet mellem de to fragmenter, med brug af hver sides ord-bokse frem for sidegeometrien. Mens ExtractDocumentTables gennemløber dokumentet, registrerer den pr. side den laveste nederste kant af ethvert ord, hvis top ligger over sidefodsbåndet, og den højeste topkant af ethvert ord, hvis bund ligger under sidehovedbåndet. Begge bånd er ContinuationMargin point dybe, så samme option gør nu dobbelttjeneste som sidekant-slæk og som højden af zonerne for løbende sidehoved og sidefod. Et par fragmenter består, når det tidligere fragments nederste kant er på eller under den laveste brødtekst på sin side, og det senere fragments øverste kant er på eller over den højeste brødtekst på næste side, hver inden for AlignmentTolerance. I klart sprog: Tabellen var det sidste på side N og det første på side N+1, og et sidetal eller en dokumenttitel i marginbåndet tæller ikke. Den udelukkelse er ikke vilkårlig. ISO 32000-1 §14.8.2.2 klassificerer løbende sidehoveder og sidefødder som pagineringsartefakter, indhold der findes på grund af sideskiftet frem for på trods af det, og samme idé, der lader en tagged reader springe dem over, er det, der lader en tabel fortsætte forbi dem. Marked content-artiklen dækker, hvordan taggede filer deklarerer de artefakter eksplicit; her udledes klassificeringen af position, fordi de fleste eksporterede tabeller slet ikke bærer tags

Hvorfor PDFium Component tabel-fortsættelse behøver to tests: Med Word én-tommers-marginer kræver sidekant-testen fragmentkanter inden for 36 pt-vinduer, layoutet aldrig når, mens den indholdsbevidste test sammenligner ord-bokse og kæder, når tabellen er det sidste brødtekstindhold på side N og det første på side N+1, idet båndene for løbende sidehoved og sidefod ignoreres
Hvilken som helst af testene åbner porten, og først derefter kører de resterende tjek: Nabosider, ingen fuldbredde caption-række på det senere fragment og kolonnegrænser, der matcher inden for det dobbelte af AlignmentTolerance

De to tests kombineres med OR. En layout-engine-rapport, hvis tabeller løber til papirkanten, består den første; en Word-eksport, hvis tabeller stopper ved marginen, består den anden; et dokument, der gør begge dele, består to gange. Først efter at én af dem lykkes, kører de resterende porte, og de kører i fast rækkefølge: Sidetallene skal være nabosider, det senere fragment må ikke åbne med en caption-række, og kolonnegrænserne skal matche inden for det dobbelte af AlignmentTolerance, hvilket er 6 point ved default-værdierne. Enumerationen er TPdfTableContinuation med værdierne ptcNone, ptcStart, ptcMiddle og ptcEnd. Et fragment, der var markeret ptcEnd og derefter kæder videre til endnu en side, forfremmes til ptcMiddle, så en tre-siders tabel læses start, middle, end i sideorden. Gruppenumre starter ved 1, og 0 betyder ukædet, og ToJson emitterer samme information som continuation- og continuationGroup-medlemmerne, hvilket er formen at foretrække, hvis en downstream-service laver syningen

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;     // default; vist for tydelighedens skyld
    Options.ContinuationMargin := 54;        // to-linjers sidefod, ca. 50 pt dyb

    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;

Hvordan forhindrer en caption-række to tabeller i at smelte sammen?

Et næste-side-fragment, hvis første række er én celle, der spænder over alle kolonner, behandles som en ny tabel, aldrig som resten af den forrige. Denne regel findes, fordi det indholdsbevidste tjek alene kæder for ivrigt. Casen, der eksponerede det, var en transskript-agtig formular: En tabel slutter nær bunden af side 1, en anden tabel med identiske kolonnebredder starter nær toppen af side 2, intet andet end sidefoden ligger mellem dem, og kolonnerne matcher til punkt og prikke. Under sidekant-testen mødtes de to aldrig, for ingen af dem rørte en kant; under indholdstesten kædede de straks, og en formular med sektioner blev ét usammenhængende gitter. Det, der adskiller dem, er synligt i cellestrukturen. Den anden tabel åbner med en sektions-caption som "RECIPIENT INFORMATION" lagt ud som én flettet celle over den fulde bredde, og en ægte fortsættelse gør aldrig det, for captionen tilhører tabellen, der allerede startede på forrige side. TableStartsWithCaptionRow indkoder præcis det: Fragmentet har mindst to kolonner og indeholder en celle med RowIndex = 0, ColumnIndex = 0 og ColumnSpan = ColumnCount. Tjekket kører kun på det senere fragment, så en tabel, hvis egen caption-række sidder på dens første side, er upåvirket; captionen er på side N, og kun side N+1-fragmentet inspiceres

Caption-række-porten i PDFium Component: En ægte fortsættelse åbner med dataceller og tilslutter sig samme ContinuationGroup, mens et senere fragment, hvis række nul holder én flettet celle med RowIndex 0, ColumnIndex 0 og ColumnSpan lig ColumnCount, nægtes som fortsættelse og rapporteres som en ny tabel
Inspektionen rører kun det senere fragment, så en tabel, hvis egen caption-række sidder på dens første side, er upåvirket, og porten kører, efter én af de to kant-tests allerede har kædet parret

Kolonne-sammenligningen, der følger, TablesHaveMatchingColumns, er strengere end "samme kolonneantal". Den genopbygger hver fragments grænsepositioner ud fra celle-rektanglerne, interpolerer grænser, som flettede celler skjuler, og afviser parret, når en hvilken som helst grænse driver mere end tolerancen. To fire-kolonne-tabeller med forskellige proportioner holder sig derfor adskilt, selv når alt andet stemmer

Hvad sker der med en enkelt række, der spilder over på næste side?

Et linjeret gitter, der bærer én række over på den følgende side, detekteres og kædes nu, forudsat at det ender i en fortsættelseskæde; på egen hånd kasseres det. Default-MinRows på 2 findes for at forhindre et løst par linjer i at blive rapporteret som en tabel, men en sidste række, der skubbes over skiftet, er en rigtig række, som et hårdt gulv på 2 lydløst droppede, og resten af tabellen så komplet ud, når den ikke var. Dokument-niveau-scanningen håndterer det i tre trin. Når DetectContinuations og DetectRuledTables begge er sat, kører per-side-passet den linjerede detektor med rækkegulvet midlertidigt sænket til 1, hvilket er grunden til, at ExtractTables nu accepterer MinRows på 1 for linjerede gitre, mens whitespace-detektering beholder et internt gulv på 2. Fortsættelser markeres over hele resultatet. Derefter fjernes hver tabel, der er kortere end callerens MinRows og ikke er en del af nogen kæde. Én-række-fragmentet overlever kun, fordi det blev kædet, og et ét-række-gitter midt på en ellers almindelig side filtreres fra præcis som før

Sådan bevarer PDFium Component en linjeret række, der spilder over et sideskift: Per-side-passet med linjeret detektor kører med rækkegulvet på én, når DetectContinuations og DetectRuledTables er sat, fortsættelser markeres over hele resultatet, og kun fragmenter kortere end MinRows, der ligger uden for hver kæde, fjernes
Den spildte række overlever, fordi dens kæde kæder den, mens et selvstændigt ét-række-gitter på en almindelig side filtreres præcis som før, og whitespace-detekterede tabeller beholder deres to-række-gulv uden den lettelse
// Genopbyg hver kæde som én CSV og drop gentagne header-rækker
// på fortsættelsesfragmenterne
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);            // header gentaget af tekstbehandleren
      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;

To detaljer i den rutine er bevidste. Én-række-spillet strippes aldrig, fordi vagten på RowCount beholder den, og et tekstbehandlingsprogram, der gentager header-rækken på hver side, producerer et fragment, hvis første linje er headeren igen, så at droppe linje nul på middle- og end-fragmenter er rigtigt for det tilfælde og forkert for en generator, der ikke gentager headers. Tjek ét dokument, før rutinen slippes løs på en mappe

Hvor reglerne stadig stopper

Det indholdsbevidste tjek er kun så godt som det tekstlag, det læser. På en scannet side uden tekst overhovedet falder de registrerede brødtekst-ekstremer tilbage til sidegrænserne, betingelsen "intet imellem" er vakant opfyldt, og kun caption-række- og kolonne-portene resterer. Et linjeret gitter på en sådan side findes stadig som et tomt skelet, så kæden kan kæde korrekt, men intet omkring den omgivende tekst var faktisk verificeret. Tilføj et tekstlag først, hvis det betyder noget. Sidefødder renderet som billeder frem for tekst er usynlige for båndlogikken og harmløse af samme grund

Båndene er ét tal. En sidefod dybere end ContinuationMargin efterlader sine nederste linjer inde i brødtekstzonen, hvilket får det tidligere fragment til at se fulgt af tekst ud og blokerer kædningen; hæv optionen til den rigtige bånddybde, som det første eksempel gør. Hæver du den for meget, glider et kort afsluttende afsnit nær sidens bund ind i båndet og ignoreres, hvilket kæder en tabel til hvad end der følger den. Caption-reglen har et spejlvendt fejlbillede: En generator, der skriver et flettet "continued"-banner som første række af hvert fortsættelsesfragment, får de fragmenter afvist som nye tabeller, og den eneste udvej i dag er at sy efter ContinuationGroup selv uden at løsne noget, for reglen har ingen kontakt

Whitespace-detekterede tabeller får ingen af den enkelt-række-lettelse. Whitespace-strategien behøver to alignede rækker for overhovedet at se en tabel, så en ulinjeret tabel, der spilder én række, rapporteres stadig kort med den række. Når du rammer det, giver ord-boksene bag strukturerede tekstblokke og læseretning dig de rå positioner til at redde den. På det eksempelsæt, der drev dette arbejde, tretten tekstbehandlings- og browser-eksporter, kædede alle fem dokumenter med ægte fler-sidede tabeller sig ind i enkeltkæder, og transskript-formularen, der tidligere smeltede sammen, holdt sig adskilt, hvilket er den målestok, udgivelsen blev målt mod, ikke et løfte om hvert layout

Fortsættelsesmarkering, caption-reglen og enkelt-række-passet bor alle i dokument-niveau-stien, der deles af Delphi-, C++Builder- og Lazarus-builds; den fulde tabel-ekstraktions-API er beskrevet på siden PDFium Component til Delphi