PDFium Component version 3.117.0 relie une table qui se coupe à une frontière de page lorsque soit les deux fragments touchent les bords de la page, soit aucun texte de corps ne se trouve sous le premier fragment et au-dessus du second, en ignorant les en-têtes et pieds de page courants. ExtractDocumentTables applique ce test sensible au contenu comme alternative au test plus ancien sur les marges, refuse un fragment de page suivante dont la première rangée est une seule cellule de légende pleine largeur, et conserve une rangée unique qui déborde sur la page suivante comme partie de sa chaîne de continuation
L'article sur la détection et l'extraction de tables présentait la continuation comme quatre portes strictes et traitait « touche le bord de page » comme l'une d'elles. Cette description était exacte pour la version qu'elle couvrait, et elle était aussi fausse pour la plupart des tables que les gens donnent réellement à manger au composant. Cet article est la correction : quels documents le test des marges ne sait pas gérer, ce qui l'a remplacé, et les deux cas de bord que le correctif a entraînés avec lui
Pourquoi le test des marges échoue-t-il sur les exports Word ?
Le test des marges échoue parce qu'un traitement de texte arrête de disposer les rangées à la marge du bas, pas au bord du papier. Avec le ContinuationMargin par défaut de 36 points, la règle d'origine exigeait que le bord inférieur du fragment antérieur se situe à moins de 36 points du bas de page et que le bord supérieur du fragment postérieur se situe à moins de 36 points du haut de page. Un document exporté depuis Word avec ses marges par défaut d'un pouce place la dernière rangée à au moins 72 points au-dessus du bas de page, davantage si un pied de page est présent, donc la condition n'était jamais remplie. Chaque longue table d'un tel document revenait sous forme de fragments indépendants avec ContinuationGroup à zéro, et l'appelant en revenait à recoudre à la main. Le test garde du sens pour ce autour de quoi il a été conçu : des rapports générés par des moteurs de mise en page qui remplissent une page jusqu'à une boîte de contenu fixe et commencent la page suivante au ras du haut. Ce n'est pas une mauvaise règle, c'est une règle incomplète, et c'est pourquoi la version 3.117.0 l'a conservée et a ajouté un second chemin au lieu de la remplacer
Que vérifie le test sensible au contenu à la place ?
Le test sensible au contenu vérifie si autre chose que la table occupe l'espace entre les deux fragments, en utilisant les boîtes de mots de chaque page plutôt que la géométrie de la page. Pendant qu'ExtractDocumentTables parcourt le document, il enregistre, page par page, le bord inférieur le plus bas de tout mot dont le haut se trouve au-dessus de la bande de pied de page et le bord supérieur le plus haut de tout mot dont le bas se trouve sous la bande d'en-tête. Les deux bandes font ContinuationMargin points de profondeur, si bien que la même option fait désormais double emploi comme marge de manœuvre au bord de page et comme hauteur des zones d'en-tête et de pied de page courants. Une paire de fragments passe lorsque le bord inférieur de l'antérieur est au niveau ou sous le texte de corps le plus bas de sa page et que le bord supérieur du postérieur est au niveau ou au-dessus du texte de corps le plus haut de la page suivante, chacun à AlignmentTolerance près. En clair : la table était la dernière chose de la page N et la première de la page N+1, et un numéro de page ou un titre de document dans la bande de marge ne compte pas. Cette exclusion n'est pas arbitraire. ISO 32000-1 §14.8.2.2 classe les en-têtes et pieds de page courants comme des artefacts de pagination, du contenu qui existe à cause du saut de page plutôt qu'en dépit de lui, et la même idée qui permet à un lecteur balisé de les sauter est ce qui permet à une table de se poursuivre au-delà. L'article sur le contenu balisé traite de la façon dont les fichiers balisés déclarent explicitement ces artefacts ; ici la classification est déduite de la position, parce que la plupart des tables exportées ne portent aucune balise
Les deux tests se combinent en OU. Un rapport de moteur de mise en page dont les tables vont jusqu'au bord du papier passe le premier ; un export Word dont les tables s'arrêtent à la marge passe le second ; un document qui fait les deux passe deux fois. Ce n'est qu'après la réussite de l'un d'eux que les portes restantes s'exécutent, et elles s'exécutent dans un ordre fixe : les numéros de page doivent être adjacents, le fragment postérieur ne doit pas s'ouvrir sur une rangée de légende, et les frontières de colonnes doivent correspondre à deux fois AlignmentTolerance près, soit 6 points avec les valeurs par défaut. L'énumération est TPdfTableContinuation avec les valeurs ptcNone, ptcStart, ptcMiddle et ptcEnd. Un fragment marqué ptcEnd qui se relie ensuite à encore une autre page est promu en ptcMiddle, donc une table de trois pages se lit start, middle, end dans l'ordre des pages. Les numéros de groupe commencent à 1 et 0 signifie non relié, et ToJson émet la même information sous les membres continuation et continuationGroup, ce qui est la forme à préférer si c'est un service en aval qui fait la couture
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; // par défaut ; montré pour la clarté
Options.ContinuationMargin := 54; // pied de page de deux lignes, ~50 pt de haut
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;
Comment une rangée de légende empêche-t-elle deux tables de fusionner ?
Un fragment de page suivante dont la première rangée est une seule cellule couvrant toutes les colonnes est traité comme une nouvelle table, jamais comme la suite de la précédente. Cette règle existe parce que le test sensible au contenu, à lui seul, relie trop volontiers. Le cas qui l'a révélé était un formulaire de type transcription : une table se termine près du bas de la page 1, une seconde table aux largeurs de colonnes identiques commence près du haut de la page 2, rien d'autre que le pied de page ne se trouve entre elles, et les colonnes correspondent au point près. Avec le test des marges, les deux ne se rencontraient jamais puisque ni l'une ni l'autre ne touchait un bord ; avec le test de contenu, elles se sont liées immédiatement, et un formulaire à sections est devenu une seule grille incohérente. Ce qui les sépare est visible dans la structure des cellules. La seconde table s'ouvre sur une légende de section comme « RECIPIENT INFORMATION » disposée en une seule cellule fusionnée sur toute la largeur, et une vraie continuation ne fait jamais cela, parce que la légende appartient à la table qui a déjà commencé sur la page précédente. TableStartsWithCaptionRow encode exactement cela : le fragment a au moins deux colonnes et contient une cellule avec RowIndex = 0, ColumnIndex = 0 et ColumnSpan = ColumnCount. La vérification ne s'exécute que sur le fragment postérieur, donc une table dont la propre rangée de légende se trouve sur sa première page n'est pas concernée ; la légende est sur la page N, et seul le fragment de la page N+1 est inspecté
La comparaison de colonnes qui suit, TablesHaveMatchingColumns, est plus stricte que « même nombre de colonnes ». Elle reconstruit les positions de frontière de chaque fragment à partir des rectangles de cellules, interpole les frontières que les cellules fusionnées cachent, et rejette la paire dès qu'une frontière dérive de plus que la tolérance. Deux tables de quatre colonnes aux proportions différentes restent donc séparées même quand tout le reste s'aligne
Qu'arrive-t-il à une rangée unique qui déborde sur la page suivante ?
Une grille réglée qui porte une rangée sur la page suivante est désormais détectée et reliée, à condition qu'elle finisse dans une chaîne de continuation ; seule, elle est écartée. Le MinRows par défaut de 2 existe pour éviter qu'une paire de traits égarée soit signalée comme une table, mais une dernière rangée poussée au-delà du saut est une vraie rangée qu'un plancher dur de 2 faisait silencieusement disparaître, et le reste de la table semblait complet alors qu'il ne l'était pas. La passe au niveau document la gère en trois étapes. Quand DetectContinuations et DetectRuledTables sont tous deux activés, la passe par page exécute le détecteur de grilles réglées avec le plancher de rangées temporairement abaissé à 1, ce qui explique pourquoi ExtractTables accepte désormais MinRows à 1 pour les grilles réglées alors que la détection par espaces blancs garde un plancher interne de 2. Les continuations sont marquées sur le résultat complet. Puis chaque table plus courte que le MinRows de l'appelant et ne faisant partie d'aucune chaîne est retirée. Le fragment d'une seule rangée ne survit que parce qu'il a été relié, et une grille d'une rangée au milieu d'une page par ailleurs ordinaire est filtrée exactement comme avant
// Reconstruire chaque chaîne en un seul CSV, en supprimant les rangées d'en-tête
// répétées sur les fragments de continuation
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); // en-tête répété par le traitement de texte
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;
Deux détails de cette routine sont délibérés. Le débordement d'une seule rangée n'est jamais supprimé, parce que le garde sur RowCount le préserve, et un traitement de texte qui répète la rangée d'en-tête sur chaque page produit un fragment dont la première ligne est de nouveau l'en-tête, donc supprimer la ligne zéro sur les fragments middle et end est juste dans ce cas et faux pour un générateur qui ne répète pas les en-têtes. Vérifiez un document avant de lâcher la routine sur un dossier
Où les règles s'arrêtent encore
Le test sensible au contenu ne vaut que par la couche de texte qu'il lit. Sur une page numérisée sans aucun texte, les extrêmes de texte de corps enregistrés retombent sur les limites de la page, la condition « rien entre » est satisfaite de façon vide, et seules les portes de la rangée de légende et des colonnes subsistent ; une grille réglée sur une telle page est quand même trouvée comme squelette vide, donc la chaîne peut se relier correctement, mais rien du texte environnant n'a réellement été vérifié. Ajoutez d'abord une couche de texte si cela compte. Les pieds de page rendus sous forme d'images plutôt que de texte sont invisibles pour la logique des bandes et inoffensifs pour la même raison
Les bandes ne sont qu'un nombre. Un pied de page plus profond que ContinuationMargin laisse ses lignes inférieures dans la zone de corps, ce qui donne au fragment antérieur l'air suivi de texte et bloque le lien ; augmentez l'option jusqu'à la vraie profondeur de bande, comme le fait le premier exemple. Augmentez-la trop et un court paragraphe de clôture près du bas de page se glisse dans la bande et est ignoré, ce qui relie une table à ce qui la suit. La règle de légende a une défaillance en miroir : un générateur qui écrit une bannière fusionnée « continued » comme première rangée de chaque fragment de continuation verra ces fragments refusés comme nouvelles tables, et le seul remède aujourd'hui est de recoudre vous-même par ContinuationGroup après n'avoir rien assoupli, parce que la règle n'a pas d'interrupteur
Les tables détectées par espaces blancs n'ont aucun de ces secours pour une seule rangée. La stratégie par espaces blancs a besoin de deux rangées alignées pour voir une table du tout, donc une table non réglée qui déborde d'une rangée est encore signalée courte de cette rangée. Quand vous tombez dessus, les boîtes de mots derrière les blocs de texte structurés et l'ordre de lecture vous donnent les positions brutes pour la récupérer. Sur le jeu d'échantillons qui a motivé ce travail, treize exports de traitements de texte et de navigateurs, les cinq documents contenant de vraies tables multi-pages se sont tous reliés en chaînes uniques et le formulaire de transcription qui fusionnait auparavant est resté séparé, ce qui est la barre à laquelle la version a été mesurée, pas une promesse sur toutes les mises en page
Le marquage de continuation, la règle de légende et la passe des rangées uniques vivent tous dans le chemin au niveau document partagé par les compilations Delphi, C++Builder et Lazarus ; l'API complète d'extraction de tables est décrite sur la page PDFium Component pour Delphi