Technischer Artikel

Gebändertes PDF-Rendering in Delphi: negativer Y-Offset

Das erste Band enthielt die gesamte Zeichnung, zu einem Streifen zusammengedrückt, und die fünf folgenden Bänder kamen leer zurück. Das war der alte bandweise Export, und PDFiumPas behob ihn in v3.66.0: RenderPageBanded übergibt FPDF_RenderPageBitmap bei jedem einzelnen Band die vollständige Zielbreite und -höhe der Seite zusammen mit einem negativen vertikalen Offset. Der native Clip schreibt dadurch nur die Zeilen des aktuellen Bands, während die Seite ihre vollständige Koordinatengeometrie behält. Der Anwendungsfall ist unspektakulär und unvermeidlich: Jemand übergibt einen E-Size-Plot oder eine zusammengesetzte Panoramaseite und möchte ein Rasterbild mit 600 DPI. Ein ISO-A0-Blatt hat bei 600 DPI 19866 × 28086 Pixel, und eine 32-Bit-Zielbitmap dieser Größe benötigt etwas mehr als 2 GB zusammenhängenden Speicher. Unter 32-Bit-Delphi scheitert diese Allokation schlicht. Unter 64 Bit gelingt sie oft genug, um das Problem vom Test zum Kundenproblem zu machen. Banded Rendering existiert, damit der Spitzenbedarf ein Streifen und nicht eine Seite ist

Warum enthielt jedes Band die ganze Seite?

Der alte Code verwechselte zwei verschiedene Argumentpaare im PDFium-Aufruf zum Rendern einer Seite. FPDF_RenderPageBitmap nimmt start_x, start_y, size_x und size_y. Das Größenpaar gibt an, auf welche Größe die ganze Seite skaliert wird, das Startpaar, wo diese skalierte Seite innerhalb der Zielbitmap liegt. Die Schleife vor v3.66.0 rief den Bibliothekshelper RenderPage mit der Oberkante des Bands als Zieloffset und der Bandhöhe als Seitenhöhe auf. Diese beiden Zahlen gingen direkt in den nativen Aufruf, daher skalierte PDFium die ganze Seite in ein Rechteck mit nur BandHeight Zeilen und zeichnete sie anschließend bei y = BandTop in eine Bitmap, die selbst nur BandHeight Zeilen hoch war. Das Ergebnis ist nach dem Hinsehen exakt vorhersehbar: Band null erhielt die gesamte Seite vertikal auf die Bandhöhe zusammengedrückt. Jedes spätere Band erhielt dieselbe zusammengedrückte Seite unterhalb der Unterkante seiner Bitmap und kam daher als Hintergrundfüllung zurück. Der Fehler verbirgt sich im einzigen Fall, den die meisten Smoke-Tests verwenden: Ist die Renderhöhe einer Seite kleiner als die Bandhöhe, gibt es nur ein Band und die falsche Geometrie fällt zufällig mit der richtigen zusammen. Alles, was höher als ein Band ist, zeigt den Fehler sofort

Was der negative Offset garantiert

Die korrigierte Implementierung führt jedes Band über RenderTile, die eine Stelle in der Komponente, die den Unterschied bereits verstand. RenderTile nimmt einen Kachelursprung in vollständigen Seitenpixelkoordinaten sowie getrennte Werte für PageWidth und PageHeight an und übergibt PDFium -Left und -Top, während die Seitengröße unverändert bleibt. Das Negieren des Offsets schiebt die Seite in voller Größe nach oben, bis das angeforderte Band in Zeile null der Zielbitmap liegt; PDFium clippt dann nativ gegen die Bitmapgrenzen, sodass außerhalb des Bands nichts gerastert wird. Die Abbildung von Seite auf Gerät aus ISO 32000-1 Abschnitt 8.3.2 bleibt vom ersten bis zum letzten Band identisch. Genau das ist der Punkt: Band N ist bitidentisch zu den Zeilen BandTop bis BandTop + h eines einzelnen vollständigen Seitenrenders, und die Regression prüft genau das Pixel für Pixel gegen die Ausgabe von RenderPage bei denselben Dimensionen

// Ein Band von Hand. Die Zielbitmap ist nur BandHeight Zeilen hoch,
// aber die Zielgröße der Seite bleibt die vollständige Breite x Höhe
Band := Pdf.RenderTile(0, BandTop,          // Kachelursprung in Seitenpixeln
                       Width, BandHeight,   // Größe der Zielbitmap
                       Width, Height);      // vollständige Zielgröße der Seite
try
  // Band enthält nun die Zeilen BandTop .. BandTop + BandHeight - 1 der Seite
finally
  Band.Free;
end;

Die öffentliche Band-API ist eine Callback-Schleife. RenderPageBanded(Width, Height, BandHeight, BandCallback, Rotation, Options, Color) liefert die Anzahl der tatsächlich gerenderten Bänder oder 0, wenn Argumente abgelehnt wurden, und hält den Render-Lock der Komponente über den gesamten Durchlauf. Die Callback-Signatur ist TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object. Die Bitmap ist pf32bit, Width Pixel breit und höchstens BandHeight hoch; sie wird freigegeben, sobald Ihr Handler zurückkehrt, daher müssen Sie alles, was Sie behalten wollen, kopieren. Gibt der Handler False zurück, endet der Durchlauf nach dem aktuellen Band. Das ist dasselbe kooperative Abbruchmodell wie beim abbrechbaren progressiven PDF-Rendering in Delphi, nur auf Streifenebene und nicht auf der Granularität der PDFium-Continuation

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 stirbt bei der Rückkehr aus dieser Methode – hier verbrauchen
  Inc(FRows, Bitmap.Height);
  Result := not FCancelled;
end;

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

PNG und TIFF ohne vollständige Seitenbitmap streamen

Rendering in Bändern hilft nur, wenn auch der Encoder sequentiell arbeitet. Daher ergänzt v3.66.0 RenderPageBandedToStream, das PNG oder TIFF direkt in einen Aufruferstream schreibt. TPdfBandedImageStreamOptions.Default setzt eine Bandhöhe von 256 Zeilen, PNG-Kompressionsstufe 6 und MaxOutputBytes auf 0, also unbegrenzt. Der zurückgegebene TPdfBandedImageReport enthält Format, Width, Height, BandsRendered, BandsEncoded, RowsEncoded, PeakBandBytes, OutputBytes und Completed. PeakBandBytes ist die Zahl, die beim Auslegen eines Jobs tatsächlich zählt: Sie ist Width * BandHeight * 4, daher erreicht das A0-Blatt oben ungefähr 19 MB Bandpuffer statt eines 2-GB-Seitenpuffers

Der PNG-Encoder ist bewusst eng ausgelegt. Er gibt festes RGB8 aus, schreibt ein IHDR mit Bit-Tiefe 8 und Farbtyp 2, baut jede Scanline mit Filtertyp 0 (ISO/IEC 15948, Filtermethode 0, Filtertyp None) und führt sie durch den zlib-Kompressionsstream der Plattform. Die komprimierten Bytes kommen als CRC-tragende IDAT-Chunks in Reihenfolge zurück. Die interessante Einschränkung betrifft den Stream unter der Deflate-Schicht: Er beantwortet Positionsabfragen, weil der Kompressionsstream sie stellt, aber jeder echte Seek-Versuch löst einen Fehler aus. Das ist Absicht. Sobald ein IDAT-Chunk und sein CRC auf der Leitung liegen, gibt es kein Zurück, um sie zu korrigieren, und ein stiller Seek würde eine Ausgabe beschädigen, die strukturell weiterhin gültig aussieht

Der TIFF-Encoder schreibt klassisches Little-Endian-TIFF, das II-Byte-Order-Mark gefolgt vom Magic 42, mit einem Strip je Band. Die Pixel werden zuerst gestreamt, und das zehn Einträge umfassende IFD wird am Ende erzeugt, sobald Strip-Offsets und Bytezahlen bekannt sind. Die Kompression ist Tag 259 mit Wert 1, also gibt es überhaupt keine Entropiekodierung: Der Payload ist exakt Width * Height * 3 Bytes lang, PhotometricInterpretation ist RGB, PlanarConfiguration ist chunky, und RowsPerStrip verzeichnet die Bandhöhe, während der letzte kurze Strip über seinen eigenen StripByteCounts-Eintrag beschrieben wird. Die Bandhöhe verändert somit Spitzenbedarf und Stripzahl, nicht aber die Ausgabegröße. Das sollte man wissen, bevor man sie justiert. Wenn Sie kleine statt verlustfreie Dateien benötigen, ist der seitenweise Pfad zum Konvertieren von PDF-Seiten in JPEG-Bilder mit der PDFium-VCL-Komponente weiterhin das bessere Werkzeug

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, nicht 19866 * 28086 * 4
end;

Wo endet ein bandweiser Export?

Zwei Obergrenzen begrenzen die Ausgabe, und sie scheitern absichtlich an unterschiedlichen Stellen. Die erste ist das Budget des Aufrufers: MaxOutputBytes wird von einem begrenzten Schreibstream durchgesetzt, der EPdfError vor jedem Schreibvorgang auslöst, der das Limit überschreiten würde. Das Budget ist also eine harte Kappe und kein nachträglicher Bericht. Die zweite Grenze ist strukturell. Klassisches TIFF speichert Strip-Offsets als 32-Bit-Werte, daher prüft BeginImage Width * Height * 3 plus Header und Verzeichnis gegen diese Obergrenze und lehnt den Job ab, bevor ein einziges Pixel geschrieben wird. Dieselbe Prüfung läuft vorab gegen MaxOutputBytes, denn ein TIFF, dessen Budget den eigenen Pixel-Payload nicht tragen kann, ist den Start nicht wert. PNG hat keine entsprechende Grenze, weil IDAT-Chunks rein sequentiell sind und keine 32-Bit-Offsettabelle überlaufen kann

Man sollte klar sehen, was ein gestoppter Export zurücklässt. Erreicht der Durchlauf nicht die letzte Zeile, bleibt Completed auf False, und der Encoder wird mit EndImage(False) beendet. Dadurch werden weder der PNG-IEND-Chunk noch das TIFF-IFD geschrieben. Die Teildatei ist daher ungültig, und jeder Decoder wird das melden, statt ein plausibel aussehendes Bild mit fehlenden Zeilen zu akzeptieren. Diese Bereinigung ist so verpackt, dass ein zweiter Fehler in EndImage die ursprüngliche Exception nicht ersetzt. Das ist der Unterschied zwischen einem Stacktrace, der die echte Ursache nennt, und einem, der den Hausmeister nennt. Wenn Fortschritt erhalten bleiben muss, setzen Sie in Ihrem eigenen Callback pro Band einen Checkpoint; die Taktiken zum Strip-Caching aus dem PDFium-Delphi-Leitfaden zu Rendercache und Zoom gelten auch hier

Eigenen Codec anschließen

Wenn PNG und TIFF nicht das Ziel sind, nimmt RenderPageBandedToEncoder einen Nachkommen von TPdfBandedImageEncoder und treibt dieselbe Schleife an. Der Lebenszyklus ist ausdrücklich und kurz: BeginImage(Width, Height), dann einmal je Streifen WriteBand(BandIndex, BandTopY, Bitmap) in streng aufsteigender Reihenfolge, danach EndImage(Completed); GetBytesWritten speist Report.OutputBytes. Die integrierten Encoder weisen ein Band außerhalb der Reihenfolge direkt zurück, statt es zu puffern, und jeder eigene Encoder sollte dasselbe tun, denn ein Codec, der Streifen still umsortiert, erzeugt eine Datei, die öffnet und lügt. Das ist die Schnittstelle für JPEG-2000-Kacheln, einen JPEG-Writer, der eine MCU-Zeile nach der anderen erhält, oder eine direkte Zuführung in einen Druckspooler

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;
  // Bitmap.ScanLine[0 .. Bitmap.Height - 1] hier an den Codec geben
  Inc(FNextBand);
  Result := True;
end;

Eine Cross-Compiler-Falle, die man kennen sollte

Die zlib-Unit heißt auf jeder unterstützten Toolchain anders: Delphi XE5 und später verwenden System.ZLib, FPC zstream und älteres Delphi schlicht ZLib. Das ist gewöhnliche bedingte Kompilierung. Die Falle besteht darin, dass alle drei Kompressionsstufenkonstanten namens clNone und clDefault exportieren, die frontal mit den gleichnamigen TColor-Membern der Graphics-Unit kollidieren. Sobald die zlib-Unit im Implementation-Uses-Abschnitt erscheint, kann ein unqualifiziertes clNone im Rendercode ohne Diagnose zu einer Kompressionsstufe statt zu einer Farbe aufgelöst werden. PDFiumPas legt das mit ausdrücklichen Farbsentinel-Aliassen fest: PdfGraphicsColorNone und PdfGraphicsColorDefault werden einmal an die vollständig qualifizierten Grafik-Konstanten gebunden und überall verwendet, wo ein Renderhintergrund oder Farbschemesentinel verglichen wird. Drei Codezeilen, und die Symbolauflösung driftet nicht mehr zwischen Compilern

Banded Rendering sieht wie eine Komfortfunktion aus, bis die Seite nicht in den RAM passt und es der einzige funktionierende Weg ist. Die korrigierte Bandgeometrie, die sequentiellen PNG- und TIFF-Encoder sowie die Schnittstelle für eigene Encoder gehören zur PDFium Delphi component; der vollständige Band-gegen-Seite-Pixelvergleich läuft in der Regressionssuite für Delphi, Lazarus und C++Builder