Article technique

Filets de table depuis des rectangles remplis en Delphi

L'extraction de tables de PDFium Component, à partir de la version 3.117.0, traite un mince rectangle rempli comme un filet de table. Avec DetectFilledRulings activé, ce qui est la valeur par défaut, une boîte remplie alignée sur les axes et pas plus épaisse que MaxRulingThickness (3 points) devient un filet le long de son axe long, une boîte remplie plus grande apporte ses quatre bords, et chaque coordonnée de filet est aimantée à RulingSnapTolerance (4 points) près avant que la grille ne soit assemblée. Les tables exportées depuis Word, Google Docs et les navigateurs atteignent donc le détecteur de grilles réglées sous forme de grilles complètes au lieu de retomber sur la détection par espaces blancs en fragments

L'article précédent sur la détection et l'extraction de tables affirmait que la détection de grilles réglées utilise les lignes tracées et que chaque segment de chemin tracé est transformé en coordonnées de page. Cette phrase était vraie et incomplète. Le comptage des objets chemin sur un ensemble de 13 documents d'exemple réels a montré que 9 d'entre eux ne contiennent aucun chemin tracé, alors que chacune de leurs pages porte des centaines de rectangles remplis épais de 0,5 à 1 point. Le détecteur limité aux traits ne voyait rien, chaque page retombait sur la détection par espaces blancs, et la sortie était une dispersion de petits fragments plutôt que des tables. Le préréglage compact-columns ajouté en 3.116.4 avait adouci cela au niveau des fragments ; la cause racine était que le détecteur lisait le mauvais opérateur de peinture

Pourquoi une table exportée depuis Word n'a-t-elle aucune ligne tracée ?

Un traitement de texte ne pense pas une bordure comme une ligne ; il la pense comme une boîte avec une largeur, et il peint cette boîte avec un remplissage. ISO 32000-1 §8.5.2.1 définit l'opérateur re comme ajoutant un sous-chemin rectangulaire, et §8.5.3 sépare les opérateurs de peinture : S trace le chemin avec la largeur de ligne courante, f remplit son intérieur. Une bordure de cellule de 0,5 point ressort comme x y w 0.5 re f, et la machinerie de tracé, largeur de ligne, jointures et motif de tirets compris, ne tourne jamais. Le fond de cellule est la même construction avec une boîte plus grande. Une grille tracée avec m, l et S est ce que le détecteur d'origine attendait, et c'est ce que presque rien de ce qui est exporté par une application bureautique ne produit :

% une bordure de cellule d'un export de traitement de texte : une boîte remplie de 0,5 pt de haut
72 700 468 0.5 re f
% fond de cellule : une boîte remplie de la taille de la cellule
72 676 117 24 re f
% la ligne de grille tracée pour laquelle le détecteur d'origine a été écrit
72 700 m 540 700 l S

Pour un détecteur qui demande à FPDFPath_GetDrawMode seulement si le drapeau de tracé est positionné, les deux boîtes remplies sont invisibles. Les mots à l'intérieur des cellules atteignent alors la détection par espaces blancs, où des colonnes séparées par une gouttière de 6 points se situent sous le MinColumnGap par défaut de 12 points, et ce qui revient est le sous-ensemble de rangées qui s'aligne tant bien que mal pour passer MinRows. C'est le comportement en fragments, et aucun réglage de paramètre ne le transforme en la grille que l'auteur a dessinée

Comment PDFium Component transforme-t-il une boîte remplie en filet ?

TableCollectObjectRulings inspecte chaque objet chemin sous-chemin par sous-chemin. Le mode de dessin vient de FPDFPath_GetDrawMode ; un chemin compte comme rempli quand DetectFilledRulings est actif et que le mode de remplissage n'est pas none. Chaque point est transformé par la matrice de l'objet et collecté, jusqu'à MaxSubpathPoints (8) par sous-chemin, et tout segment courbe marque le sous-chemin comme courbe. Quand le sous-chemin se referme ou qu'un nouveau MoveTo commence, FlushSubpath décide de ce qu'il était : un sous-chemin courbe est écarté, et de même tout polygone fermé dont les points ne se situent pas tous à PointTolerance (0,05 point) près des bords de la boîte englobante sur au moins un axe. Un triangle, un chevron ou une languette arrondie ne devient jamais un filet, et c'est ce qui garde les ornements hors de la grille

Schéma PDFium Component montrant comment TableCollectObjectRulings transforme les sous-chemins fermés en filets de table en Delphi : FlushSubpath écarte les contours courbes et les polygones hors des bords de la boîte englobante, MaxRulingThickness découpe les boîtes minces en un filet par axe long, les cellules à fond donnent quatre filets de bord et DetectFilledRulings tient les minuscules carrés à l'écart
Un sous-chemin fermé ne survit que s'il est aligné sur les axes, et la boîte englobante décide ensuite s'il s'agit d'un filet, des quatre bords d'une cellule à fond, ou de rien du tout

Ce qui survit est un rectangle aligné sur les axes, classé par sa boîte englobante. Une largeur inférieure ou égale à MaxRulingThickness avec une hauteur supérieure donne un filet vertical au centre horizontal, couvrant la boîte de bas en haut ; le cas miroir donne un filet horizontal. Les deux dimensions au-dessus du seuil signifient une cellule à fond, et la boîte apporte quatre filets, un par bord. Les deux dimensions en dessous du seuil n'apportent rien, donc une puce carrée de 2 points n'est pas prise pour une ligne. Un chemin tracé suit l'ancien itinéraire via AddLine, un filet par segment aligné sur les axes, donc une grille dessinée avec S est traitée exactement comme avant, et un chemin peint avec à la fois remplissage et tracé produit des morceaux qui se chevauchent que la passe de fusion effondre :

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  Mode: string;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // base 1

    Options := TPdfTableExtractionOptions.Default;
    // ce sont les valeurs par défaut de la 3.117.0, détaillées pour la clarté
    Options.DetectFilledRulings := True;     // les boîtes minces remplies deviennent des filets
    Options.MaxRulingThickness := 3.0;       // points ; les boîtes plus épaisses comptent comme fond
    Options.RulingSnapTolerance := 4.0;      // points ; 0 désactive l'aimantation
    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';
      Writeln(Format('%dx%d %s, confidence %.2f',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence]));
    end;
  finally
    Pdf.Free;
  end;
end;

Que fait RulingSnapTolerance pour les tables à cellules en fond ?

RulingSnapTolerance est ce qui fait qu'une table construite uniquement à partir de fonds se connecte en une seule grille. Certains exports ne dessinent aucune bordure : chaque cellule est une boîte remplie de sa propre couleur, et les boîtes voisines sont séparées par une gouttière blanche de 1 à 3 points. Chaque boîte donne quatre filets de bord, mais le bord droit d'une cellule et le bord gauche de la suivante se trouvent à 2 points l'un de l'autre, et le test de connectivité utilise RulingTolerance, qui vaut 1 point par défaut. Sans aimantation, chaque cellule forme sa propre composante connexe de quatre filets, aucune composante n'atteint MinRows, et la page ne signale rien. TableSnapRulings rassemble chaque coordonnée X en jeu (la position de chaque filet vertical plus le début et la fin de chaque filet horizontal) et chaque coordonnée Y de même, trie chaque liste, la regroupe en chaînant les valeurs dont le voisin ne diffère pas de plus que la tolérance, remplace chaque groupe par sa moyenne, puis déplace chaque position, début et fin vers le centre de groupe le plus proche. Les deux côtés d'une gouttière deviennent la même ligne, et la connectivité tient

Schéma PDFium Component montrant RulingSnapTolerance connectant une table à cellules en fond en Delphi : les cellules voisines laissent une gouttière de 2 pt, leurs filets de bord se situent au-delà du RulingTolerance de 1 pt, et TableSnapRulings chaîne les deux valeurs X en une seule moyenne de groupe pour que le test de connectivité voie enfin une ligne de grille partagée
L'aimantation s'exécute avant la fusion et avant le détecteur de grilles réglées, donc les deux côtés d'une gouttière blanche deviennent une seule ligne et chaque cellule cesse d'être une île de quatre filets

L'aimantation s'exécute avant TableMergeRulings, qui trie les filets et joint les morceaux colinéaires qui se touchent ou se chevauchent à RulingTolerance près, et les deux s'exécutent avant que TableDetectRuled ne voie les données, donc le test de connectivité par paires est proportionnel au nombre de lignes de grille plutôt qu'au nombre de fragments par cellule. Sur une grille tracée, les passes sont inoffensives, parce que des coordonnées déjà identiques s'aimantent sur elles-mêmes. La seule chose à garder en tête est que le groupement en chaîne n'a pas de limite de largeur propre : une série de coordonnées espacées de 3 points s'effondre en un seul centre. Avec la valeur par défaut de 4 points, cela n'affecte que des colonnes plus étroites qu'un caractère, mais si un document a de vraies gouttières de 3 points qui doivent rester séparées, baissez la tolérance ou mettez-la à 0 pour désactiver l'aimantation :

// Isoler la stratégie réglée et comparer ce que chaque réglage voit sur une page
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;

// Un export Word rapporte typiquement 0, N puis moins que N :
// le mode tracé seul ne voit rien, l'aimantation connecte les cellules en fond,
// et désactiver l'aimantation laisse chaque cellule en fond comme une île
Writeln(CountRuledTables(Pdf, False, 4.0));
Writeln(CountRuledTables(Pdf, True, 4.0));
Writeln(CountRuledTables(Pdf, True, 0.0));

Les filets à l'intérieur des form XObjects

Les outils de mise en page enveloppent fréquemment une table, ou tout le corps de page, dans un form XObject et le peignent avec Do. ISO 32000-1 §8.10.1 spécifie que la matrice du formulaire est concaténée avec la matrice de transformation courante quand le formulaire est peint, donc un rectangle à l'intérieur du formulaire vit dans l'espace du formulaire et n'atterrit sur la page qu'après deux transformations ou plus. TableCollectObjectRulings descend récursivement dans les objets de formulaire quand IncludeFormXObjects est activé : il lit la matrice de l'objet, la combine avec la matrice parente via TableMultiplyMatrix, dont l'ordre des arguments signifie « passer par la première matrice, puis la seconde », et énumère les enfants avec FPDFFormObj_CountObjects et FPDFFormObj_GetObject en transmettant la matrice combinée. Un emboîtement plus profond que MaxFormDepth (8) est sauté en silence, ce qui est un garde-fou contre les fichiers pathologiques plutôt qu'une limite qu'un vrai export approche. La raison pour laquelle l'ordre de multiplication compte est la même que celle discutée dans préfixer ou suffixer une matrice : intervertir les opérandes déplace le terme de translation, et un filet qui devrait atterrir en haut de la page atterrit à l'origine

Schéma PDFium Component des filets à l'intérieur d'un form XObject en Delphi : un mince rectangle écrit 72 700 468 0.5 re f vit dans l'espace du formulaire et n'atterrit sur la page qu'après que TableMultiplyMatrix a combiné la CTM parente avec la matrice du formulaire, en descendant via FPDFFormObj_CountObjects jusqu'à MaxFormDepth
Le rectangle est écrit dans l'espace du formulaire et n'atteint le haut de la page qu'après la multiplication des matrices dans un ordre qui garde le terme de translation là où il doit être

Pourquoi le budget de filets a-t-il quadruplé ?

Le MaxRulingSegments par défaut est passé de 4096 à 16384 en 3.117.0 parce que les bordures par cellule arrivent en bien plus grand nombre que les lignes de grille tracées. Une table tracée de 30 rangées et 6 colonnes représente 38 segments de ligne. La même table exportée en boîtes remplies représente jusqu'à quatre bordures par cellule, 720 morceaux avant fusion, et un formulaire à cellules en fond double cela. Deux telles tables sur une page auraient épuisé l'ancien budget. Le budget est appliqué dans TableAppendRuling via Check, qui lève EPdfError avec le message « Table ruling-segment budget exceeded » ; il n'y a pas de résultat dégradé, pas de grille partielle, et la passe par espaces blancs ne tourne pas non plus. Si vous fixez un budget plus serré pour de l'entrée non fiable, attrapez l'exception et décidez, plutôt que de lire un résultat vide comme « aucune table » :

Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := 2048;        // volontairement serré pour de l'entrée non fiable
try
  Tables := Pdf.ExtractTables(Options);
except
  on E: EPdfError do
  begin
    Log(E.Message);                       // 'Table ruling-segment budget exceeded'
    Options.MaxRulingSegments := 16384;   // la valeur par défaut de la 3.117.0
    Tables := Pdf.ExtractTables(Options);
  end;
end;

Résultats mesurés et où l'approche s'arrête

Sur les mêmes 13 documents d'exemple, l'extraction est passée de 43 tables, dont 9 réglées et 34 fragments par espaces blancs ou faux positifs, à 41 tables réglées et aucun faux positif par espaces blancs. Une partie de ce nettoyage revient à deux changements compagnons de la 3.117.0 : les mots déjà revendiqués par une grille réglée sont retirés avant que la détection par espaces blancs ne tourne, si bien qu'une table n'est jamais signalée deux fois, et une frontière de colonne par espaces blancs doit désormais être un corridor sans texte sur chaque rangée qu'elle sépare, ce qui a stoppé les paragraphes justifiés qui se qualifiaient comme tables 5x4. C'est le lecteur de rectangles remplis qui a fait passer les tables elles-mêmes de la colonne des fragments à celle des grilles réglées

Les limites méritent d'être énoncées clairement. Une page sans couche de texte donne toujours le squelette de la grille, chaque cellule vide, parce que les filets viennent de la géométrie et le texte de la page de texte ; les pages numérisées ont besoin d'un OCR au préalable. Les formes remplies à courbes, coins arrondis ou contours non rectangulaires sont entièrement écartées, donc une table dont les bordures sont dessinées comme des contours de rectangles arrondis a besoin de la détection par espaces blancs comme avant. Une table sans bordures ni fond n'est touchée par rien de tout cela et reste le domaine de la stratégie par espaces blancs décrite dans l'article sur l'extraction de tables ; 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 propre à un domaine. La démo TableExtractionLab livrée avec le composant expose DetectFilledRulings dans son panneau d'options, ce qui est le moyen le plus rapide de voir à quoi ressemble un export donné avec et sans lui ; l'API complète est décrite sur la page PDFium Component pour Delphi