Teknisk artikel

Tabellfortsättning över PDF-sidor efter innehåll i Delphi

PDFium Component version 3.117.0 kopplar ihop en tabell som bryts över en sidgräns när antingen båda fragmenten når sidkanterna eller ingen brödtext ligger under det första fragmentet och ovanför det andra, med löpande sidhuvud och sidfot ignorerade. ExtractDocumentTables tillämpar det innehållsmedvetna testet som ett alternativ till det äldre sidmarginalstestet, avvisar ett nästa-sida-fragment vars första rad är en enda fullbreddsrubrikcell, och behåller en enstaka rad som spiller över på följande sida som en del av sin fortsättningskedja

Artikeln om tabelldetektering och -utvinning presenterade fortsättning som fyra strikta grindar och behandlade "når sidkanten" som en av dem. Den beskrivningen var korrekt för den release den täckte, och den var också fel för de flesta tabeller folk faktiskt matar komponenten med. Den här artikeln är rättelsen: vilka dokument marginaltestet inte klarar, vad som ersatte det, och de två sidofall som fixen drog med sig

Varför misslyckas sidmarginalstestet på Word-exporter?

Sidmarginalstestet misslyckas för att ett ordbehandlingsprogram slutar lägga ut rader vid nedre marginalen, inte vid papperskanten. Med standardvärdet ContinuationMargin på 36 punkter krävde den ursprungliga regeln att det tidigare fragmentets underkant låg inom 36 punkter från sidans botten och det senare fragmentets överkant inom 36 punkter från sidans topp. Ett dokument exporterat från Word med sina standardmarginaler på en tum placerar sista raden minst 72 punkter ovanför sidans botten, ännu högre om en sidfot finns, så villkoret höll aldrig. Varje lång tabell i ett sådant dokument kom tillbaka som oberoende fragment med ContinuationGroup på noll, och anroparen var tillbaka vid att sy ihop för hand. Testet är fortfarande vettigt för det det designades kring: rapporter genererade av layoutmotorer som fyller en sida till en fast innehållsbox och startar nästa sida kant i kant högst upp. Det är inte en dålig regel, det är en ofullständig regel, vilket är varför version 3.117.0 behöll den och lade till en andra väg i stället för att ersätta den

Vad kontrollerar det innehållsmedvetna testet i stället?

Det innehållsmedvetna testet kontrollerar om något annat än tabellen upptar utrymmet mellan de två fragmenten, och använder ordfyrkanterna på varje sida i stället för sidgeometrin. Medan ExtractDocumentTables går genom dokumentet registrerar den, per sida, den lägsta underkanten av något ord vars överkant ligger ovanför sidfotsbandet och den högsta överkanten av något ord vars underkant ligger nedanför sidhuvudsbandet. Båda banden är ContinuationMargin punkter djupa, så samma inställning gör nu dubbel tjänst som spelrum vid sidkanten och som höjden på zonerna för löpande sidhuvud och sidfot. Ett fragmentpar godkänns när det tidigare fragmentets underkant ligger i nivå med eller under den lägsta brödtexten på sin sida och det senare fragmentets överkant ligger i nivå med eller över den högsta brödtexten på nästa sida, vardera inom AlignmentTolerance. I klartext: tabellen var det sista på sidan N och det första på sidan N+1, och ett sidnummer eller en dokumenttitel i marginalbandet räknas inte. Det undantaget är inte godtyckligt. ISO 32000-1 §14.8.2.2 klassificerar löpande sidhuvuden och sidfötter som pagineringsartefakter, innehåll som finns på grund av sidbrytningen snarare än trots den, och samma tanke som låter en taggad läsare hoppa över dem är vad som låter en tabell fortsätta förbi dem. Artikeln om markerat innehåll tar upp hur taggade filer deklarerar de artefakterna uttryckligen; här härleds klassificeringen ur position, eftersom de flesta exporterade tabeller inte bär några taggar alls

Varför tabellfortsättning i PDFium Component behöver två test: med Words en-tumsmarginaler kräver sidmarginalstestet att fragmentkanter ligger inom 36 pt-fönster som layouten aldrig når, medan det innehållsmedvetna testet jämför ordfyrkanter och kopplar ihop när tabellen är det sista brödtextinnehållet på sidan N och det första på sidan N+1, med löpande sidhuvuds- och sidfotsband ignorerade
Vilket som helst av testerna öppnar grinden, och först därefter kör resten av kontrollerna: intilliggande sidor, ingen fullbreddsrubrikrad på det senare fragmentet, och kolumngränser som stämmer inom två gånger AlignmentTolerance

De två testerna kombineras med OR. En layoutmotorrapport vars tabeller går ända till papperskanten klarar det första; en Word-export vars tabeller stannar vid marginalen klarar det andra; ett dokument som gör båda klarar båda. Först efter att ett av dem lyckats kör resten av grindarna, och de kör i fast ordning: sidnumren måste vara intilliggande, det senare fragmentet får inte inledas med en rubrikrad, och kolumngränserna måste stämma inom två gånger AlignmentTolerance, vilket är 6 punkter med standardvärdena. Uppräkningen är TPdfTableContinuation med värdena ptcNone, ptcStart, ptcMiddle och ptcEnd. Ett fragment som märktes ptcEnd och sedan kopplas vidare till ännu en sida befordras till ptcMiddle, så en tabell över tre sidor läses som start, middle, end i sidordning. Gruppnumren börjar på 1 och 0 betyder okopplad, och ToJson ger samma information som medlemmarna continuation och continuationGroup, vilket är formen att föredra om en nedströms tjänst gör ihopsyrningen

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;     // standard; visas för tydlighetens skull
    Options.ContinuationMargin := 54;        // tvåradig sidfot, ca 50 pt djup

    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;

Hur hindrar en rubrikrad två tabeller från att smälta samman?

Ett nästa-sida-fragment vars första rad är en enda cell som spänner över varje kolumn behandlas som en ny tabell, aldrig som resten av den föregående. Regeln finns för att det innehållsmedvetna testet på egen hand kopplar ihop alltför villigt. Fallet som avslöjade det var ett formulär av utskriftstyp: en tabell slutar nära botten av sida 1, en andra tabell med identiska kolumnbredder börjar nära toppen av sida 2, inget annat än sidfoten ligger mellan dem, och kolumnerna stämmer på punkten. Under marginaltestet möttes de aldrig eftersom ingen av dem nådde en kant; under innehållstestet kopplades de ihop direkt, och ett formulär med avsnitt blev ett enda osammanhängande rutnät. Det som skiljer dem åt syns i cellstrukturen. Den andra tabellen inleds med en avsnittsrubrik som "RECIPIENT INFORMATION" utlagd som en enda sammanslagen cell över hela bredden, och en äkta fortsättning gör aldrig det, för rubriken tillhör den tabell som redan började på föregående sida. TableStartsWithCaptionRow kodar exakt det: fragmentet har minst två kolumner och innehåller en cell med RowIndex = 0, ColumnIndex = 0 och ColumnSpan = ColumnCount. Kontrollen körs bara på det senare fragmentet, så en tabell vars egen rubrikrad ligger på dess första sida påverkas inte; rubriken ligger på sidan N, och bara fragmentet på sidan N+1 inspekteras

Grinden för rubrikrader i PDFium Component: en äkta fortsättning inleds med dataceller och ansluter till samma ContinuationGroup, medan ett senare fragment vars rad noll håller en enda sammanslagen cell med RowIndex 0, ColumnIndex 0 och ColumnSpan lika med ColumnCount avvisas som fortsättning och rapporteras som en ny tabell
Inspektionen rör bara det senare fragmentet, så en tabell vars egen rubrikrad ligger på dess första sida påverkas inte, och grinden kör efter att ett av de två kanttesterna redan har kopplat ihop paret

Kolumnjämförelsen som följer, TablesHaveMatchingColumns, är striktare än "samma kolumnantal". Den bygger om gränspositionerna för varje fragment ur cellrektanglarna, interpolerar gränser som sammanslagna celler döljer, och avvisar paret när någon gräns driver mer än toleransen. Två fyrakolumnstabeller med olika proportioner hålls därför åtskilda även när allt annat stämmer

Vad händer med en enstaka rad som spiller över på nästa sida?

Ett linjerat rutnät som för en rad över till följande sida upptäcks och kopplas nu, förutsatt att det hamnar i en fortsättningskedja; på egen hand kastas det. Standardvärdet MinRows på 2 finns för att hindra ett löst par linjer från att rapporteras som en tabell, men en sista rad som knuffats över brytningen är en riktig rad som ett hårt golv på 2 tyst tappade, och resten av tabellen såg komplett ut när den inte var det. Den dokumentövergripande genomsökningen hanterar det i tre steg. När DetectContinuations och DetectRuledTables båda är satta kör sidgenomgången den linjerade detektorn med radgolvet tillfälligt sänkt till 1, vilket är varför ExtractTables nu accepterar MinRows på 1 för linjerade rutnät medan whitespace-detekteringen behåller ett internt golv på 2. Fortsättningar markeras över hela resultatet. Sedan tas varje tabell bort som är kortare än anroparens MinRows och inte ingår i någon kedja. Det enradiga fragmentet överlever bara för att det kopplades, och ett enradigt rutnät mitt på en i övrigt vanlig sida filtreras bort precis som förut

Hur PDFium Component behåller en linjerad rad som spiller över en sidbrytning: den sidvisa linjerade genomgången kör med radgolvet på ett när DetectContinuations och DetectRuledTables är satta, fortsättningar markeras över hela resultatet, och bara fragment kortare än MinRows som står utanför varje kedja tas bort
Den överflödade raden överlever för att dess kedja länkar den, medan ett fristående enradigt rutnät på en vanlig sida filtreras precis som förut, och whitespace-detekterade tabeller behåller sitt tvåradsgolv utan någon sådan lindring
// Bygg om varje kedja till en CSV och släpp upprepade rubrikrader
// på fortsättningsfragmenten
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);            // rubrik upprepad av ordbehandlingsprogrammet
      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;

Två detaljer i den rutinen är avsiktliga. Den enradiga överflödningen skalas aldrig bort, för vakten på RowCount behåller den, och ett ordbehandlingsprogram som upprepar rubrikraden på varje sida ger ett fragment vars första rad är rubriken igen, så att släppa rad noll på middle- och end-fragment är rätt för det fallet och fel för en generator som inte upprepar rubriker. Kontrollera ett dokument innan du släpper rutinen lös på en mapp

Var reglerna fortfarande tar stopp

Det innehållsmedvetna testet är bara så bra som det textlager det läser. På en skannad sida helt utan text faller de registrerade brödtextextremvärdena tillbaka på sidans gränser, villkoret "inget mellan" uppfylls tomt, och bara grindarna för rubrikrad och kolumner återstår; ett linjerat rutnät på en sådan sida hittas ändå som ett tomt skelett, så kedjan kan kopplas rätt, men inget om den omgivande texten verifierades faktiskt. Lägg till ett textlager först om det spelar roll. Sidfötter som renderas som bilder i stället för text är osynliga för bandlogiken och ofarliga av samma skäl

Banden är en enda siffra. En sidfot djupare än ContinuationMargin lämnar sina nedre rader inne i brödtextzonen, vilket får det tidigare fragmentet att se ut att följas av text och blockerar länken; höj inställningen till det verkliga banddjupet, som det första exemplet gör. Höj den för mycket och ett kort avslutande stycke nära sidans botten glider in i bandet och ignoreras, vilket kopplar en tabell till vad som än följer efter den. Rubrikregeln har ett spegelvänt fel: en generator som skriver en sammanslagen "continued"-banner som första rad i varje fortsättningsfragment får de fragmenten avvisade som nya tabeller, och enda botemedlet i dag är att sy ihop själv efter ContinuationGroup, utan att lätta på något, eftersom regeln inte har någon brytare

Whitespace-detekterade tabeller får ingen av lindringen för enstaka rader. Whitespace-strategin behöver två justerade rader för att alls se en tabell, så en olinjerad tabell som spiller en rad rapporteras fortfarande en rad för kort. När du stöter på det ger ordfyrkanterna bakom strukturerade textblock och läsordning dig råpositionerna för att återställa den. På det urval som drev det här arbetet, tretton exporter från ordbehandlare och webbläsare, kopplades de fem dokumenten med äkta flersidiga tabeller alla till enkla kedjor och utskriftsformuläret som tidigare smälte samman hölls åtskilt, vilket är ribban releasen mättes mot, inte ett löfte om varje layout

Fortsättningsmarkering, rubrikregeln och enradspasset ligger alla i den dokumentövergripande vägen som delas av Delphi-, C++Builder- och Lazarus-byggen; hela API:et för tabellutvinning beskrivs på sidan för PDFium Component for Delphi