Article technique

Rendu PDF par bandes dans Delphi : offset Y négatif

La première bande contenait tout le dessin écrasé en une seule bande, et les cinq bandes suivantes revenaient vides. C’était l’ancien export par bandes, et PDFiumPas l’a corrigé dans la version 3.66.0 : RenderPageBanded transmet désormais à FPDF_RenderPageBitmap la largeur et la hauteur cibles de la page entière pour chaque bande, avec un offset vertical négatif, de sorte que le clipping natif n’écrive que les lignes de la bande courante tandis que la page conserve sa géométrie de pleine page. Le cas d’usage est banal et inévitable. Quelqu’un vous remet un plan au format E ou une page panoramique assemblée et demande un raster à 600 DPI. Une feuille ISO A0 à 600 DPI mesure 19866 x 28086 pixels, et un bitmap de destination 32 bits de cette taille représente un peu plus de 2 Go de mémoire contiguë. Sous Delphi 32 bits, l’allocation échoue tout simplement. En 64 bits, elle réussit assez souvent pour transformer l’échec en problème client plutôt qu’en problème de test. Le rendu par bandes existe pour que l’allocation de pointe soit une bande, pas une page

Pourquoi chaque bande contenait-elle toute la page ?

L’ancien code confondait deux paires d’arguments différentes dans l’appel de rendu de page PDFium. FPDF_RenderPageBitmap prend start_x, start_y, size_x et size_y ; la paire de tailles indique les dimensions auxquelles toute la page doit être mise à l’échelle, tandis que la paire de départ indique où cette page mise à l’échelle se place dans le bitmap de destination. La boucle de bandes antérieure à la version 3.66.0 appelait l’helper RenderPage de la bibliothèque avec le haut de la bande comme offset de destination et la hauteur de la bande comme hauteur de page. Ces deux nombres passaient directement à l’appel natif : PDFium mettait donc toute la page à l’échelle dans un rectangle de seulement BandHeight lignes, puis la dessinait à y = BandTop dans un bitmap qui ne faisait lui-même que BandHeight lignes. Le résultat est exactement celui que l’on prédit une fois qu’on le voit. La bande zéro recevait toute la page écrasée verticalement à la hauteur de la bande. Chaque bande suivante recevait cette même page écrasée, repoussée sous le bord inférieur de son bitmap ; elle revenait donc comme un remplissage d’arrière-plan. Le bug se cache dans le cas utilisé par la plupart des smoke tests, une page dont la hauteur de rendu est inférieure à la hauteur de bande, car il n’y a alors qu’une seule bande et la mauvaise géométrie coïncide par hasard avec la bonne. Tout ce qui dépasse une bande l’expose immédiatement

Ce que garantit l’offset négatif

L’implémentation corrigée fait passer chaque bande par RenderTile, seul endroit du composant qui comprenait déjà la distinction. RenderTile prend une origine de tuile en coordonnées de pixels de pleine page ainsi qu’une PageWidth et une PageHeight distinctes, et transmet à PDFium -Left et -Top sans modifier la taille de la page. Nier l’offset fait glisser la page complète vers le haut jusqu’à ce que la bande demandée se trouve à la ligne zéro du bitmap de destination ; PDFium applique ensuite nativement le clipping aux limites du bitmap et rien en dehors de la bande n’est rastérisé. Le mappage page-vers-périphérique décrit dans l’ISO 32000-1 clause 8.3.2 reste identique de la première bande à la dernière, ce qui est précisément le but : la bande N est identique bit par bit aux lignes BandTop à BandTop + h d’un rendu pleine page unique, et la suite de régression l’affirme exactement, pixel par pixel, par rapport à la sortie RenderPage aux mêmes dimensions

// Une bande à la main. Le bitmap de destination ne fait que BandHeight lignes,
// mais la taille cible de la page reste la largeur Width x la hauteur Height complète
Band := Pdf.RenderTile(0, BandTop,          // origine de tuile en pixels de page
                       Width, BandHeight,   // taille du bitmap de destination
                       Width, Height);      // taille cible de la page entière
try
  // Band contient maintenant les lignes BandTop .. BandTop + BandHeight - 1 de la page
finally
  Band.Free;
end;

L’API publique par bandes est une boucle de callback. RenderPageBanded(Width, Height, BandHeight, BandCallback, Rotation, Options, Color) renvoie le nombre de bandes réellement rendues, ou 0 lorsque les arguments sont rejetés, et conserve le verrou de rendu du composant pendant toute la passe. La signature du callback est TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object. Le bitmap est pf32bit, large de Width pixels et jamais plus haut que BandHeight ; il est libéré dès que votre gestionnaire revient, copiez donc tout ce que vous voulez conserver. Renvoyer False arrête la passe après la bande courante, ce qui vous donne le même modèle d’annulation coopérative que celui utilisé par le rendu PDF progressif et annulable dans Delphi, mais à la granularité des bandes plutôt qu’à celle des continuations PDFium

type
  TBandSink = class
  private
    FCancelled: Boolean;
    FRows: Integer;
  public
    function HandleBand(BandIndex, BandTopY: Integer;
      Bitmap: TBitmap): Boolean;
    property Rows: Integer read FRows;
  end;

function TBandSink.HandleBand(BandIndex, BandTopY: Integer;
  Bitmap: TBitmap): Boolean;
begin
  // Bitmap disparaît au retour de cette méthode : consommez-le ici
  Inc(FRows, Bitmap.Height);
  Result := not FCancelled;
end;

// ...
Pdf.PageNumber := 1;
Bands := Pdf.RenderPageBanded(19866, 28086, 256, Sink.HandleBand);

Diffuser du PNG et du TIFF sans bitmap pleine page

Le rendu par bandes n’aide que si l’encodeur est séquentiel lui aussi ; la version 3.66.0 a donc ajouté RenderPageBandedToStream, qui écrit directement du PNG ou du TIFF dans un flux appartenant à l’appelant. TPdfBandedImageStreamOptions.Default initialise une hauteur de bande de 256 lignes, un niveau de compression PNG de 6 et un MaxOutputBytes de 0, ce qui signifie illimité. Le TPdfBandedImageReport renvoyé contient Format, Width, Height, BandsRendered, BandsEncoded, RowsEncoded, PeakBandBytes, OutputBytes et Completed. PeakBandBytes est le nombre qui vous intéresse réellement pour dimensionner un traitement : il vaut Width * BandHeight * 4, si bien que la feuille A0 ci-dessus atteint environ 19 Mo de tampon de bande au lieu de 2 Go de tampon de page

L’encodeur PNG est volontairement étroit. Il émet du RGB8 fixe, écrit un IHDR avec une profondeur de 8 bits et un type de couleur 2, puis construit chaque ligne de balayage avec le type de filtre 0 (méthode de filtre ISO/IEC 15948 0, type de filtre None) et la pousse dans le flux de compression zlib de la plateforme. Les octets compressés ressortent sous forme de blocs IDAT avec CRC, écrits dans l’ordre. La contrainte intéressante se trouve dans le flux sous la couche deflate : il répond aux requêtes de position, parce que le flux de compression les émet, mais toute tentative de vrai seek lève une erreur. C’est intentionnel. Une fois qu’un bloc IDAT et son CRC sont sur le fil, il n’est plus possible de revenir en arrière pour les corriger, et un seek silencieux corromprait une sortie qui semblerait encore structurellement valide

L’encodeur TIFF écrit un TIFF classique little-endian, la marque d’ordre des octets II suivie du magic 42, avec une bande par strip. Les pixels sortent d’abord et l’IFD à dix entrées est généré à la fin, une fois connus les offsets et les comptes d’octets des strips. La compression est la valeur 1 du tag 259, sans aucun codage entropique : la charge utile fait exactement Width * Height * 3 octets, PhotometricInterpretation vaut RGB, PlanarConfiguration vaut chunky et RowsPerStrip enregistre la hauteur de bande, tandis que le dernier strip court est décrit par sa propre entrée StripByteCounts. La hauteur de bande modifie donc la mémoire de pointe et le nombre de strips, mais pas la taille de sortie, ce qu’il vaut mieux savoir avant de la régler. Si vous voulez des fichiers petits plutôt que sans perte, le chemin par page dans la conversion de pages PDF en images JPEG avec le composant PDFium VCL reste le meilleur outil

var
  StreamOptions: TPdfBandedImageStreamOptions;
  Report: TPdfBandedImageReport;
  Output: TFileStream;
begin
  StreamOptions := TPdfBandedImageStreamOptions.Default(pbifPng);
  StreamOptions.BandHeight := 512;
  StreamOptions.CompressionLevel := 6;
  StreamOptions.MaxOutputBytes := Int64(256) * 1024 * 1024;

  Output := TFileStream.Create('sheet-a0-600dpi.png', fmCreate);
  try
    Report := Pdf.RenderPageBandedToStream(Output, 19866, 28086,
      StreamOptions);
  finally
    Output.Free;
  end;

  if not Report.Completed then
    raise Exception.Create('Banded export stopped before the last row');
  // Report.PeakBandBytes = 19866 * 512 * 4, pas 19866 * 28086 * 4
end;

Où un export par bandes s’arrête-t-il ?

Deux plafonds bornent la sortie et ils échouent volontairement à des endroits différents. Le premier est le budget de l’appelant : MaxOutputBytes est imposé par un flux d’écriture borné qui lève EPdfError avant toute écriture qui dépasserait la limite ; le budget est donc un plafond ferme, pas un rapport a posteriori. Le second est structurel. Le TIFF classique stocke les offsets de strips sur 32 bits, si bien que BeginImage valide Width * Height * 3, l’en-tête et le répertoire contre ce plafond et rejette le traitement avant l’écriture du moindre pixel ; le même contrôle s’exécute d’emblée contre MaxOutputBytes, car un TIFF dont le budget ne peut pas couvrir sa propre charge de pixels ne vaut pas la peine d’être commencé. Le PNG n’a pas de limite équivalente, puisque les blocs IDAT sont purement séquentiels et qu’il n’existe aucune table d’offset 32 bits à faire déborder

Il faut être lucide sur ce que laisse un export interrompu. Lorsque la passe n’atteint pas la dernière ligne, Completed reste à False et l’encodeur est démonté avec EndImage(False), qui n’écrit volontairement ni le bloc PNG IEND ni l’IFD TIFF. Le fichier partiel est donc invalide et chaque décodeur le dira, au lieu de produire une image plausible avec des lignes manquantes. Ce nettoyage est enveloppé de sorte qu’un échec secondaire dans EndImage ne puisse pas remplacer l’exception d’origine, ce qui fait la différence entre une trace de pile qui nomme la vraie cause et une trace qui nomme le concierge. Si vous avez besoin d’une progression persistante, faites un checkpoint par bande dans votre propre callback ; les techniques de cache au niveau des strips décrites dans le guide du cache de rendu et du zoom PDFium pour Delphi s’appliquent ici aussi

Brancher votre propre codec

Lorsque PNG et TIFF ne sont pas la cible, RenderPageBandedToEncoder prend un descendant de TPdfBandedImageEncoder et pilote la même boucle. Le cycle de vie est explicite et court : BeginImage(Width, Height), puis WriteBand(BandIndex, BandTopY, Bitmap) une fois par strip en ordre strictement croissant, puis EndImage(Completed), avec GetBytesWritten qui alimente Report.OutputBytes. Les encodeurs intégrés rejettent immédiatement une bande dans le désordre plutôt que d’essayer de la mettre en tampon, et tout encodeur que vous écrivez devrait faire de même, car un codec qui réordonne silencieusement les strips produit un fichier qui s’ouvre et ment. C’est le point d’extension à utiliser pour des tuiles JPEG 2000, un écrivain JPEG alimenté bande par bande par ligne MCU ou une alimentation directe d’un spooler d’impression

type
  TCodecBandEncoder = class(TPdfBandedImageEncoder)
  private
    FNextBand: Integer;
    FWritten: Int64;
  public
    procedure BeginImage(Width, Height: Integer); override;
    function WriteBand(BandIndex, BandTopY: Integer;
      Bitmap: TBitmap): Boolean; override;
    procedure EndImage(Completed: Boolean); override;
    function GetBytesWritten: Int64; override;
  end;

function TCodecBandEncoder.WriteBand(BandIndex, BandTopY: Integer;
  Bitmap: TBitmap): Boolean;
begin
  if BandIndex <> FNextBand then
    raise EPdfError.Create('Bands must arrive in order');
  Bitmap.PixelFormat := pf32bit;
  // Fournir Bitmap.ScanLine[0 .. Bitmap.Height - 1] au codec ici
  Inc(FNextBand);
  Result := True;
end;

Un piège inter-compilateurs à connaître

L’unité zlib porte un nom différent dans chaque toolchain prise en charge : Delphi XE5 et les versions ultérieures utilisent System.ZLib, FPC utilise zstream et les anciens Delphi utilisent simplement ZLib. C’est de la compilation conditionnelle routinière. Le piège est que les trois exportent des constantes de niveau de compression nommées clNone et clDefault, qui entrent directement en collision avec les membres TColor du même nom dans l’unité graphique. Dès que l’unité zlib apparaît dans la clause uses de l’implémentation, un clNone non qualifié dans le code de rendu peut se résoudre en niveau de compression plutôt qu’en couleur, sans diagnostic. PDFiumPas verrouille ce point avec des alias explicites de sentinelles de couleur, PdfGraphicsColorNone et PdfGraphicsColorDefault, liés une fois aux constantes graphiques entièrement qualifiées et utilisés partout où un arrière-plan de rendu ou une sentinelle de schéma de couleurs est comparé. Trois lignes de code, et la résolution des symboles cesse de dériver entre les compilateurs

Le rendu par bandes ressemble à une fonctionnalité de confort jusqu’à ce que l’on rencontre la page qui ne tient pas en RAM ; il devient alors le seul chemin qui fonctionne. La géométrie corrigée des bandes, les encodeurs PNG et TIFF séquentiels et le point d’extension d’encodeur personnalisé sont tous livrés dans le composant PDFium pour Delphi, avec la comparaison complète des pixels bande contre page exécutée dans la suite de régression sous Delphi, Lazarus et C++Builder