Technischer Artikel

PDF-Seiten in Delphi in 1-Bit-Monochrom rendern

Ein Fax-Gateway benötigt kein 24-Bit-Rendering Ihrer Seite. Ebenso wenig die Archivierungspipeline, die eine Million gescannt aussehender Rechnungen speichert, oder das OCR-Frontend, das alles auf Schwarz-Weiß schwellwertet, bevor es überhaupt nach einem Zeichen sucht. Alle drei wollen dasselbe: Eine saubere 1-Bit-Bitmap, ein Bit pro Pixel, bei der jeder Punkt entweder Tinte oder Papier darstellt. Übergeben Sie ihnen eine Vollfarben-BMP, werden sie ohnehin 23 Bit pro Pixel wegwerfen — meist mit einem schlechteren Dithering-Durchlauf, als Sie es selbst hätten tun können. Die interessante Frage ist, wo diese Abwärtskonvertierung stattfinden soll. Die Antwort in PDFlibPas sagt etwas Nützliches darüber aus, wie man einen Renderer erweitert, den man lieber nicht umschreiben möchte

PDFlibPas is eine native Object-Pascal-PDF-Bibliothek für Delphi und C++Builder. Ihr Rendering-Kern rasterisiert eine Seite in eine Bitmap und kann BMP, PNG, JPEG, WMF und eine Handvoll anderer Formate ausgeben. Was sie bis vor Kurzem nicht tat, war die Rückgabe einer echten Monochrom-Bitmap oder das Rendern nur eines Teils einer Seite. Beides wurde in Version 3.83.0 eingeführt, und beides wurde als dünne, komfortable Schicht auf dem bestehenden Renderer aufgebaut, anstatt den Rasterisierer selbst zu verändern. Diese Einschränkung ist die ganze Geschichte

Warum nach dem Rendern abwärtskonvertieren und nicht im Renderer selbst

Der offensichtliche Weg, ein 1-Bit-Bild zu erzeugen, besteht darin, dem Rasterisierer mitzuteilen, in 1-Bit zu zeichnen. Das ist aber auch der Weg, der alles andere zerstört. Die interne Bitmap des Renderers wird mit einem fest codierten PixelFormat := pf24bit im PDFlibRenderer-Konstruktor erstellt, und diese 24-Bit-Oberfläche wird von jedem Render-Pfad gemeinsam genutzt: PNG-Export, die Vorschau des Gerätekontexts (Device Context), JPEG-Ausgabe — von allem. Stellt man an der Quelle auf pf1bit um, hat man keine Monochrom-Funktion hinzugefügt, sondern die Farbtreue für jeden Aufrufer in der Bibliothek verschlechtert und sich die Aufgabe eingehandelt, ein Dutzend nachgelagerter Regressionen zu beheben

Daher geht RenderPageToMonochromeFile den umgekehrten Weg. Die Methode rendert die Seite normal in eine temporäre 24-Bit-BMP und reduziert sie erst dann in einem Nachbearbeitungsschritt auf 1-Bit. Der Renderer bleibt unberührt. Das Monochrom-Verhalten liegt vollständig in der Komfortmethode, was bedeutet, dass es niemanden beeinflussen kann, der sie nicht aufruft. Dies ist die Art von Kompromiss, die es wert ist, explizit genannt zu werden: Ein Nachbearbeitungsprozess bezahlt eine zusätzliche Bitmap-Allokation und eine temporäre Datei und hält dafür einen tragenden Kern völlig aus dem Fokus. Für ein Feature, das Fax- und Archivierungs-Randfällen dient, ist das die richtige Seite der Bilanz

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;

Wie die Reduzierung auf 1-Bit tatsächlich abläuft

Die Abwärtskonvertierung stützt sich eher auf GDI als auf eine selbst geschriebene Schwellenwert-Schleife, und diese Wahl ist entscheidend für die Ausgabequalität. Innerhalb der Methode wird die temporäre 24-Bit-Bitmap in ein TBitmap geladen, ein zweites TBitmap mit PixelFormat := pf1bit und denselben Abmessungen erstellt, und die Pixel werden mit einem einzigen Blit übertragen:

// 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');

Der Trick ist SetStretchBltMode mit HALFTONE. Obwohl Quelle und Ziel dieselbe Größe haben, also keine Skalierung stattfindet, der Streckmodus dennoch, wie GDI die Farben in der 1-Bit-Palette abbildet. HALFTONE bewirkt, dass ein Halbton-Dithering angewendet wird, wodurch graue Bereiche und kantengeglättete Textränder in Muster aus schwarzen und weißen Punkten umgewandelt werden, anstatt hart auf die nächstliegende der beiden Farben zu clippen. Lässt man den Aufruf weg oder verwendet den Standardwert BLACKONWHITE, posterisieren Graustufen-Inhalte in blockige, schwellwertige Formen. Für eingescannte Dokumente und OCR-Vorverarbeitungsausgaben ist das geditherte Ergebnis fast immer das, was man möchte

Ein Detail ist unverhandelbar und leicht falsch zu machen: Die temporäre Render-Ausgabe muss eine BMP-Datei sein. RenderPageToMonochromeFile ruft den allgemeinen Renderer mit dem Optionscode 0 (BMP) auf. Das Optionsargument in RenderPageToFile ist ein kleiner Ganzzahl-Enum, und die Werte sind für diesen Zweck nicht austauschbar: 0 ist BMP, 1 ist JPEG, 2 ist WMF, 3 ist EMF, 5 ist PNG usw. Der Abwärtskonverter führt dann TBitmap.LoadFromStream auf der temporären Datei aus. Übergibt man ein WMF (durch Übergabe von 2), wirft das Laden den Fehler "Bitmap image is not valid", da eine Windows-Metafile ein Vektor-Datensatzstrom und kein DIB (Device-Independent Bitmap) ist. Die monochrome Abwärtskonvertierung ist von Ende zu Ende eine Rasteroperation, daher muss das Zwischenformat ein Rasterformat sein

Nur einen Teilbereich einer Seite rendern

Die zweite Methode, RenderPageRegionToFile, renders nur ein Rechteck der Seite anstatt der ganzen Seite. Die Anwendungsfälle sind jedem bekannt, der schon einmal einen Dokumenten-Viewer erstellt hat: Das Ausschneiden eines Unterschriftenblocks aus einem Vertrag, das Erzeugen einer Kachel (Tile) für eine vergrößerte Ansicht einer großen Zeichnung oder das Extrahieren eines gestempelten Bereichs für ein Vorschaubild, ohne die gesamte Seite mit hoher DPI rasterisieren zu müssen. Die Signatur ist einfach:

// 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');

Der Clip-String besteht aus vier durch Kommas getrennten Double-Werten in PDF-Punkten, die innerhalb der Methode manuell geparst werden, um Locale- und DelimitedText-Eigenheiten zu umgehen. Aus der Breite und Höhe berechnet die Methode die Größe der Ausgabe-Bitmap als Round(Width * DPI / 72) mal Round(Height * DPI / 72), allokiert eine speicherinterne pf24bit-Bitmap exakt dieser Größe und rendert über RenderPageToDCClip in deren Gerätekontext. Die Ergebnisdatei enthält nur das zugeschnittene Rechteck, skaliert auf den Bereich und nicht auf die gesamte Seite

Der Clip-Parameter, der nichts bewirkte

Hier war die Arbeit genauer, als sie aussieht. RenderPageToDCClip trug lange Zeit einen Clip-Parameter, und das war eine Lüge. Der Aufruf akzeptierte das Argument, leitete es an TPDFPageTree.RenderPageToDC weiter, und diese Implementierung ignorierte es vollständig, ohne es jemals an den Renderer zu übergeben. Sie konnten ein beliebiges Rechteck übergeben und erhielten dennoch die gesamte Seite zurück. Jeder, der RenderPageToDCClip in Erwartung eines Ausschnitts verdrahtet hatte, erhielt ein Ganzseiten-Rendering und hat dies, je nach Layout, vielleicht nicht einmal bemerkt

Version 3.83.0 hat die Verbindung hergestellt. RenderPageToDC parst nun dasselbe Punkt-Rechteck "Left,Top,Width,Height" und wendet es als echten GDI-Clip-Bereich auf den Zielgerätekontext an, bevor der Renderer zeichnet. Die Umrechnung von Punkten in Gerätepixel ist der übliche Skalierungsfaktor DPI / 72, angewendet auf alle vier Kanten. Der Ablauf um das Rendern herum ist das übliche Speichern/Ausschneiden/Wiederherstellen-Verfahren:

// 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);

Das Paar SaveDC / RestoreDC(-1) sorgt dafür, dass dieser Aufruf wiederholt sicher ausgeführt werden kann: Der Clip-Bereich wird auf den DC-Zustandsstapel gelegt, die Seite wird gezeichnet und der ursprüngliche Clip wird wieder zurückgeholt, unabhängig davon, wie das Rendern endet. RestoreDC(TargetDC, -1) stellt den zuletzt gespeicherten Zustand wieder her, was das Standard-Idiom für ein ausgewogenes Speichern/Wiederherstellen ist. Lässt man das Wiederherstellen weg, würde ein Aufrufer, der denselben DC für ein nachfolgendes Ganzseiten-Rendering wiederverwendet, feststellen, dass dieses rätselhafterweise auf den letzten Bereich zugeschnitten ist. Die Behebung des ungenutzten Parameters behob auch kostenlos RenderPageRegionToFile, da diese neue Methode genau diesen Pfad durchläuft

Ein Verhaltenspunkt, den man verinnerlichen sollte: Der Clip schneidet aus (crops), er skaliert nicht. Die Seite wird weiterhin mit der gewünschten DPI an ihrer normalen Position rasterisiert, und der Clip-Bereich verwirft einfach alles außerhalb des Rechtecks. Sie zoomen den Bereich nicht heran, um die Ausgabe wahlweise zu füllen; Sie schneiden ein Fenster aus dem hochauflösenden Rendering heraus. Wenn Sie einen Bereich vergrößern möchten, erhöhen Sie die DPI. Die Koordinaten des Rechtecks werden nach der Skalierung von Punkten in Pixel im Geräteraum interpretiert, gemessen von der oberen linken Ecke der gerenderten Oberfläche. Planen Sie daher Ihre Left- und Top-Werte von der Oberkante der Seite nach unten. Für einen tieferen Einblick, wie PDFlibPas einen Gerätekontext für die Bildschirmausgabe ansteuert, führt der Begleitartikel über Druckvorschau und Gerätekontext-Ausgabe durch dieselbe DC-Verdrahtung aus Sicht der Anzeige

Die ehrliche Grenze: 1-Bit-BMP, nicht G4-TIFF

Es wäre leicht, dies als „faxbereite Ausgabe“ anzupreisen, daher ist hier die Grenze deutlich formuliert. RenderPageToMonochromeFile erzeugt eine pf1bit-BMP. Es wird keine CCITT Group 4 TIFF erzeugt, was das Format ist, das ein echter Fax-Workflow oder ein TIFF-Archiv normalerweise erwartet. Der Grund ist eher konkret als ein Versehen: Die CCITT-Einheit von PDFlibPas dekodiert derzeit G4-Streams, verfügt jedoch über keinen G4-Encoder. Ohne Encoder gibt es keine Möglichkeit, komprimierte monochrome Durchläufe zu schreiben, sodass der monochrome Pfad bei einer unkomprimierten 1-Bit-DIB stoppt

In der Praxis ist das immer noch nützlich. Eine 1-Bit-BMP ist das korrekte Pixelformat, gedithert und bereit, und die meisten Fax-, Archivierungs- oder OCR-Toolchains können sie problemlos einlesen oder in einem nachfolgenden Schritt selbst in G4 konvertieren. Wenn Ihre Anforderung jedoch buchstäblich eine Group-4-TIFF direkt aus der Bibliothek ist, ist dies noch nicht der Fall, und Sie sollten einen eigenen Kompressionsschritt einplanen. Zu wissen, wo eine funktionierende Eigenschaft aufhört, ist genauso viel wert wie zu wissen, was sie tut

Beide Methoden sind bewusst klein gehalten, und das ist die Lektion für das Design, die man von dieser Seite mitnehmen sollte: Eine Komfort-API, die auf einem Renderer aufsetzt, kann echte Fähigkeiten (monochrome Ausgabe, Bereichszuschnitt) hinzufügen, ohne in den Rasterisierer einzugreifen und jeden anderen Aufrufer zu destabilisieren. Wenn Sie zwischen Rendering-Engines für die zugrunde liegende Rasterisierung wählen müssen, behandelt die Übersicht über PDF-Rendering mit mehreren Engines in Delphi die Kompromisse ausführlich. Um die vollständige Rendering-Oberfläche und den Rest der API zu sehen, bietet die Produktseite der PDFlibPas Delphi-PDF-Bibliothek das vollständige Bild