Article technique

Tables PDF multi-pages : suite sensible au contenu Delphi

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

Pourquoi la continuation de tables PDFium Component demande deux tests : avec des marges Word d'un pouce le test de marge de page exige des bords de fragments dans des fenêtres de 36 pt que la mise en page n'atteint jamais, tandis que le test sensible au contenu compare des boîtes de mots et lie quand la table est le dernier contenu de corps de la page N et le premier de la page N+1, en ignorant les bandes d'en-tête courante et de pied de page
L'un ou l'autre test ouvre la porte, et seulement alors les contrôles restants tournent : pages adjacentes, pas de ligne de légende pleine largeur sur le fragment ultérieur, et frontières de colonnes correspondant à moins de deux fois AlignmentTolerance

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

Le verrou de ligne de légende dans PDFium Component : une vraie continuation s'ouvre avec des cellules de données et rejoint le même ContinuationGroup, pendant qu'un fragment ultérieur dont la ligne zéro tient une cellule fusionnée avec RowIndex 0, ColumnIndex 0 et ColumnSpan égal à ColumnCount est refusé comme continuation et rapporté comme nouvelle table
L'inspection ne touche que le fragment ultérieur, si bien qu'une table dont la ligne de légende propre se trouve sur sa première page n'est pas affectée, et le verrou ne tourne qu'après que l'un des deux tests de bord a passé

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

Comment PDFium Component garde une ligne réglée qui déborde d'une coupure de page : la passe réglée par page tourne avec le plancher de lignes à un quand DetectContinuations et DetectRuledTables sont réglés, les continuations sont marquées sur le résultat complet, et seuls les fragments plus courts que MinRows qui se trouvent hors de toute chaîne sont retirés
La ligne débordée survit parce que sa chaîne la lie, pendant qu'une grille à une ligne autonome sur une page ordinaire est filtrée comme d'habitude, et les tables détectées par blancs gardent leur plancher de deux lignes sans pareil soulagement

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