Article technique

Mesure du texte PDF pour la mise en page et le retour à la ligne dans Delphi

L'appel qui place du texte sur une page PDF est simple. Vous donnez à AddText une chaîne (string), une police, une taille et une position, et les glyphes apparaissent. Ce qu'il ne fait pas, c'est vous dire quelle sera la largeur de cette chaîne une fois qu'elle aura été dessinée, et il ne divise pas une longue chaîne sur plusieurs lignes. Un seul appel peint une séquence (run) de texte à une position. Si la séquence est plus large que la colonne dans laquelle vous vouliez qu'elle s'intègre, elle dépasse simplement le bord, et rien dans l'appel de dessin ne vous en avertit. Au moment où vous voulez un paragraphe plutôt qu'une simple étiquette, la pièce manquante est la largeur d'une chaîne dans la police et la taille choisies, mesurée avant de la valider (commit) sur la page

C'est le problème classique de mise en page. Pour envelopper (wrap) un paragraphe dans une colonne, vous devez savoir, mot par mot, combien d'espace horizontal prendra chaque ligne candidate, et vous devez le savoir avant de dessiner quoi que ce soit. Le retour à la ligne (word wrap) est une boucle de mesure enroulée autour d'un appel de dessin, et une liaison (binding) qui ne fait que dessiner vous donne la seconde moitié. Le support de mesure de texte dans le composant PDFium comble cette lacune avec deux fonctions, MeasureText et MeasureTextWidth, qui signalent l'étendue (extent) rendue d'une chaîne sans mettre de marque sur aucune page

Pourquoi la mesure est un class helper, pas une nouvelle méthode sur TPdf

Le support de mesure arrive sous la forme d'un class helper (assistant de classe) Delphi pour TPdf, vivant dans sa propre unité, plutôt que comme de nouvelles méthodes boulonnées dans la classe TPdf. Un class helper est une fonctionnalité du langage qui vous permet d'attacher des méthodes à un type existant depuis l'extérieur de sa déclaration. Une fois l'unité dans la portée (in scope), les nouvelles méthodes sont appelées exactement comme si elles appartenaient à la classe, de sorte qu'une méthode d'assistance (helper method) se lit comme Pdf.MeasureTextWidth(...) sans objet distinct à construire ou à faire circuler

La raison de cette couche (layer) de cette manière est la séparation. Le type TPdf de base reste tel qu'il est, sans aucun champ ajouté et sans aucune signature existante touchée, de sorte qu'un projet qui n'a jamais besoin de mise en page ne porte jamais le code de mesure. Un projet qui en a besoin ajoute une unité à une clause uses et les méthodes s'allument (light up). La capacité devient optionnelle (opt-in) à la granularité d'une seule unité, ce qui est le moyen le plus propre d'étendre un type que vous ne possédez pas ou que vous ne voulez pas perturber

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // l'unité d'assistance (helper unit) ; amène MeasureText dans la portée sur TPdf

// Avec l'unité dans la portée, les méthodes se lisent comme des membres de TPdf :
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W et H sont maintenant la largeur et la hauteur rendues en unités utilisateur PDF
end;

Mesurer sans toucher la page

La mesure doit être exempte d'effets secondaires. Elle doit signaler une largeur sans rien laisser derrière elle, car vous l'appelez de nombreuses fois tout en décidant d'une mise en page et la page doit avoir exactement l'aspect qu'elle aurait eu si vous n'aviez jamais mesuré du tout. La technique qui rend cela possible consiste à créer un objet texte, à lui demander sa taille et à le jeter avant qu'il ne soit jamais attaché à une page

La séquence est de quatre appels PDFium. FPDFPageObj_NewTextObj crée un objet texte par rapport au document, compte tenu du nom et de la taille de la police. FPDFText_SetText définit la chaîne que cet objet transporte. FPDFPageObj_GetBounds relit la boîte de délimitation (bounding box) de l'objet. FPDFPageObj_Destroy libère l'objet. De manière cruciale, rien dans cette séquence n'appelle l'API d'insertion de page. L'objet est créé, interrogé et détruit de manière isolée, de sorte que le document reste inchangé lorsque la fonction revient. Il s'agit d'une sonde jetable (throwaway probe) dont la seule sortie sont les quatre nombres de sa boîte de délimitation

C'est la méthode robuste pour le faire, car PDFium n'expose pas de largeur d'avance pratique par glyphe (per-glyph advance width) que vous pourriez additionner vous-même. Les métriques des glyphes dépendent du programme de police, de l'encodage et de la façon dont PDFium charge la police (face), et il n'y a pas d'appel public qui vous donne l'avance de chaque caractère dans une chaîne. La boîte de délimitation d'un véritable objet texte, en revanche, est calculée par la même machinerie qui disposerait les glyphes pour le dessin, elle reflète donc l'étendue (extent) rendue réelle plutôt qu'une approximation. La construction d'un objet jetable et la lecture de ses limites (bounds) est la mesure la plus fiable que la bibliothèque puisse donner

// La forme de MeasureText, exprimée par rapport aux appels PDFium vérifiés.
// Un objet texte est construit, mesuré et détruit ; aucune page n'est impliquée.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
  FontSize: Single; out Width, Height: Double);
var
  TextObject: FPDF_PAGEOBJECT;
  L, B, R, T: Single;
begin
  Width  := 0;
  Height := 0;
  if Self.Document = nil then
    Exit;
  TextObject := FPDFPageObj_NewTextObj(Self.Document,
    FPDF_BYTESTRING(AnsiString(Font)), FontSize);
  if TextObject = nil then
    Exit;
  try
    if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
      Exit;
    if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
    begin
      Width  := R - L;
      Height := T - B;
    end;
  finally
    FPDFPageObj_Destroy(TextObject);   // sonde jetée, page intacte
  end;
end;

Coordonnées et unités du résultat

La boîte de délimitation revient sous la forme de quatre bords, gauche, bas, droite et haut, et les deux dimensions en découlent par soustraction. La largeur est la droite moins la gauche et la hauteur est le haut moins le bas. Les deux sont exprimés en unités utilisateur PDF, où une unité correspond à un soixante-douzième de pouce, le même espace de coordonnées dans lequel vous positionnez le texte sur la page. Il n'y a pas d'unité de périphérique cachée et aucun pixel impliqué à ce stade. Une largeur de 36 signifie un demi-pouce de page, quelle que soit la résolution de rendu finale

L'axe vertical s'étend de la façon dont le PDF le définit, avec Y augmentant vers le haut, c'est pourquoi la hauteur est le haut moins le bas plutôt que l'inverse. Ce détail compte lorsque vous avancez un curseur vers le bas d'une colonne. Vous mesurez la hauteur d'une ligne, puis la soustrayez de la ligne de base (baseline) courante pour trouver la suivante, car se déplacer vers le bas de la page signifie se déplacer vers un Y plus petit. Si votre destination est un écran plutôt que du papier, vous convertissez les unités utilisateur en pixels de périphérique (device pixels) avec la résolution d'affichage : une valeur en unités utilisateur multipliée par le DPI et divisée par 72 donne des pixels, de sorte qu'une largeur de colonne que vous définissez en points peut être comparée à une séquence (run) mesurée avant de décider où va la rupture (break)

Ce qui se passe sur une entrée dégénérée

Les fonctions sont écrites pour échouer silencieusement. S'il n'y a pas de document ouvert, ou si l'objet texte ne peut pas être créé, le résultat est une étendue (extent) nulle plutôt qu'une exception levée. La largeur et la hauteur sont initialisées à zéro en haut et ne sont écrasées qu'une fois qu'une boîte de délimitation a été relue avec succès. Une chaîne vide, un document manquant, une police que la bibliothèque ne peut pas résoudre en un objet, chacun de ces éléments renvoie zéro au lieu de lever (throwing) une exception

Ce choix maintient une boucle de mesure simple, car une boucle qui s'étend sur des milliers de mots n'est pas l'endroit pour la gestion des exceptions à chaque itération. Le coût est que l'appelant porte la vérification. Une largeur nulle est une sentinelle, pas un fait concernant le texte, donc le code qui divise par une largeur mesurée ou suppose une valeur positive doit se prémunir contre zéro avant de lui faire confiance. Traitez zéro comme "n'a pas pu mesurer" et le contrat est clair ; l'ignorer et une entrée dégénérée devient tranquillement une mise en page avec une colonne de glyphes superposés (overlapping)

Un retour à la ligne glouton (greedy word wrap) basé sur la mesure

Avec une fonction de largeur en main, le retour à la ligne est une courte boucle gloutonne (greedy loop). Vous divisez le paragraphe en mots, gardez une ligne courante et pour chaque mot, vous mesurez ce que serait la ligne si vous y ajoutiez ce mot. Tant que la ligne d'essai correspond toujours à la largeur de la colonne, vous continuez à ajouter ; lorsqu'elle déborderait, vous videz (flush) la ligne courante avec AddText et en commencez une nouvelle avec le mot qui ne convenait pas. L'accumulation se fait entièrement avec MeasureTextWidth, et la seule chose qui atteint jamais la page est une ligne que vous avez déjà confirmée comme convenable (fits)

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<string>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := string(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // Mesurer la ligne candidate avant de dessiner quoi que ce soit.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(Line, Font, FontSize, X, Y);   // vider la ligne qui convenait
      Y    := Y - LineHeight;                    // Y diminue en descendant
      Line := Words[I];                          // le mot qui déborde commence la ligne suivante
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(Line, Font, FontSize, X, Y);      // vider la ligne finale
end;

La boucle mesure la ligne d'essai plutôt que de mesurer chaque mot et de faire la somme, car la largeur d'une ligne n'est pas la somme des largeurs de ses mots. Les espaces entre les mots contribuent, et une séquence (run) mesurée capture cela directement. La règle gloutonne (greedy rule), ajuster autant de mots que la colonne le permet et couper (break) au dernier qui convient, est la même règle qui comble l'écart entre un AddText brut et un vrai paragraphe. L'appel de dessin n'a jamais été la partie difficile. La mesure qui doit la précéder l'est, et c'est exactement ce que l'assistant (helper) fournit

Où cela s'intègre (fits)

La mesure est la couche entre la génération de contenu et son rendu, elle s'associe donc naturellement au reste d'un flux de travail (workflow) de document créé à partir de zéro. Si vous assemblez des pages et placez du texte en premier lieu, le travail préparatoire (groundwork) se trouve dans la création de documents PDF à partir de zéro avec le composant PDFium dans Delphi, où AddText et la configuration de la page sont couverts en détail. Lorsque la police que vous mesurez compte autant que la chaîne, car les métriques dépendent de la police (face), l'analyse des propriétés de police PDF avec le composant PDFium dans Delphi montre comment la bibliothèque signale les informations de police qui pilotent ces boîtes de délimitation (bounding boxes). Les deux s'appuient sur la même liaison (binding), le Composant PDFium pour Delphi et Lazarus, où l'assistant de mesure (measurement helper) est livré aux côtés des API de document, de page et de texte décrites sur ce blog