Une table qui traverse trois pages d'un export Word ne s'approche jamais à moins de 72 points du bas du papier. Des marges d'un pouce font 72 points, et le traitement de texte cesse de placer des lignes à la marge. Un lieur qui ne joint des fragments que quand ils se trouvent à moins de 36 points des bords de page voit trois tables sans rapport avec des colonnes identiques. La question qui marche est différente : y a-t-il autre chose qu'un en-tête ou pied de page courant entre la fin d'un fragment et le début du suivant ?
Cette question apporte ses deux propres pièges. Un formulaire peut finir une table au bas de la page 1 et ouvrir une autre table avec les mêmes largeurs de colonnes en haut de la page 2. Et une grille réglée peut pousser une seule ligne par-dessus la coupure, trop courte pour un détecteur qui veut deux lignes avant d'appeler quelque chose une table
Deux tests de bord dans ExtractDocumentTables
PDFium Component marque les continuations dans TPdf.ExtractDocumentTables, le pendant au niveau document du ExtractTables par page couvert dans l'article de détection et extraction de tables. Avec DetectContinuations réglé, comme il l'est dans TPdfTableExtractionOptions.Default, la méthode extrait chaque page, trie les tables par numéro de page puis du haut de chaque page vers le bas, et vérifie chaque paire de voisins dans cette liste. Seuls les voisins sont comparés. Un candidat continuation est donc toujours la table la plus basse de la page N appariée avec la table la plus haute de la page N+1, et une petite table placée sous une longue sur la page N prend la place de la longue dans cet appariement. Quand une petite table de notes se trouve sous une longue, liez ces fragments dans votre propre code à partir des numéros de page et du ColumnCount
Le premier test est purement géométrique. Le bord bas du fragment antérieur doit se trouver à moins de ContinuationMargin points du bas de la page, et le bord haut du fragment ultérieur à la même distance du haut de la page suivante. Le défaut est 36 points, un demi-pouce. Les rapports de moteurs de mise en page qui remplissent une boîte de contenu fixe passent ça facilement. Un export Word avec des marges d'un pouce rate d'au moins 36 points de chaque côté, et élargir la marge jusqu'à ce que ça passe tourne mal, parce que le même nombre règle les bandes d'en-tête et de pied du second test
Ce second test, celui sensible au contenu, est ce qui traite la sortie des traitements de texte. Les deux se combinent en OU : l'un ou l'autre ouvre la porte
Ce qui compte comme texte entre deux fragments ?
Chaque mot d'une page compte comme texte de corps sauf s'il se trouve entièrement à l'intérieur de la bande d'en-tête courante ou de la bande de pied, et les deux bandes sont profondes de ContinuationMargin points. Par page, ExtractDocumentTables note le bord bas le plus bas parmi les mots dont le haut est au-dessus de la bande de pied et le bord haut le plus haut parmi les mots dont le bas est sous la bande d'en-tête, le texte de cellules propre de la table compris. Quand la table est le dernier contenu de corps de la page N, son bord bas se trouve au niveau ou sous le mot le plus bas, et le test passe à moins de AlignmentTolerance, qui vaut 3 points par défaut. Le fragment ultérieur doit remplir la condition miroir en haut de la page N+1
Ignorer les bandes a un fondement dans la spécification. L'ISO 32000-1, section 14.8.2.2, traite les courants de tête, pieds et numéros de page comme artefacts de pagination, du contenu qui n'existe que parce que le document a été coupé en pages. Un fichier balisé peut les déclarer avec /Artifact, comme l'explique l'article sur le contenu marqué, mais les exports qui ont le plus besoin de recoudre des tables portent rarement des balises, si bien que la position tient lieu de balise. Avec la bande par défaut, un numéro de page dont les glyphes culminent à 30 points au-dessus du bord bas est ignoré. Une ligne d'avertissement qui atteint 40 points est du texte de corps, et elle bloque le lien
Sur une page sans couche de texte, les extrêmes notés retombent sur les bornes de page, le test sensible au contenu passe trivialement, et les verrous restants décident. Les pieds dessinés comme images ou contours vectoriels sont invisibles pour la même raison et ne bloquent jamais un lien
Passer un test de bord ouvre seulement la porte. Les pages doivent être consécutives, le fragment ultérieur ne doit pas s'ouvrir sur une ligne de légende, et les frontières de colonnes doivent s'aligner à moins de deux fois AlignmentTolerance, 6 points aux défauts. Quand tout ça tient, le fragment antérieur devient ptcStart avec un numéro de groupe frais, ou passe de ptcEnd à ptcMiddle s'il a déjà clôturé une chaîne, et le fragment ultérieur devient ptcEnd dans le même groupe. Une table étalée sur trois pages se lit début, milieu, fin. Les numéros de groupe démarrent à 1 ; le groupe 0 avec ptcNone signifie que la table se tient seule. ToJson écrit les mêmes données comme "continuationGroup" et "continuation" (none, start, middle ou end), si bien qu'un service qui reçoit du JSON peut recoudre tout seul
Le listing ci-dessous suppose un export Word avec un pied de page de deux lignes profond d'environ 50 points, si bien que la marge monte à 54 pour garder les deux lignes dans la bande
uses
System.SysUtils, 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; // DetectContinuations = True
Options.ContinuationMargin := 54; // pied de page supposé ~50 pt de profondeur
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 x %d',
[Tables[I].PageNumber, Tables[I].RowCount,
Tables[I].ColumnCount]));
end;
finally
Pdf.Free;
end;
end.
Comment une première ligne pleine largeur garde deux tables séparées
Un fragment ultérieur dont la première ligne est une cellule unique couvrant chaque colonne est rapporté comme une nouvelle table, même quand un test de bord passe et que les colonnes correspondent. Les formulaires sont l'endroit où le test sensible au contenu irait trop loin : une table finit près du bas de la page 1, la section suivante s'ouvre en page 2 avec des largeurs de colonnes identiques, et chaque contrôle géométrique passe. La différence est la ligne d'ouverture de la seconde table, une légende de section comme RECIPIENT INFORMATION disposée en une cellule fusionnée sur toute la largeur. Une vraie continuation ne s'ouvre pas ainsi, parce que la légende d'une table reste sur la page où la table démarre
La règle ne lit que les cellules du fragment ultérieur. Ce fragment a besoin d'au moins deux colonnes et d'une cellule avec RowIndex = 0, ColumnIndex = 0 et ColumnSpan = ColumnCount. Le fragment antérieur n'est jamais inspecté, si bien qu'une table dont la légende propre se trouve en haut de la page N lie normalement à sa continuation en page N+1
La comparaison de colonnes qui suit est plus stricte qu'un compte de colonnes égal. Les positions de frontières de chaque fragment sont reconstruites depuis ses rectangles de cellules ; une frontière cachée sous une cellule fusionnée est comblée en la espaçant uniformément entre les frontières connues les plus proches de chaque côté. Puis chaque paire de frontières est comparée, et une différence au-dessus de 6 points rejette le lien. Deux tables à quatre colonnes avec des proportions différentes restent séparées. Il en va de même pour une vraie continuation dont le générateur a déplacé une colonne de 8 points entre les pages ; monter AlignmentTolerance pour l'accepter relâche aussi le groupement utilisé pendant la détection
Une seule ligne réglée par-dessus une coupure de page
Une grille réglée qui pousse une seule ligne sur la page suivante garde cette ligne quand elle se lie dans une chaîne et la perd quand elle ne le fait pas. Le MinRows par défaut de 2 empêche une paire de règles égarées d'être rapportée comme table. Une ligne poussée par-dessus la coupure est différente : c'est de vraies données, et l'abandonner laisserait la table recousue une ligne courte sans fragment restant pour expliquer le trou
Le balayage du document le traite en trois étapes. Quand DetectContinuations et DetectRuledTables sont tous deux allumés, le détecteur réglé tourne sur chaque page avec son plancher de lignes abaissé à 1. Les continuations sont ensuite marquées sur le résultat entier. Finalement, chaque table avec moins de lignes que le MinRows de l'appelant qui n'a pas fini dans une chaîne est retirée. Une grille à une ligne au milieu d'une page ordinaire traverse ce filtre et disparaît, ce qui est exactement le but de MinRows
Laissez MinRows à 2. Le régler vous-même à 1 éteint le filtre final, et chaque grille réglée à une ligne de chaque page revient
L'exception ne vaut que pour les grilles réglées. La stratégie par blancs a besoin d'au moins deux lignes alignées pour reconnaître une table, si bien qu'elle garde un plancher interne de 2 quoi que dise MinRows ; ExtractTables accepte MinRows = 1, mais seule la détection réglée l'honore. Pour une table non réglée qui déborde d'une ligne, les boîtes de mots de la page ou les blocs décrits dans blocs de texte structuré et ordre de lecture vous donnent les positions pour recouvrer cette ligne vous-même
Recoudre une chaîne en une seule table
Les chaînes reviennent contiguës. Les liens ne sont jamais faits qu'entre voisins dans le tableau trié, si bien qu'un fragment ptcStart est suivi directement de ses fragments ptcMiddle et ptcEnd, et une boucle qui se remet à zéro sur ptcNone ou ptcStart et écrit sur ptcNone ou ptcEnd n'a jamais à chercher quoi que ce soit par numéro de groupe
La ligne d'en-tête répétée est la partie épineuse : Word peut la répéter sur chaque page, d'autres générateurs non. Le drapeau IsHeader sur TPdfTableCell ne peut pas trancher, parce qu'il est assigné par position, RowIndex < HeaderRowCount, séparément pour chaque fragment. La ligne 0 d'une continuation est marquée comme en-tête qu'elle répète l'en-tête ou porte des données, et la ligne débordée d'une seule ligne de la section précédente est marquée aussi. La routine ci-dessous compare le texte de la première ligne de chaque continuation avec la première ligne de la chaîne et l'abandonne seulement sur correspondance exacte
Elle construit aussi les lignes depuis Cells au lieu de scinder la sortie de ToCsv en lignes. Une cellule dont le texte s'enveloppe à l'intérieur de la cellule revient avec un CR LF entre ses lignes visuelles, ToCsv garde cette coupure à l'intérieur du champ cité, et supprimer la première ligne du texte CSV couperait une cellule d'en-tête à deux lignes en deux
// Utilitaires côté lecteur (pas une partie de la bibliothèque) : reconstruisez chaque chaîne
// comme un fichier séparé par tabulations, en abandonnant les en-têtes répétés sur les pages suivantes
function RowText(const T: TPdfTable; Row: Integer): string;
var
Parts: array of string;
I: Integer;
begin
SetLength(Parts, T.ColumnCount);
for I := 0 to High(T.Cells) do
if (T.Cells[I].RowIndex = Row) and (T.Cells[I].ColumnIndex >= 0) and
(T.Cells[I].ColumnIndex < T.ColumnCount) then
Parts[T.Cells[I].ColumnIndex] :=
StringReplace(string(T.Cells[I].Text), #13#10, ' ', [rfReplaceAll]);
Result := '';
for I := 0 to High(Parts) do
begin
if I > 0 then
Result := Result + #9;
Result := Result + Parts[I];
end;
end;
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
I, R, FirstRow: Integer;
Header: string;
Lines: TStringList;
begin
Lines := TStringList.Create;
try
for I := 0 to High(Tables) do
begin
FirstRow := 0;
if Tables[I].Continuation in [ptcNone, ptcStart] then
begin
Lines.Clear;
Header := RowText(Tables[I], 0);
end
else if (Trim(Header) <> '') and (RowText(Tables[I], 0) = Header) then
FirstRow := 1; // en-tête répété sur cette page
for R := FirstRow to Tables[I].RowCount - 1 do
Lines.Add(RowText(Tables[I], R));
if Tables[I].Continuation in [ptcNone, ptcEnd] then
Lines.SaveToFile(Format('%s\table%d-page%d.tsv',
[Folder, I, Tables[I].PageNumber]));
end;
finally
Lines.Free;
end;
end;
La comparaison exacte est le bon choix. Un en-tête répété par le traitement de texte correspond caractère pour caractère, pendant qu'une correspondance floue finirait par avaler une ligne de données qui ressemble à l'en-tête. Un en-tête supplémentaire égaré est facile à repérer dans la sortie ; une ligne de données manquante ne l'est pas
Mesurer la bande de pied au lieu de deviner
ContinuationMargin fait trois travaux avec un nombre : le jeu de bord de page pour le premier test, la profondeur de bande de pied et la profondeur de bande d'en-tête pour le second. Un pied plus profond que la bande laisse sa ligne supérieure dans la zone de corps, le fragment antérieur a l'air suivi de texte, et la chaîne casse à cette page. Une bande réglée trop profonde se met à avaler du vrai contenu : une courte ligne de clôture juste au-dessus de la marge basse tombe à l'intérieur, et la table au-dessus de cette ligne peut alors se lier à ce qui ouvre la page suivante
Le document lui-même peut fournir le nombre. TPdf.PageWordBoxes renvoie chaque mot de la page courante avec un Rect en espace page dont le Top est plus grand que son Bottom, si bien que le Top le plus haut parmi les mots se trouvant sous la marge de corps est la profondeur du pied. La sonde ci-dessous prend une valeur juste sous la marge basse de la mise en page, 70 pour une page d'un pouce, et garde 36 points comme plancher pour les documents sans pied de page
// Utilitaire côté lecteur (pas une partie de la bibliothèque) : le plus haut sommet de mot
// trouvé sous BodyMargin sur une page quelconque, c.-à-d. la profondeur de la zone de pied de page
function MeasureFooterDepth(Pdf: TPdf; BodyMargin: Double): Double;
var
P, I: Integer;
Words: TPdfWordBoxes;
begin
Result := 0;
for P := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := P;
Words := Pdf.PageWordBoxes;
for I := 0 to High(Words) do
if (Words[I].Rect.Bottom < BodyMargin) and
(Words[I].Rect.Top > Result) then
Result := Words[I].Rect.Top;
end;
end;
// utilise System.Math pour Max
Options := TPdfTableExtractionOptions.Default;
Options.ContinuationMargin := Max(36.0, MeasureFooterDepth(Pdf, 70) + 2);
Tables := Pdf.ExtractDocumentTables(Options);
La bande a la même profondeur aux deux bords, alors mesurez aussi un en-tête courant profond contre le haut de page et utilisez la plus grande valeur. Gardez le jeu à une couple de points ; chaque point de plus est un point de texte de corps que le test sensible au contenu cesse de voir
Une disposition bat la règle de légende par construction : un générateur qui écrit une bannière "suite" fusionnée comme première ligne de chaque fragment de continuation. Chaque tel fragment est rapporté comme nouvelle table, alors liez-les dans votre propre code à partir de numéros de pages consécutifs, de ColumnCount égaux et du texte de bannière en ligne 0
Le marquage de continuations, la règle de légende et l'exception à une ligne tournent tous à l'intérieur de ExtractDocumentTables, qui se comporte pareil dans les builds Delphi, C++Builder et Lazarus. L'ensemble complet des options d'extraction de tables est listé sur la page PDFium Component for Delphi