Technisch artikel

PDF-tabellen over paginagrenzen linken op inhoud in Delphi

Een tabel die over drie pagina's van een Word-export loopt, komt nooit dichter dan 72 punten bij de onderrand van het papier. Eén-inch-marges zijn 72 punten, en de tekstverwerker stopt met het plaatsen van rijen bij de marge. Een linker die fragmenten alleen samenvoegt wanneer ze binnen 36 punten van de paginaranden zitten, ziet drie ongerelateerde tabellen met identieke kolommen. De vraag die wél werkt is anders: staat er iets anders dan een doorlopende header of footer tussen het einde van het ene fragment en het begin van het volgende?

Die vraag brengt twee eigen valkuilen mee. Een formulier kan de ene tabel onderaan pagina 1 laten eindigen en bovenaan pagina 2 een andere tabel openen met dezelfde kolombreedten. En een lijnengrid kan één enkele rij over de breuk duwen, te kort voor een detector die twee rijen wil zien voordat hij iets een tabel noemt

Twee randtests in ExtractDocumentTables

PDFium Component markeert continuaties in TPdf.ExtractDocumentTables, de documentniveau-tegenhanger van de per-pagina-ExtractTables uit het artikel over tabel-detectie en -extractie. Met DetectContinuations aan, zoals in TPdfTableExtractionOptions.Default, extraheert de methode elke pagina, sorteert de tabellen op paginanummer en dan van boven naar beneden binnen elke pagina, en checkt elk paar buren in die lijst. Alleen buren worden vergeleken. Een continuatiekandidaat is daarom altijd de onderste tabel op pagina N gekoppeld aan de bovenste tabel op pagina N+1, en een kleine tabel die onder een lange op pagina N staat, neemt de plek van de lange in dat paar in. Wanneer er een kort notitietabelje onder een lange staat, link die fragmenten dan in je eigen code via paginanummers en ColumnCount

De eerste test is puur geometrisch. De onderrand van het eerdere fragment moet binnen ContinuationMargin punten van de pagina-onderkant liggen, en de bovenrand van het latere fragment binnen dezelfde afstand van de bovenkant van de volgende pagina. De default is 36 punten, een halve inch. Rapporten uit layout-engines die een vaste contentbox vullen, passeren die moeiteloos. Een Word-export met één-inch-marges mist aan elke kant ten minste 36 punten, en de marge oprekken tot de test slaagt gaat mis, want hetzelfde getal stelt ook de header- en footerbanden van de tweede test in

Die tweede test, de inhoudsbewuste, is wat tekstverwerker-output aan kan. De twee combineren met OR: één van beide opent de poort

Wat geldt als tekst tussen twee fragmenten?

Elk woord op een pagina geldt als bodytekst tenzij hij volledig binnen de doorlopende headerband of de footerband ligt, en beide banden zijn ContinuationMargin punten diep. Per pagina registreert ExtractDocumentTables de laagste onderrand onder de woorden waarvan de top boven de footerband ligt en de hoogste bovenrand onder de woorden waarvan de onderkant onder de headerband ligt, de eigen celtekst van de tabel incluis. Als de tabel de laatste bodycontent op pagina N is, ligt zijn onderrand op of onder het laagste woord, en de test slaagt binnen AlignmentTolerance, dat default op 3 punten staat. Het latere fragment moet de gespiegelde voorwaarde bovenaan pagina N+1 halen

De banden negeren heeft een voet in de specificatie. ISO 32000-1 §14.8.2.2 behandelt doorlopende koppen, voeten en paginanummers als pagination-artifacts, content die alleen bestaat omdat het document in pagina's is gesneden. Een getagd bestand kan ze declareren met /Artifact, zoals het artikel over marked content uitlegt, maar de exports die tafelstitching het hardst nodig hebben dragen zelden tags, dus de positie staat in voor de tag. Met de default band wordt een paginanummer waarvan de glyphs 30 punten boven de onderrand uitkomen genegeerd Een disclaimerregel die tot 40 punten reikt is bodytekst, en hij blokkeert de koppeling

Op een pagina zonder tekstlaag vallen de opgeslagen extremen terug op de paginagrenzen, de inhoudsbewuste test slaagt dan vanzelf, en de overige poorten beslissen. Footers die als afbeeldingen of vectoromlijningen zijn getekend zijn om dezelfde reden onzichtbaar en blokkeren nooit een koppeling

Waarom tabelvervolgen in PDFium Component twee tests nodig heeft: met Word-marges van één inch eist de paginamargetest fragmentranden binnen vensters van 36 pt die de lay-out nooit haalt, terwijl de inhoudsbewuste test woordboxen vergelijkt en koppelt wanneer de tabel de laatste broodinhoud op pagina N is en de eerste op pagina N+1, met negeren van de banden voor doorlopende kop- en voettekst
Elk van beide tests opent de gate, en pas dan lopen de overige controles: aangrenzende pagina's, geen bijschriftrij over de volle breedte op het latere fragment, en kolomgrenzen die binnen tweemaal AlignmentTolerance overeenkomen

Een randtest halen opent alleen de poort. De pagina's moeten opeenvolgend zijn, het latere fragment mag niet openen met een caption-rij, en de kolomgrenzen moeten binnen tweemaal AlignmentTolerance uitlijnen, 6 punten bij de defaults. Als dat alles klopt, wordt het eerdere fragment ptcStart met een vers groepnummer, of schuift hij van ptcEnd naar ptcMiddle als hij al een keten sloot, en het latere fragment wordt ptcEnd in dezelfde groep. Een tabel over drie pagina's leest start, middle, end. Groepnummers beginnen bij 1; groep 0 met ptcNone betekent dat de tabel alleen staat. ToJson schrijft dezelfde data als "continuationGroup" en "continuation" (none, start, middle of end), dus een service die JSON ontvangt kan zelf stitchen

De listing hieronder gaat uit van een Word-export met een voet van twee regels van zo'n 50 punten diep, dus de marge gaat naar 54 om beide regels binnen de band te houden

uses
  System.SysUtils, 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;  // DetectContinuations = True
    Options.ContinuationMargin := 54;               // footer verondersteld ~50 pt diep

    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 x %d',
          [Tables[I].PageNumber, Tables[I].RowCount,
           Tables[I].ColumnCount]));
      end;
  finally
    Pdf.Free;
  end;
end.

Hoe een volle breedte eerste rij twee tabellen uit elkaar houdt

Een later fragment waarvan de eerste rij één cel is die alle kolommen beslaat, wordt als nieuwe tabel gemeld, ook wanneer een randtest slaagt en de kolommen matchen. Formulieren zijn waar de inhoudsbewuste test te ver zou doorschieten: de ene tabel eindigt onderaan pagina 1, de volgende sectie opent bovenaan pagina 2 met identieke kolombreedten, en elke geometrische check slaagt. Het verschil zit in de openingsrij van de tweede tabel, een sectiecaption zoals RECIPIENT INFORMATION als één samengevoegde cel over de volle breedte. Een echte continuatie opent niet zo, want de caption van een tabel blijft op de pagina waar de tabel begint

De regel leest alleen de cellen van het latere fragment. Dat fragment heeft ten minste twee kolommen nodig en een cel met RowIndex = 0, ColumnIndex = 0 en ColumnSpan = ColumnCount. Het eerdere fragment wordt nooit bekeken, dus een tabel waarvan de eigen caption bovenaan pagina N staat, linkt normaal naar zijn continuatie op pagina N+1

De caption-rij-poort in PDFium Component: een echte continuatie opent met datacellen en sluit aan bij dezelfde ContinuationGroup, terwijl een latere fragment waarvan rij nul één samengevoegde cel bevat met RowIndex 0, ColumnIndex 0 en ColumnSpan gelijk aan ColumnCount wordt geweigerd als continuatie en als nieuwe tabel wordt gemeld
De inspectie raakt alleen het latere fragment, dus een tabel waarvan de eigen caption-rij op zijn eerste pagina staat blijft onaangetast, en de poort draait pas nadat één van de twee randtests is geslaagd

De kolomvergelijking die daarna volgt is strenger dan een gelijk aantal kolommen. De grensposities van elk fragment worden herbouwd uit zijn celrechthoeken; een grens die onder een samengevoegde cel verborgen zit, wordt aangevuld door hem gelijkmatig te verdelen tussen de dichtstbijzijnde bekende grenzen aan weerszijden. Daarna wordt elk grenspaar vergeleken, en een verschil boven 6 punten verwerpt de koppeling. Twee tabellen met vier kolommen en andere verhoudingen blijven uit elkaar. Ook een echte continuatie waarvan de generator een kolom met 8 punten tussen pagina's verschoof; AlignmentTolerance verhogen om die te accepteren, maakt ook de clustering die tijdens detectie wordt gebruikt losser

Eén gelijnde rij over een paginabreuk

Een lijnengrid dat één enkele rij op de volgende pagina duwt, houdt die rij wanneer hij in een keten linkt en verliest hem wanneer dat niet zo is. De default MinRows van 2 voorkomt dat twee verdwaalde lijnen als tabel worden gemeld. Een rij die over de breuk is geduwd is anders: het is echte data, en hem laten vallen laat de gestitchte tabel één rij te kort achter zonder fragment dat het gat verklaart

De documentscan handelt dat in drie stappen af. Als DetectContinuations en DetectRuledTables allebei aan staan, draait de ruled-detector op elke pagina met zijn rijvloer verlaagd naar 1. Daarna worden continuaties over het hele resultaat gemarkeerd. Ten slotte wordt elke tabel met minder rijen dan de MinRows van de aanroeper die niet in een keten belandde, verwijderd. Een één-rij-grid midden op een gewone pagina gaat door dat filter en verdwijnt, wat precies de bedoeling van MinRows is

Hoe PDFium Component een gelijnde rij vasthoudt die over een paginabreuk valt: de per-pagina-ruled-pass draait met de rijvloer op één wanneer DetectContinuations en DetectRuledTables gezet zijn, continuaties worden over het volledige resultaat gemarkeerd, en alleen fragmenten korter dan MinRows die buiten elke keten zitten worden verwijderd
De overgespoelde rij overleeft het omdat zijn keten hem linkt, terwijl een losse één-rij-grid op een gewone pagina zoals gewoonlijk wordt gefilterd, en whitespace-gevonden tabellen houden hun twee-rij-vloer zonder zulke verzachting

Laat MinRows op 2 staan. Hem zelf op 1 zetten zet het eindfilter uit, en elk één-rij-lijnengrid op elke pagina komt terug

De uitzondering geldt alleen voor lijnengrids. De whitespace-strategie heeft ten minste twee uitgelijnde rijen nodig om een tabel te herkennen, dus hij houdt een interne vloer van 2 aan ongeacht wat MinRows zegt; ExtractTables accepteert MinRows = 1, maar alleen ruled-detectie honoreert hem. Voor een ongelijnde tabel die één rij overstuurt, geven de word-boxes van de pagina of de blocks uit structured text blocks en leesvolgorde je de posities om die rij zelf terug te halen

Een keten terugstitchen tot één tabel

Ketens komen aaneengesloten terug. Links worden alleen gelegd tussen buren in de gesorteerde array, dus een ptcStart-fragment wordt direct gevolgd door zijn ptcMiddle- en ptcEnd-fragmenten, en een lus die op ptcNone of ptcStart reset en op ptcNone of ptcEnd uitschrijft, hoeft nooit iets op groepnummer op te zoeken

De herhaalde headerrij is het lastige deel: Word kan hem op elke pagina herhalen, andere generators doen dat niet. De IsHeader-flag op TPdfTableCell kan dat niet beslechten, want hij wordt per positie toegekend, RowIndex < HeaderRowCount, afzonderlijk per fragment. Rij 0 van een continuatie wordt als header gemarkeerd of hij nu de header herhaalt of data draagt, en de één-rij-spill uit de vorige sectie ook. De routine hieronder vergelijkt de tekst van de eerste rij van elke continuatie met de eerste rij van de keten en gooit hem alleen weg bij exacte match

Hij bouwt rijen ook uit Cells in plaats van de ToCsv-uitvoer in regels te splitsen. Een cel waarvan de tekst in de cel afbreekt, komt terug met een CR LF tussen zijn visuele regels, ToCsv houdt die breuk binnen het gequote veld, en de eerste regel van de CSV-tekst wissen zou een twee-regelige headercel halverwege hakken

// Helpers aan de lezerkant (geen onderdeel van de bibliotheek): herbouw elke keten
// als één tab-gescheiden bestand, met op latere pagina's herhaalde headers weg
function RowText(const T: TPdfTable; Row: Integer): string;
var
  Parts: array of string;
  I: Integer;
begin
  SetLength(Parts, T.ColumnCount);
  for I := 0 to High(T.Cells) do
    if (T.Cells[I].RowIndex = Row) and (T.Cells[I].ColumnIndex >= 0) and
       (T.Cells[I].ColumnIndex < T.ColumnCount) then
      Parts[T.Cells[I].ColumnIndex] :=
        StringReplace(string(T.Cells[I].Text), #13#10, ' ', [rfReplaceAll]);
  Result := '';
  for I := 0 to High(Parts) do
  begin
    if I > 0 then
      Result := Result + #9;
    Result := Result + Parts[I];
  end;
end;

procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
  I, R, FirstRow: Integer;
  Header: string;
  Lines: TStringList;
begin
  Lines := TStringList.Create;
  try
    for I := 0 to High(Tables) do
    begin
      FirstRow := 0;
      if Tables[I].Continuation in [ptcNone, ptcStart] then
      begin
        Lines.Clear;
        Header := RowText(Tables[I], 0);
      end
      else if (Trim(Header) <> '') and (RowText(Tables[I], 0) = Header) then
        FirstRow := 1;                     // header herhaald op deze pagina
      for R := FirstRow to Tables[I].RowCount - 1 do
        Lines.Add(RowText(Tables[I], R));
      if Tables[I].Continuation in [ptcNone, ptcEnd] then
        Lines.SaveToFile(Format('%s\table%d-page%d.tsv',
          [Folder, I, Tables[I].PageNumber]));
    end;
  finally
    Lines.Free;
  end;
end;

Exacte vergelijking is de juiste keuze. Een header die de tekstverwerker herhaalt, matcht teken voor teken, terwijl een fuzzy match vroeg of laat een datarij zou inslikken die op de header lijkt. Een verdwaalde extra header is makkelijk te vinden in de uitvoer; een ontbrekende datarij niet

De footerband meten in plaats van gokken

ContinuationMargin doet drie klussen met één getal: paginarandspeelruimte voor de eerste test, footerband-diepte en headerband-diepte voor de tweede. Een footer dieper dan de band laat zijn bovenste regel in de bodyzone achter, het eerdere fragment lijkt alsof er tekst op volgt, en de keten breekt op die pagina. Een band die te diep is ingesteld begint echte content in te slikken: een korte afsluitregel net boven de ondermarge valt erin, en de tabel boven die regel kan dan linken naar wat de volgende pagina maar opent

Het document zelf kan het getal aanleveren. TPdf.PageWordBoxes geeft elk woord op de huidige pagina terug met een pagina-ruimte Rect waarvan Top groter is dan Bottom, dus de hoogste Top onder de woorden die onder de bodymarge liggen, is de footerdiepte. De sonde hieronder neemt een waarde net onder de ondermarge van de layout, 70 voor een één-inch-pagina, en houdt 36 punten als vloer voor documenten zonder footer

// Helper aan de lezerkant (geen onderdeel van de bibliotheek): de hoogste word-top
// gevonden onder BodyMargin op een pagina, dus de diepte van de footer-zone
function MeasureFooterDepth(Pdf: TPdf; BodyMargin: Double): Double;
var
  P, I: Integer;
  Words: TPdfWordBoxes;
begin
  Result := 0;
  for P := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := P;
    Words := Pdf.PageWordBoxes;
    for I := 0 to High(Words) do
      if (Words[I].Rect.Bottom < BodyMargin) and
         (Words[I].Rect.Top > Result) then
        Result := Words[I].Rect.Top;
  end;
end;

// gebruikt System.Math voor Max
Options := TPdfTableExtractionOptions.Default;
Options.ContinuationMargin := Max(36.0, MeasureFooterDepth(Pdf, 70) + 2);
Tables := Pdf.ExtractDocumentTables(Options);

De band is aan beide randen even diep, dus meet ook een diepe doorlopende header tegen de pagina-bovenkant en gebruik het grootste van de twee. Houd de speelruimte op een paar punten; elk extra punt is een punt bodytekst die de inhoudsbewuste test niet meer ziet

Eén layout verslaat de caption-regel uit overweging: een generator die een samengevoegde "continued"-banner als eerste rij van elk continuatiefragment wegschrijft. Elk zo'n fragment wordt als nieuwe tabel gemeld, dus link ze in je eigen code via opeenvolgende paginanummers, gelijke ColumnCount en de bannertekst in rij 0

Continuatiemarkering, de caption-regel en de één-rij-uitzondering draaien allemaal binnen ExtractDocumentTables, dat zich in de Delphi-, C++Builder- en Lazarus-builds hetzelfde gedraagt. De volledige set tabel-extractie-opties staat op de pagina van PDFium Component for Delphi