Article technique

Filets de table depuis des rectangles remplis en Delphi

Un tableau exporté depuis Word ou Google Docs peut porter des centaines de morceaux de bordure sur une page et pas une seule ligne tracée. Décompressez la page avec qpdf --qdf et le flux de contenu se lit comme une colonne de paires re f, chacune décrivant une boîte d'un demi-point de haut. Un détecteur de tableaux qui ne cherche que des segments tracés ne trouve rien, retombe sur l'alignement des mots par les blancs entre eux, et rend une dispersion de fragments de deux ou trois lignes là où l'auteur a dessiné une grille propre. Les bordures sont toutes là. Elles sont peintes avec un remplissage, donc le détecteur doit lire les rectangles remplis comme des filets puis ramener les bords presque contigus sur des lignes de grille partagées

Pourquoi un tableau Word n'a pas de lignes tracées

Un traitement de texte traite une bordure de cellule comme une boîte avec une largeur, et il peint cette boîte avec l'opérateur de remplissage. ISO 32000-1 §8.5.2.1 définit re comme l'ajout d'un sous-chemin rectangulaire fermé, et §8.5.3 répartit les opérateurs de peinture : S trace le chemin avec la largeur de trait courante, f remplit son intérieur. Une bordure de 0,5 point sort en x y w 0.5 re f, et la machinerie de tracé (largeur de trait, jointures, motif de pointillés) ne tourne jamais. Le fond de cellule est la même construction avec une boîte plus grande. La forme tracée qu'attend un détecteur orienté lignes, m, l, S, est celle que les exports bureautiques utilisent rarement :

% one cell border from a word-processor export: a 0.5 pt tall filled box
72 700 468 0.5 re f
% fond de cellule : une boîte remplie à la taille de la cellule
72 676 117 24 re f
% la ligne de grille tracée que cherche un détecteur tracé-seulement
72 700 m 540 700 l S

Si vous écriviez votre propre détecteur sur l'API PDFium brute, le premier jet appelle d'ordinaire FPDFPath_GetDrawMode, garde le chemin quand le drapeau de tracé est posé, et passe à la suite. Les deux boîtes remplies ci-dessus échouent à ce test. Les mots dans les cellules arrivent à la détection par blancs, et sur un export compact les colonnes siègent peut-être à 6 points d'écart. Dans PDFium Component, le MinColumnGap par défaut est de 12 points, donc ces colonnes ne s'enregistrent pas. Abaisser MinColumnGap jusqu'à ce qu'elles s'enregistrent fait enregistrer aussi l'espacement étiré des paragraphes justifiés, et la grille que l'auteur a dessinée ne revient jamais, parce que l'information qui la définit vit dans les boîtes remplies

Comment PDFium Component lit une boîte remplie comme un filet

PDFium Component lit la géométrie de tableaux depuis chaque objet chemin de la page, un sous-chemin à la fois, et comme TPdfTableExtractionOptions.DetectFilledRulings vaut True par défaut, un chemin dont le mode de remplissage est autre chose que none est examiné pour des filets au côté des chemins tracés. Le filtre qui suit est strict à dessein. Un sous-chemin rempli ne se qualifie que s'il a entre quatre et huit points, ne contient aucun segment de courbe, et chaque point siège sur un coin de la boîte englobante du sous-chemin à 0,05 point près. Un rectangle dessiné avec re passe, et un dessiné comme cinq points explicites avec un coin répété aussi. Un triangle, un chevron, un contour en L ou un onglet arrondi échoue au test et ne contribue rien

Diagramme de la façon dont PDFium Component en Delphi classifie un sous-chemin rempli fermé : les contours courbés et les polygones à points hors des coins de la boîte englobante sont jetés, une boîte pas plus épaisse que MaxRulingThickness donne un filet le long de son grand axe, une cellule avec fond donne quatre filets de bords et un tout petit carré n'en donne aucun
Un sous-chemin fermé ne survit que s'il est aligné sur les axes, et la boîte englobante décide ensuite s'il vaut un filet, quatre bords d'une cellule avec fond, ou rien du tout

Le rectangle survivant est classifié par sa boîte englobante contre MaxRulingThickness, qui vaut 3 points par défaut. Une largeur au seuil ou en dessous avec une hauteur au-dessus donne un filet vertical au centre horizontal, courant toute la hauteur de la boîte ; le cas miroir donne un filet horizontal. Quand les deux dimensions dépassent le seuil, la boîte est traitée comme cellule avec fond et contribue quatre filets, un par bord. Quand les deux dimensions sont au seuil ou en dessous, la boîte ne contribue rien, donc une puce carrée de 2 points dans une liste ne devient jamais un bout de grille de tableau. La couleur de remplissage ne joue aucun rôle dans tout cela. Une boîte blanche sur page blanche produit les mêmes filets qu'une noire, c'est pourquoi un panneau de fond à la taille du tableau peut apparaître comme un cadre extérieur

Les chemins tracés gardent leur propre route. Tout segment droit dont les extrémités diffèrent d'au plus RulingTolerance (1 point par défaut) sur un axe devient un filet le long de l'autre, les segments de courbe sont sautés, et le segment fermant d'un sous-chemin fermé compte aussi. Un chemin peint avec remplissage et tracé contribue par les deux routes, et les morceaux qui se chevauchent sont effondrés plus tard par l'étape de fusion. Un rectangle arrondi tracé donne quand même des filets pour ses côtés droits ; seuls les arcs aux coins tombent. L'appel d'extraction lui-même n'exige aucune préparation :

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

    Options := TPdfTableExtractionOptions.Default;
    // les champs des filets remplis, épelés à leurs valeurs par défaut
    Options.DetectFilledRulings := True;
    Options.MaxRulingThickness := 3.0;       // points ; doit être supérieur à 0
    Options.RulingSnapTolerance := 4.0;      // points ; 0 coupe le rattachement
    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;

Mettre MaxRulingThickness à 0 ne coupe pas la fonctionnalité. ExtractTables valide les options de géométrie d'avance et lève EPdfError avec « Table extraction geometry options are invalid » pour un seuil de zéro ou moins. Pour obtenir le comportement tracé-seulement, mettez DetectFilledRulings à False. Gardez-la active pour tout ce qui sort d'une suite bureautique ou d'une boîte d'impression de navigateur ; le filtre strict de rectangles est ce qui garde le coût bas, puisque les remplissages décoratifs ne le passent presque jamais

Que fait RulingSnapTolerance pour les tables à cellules en fond ?

RulingSnapTolerance est ce qui permet à un tableau bâti de fonds seuls de se connecter en une seule grille. Certains exports ne dessinent aucune bordure : chaque cellule est une boîte remplie séparée de ses voisines par une gouttière blanche de 1 à 3 points. Chaque boîte produit ses quatre filets de bords, mais le bord droit d'une cellule et le bord gauche de la suivante siègent, disons, à 2 points d'écart, et le test qui décide si deux filets appartiennent à la même grille n'autorise que RulingTolerance, 1 point. Sans rattachement, chaque cellule est une île de quatre filets décrivant une grille 1x1, rien n'atteint MinColumns ni MinRows (tous deux à 2 par défaut), et la passe avec filets ne rapporte aucun tableau sur une page qui en a visiblement un

Diagramme de RulingSnapTolerance dans PDFium Component pour Delphi : des cellules voisines avec fond laissent une gouttière de 2 pt, leurs filets de bords à X 230.0 et 232.0 siègent au-delà du RulingTolerance de 1 pt, et le rattachement regroupe les deux valeurs à 231.0 pour que les deux cellules partagent une ligne de grille
Le rattachement tourne avant la fusion et avant le détecteur à filets, donc deux côtés d'une gouttière blanche deviennent une ligne et chaque cellule cesse d'être une île de quatre filets

L'étape de rattachement ramasse chaque coordonnée X en jeu, c'est-à-dire la position de chaque filet vertical plus le début et la fin de chaque filet horizontal, et chaque coordonnée Y de la même façon. Chaque liste est triée et scindée en amas partout où deux voisins diffèrent de plus que la tolérance ; chaque amas est remplacé par sa moyenne, et chaque position, début et fin se déplace alors vers le centre le plus proche. Des bords à 230.0 et 232.0 deviennent tous deux 231.0, les deux cellules partagent une ligne, et les filets horizontaux s'étirent avec eux pour que les angles se rejoignent

Morceaux colinéaires qui se touchent ou se chevauchent à RulingTolerance près fusionnent en lignes uniques. Alors seulement le détecteur de grille tourne, comparant les filets deux à deux ; un tableau de 30 lignes arrivé en 720 morceaux de bordure atteint cette comparaison comme quelques dizaines de lignes

Sur une grille tracée, l'étape ne change rien, parce que les coordonnées qui concordent déjà s'attachent à elles-mêmes. Le hic, c'est que le chaînage n'a pas de limite de largeur propre. Une suite de valeurs chacune à 3 points d'écart, 100, 103, 106 et 109, s'effondre en un centre à 104.5 même si les valeurs extérieures sont à 9 points d'écart. Avec le défaut de 4 points, cela ne mord que sur des colonnes plus étroites qu'un caractère, donc la sortie bureautique peut garder le défaut de 4 points. Abaissez vers 1 ou 2 quand un formulaire a des colonnes délibérément étroites (cases à cocher, champs de numéro de compte à un chiffre par cellule) et mettez 0 pour couper le rattachement. Faire tourner la stratégie à filets seule sous trois réglages montre de quel mécanisme une page dépend :

// Stratégie à filets seulement, pour que la détection par blancs ne puisse pas masquer le résultat
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;

// Attendu pour un tableau dessiné uniquement en cellules avec fond et gouttières de 2 pt :
//   tracé seulement          -> 0  (aucune géométrie tracée du tout)
//   boîtes remplies, snap 4   -> 1  (les bords de la gouttière partagent une ligne)
//   boîtes remplies, snap 0   -> 0  (chaque cellule reste une île 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));

Quand le troisième compte tombe sous le second, la page se fie au rattachement, et c'est la page à contrôler à l'œil avant d'abaisser la tolérance

Les filets à l'intérieur des form XObjects

Les outils de mise en page enveloppent souvent un tableau, ou tout le corps de la page, dans un form XObject et le peignent avec Do. ISO 32000-1 §8.10.1 dit que la matrice du formulaire est concaténée avec la matrice de transformation courante quand le formulaire est peint, donc un rectangle dans le formulaire est écrit en espace formulaire et n'atteint l'espace page qu'après deux transformations ou plus. Avec IncludeFormXObjects positionné, ce qui est le défaut, PDFium Component entre dans chaque objet formulaire, combine la matrice de l'objet avec celle de son parent, et remet la matrice combinée à chaque enfant, donc une bordure dessinée en 72 700 468 0.5 re f dans un formulaire translaté atterrit là où la visionneuse la montre. La marche s'arrête à huit niveaux de formulaires imbriqués

Diagramme des filets dans un form XObject dans PDFium Component pour Delphi : un rectangle fin écrit en 72 700 468 0.5 re f vit en espace formulaire et n'atteint le haut de la page qu'après que la matrice du formulaire et la transformation parent ont été combinées dans le bon ordre
Le rectangle s'écrit en espace formulaire et n'atteint le haut de la page qu'après que les matrices ont été multipliées dans un ordre qui garde le terme de translation où il doit rester

L'ordre de cette combinaison compte. Un point dans le formulaire passe d'abord par la matrice de l'objet puis par la matrice parente ; inversez les opérandes et le terme de translation s'applique dans le mauvais espace, donc un filet qui appartient au haut de la page finit près de l'origine ou hors page. Le même piège revient chaque fois que vous bâtissez des matrices vous-même, et l'article sur prepend contre append des matrices le parcourt avec un pivot de rotation

Pourquoi le budget de filets par défaut vaut-il 16384 ?

Le MaxRulingSegments par défaut de 16384 reflète combien de morceaux produit un export de bordures par cellule. Un tableau tracé de 30 lignes et 6 colonnes exige 38 segments de ligne : 31 horizontaux et 7 verticaux. Le même tableau exporté en boîtes remplies peut porter quatre morceaux de bordure par cellule, 720 avant fusion, et fonder chaque cellule ajoute quatre autres filets de bords par cellule, 1440 en tout. Deux tels tableaux sur une page exigent déjà près de 3000

Dépasser le budget est une erreur, et elle fait surface comme telle. ExtractTables lève EPdfError avec le message « Table ruling-segment budget exceeded », ne renvoie aucune grille partielle, et la passe par blancs pour cette page ne tourne pas non plus. Un budget serré est raisonnable pour des entrées non fiables, où une page fabriquée pourrait tenir des millions de minuscules boîtes, mais alors l'appelant doit distinguer « trop complexe pour ce budget » de « aucun tableau sur cette page » :

function TryExtractTables(Pdf: TPdf; Budget: Integer;
  out Tables: TPdfTables): Boolean;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.MaxRulingSegments := Budget;     // 16384 par défaut
  try
    Tables := Pdf.ExtractTables(Options);
    Result := True;
  except
    on E: EPdfError do
    begin
      // LogWarning est votre propre routine de journalisation, hors de la bibliothèque
      LogWarning(Format('Page %d: %s', [Pdf.PageNumber, E.Message]));
      Tables := nil;
      Result := False;                     // hors budget ou options invalides
    end;
  end;
end;

// Un résultat False signifie « non décidé », ce qui diffère d'un tableau vide
if not TryExtractTables(Pdf, 4096, Tables) then
  QueueForReview(Pdf.FileName, Pdf.PageNumber);  // aussi votre propre routine

Le même type d'exception couvre aussi le budget de mots et les options invalides, donc le message journalisé est ce qui les distingue dans une file de revue

Là où la voie à filets passe la main

La détection à filets tourne d'abord, la détection par blancs ensuite, et deux règles les empêchent de se marcher dessus. Les mots dont les centres tombent dans une grille déjà rapportée par la passe à filets sont retirés avant que la détection par blancs ne démarre, donc un tableau n'est jamais rapporté deux fois. Une frontière de colonne par blancs doit aussi être un couloir vertical sans texte à travers chaque ligne qu'elle sépare, ce qui empêche les paragraphes justifiés de scorer comme tableaux ; l'article sur les faux positifs de tableaux par blancs couvre cette règle. Lire les rectangles remplis déplace les tableaux exportés par les suites bureautiques hors de la voie par blancs et dans la voie à filets

Comme les filets viennent des objets chemins et le texte de la page texte, une page sans couche de texte produit quand même le squelette de grille avec toutes les cellules vides. La confiance d'une grille à filets vaut 0,88 plus 0,12 fois la fraction des cellules qui tiennent du texte, donc un squelette vide rapporte exactement 0,88 et le compte de cellules du premier listage montre 0 cellules avec texte. Cette combinaison est le signal de lancer l'OCR d'abord, comme décrit dans l'ajout d'une couche de texte cherchable avec OCR. Les formes remplies avec courbes ou contours non rectangulaires ne contribuent rien, donc un tableau dont les cellules sont des rectangles arrondis remplis tombe à la détection par blancs. Un tableau sans bordures ni fonds appartient à la stratégie par blancs décrite dans l'article de détection et extraction de tableaux, et quand même cela ne suffit pas, les boîtes de mots et les blocs de texte structuré et ordre de lecture sont la matière première d'un lecteur écrit pour une famille de documents

La démo TableExtractionLab livrée avec le composant a une case à cocher pour DetectFilledRulings, donc vous pouvez charger un export et voir la grille apparaître et disparaître sans écrire de code. Options, types d'enregistrements et le reste de l'API d'extraction sont listés sur la page produit de PDFium Component pour Delphi