Article technique

Rendu PDF monochrome 1 bit dans Delphi

Une passerelle fax ne veut pas de votre rendu de page en 24 bits. Pas plus que le pipeline d’archivage qui stocke un million de factures scannées, ni que le front-end OCR qui réduit tout au noir et blanc avant même de chercher un caractère. Les trois veulent la même chose : un bitmap 1 bit propre, un bit par pixel, où chaque point n’est que de l’encre ou du papier. Si vous leur donnez un BMP couleur pleine, ils jetteront de toute façon 23 bits par pixel, souvent avec un tramage moins bon que celui que vous auriez pu faire vous-même. La vraie question est de savoir où cette conversion doit se produire, et la réponse dans PDFlibPas dit quelque chose d’utile sur la façon d’étendre un moteur de rendu que vous préférez ne pas réécrire

PDFlibPas est une bibliothèque PDF native en Object Pascal pour Delphi et C++Builder. Son moteur de rendu rasterise une page en bitmap et peut produire BMP, PNG, JPEG, WMF et quelques autres formats. Ce qu’il ne faisait pas jusqu’à récemment, c’était renvoyer un vrai bitmap monochrome ou rendre seulement une partie d’une page. Les deux ont été ajoutés en v3.83.0, et les deux ont été construits comme de minces couches de commodité au-dessus du moteur existant, plutôt que comme des modifications du rasterizer lui-même. Cette contrainte est toute l’histoire

Pourquoi convertir après le rendu, et non dans le moteur

La manière évidente de produire une image 1 bit consiste à demander au rasterizer de dessiner en 1 bit. C’est aussi la façon de tout casser par ailleurs. Le bitmap interne du moteur est créé avec un PixelFormat := pf24bit dans le PDFlibRenderer constructeur PDFlibRenderer, et cette surface 24 bits est partagée par tous les chemins de rendu : export PNG, aperçu via contexte de périphérique, sortie JPEG, tout. Si vous le basculez vers pf1bit à la source, vous n’ajoutez pas une fonction monochrome, vous dégradez la fidélité couleur pour tous les appelants de la bibliothèque et vous vous inscrivez pour déboguer une douzaine de régressions en aval

Ainsi, RenderPageToMonochromeFile prend le chemin inverse. Il rend la page normalement, dans un BMP temporaire 24 bits, puis seulement ensuite la réduit à 1 bit comme étape de post-traitement. Le moteur reste intact. Le comportement monochrome vit entièrement dans la méthode de commodité, ce qui signifie qu’il ne peut affecter personne qui ne l’appelle pas. C’est le genre de compromis qu’il vaut la peine de nommer explicitement : un post-traitement paie une allocation de bitmap supplémentaire et un fichier temporaire, et en échange il garde hors du périmètre un cœur qui porte tout. Pour une fonctionnalité destinée aux cas limites fax et archivage, c’est le bon côté du bilan

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf');
    // 200 DPI is the classic Group 4 fax resolution; page index is 1-based
    Pdf.RenderPageToMonochromeFile(200, 1, 'invoice-page1.bmp');
  finally
    Pdf.Free;
  end;
end;

Comment la conversion en 1 bit se produit réellement

La conversion s’appuie sur GDI plutôt que sur une boucle de seuil écrite à la main, et ce choix compte pour la qualité de sortie. À l’intérieur de la méthode, le bitmap temporaire 24 bits est chargé dans un TBitmap, un second TBitmap est créé avec PixelFormat := pf1bit aux mêmes dimensions, et les pixels passent d’un coup de blit :

// inside RenderPageToMonochromeFile, after loading the 24-bit ColorBmp
MonoBmp.PixelFormat := pf1bit;
MonoBmp.Width  := ColorBmp.Width;
MonoBmp.Height := ColorBmp.Height;
// HALFTONE tells GDI to dither the 24-bit source down to 1-bit
SetStretchBltMode(MonoBmp.Canvas.Handle, HALFTONE);
StretchBlt(MonoBmp.Canvas.Handle, 0, 0, MonoBmp.Width, MonoBmp.Height,
  ColorBmp.Canvas.Handle, 0, 0, ColorBmp.Width, ColorBmp.Height, SRCCOPY);
MonoBmp.SaveToFile('out.bmp');

L’astuce, c’est SetStretchBltMode avec HALFTONE. Même si la source et la destination ont la même taille, donc sans changement d’échelle, le mode d’étirement continue de gouverner la manière dont GDI mappe les couleurs dans la palette 1 bit. HALFTONE lui fait appliquer un tramage demi-teinte, transformant les zones grises et les contours de texte antialiasés en motifs de points noirs et blancs plutôt qu’en une coupure nette vers la couleur la plus proche. Supprimez l’appel du mode, ou utilisez le défaut BLACKONWHITE, et le contenu en niveaux de gris se posterise en formes massives à seuil. Pour les sorties destinées aux documents scannés et au prétraitement OCR, le résultat tramé est presque toujours ce qu’il faut

Un détail est non négociable et facile à rater : le rendu temporaire doit être un BMP. RenderPageToMonochromeFile appelle le moteur général avec un code d’options à 0, qui correspond au BMP. L’argument d’options de RenderPageToFile est une petite énumération entière, et les valeurs ne sont pas interchangeables pour cet usage : 0 est BMP, 1 JPEG, 2 WMF, 3 EMF, 5 PNG, et ainsi de suite. Le convertisseur effectue ensuite TBitmap.LoadFromStream sur le fichier temporaire. Donnez-lui un WMF, en passant 2, et ce chargement lève "Bitmap image is not valid", parce qu’un Windows Metafile est un flux d’enregistrements vectoriels, pas un DIB. La conversion monochrome est une opération raster de bout en bout, donc l’intermédiaire doit lui aussi être un format raster

Rendre seulement une sous-région d’une page

La seconde méthode, RenderPageRegionToFile, rend seulement un rectangle de la page au lieu de la page entière. Les cas d’usage sont familiers dès qu’on a déjà construit une visionneuse documentaire : découper un bloc de signature dans un contrat, générer une tuile pour une carte zoomée d’un grand dessin, ou extraire une zone tamponnée pour une miniature sans payer le raster complet à haute résolution. La signature est simple :

// Clip is "Left,Top,Width,Height" in PDF points (72 pt = 1 inch)
// Here: a 2.5in x 1in box, one inch in from the top-left of the page
Pdf.RenderPageRegionToFile(150, 1, '72,72,180,72', 'sig-block.bmp');

La chaîne de découpe est composée de quatre doubles séparés par des virgules en points PDF, analysés manuellement dans la méthode pour contourner les particularités locales et celles de DelimitedText. À partir de la largeur et de la hauteur, la méthode calcule la taille du bitmap de sortie comme Round(Width * DPI / 72) par Round(Height * DPI / 72), alloue un bitmap mémoire pf24bit exactement à cette taille, puis le rend dans son contexte de périphérique via RenderPageToDCClip. Le fichier résultat ne contient que le rectangle découpé, dimensionné à la région plutôt qu’à la page entière

Le paramètre de découpe qui ne faisait rien

C’est ici que le travail était plus net qu’il n’y paraît. RenderPageToDCClip portait depuis longtemps un paramètre Clip, et c’était un mensonge. L’appel acceptait l’argument, le transmettait à TPDFPageTree.RenderPageToDC, et cette implémentation l’ignorait complètement, sans jamais le remettre au moteur. Vous pouviez passer n’importe quel rectangle et obtenir la page entière. Quiconque avait branché RenderPageToDCClip en attendant un recadrage obtenait un rendu plein format et, selon sa mise en page, pouvait ne pas s’en être aperçu

La v3.83.0 a raccordé le fil. RenderPageToDC analyse désormais le même rectangle en points "Left,Top,Width,Height" et l’applique comme une véritable région de découpe GDI sur le contexte de périphérique cible avant que le moteur ne dessine. La conversion des points vers les pixels du périphérique suit le facteur d’échelle habituel DPI / 72, appliqué aux quatre bords. La séquence autour du rendu est la danse classique save/clip/restore :

// inside TPDFPageTree.RenderPageToDC, when Clip is non-empty
ScaleFactor := DPI / 72;
SaveDC(TargetDC);
IntersectClipRect(TargetDC,
  Round(ClipLeft * ScaleFactor),
  Round(ClipTop * ScaleFactor),
  Round((ClipLeft + ClipWidth) * ScaleFactor),
  Round((ClipTop + ClipHeight) * ScaleFactor));
// ... renderer draws the page here ...
// in the finally block:
RestoreDC(TargetDC, -1);

La paire SaveDC / RestoreDC(-1) est ce qui permet de l’appeler plusieurs fois sans risque : la région de découpe est poussée sur la pile d’état du DC, la page est dessinée, puis la découpe d’origine est restaurée quel que soit le mode de sortie du rendu. RestoreDC(TargetDC, -1) rétablit l’état sauvegardé le plus récent, ce qui est l’idiome standard pour un save/restore équilibré. Oubliez la restauration et un appelant qui réutilise le même DC pour un rendu pleine page suivant le verra mystérieusement découpé selon la dernière région. La correction du paramètre mort a également corrigé RenderPageRegionToFile gratuitement, puisque cette nouvelle méthode emprunte exactement ce chemin

Un point de comportement à garder en tête : le découpage recadre, il ne met pas à l’échelle. La page est toujours rasterisée à la résolution demandée, dans sa position normale, et la région découpée élimine simplement tout ce qui est hors du rectangle. Vous ne zoomez pas la région pour remplir la sortie ; vous coupez une fenêtre dans le rendu pleine résolution. Si vous voulez agrandir une région, augmentez le DPI. Les coordonnées du rectangle sont interprétées dans l’espace périphérique après mise à l’échelle points vers pixels, mesurées depuis le coin supérieur gauche de la surface rendue, donc planifiez Left et Top à partir du haut de la page vers le bas. Pour un tour plus approfondi de la façon dont PDFlibPas pilote un contexte de périphérique pour la sortie à l’écran, l’article compagnon sur l’aperçu avant impression et la sortie via contexte de périphérique détaille la même plomberie côté affichage

La limite honnête : BMP 1 bit, pas TIFF G4

Il serait facile de survendre cela comme une « sortie prête pour le fax », alors posons la limite clairement. RenderPageToMonochromeFile produit un BMP pf1bit. Il ne produit pas un TIFF CCITT Group 4, qui est le format qu’un vrai flux fax ou une archive TIFF attend généralement. La raison est concrète plutôt qu’un oubli : l’unité CCITT de PDFlibPas décode actuellement les flux G4 mais ne possède pas d’encodeur G4. Sans encodeur, il n’y a nulle part où écrire des runs monochromes compressés, donc le chemin monochrome s’arrête à un DIB 1 bit non compressé

En pratique, cela reste utile. Un BMP 1 bit est le bon format pixel, tramé et prêt, et la plupart des chaînes fax, archivage ou OCR l’ingèrent volontiers ou le convertissent elles-mêmes en G4 en une étape aval. Mais si votre exigence est littéralement un TIFF Group 4 directement sorti de la bibliothèque, ce n’est pas encore cela, et vous devez prévoir votre propre étape de compression. Savoir où une fonctionnalité s’arrête vaut autant que savoir ce qu’elle fait

Les deux méthodes sont volontairement petites, et c’est la leçon de conception à retenir de cette page : une API de commodité au-dessus d’un moteur de rendu peut ajouter une vraie capacité, sortie monochrome, recadrage de région, sans toucher au rasterizer et déstabiliser tous les autres appelants. Quand vous devez choisir entre plusieurs moteurs pour le raster sous-jacent, la vue d’ensemble sur le rendu PDF multi-moteurs dans Delphi détaille les compromis. Pour voir toute la surface de rendu et le reste de l’API, la page produit PDFlibPas Delphi PDF Library donne le tableau complet