Een tabel geëxporteerd uit Word of Google Docs kan honderden randstukken op één pagina meedragen en niet één getrokken lijn. Decomprimeer de pagina met qpdf --qdf en de content stream leest als een kolom re f-paren, elk beschrijvend een box van een halve punt hoog. Een tabelfinder die alleen naar getrokken segmenten kijkt vindt niets, valt terug op woorden naast elkaar zetten op de witruimte ertussen, en geeft een versplintering van fragmenten van twee en drie rijen terug waar de auteur één strak raster tekende. De randen zijn er allemaal. Ze zijn geschilderd met een fill, dus de finder moet gevulde rechthoeken als randen lezen en vervolgens bijna rakende zijden op gedeelde rasterlijnen trekken
Waarom een Word-tabel geen getrokken lijnen heeft
Een tekstverwerker behandelt een celrand als een box met een breedte, en hij schildert die box met de fill-operator. ISO 32000-1 §8.5.2.1 definieert re als het toevoegen van een gesloten rechthoekig subpad, en §8.5.3 splitst de schilderoperatoren: S trekt het pad met de huidige lijnbreedte, f vult het interieur. Een rand van 0,5 punt komt naar buiten als x y w 0.5 re f, en de stroke-machinerie (lijnbreedte, joins, dash-patroon) draait nooit. Celarcering is dezelfde constructie met een grotere box. De getrokken vorm die een lijngerichte finder verwacht, m, l, S, is degene die office-exports zelden gebruiken:
% één celrand uit een tekstverwerker-export: een gevulde box van 0.5 pt hoog
72 700 468 0.5 re f
% celarcering: een gevulde box ter grootte van de cel
72 676 117 24 re f
% de getrokken rasterlijn die een alleen-stroke-finder zoekt
72 700 m 540 700 l S
Schreef je zelf een finder op de rauwe PDFium-API, dan roept de eerste versie meestal FPDFPath_GetDrawMode aan, houdt het pad aan wanneer de stroke-vlag staat en gaat door. Beide gevulde boxen hierboven zakken voor die test. De woorden in de cellen komen bij de witruimtedetectie, en op een compacte export liggen de kolommen misschien 6 punten uit elkaar. In PDFium Component is de default MinColumnGap 12 punten, dus die kolommen registreren niet. MinColumnGap verlagen tot ze dat wel doen laat ook de uitgerekte woordafstand van uitgevulde alineas registreren, en het raster dat de auteur tekende komt nooit terug, want de informatie die het definieert woont in de gevulde boxen
Hoe PDFium Component een gevulde box als rand leest
PDFium Component leest tabelgeometrie uit elk padobject op de pagina, één subpad per keer, en omdat TPdfTableExtractionOptions.DetectFilledRulings standaard True is, wordt een pad waarvan de fill-mode iets anders dan none is op randen onderzocht naast getrokken paden. Het filter dat volgt is met opzet streng. Een gevuld subpad kwalificeert alleen wanneer het vier tot acht punten heeft, geen enkel curvesegment bevat, en elk punt binnen 0,05 punt op een hoek van de bounding box van het subpad ligt. Een met re getekende rechthoek passeert, en ook een die als vijf expliciete punten met een herhaalde hoek is getekend. Een driehoek, een chevron, een L-vormige omtrek of een afgeronde tab zakt voor de test en levert niets op
De overlevende rechthoek wordt op zijn bounding box geclassificeerd tegen MaxRulingThickness, standaard 3 punten. Breedte op of onder de drempel met hoogte erboven geeft één verticale rand op het horizontale middelpunt, over de volle hoogte van de box; het spiegelgeval geeft één horizontale rand. Wanneer beide afmetingen boven de drempel zitten wordt de box als gearceerde cel behandeld en levert hij vier randen op, één per zijde. Zitten beide afmetingen op of onder de drempel, dan levert de box niets, dus een vierkante bullet van 2 punten in een lijst wordt nooit een stompje tabelraster. Vulkleur speelt in dit alles geen rol. Een witte box op een witte pagina produceert dezelfde randen als een zwarte, en daarom kan een achtergrondpaneel ter grootte van de tabel opdoen als buitenkader
Getrokken paden houden hun eigen route. Elk recht segment waarvan de eindpunten op één as hoogstens RulingTolerance (standaard 1 punt) uit elkaar liggen wordt een rand langs de andere as, curvesegmenten worden overgeslagen, en het sluitsegment van een gesloten subpad telt ook mee. Een pad met zowel fill als stroke levert via beide routes bij, en de overlappende stukken vallen later uiteen in de mergestap. Een getrokken afgeronde rechthoek geeft nog steeds randen voor zijn rechte zijden; alleen de bogen op de hoeken vallen weg. De extractieaanroep zelf heeft geen speciale opzet nodig:
uses
System.SysUtils, PDFium;
var
Pdf: TPdf;
Options: TPdfTableExtractionOptions;
Tables: TPdfTables;
Mode: string;
I, J, WithText: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'itinerary-from-word.pdf';
Pdf.LoadDocument;
Pdf.PageNumber := 1; // 1-based
Options := TPdfTableExtractionOptions.Default;
// de filled-ruling-velden, uitgeschreven op hun defaultwaarden
Options.DetectFilledRulings := True;
Options.MaxRulingThickness := 3.0; // punten; moet groter dan 0 zijn
Options.RulingSnapTolerance := 4.0; // punten; 0 zet snappen uit
Options.IncludeFormXObjects := True;
Tables := Pdf.ExtractTables(Options);
for I := 0 to High(Tables) do
begin
if Tables[I].DetectionMode = ptdmRuled then
Mode := 'ruled'
else
Mode := 'whitespace';
WithText := 0;
for J := 0 to High(Tables[I].Cells) do
if Tables[I].Cells[J].Text <> '' then
Inc(WithText);
Writeln(Format('%dx%d %s, confidence %.2f, %d of %d cells hold text',
[Tables[I].RowCount, Tables[I].ColumnCount, Mode,
Tables[I].Confidence, WithText, Length(Tables[I].Cells)]));
end;
finally
Pdf.Free;
end;
end;
MaxRulingThickness op 0 zetten schakelt de feature niet uit. ExtractTables valideert de geometrie-opties vooraf en gooit een EPdfError met "Table extraction geometry options are invalid" bij een drempel van nul of lager. Voor het alleen-stroke-gedrag zet je DetectFilledRulings op False. Laat hem aan voor alles wat uit een officepakket of een afdrukvenster van een browser komt; het strenge rechthoekenfilter houdt de kosten laag, want decoratieve fills passeren hem bijna nooit
Wat doet RulingSnapTolerance voor tabellen met gearceerde cellen?
RulingSnapTolerance is wat een tabel die alleen uit arcering bestaat tot één raster laat verbinden. Sommige exports tekenen helemaal geen rand: elke cel is een gevulde box, gescheiden van zijn buren door een witte goot van 1 tot 3 punten. Elke box levert zijn vier zijranden, maar de rechterzijde van de ene cel en de linkerzijde van de volgende zitten bijvoorbeeld 2 punten uit elkaar, en de test die beslist of twee randen bij hetzelfde raster horen staat alleen RulingTolerance toe, 1 punt. Zonder snappen is elke cel een eiland van vier randen dat een raster van 1x1 beschrijft, niets haalt MinColumns of MinRows (beide standaard 2), en de ruled-pass meldt geen tabel op een pagina die er duidelijk één heeft
De snap-stap verzamelt elke X-coördinaat die in het spel is, dus de positie van elke verticale rand plus het begin en einde van elke horizontale, en elke Y-coördinaat op dezelfde manier. Elke lijst wordt gesorteerd en gesplitst in clusters waar twee buren meer dan de tolerantie uit elkaar liggen; elk cluster wordt vervangen door zijn gemiddelde, en daarna schuift elke positie, elk begin en elk einde naar het dichtstbijzijnde middelpunt. Randen op 230.0 en 232.0 worden allebei 231.0, de twee cellen delen een lijn, en de horizontale randen rekken mee zodat de hoeken elkaar raken
Collineaire randstukken die elkaar raken of overlappen binnen RulingTolerance worden samengevoegd tot enkele lijnen. Pas daarna draait de ruled-vinder, die randen paarsgewijs vergelijkt; een tabel van 30 rijen die aankwam als 720 randstukken bereikt die vergelijking als een paar dozijn lijnen
Op een getrokken raster verandert de stap niets, want coördinaten die al matchen snappen op zichzelf. De vangst is dat ketenvorming geen eigen breedtelimiet heeft. Een reeks waarden die elk 3 punten uit elkaar liggen, op 100, 103, 106 en 109, klapt in één middelpunt op 104.5 ook al liggen de buitenste waarden 9 punten uit elkaar. Met de default van 4 punten bijt dat alleen op kolommen smaller dan een teken, dus office-output kan de default van 4 punten houden. Verlaag hem richting 1 of 2 wanneer een formulier opzettelijk smalle kolommen heeft (vinkvakjes, cijfer-per-cel-rekeningnummervelden) en zet hem op 0 om snappen uit te zetten. De ruled-strategie alleen onder drie instellingen draaien laat zien van welk mechanisme een pagina afhankelijk is:
// Alleen de ruled-strategie, zodat witruimtedetectie het resultaat niet kan maskeren
function CountRuledTables(Pdf: TPdf; FilledRulings: Boolean;
SnapTolerance: Double): Integer;
var
Options: TPdfTableExtractionOptions;
begin
Options := TPdfTableExtractionOptions.Default;
Options.DetectWhitespaceTables := False;
Options.DetectFilledRulings := FilledRulings;
Options.RulingSnapTolerance := SnapTolerance;
Result := Length(Pdf.ExtractTables(Options));
end;
// Verwacht voor een tabel die alleen als gearceerde cellen met 2 pt-goten is getekend:
// stroke only -> 0 (helemaal geen getrokken geometrie)
// filled boxes, snap 4 -> 1 (gootzijden delen één lijn)
// filled boxes, snap 0 -> 0 (elke cel blijft een eiland van 1x1)
Writeln('stroke only: ', CountRuledTables(Pdf, False, 4.0));
Writeln('filled boxes, snap 4: ', CountRuledTables(Pdf, True, 4.0));
Writeln('filled boxes, snap 0: ', CountRuledTables(Pdf, True, 0.0));
Als de derde telling onder de tweede zakt, vertrouwt de pagina op snappen, en dat is de pagina die je met het oog moet checken voordat je de tolerantie verlaagt
Randen binnen form XObjects
Paginalayouttools wikkelen een tabel, of de hele pagina-inhoud, vaak in een form XObject en schilderen die met Do. ISO 32000-1 §8.10.1 zegt dat de formmatrix wordt samengesteld met de huidige transformatiematrix wanneer de form wordt geschilderd, dus een rechthoek binnen de form wordt geschreven in formruimte en bereikt paginaruimte pas na twee of meer transformaties. Met IncludeFormXObjects gezet, de default, loopt PDFium Component elk formobject binnen, combineert de objectmatrix met de matrix van zijn ouder en geeft de gecombineerde matrix door aan elk kind, dus een rand geschreven als 72 700 468 0.5 re f binnen een getransleerde form landt waar de viewer hem toont. De wandeling stopt bij acht niveaus geneste forms
De volgorde van die combinatie doet ertoe. Een punt binnen de form gaat eerst door de objectmatrix en daarna door de oudermatrix; wissel de operanden om en de translatieterm wordt in de verkeerde ruimte toegepast, dus een rand die bovenaan de pagina hoort belandt bij de oorsprong of buiten de pagina. Dezelfde val duikt op zodra je matrices zelf bouwt, en het artikel over matrix prepend tegenover append doorloopt hem met een rotatiepivot
Waarom is het default randenbudget 16384?
De default MaxRulingSegments van 16384 weerspiegelt hoeveel stukken een per-cel-randexport produceert. Een getrokken tabel van 30 rijen en 6 kolommen heeft 38 lijnsegmenten nodig: 31 horizontaal en 7 verticaal. Dezelfde tabel geëxporteerd als gevulde boxen kan vier randstukken per cel meedragen, 720 vóór het samenvoegen, en elke cel arceren voegt nog eens vier zijranden per cel toe, 1440 in totaal. Twee zulke tabellen op één pagina hebben al snel bijna 3000 nodig
Het budget overschrijden is een fout, en hij komt er ook als zo naar boven. ExtractTables gooit een EPdfError met de melding "Table ruling-segment budget exceeded", geeft geen deelraster terug, en de witruimtepass voor die pagina draait ook niet. Een krap budget is redelijk voor niet-vertrouwde invoer, waar een craftte pagina miljoenen piepkleine boxen kan bevatten, maar dan moet de aanroeper "te complex voor dit budget" kunnen onderscheiden van "geen tabellen op deze pagina":
function TryExtractTables(Pdf: TPdf; Budget: Integer;
out Tables: TPdfTables): Boolean;
var
Options: TPdfTableExtractionOptions;
begin
Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := Budget; // 16384 standaard
try
Tables := Pdf.ExtractTables(Options);
Result := True;
except
on E: EPdfError do
begin
// LogWarning is je eigen logroutine, geen deel van de library
LogWarning(Format('Page %d: %s', [Pdf.PageNumber, E.Message]));
Tables := nil;
Result := False; // over budget of ongeldige opties
end;
end;
end;
// Een False-resultaat betekent "niet beslist", wat anders is dan een lege array
if not TryExtractTables(Pdf, 4096, Tables) then
QueueForReview(Pdf.FileName, Pdf.PageNumber); // ook je eigen routine
Hetzelfde exceptiontype dekt ook het woordenbudget en ongeldige opties, dus de gelogde melding is wat ze in een reviewwachtrij uit elkaar houdt
Waar het ruled-pad het overgeeft
Ruled-detectie draait eerst, witruimtedetectie tweede, en twee regels houden ze uit elkaars vaarwater. Woorden waarvan de middelpunten binnen een raster vallen dat de ruled-pass al meldde, worden weggehaald voordat de witruimtedetectie begint, dus een tabel wordt nooit twee keer gemeld. Een witruimtekolomgrens moet ook een tekenvrije verticale corridor zijn over elke rij die ze scheidt, wat uitgevulde alineas tegenhoudt als tabellen te scoren; het artikel over whitespace-tabel-fout-positieven behandelt die regel. Gevulde rechthoeken lezen verplaatst tabeluitvoer van officepakketten van het witruimtepad naar het ruled-pad
Omdat randen uit padobjecten komen en tekst van de tekstpagina, produceert een pagina zonder tekstlaag nog steeds het rasterskelet met elke cel leeg. De confidence van een ruled-raster is 0,88 plus 0,12 keer de fractie cellen die tekst bevat, dus een leeg skelet meldt exact 0,88 en de celtelling in de eerste opsomming toont 0 cellen met tekst. Die combinatie is het signaal om eerst OCR te draaien, zoals beschreven in een doorzoekbare tekstlaag toevoegen met OCR. Gevulde vormen met curven of niet-rechthoekige omtrekken leveren niets op, dus een tabel waarvan de cellen afgeronde rechthoeken zijn valt toe aan de witruimtedetectie. Een tabel zonder randen en zonder arcering hoort bij de witruimtestrategie die in het artikel over tabeldetectie en -extractie staat beschreven, en wanneer zelfs dat niet genoeg is, zijn de woordboxen en blokken uit gestructureerde tekst en leesvolgorde het ruwe materiaal voor een reader die voor één documentfamilie is geschreven
De demo TableExtractionLab die met de component meekomt heeft een selectievakje voor DetectFilledRulings, dus je kunt een export laden en het raster zien verschijnen en verdwijnen zonder ook maar één regel code. Opties, recordtypes en de rest van de extractie-API staan op de PDFium Component for Delphi-productpagina