Articolo tecnico

Bordi di tabella come rettangoli pieni in PDFium Delphi

Una tabella esportata da Word o Google Docs può portare centinaia di pezzi di bordo su una pagina e non una sola linea tracciata. Decomprimete la pagina con qpdf --qdf e il content stream si legge come una colonna di coppie re f, ognuna che descrive una scatola alta mezzo punto. Un rilevatore di tabelle che cerca solo segmenti tracciati non trova niente, ripiega sull'allineare le parole per lo spazio bianco tra loro, e restituisce un briciolo di frammenti di due e tre righe dove l'autore aveva disegnato una griglia pulita. I bordi ci sono tutti. Sono dipinti con un fill, quindi il rilevatore deve leggere i rettangoli pieni come ruling e poi tirare i bordi quasi a contatto su linee di griglia condivise

Perché una tabella Word non ha linee tracciate

Un word processor tratta il bordo di una cella come una scatola con una larghezza, e dipinge quella scatola con l'operatore fill. ISO 32000-1 §8.5.2.1 definisce re come l'aggiunta di un sottopercorso di rettangolo chiuso, e §8.5.3 divide gli operatori di pittura: S traccia il percorso usando la larghezza di linea corrente, f ne riempie l'interno. Un bordo di 0.5 punti esce come x y w 0.5 re f, e il macchinario di tracciatura (larghezza di linea, join, pattern di tratteggio) non gira mai. L'ombreggiatura delle celle è la stessa costruzione con una scatola più grande. La forma tracciata che un rilevatore orientato alle linee si aspetta, m, l, S, è quella che gli export degli office usano raramente:

% un bordo di cella da un export di word processor: una scatola piena alta 0.5 pt
72 700 468 0.5 re f
% ombreggiatura di cella: una scatola piena della dimensione della cella
72 676 117 24 re f
% la linea di griglia tracciata che un rilevatore solo-stroke cerca
72 700 m 540 700 l S

Se scriveste il vostro rilevatore sull'API PDFium grezza, la prima bozza di solito chiama FPDFPath_GetDrawMode, tiene il percorso quando il flag stroke è impostato, e va avanti. Entrambe le scatole piene qui sopra falliscono quel test. Le parole dentro le celle arrivano alla rilevazione whitespace, e su un export compatto le colonne stanno magari a 6 punti di distanza. In PDFium Component il default di MinColumnGap è 12 punti, quindi quelle colonne non si registrano. Abbassare MinColumnGap finché si registrano lascia registrare anche lo spacing allungato delle parole dei paragrafi giustificati, e la griglia che l'autore disegnava non torna mai, perché l'informazione che la definisce vive nelle scatole piene

Come PDFium Component legge una scatola piena come ruling

PDFium Component legge la geometria delle tabelle da ogni path object sulla pagina, un sottopercorso alla volta, e dato che TPdfTableExtractionOptions.DetectFilledRulings fa default a True, un percorso la cui modalità di fill è qualsiasi cosa diversa da none viene esaminato per ruling accanto ai percorsi tracciati. Il filtro che segue è severo di proposito. Un sottopercorso pieno si qualifica solo quando ha tra quattro e otto punti, non contiene segmenti di curva, e ogni punto sta su un angolo del bounding box del sottopercorso entro 0.05 punti. Un rettangolo disegnato con re passa, e così fa uno disegnato come cinque punti espliciti con un angolo ripetuto. Un triangolo, una chevron, un contorno a L o una linguetta arrotondata fallisce il test e non contribuisce niente

Diagramma di PDFium Component su come TableCollectObjectRulings trasforma i sottopercorsi chiusi in righelli di tabella in Delphi: FlushSubpath scarta i contorni curvi e i poligoni fuori dai bordi del bounding box, MaxRulingThickness spezza i box sottili in un righello per asse maggiore, le celle ombreggiate danno quattro righelli di bordo e DetectFilledRulings tiene fuori i quadratini
Un sottopercorso chiuso sopravvive solo se è allineato agli assi, e il bounding box decide poi se è un righello, quattro bordi di una cella ombreggiata, o nulla

Il rettangolo superstite viene classificato per il suo bounding box contro MaxRulingThickness, che fa default a 3 punti. Larghezza alla soglia o sotto con altezza sopra dà un ruling verticale al centro orizzontale, che percorre l'intera altezza della scatola; il caso speculare dà un ruling orizzontale. Quando entrambe le dimensioni superano la soglia la scatola è trattata come cella ombreggiata e contribuisce quattro ruling, uno per bordo. Quando entrambe le dimensioni sono alla soglia o sotto, la scatola non contribuisce niente, così un bullet quadrato da 2 punti in una lista non diventa mai un mozzicone di griglia di tabella. Il colore di fill non gioca nessuna parte in tutto questo. Una scatola bianca su pagina bianca produce gli stessi ruling di una nera, per cui un pannello di sfondo della dimensione della tabella può apparire come una cornice esterna

I percorsi tracciati tengono la loro strada. Ogni segmento retto i cui estremi differiscono per non più di RulingTolerance (1 punto per default) su un asse diventa un ruling lungo l'altro, i segmenti di curva vengono saltati, e il segmento di chiusura di un sottopercorso chiuso conta pure. Un percorso dipinto sia con fill che con stroke contribuisce per entrambe le vie, e i pezzi sovrapposti vengono collassati più tardi dal passo di fusione. Un rettangolo arrotondato tracciato dà comunque ruling per i suoi lati retti; solo gli archi agli angoli cadono. La chiamata di estrazione in sé non vuole nessun allestimento speciale:

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;                     // a base 1

    Options := TPdfTableExtractionOptions.Default;
    // i campi dei filled ruling, enunciati ai loro valori predefiniti
    Options.DetectFilledRulings := True;
    Options.MaxRulingThickness := 3.0;       // punti; dev'essere maggiore di 0
    Options.RulingSnapTolerance := 4.0;      // punti; 0 spegne lo snapping
    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;

Impostare MaxRulingThickness a 0 non spegne la funzionalità. ExtractTables valida le opzioni di geometria all'inizio e solleva EPdfError con "Table extraction geometry options are invalid" per una soglia di zero o sotto. Per avere il comportamento solo-stroke, impostate DetectFilledRulings a False. Lasciatelo acceso per qualunque cosa uscita da una suite office o da una finestra di stampa del browser; il filtro severo sui rettangoli è ciò che tiene il costo basso, dato che i fill decorativi quasi mai lo passano

Che cosa fa RulingSnapTolerance per le tabelle con celle ombreggiate?

RulingSnapTolerance è ciò che permette a una tabella costruita dal solo shading di connettersi in una griglia sola. Alcuni export non disegnano nessun bordo: ogni cella è una scatola piena separata dalle vicine da un varco bianco di 1 a 3 punti. Ogni scatola rende i suoi quattro ruling di bordo, ma il bordo destro di una cella e il bordo sinistro della successiva stanno, diciamo, a 2 punti di distanza, e il test che decide se due ruling appartengono alla stessa griglia consente solo RulingTolerance, 1 punto. Senza snapping, ogni cella è un'isola di quattro ruling che descrive una griglia 1x1, niente raggiunge MinColumns o MinRows (entrambi 2 per default), e la passata ruled non riporta nessuna tabella su una pagina che ne ha palesemente una

Diagramma di PDFium Component su come RulingSnapTolerance connette una tabella con celle ombreggiate in Delphi: le celle vicine lasciano un gutter di 2 pt, i loro righelli di bordo stanno oltre il RulingTolerance di 1 pt, e TableSnapRulings concatena i due valori X in un unico centro di gruppo così il test di connettività vede finalmente una linea di griglia condivisa
Lo snapping gira prima del merge e prima del rilevatore di griglie, quindi i due lati di un gutter bianco diventano una sola linea e ogni cella smette di essere un'isola di quattro righelli

Il passo di snap raccoglie ogni coordinata X in gioco, ovvero la posizione di ogni ruling verticale più l'inizio e la fine di ogni ruling orizzontale, e ogni coordinata Y allo stesso modo. Ogni lista viene ordinata e divisa in cluster dove due vicini differiscono per più della tolleranza; ogni cluster viene sostituito dalla sua media, e ogni posizione, inizio e fine si sposta quindi al centro più vicino. I bordi a 230.0 e 232.0 diventano entrambi 231.0, le due celle condividono una linea, e i ruling orizzontali si allungano con loro così gli angoli si incontrano

I pezzi collineari che si toccano o si sovrappongono entro RulingTolerance vengono fusi in linee singole. Solo allora gira il rilevatore di griglia, confrontando i ruling a coppie; una tabella da 30 righe arrivata come 720 pezzi di bordo raggiunge quel confronto come qualche dozzina di linee

Su una griglia tracciata il passo non cambia niente, perché le coordinate che già combaciano fanno snap su se stesse. La becchamma è che l'incatenamento non ha un limite di larghezza proprio. Una sequenza di valori ognuno a 3 punti di distanza, a 100, 103, 106 e 109, collassa in un centro a 104.5 anche se i valori esterni stanno a 9 punti di distanza. Con il default di 4 punti questo morde solo su colonne più strette di un carattere, quindi l'output degli office può tenere il default di 4 punti. Abbassatelo verso 1 o 2 quando un modulo ha colonne deliberadamente strette (caselle di spunta, campi numero di conto a una cifra per cella) e impostatelo a 0 per spegnere lo snapping. Far girare da sola la strategia ruled sotto tre impostazioni mostra di quale meccanismo una pagina dipende:

// Solo strategia ruled, così la rilevazione whitespace non può mascherare il risultato
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;

// Atteso per una tabella disegnata solo come celle ombreggiate con varchi di 2 pt:
//   solo stroke            -> 0  (nessuna geometria tracciata per niente)
//   scatole piene, snap 4  -> 1  (i bordi del varco condividono una linea)
//   scatole piene, snap 0  -> 0  (ogni cella resta un'isola 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));

Quando il terzo conteggio scende sotto il secondo, la pagina conta sullo snapping, ed è la pagina da controllare a occhio prima di abbassare la tolleranza

I righelli dentro i form XObject

Gli strumenti di impaginazione spesso avvolgono una tabella, o l'intero corpo della pagina, in un form XObject e lo dipingono con Do. ISO 32000-1 §8.10.1 dice che la matrice del form viene concatenata con la matrice di trasformazione corrente quando il form viene dipinto, quindi un rettangolo dentro il form è scritto nello spazio del form e raggiunge lo spazio pagina solo dopo due o più trasformazioni. Con IncludeFormXObjects impostato, che è il default, PDFium Component entra in ogni form object, combina la matrice dell'oggetto con quella del suo genitore, e passa la matrice combinata a ogni figlio, quindi un bordo disegnato come 72 700 468 0.5 re f dentro un form traslato atterra dove il viewer lo mostra. La camminata si ferma a otto livelli di form annidati

Diagramma di PDFium Component sui righelli dentro un form XObject in Delphi: un rettangolo sottile scritto come 72 700 468 0.5 re f vive nello spazio del form e arriva sulla pagina solo dopo che TableMultiplyMatrix combina la CTM genitore con la matrice del form, ricorrendo attraverso FPDFFormObj_CountObjects fino a MaxFormDepth
Il rettangolo è scritto nello spazio del form e raggiunge la cima della pagina solo dopo che le matrici vengono moltiplicate in un ordine che tiene il termine di traslazione dove deve stare

L'ordine di quella combinazione conta. Un punto dentro il form passa prima per la matrice dell'oggetto e seconda per la matrice del genitore; scambiate gli operandi e il termine di traslazione viene applicato nello spazio sbagliato, così un ruling che appartiene alla cima della pagina finisce vicino all'origine o fuori pagina. La stessa trappola ricompare ogni volta che costruite matrici da soli, e l'articolo sul prepend contro append delle matrici lo attraversa con un perno di rotazione

Perché il budget dei ruling predefinito è 16384?

Il default di MaxRulingSegments a 16384 riflette quanti pezzi produce un export di bordi per cella. Una tabella tracciata da 30 righe e 6 colonne vuole 38 segmenti di linea: 31 orizzontali e 7 verticali. La stessa tabella esportata come scatole piene può portare quattro pezzi di bordo per cella, 720 prima della fusione, e l'ombreggiatura di ogni cella aggiunge altri quattro ruling di bordo per cella, 1440 in tutto. Due tabelle simili su una pagina ne vogliono già vicino a 3000

Superare il budget è un errore, e si presenta come tale. ExtractTables solleva EPdfError con il messaggio "Table ruling-segment budget exceeded", non restituisce nessuna griglia parziale, e nemmeno la passata whitespace per quella pagina gira. Un budget stretto è ragionevole per input non fidato, dove una pagina confezionata ad arte potrebbe tenere milioni di scatoline, ma allora il chiamante deve saper distinguere "troppo complesso per questo budget" da "nessuna tabella su questa pagina":

function TryExtractTables(Pdf: TPdf; Budget: Integer;
  out Tables: TPdfTables): Boolean;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.MaxRulingSegments := Budget;     // 16384 per default
  try
    Tables := Pdf.ExtractTables(Options);
    Result := True;
  except
    on E: EPdfError do
    begin
      // LogWarning è la vostra routine di logging, non parte della libreria
      LogWarning(Format('Page %d: %s', [Pdf.PageNumber, E.Message]));
      Tables := nil;
      Result := False;                     // oltre budget o opzioni invalide
    end;
  end;
end;

// Un risultato False significa "non deciso", che è diverso da un array vuoto
if not TryExtractTables(Pdf, 4096, Tables) then
  QueueForReview(Pdf.FileName, Pdf.PageNumber);  // anche questa routine vostra

Lo stesso tipo di eccezione copre anche il budget delle parole e le opzioni invalide, quindi il messaggio loggato è ciò che li distingue in una coda di revisione

Dove il percorso ruled passa il testimone

La rilevazione ruled gira per prima, la whitespace per seconda, e due regole le tengono dal pestarsi a vicenda. Le parole i cui centri cadono dentro una griglia che la passata ruled ha già riportato vengono rimosse prima che parta la rilevazione whitespace, così una tabella non viene mai riportata due volte. Un confine di colonna whitespace deve anche essere un corridoio verticale privo di testo attraverso ogni riga che separa, il che tiene i paragrafi giustificati dal fare punteggio come tabelle; l'articolo sui falsi positivi delle tabelle whitespace copre quella regola. Leggere i rettangoli pieni sposta le tabelle esportate dagli office fuori dal percorso whitespace e dentro quello ruled

Dato che i ruling vengono dai path object e il testo dalla pagina di testo, una pagina senza layer di testo produce comunque lo scheletro della griglia con ogni cella vuota. La confidence di una griglia ruled è 0.88 più 0.12 volte la frazione di celle che contengono testo, quindi uno scheletro vuoto riporta esattamente 0.88 e il conteggio celle nella prima elencazione mostra 0 celle con testo. Quella combinazione è il segnale per far girare prima l'OCR, come descritto in aggiungere un layer di testo ricercabile con OCR. Le forme piene con curve o contorni non rettangolari non contribuiscono niente, quindi una tabella le cui celle sono rettangoli arrotondati pieni cade alla rilevazione whitespace. Una tabella senza né bordi né shading appartiene alla strategia whitespace descritta nell'articolo su rilevazione ed estrazione delle tabelle, e quando nemmeno quella basta, le word box e i blocchi del testo strutturato e reading order sono la materia prima per un lettore scritto per una famiglia di documenti

La demo TableExtractionLab che viaggia col componente ha una casella di spunta per DetectFilledRulings, così potete caricare un export e vedere la griglia apparire e sparire senza scrivere codice. Opzioni, tipi record e il resto dell'API di estrazione sono elencati nella pagina prodotto di PDFium Component for Delphi