Technisch artikel

Banded PDF-rendering in Delphi: de negatieve Y-offset

De eerste band bevatte de hele tekening, samengedrukt tot één strook, en de vijf bands erna kwamen leeg terug. Dat was de oude banded export, en PDFiumPas repareerde hem in v3.66.0: RenderPageBanded geeft nu bij elke afzonderlijke band de volledige doelbreedte en -hoogte van de pagina door aan FPDF_RenderPageBitmap, samen met een negatieve verticale offset. Daardoor schrijft de native clip alleen de rijen van de huidige band terwijl de pagina zijn volledige coördinatengeometrie behoudt. De use case hierachter is saai en onvermijdelijk. Iemand geeft je een plot op E-formaat of een aan elkaar gestikte panoramapagina en wil er een raster van op 600 DPI van. Een ISO A0-vel op 600 DPI is 19866 x 28086 pixels, en een 32-bit destination bitmap van die grootte is iets meer dan 2 GB aaneengesloten geheugen. Op 32-bit Delphi faalt die allocatie gewoon. Op 64-bit lukt hij vaak genoeg om de fout een klantprobleem in plaats van een testprobleem te maken. Banded rendering bestaat zodat de piekallocatie één strook is en niet één pagina

Waarom bevatte elke band de hele pagina?

De oude code verwisselde twee verschillende paren argumenten in de PDFium-call voor pagerendering. FPDF_RenderPageBitmap neemt start_x, start_y, size_x en size_y, waarbij het size-paar zegt naar welke grootte de hele pagina moet worden geschaald en het start-paar zegt waar die geschaalde pagina binnen de destination bitmap terechtkomt. De bandloop van vóór v3.66.0 riep de libraryhelper RenderPage aan met de bovenkant van de band als destination offset en de bandhoogte als page height. Die twee getallen gingen rechtstreeks naar de native call, dus PDFium schaalde de hele pagina in een rechthoek van slechts BandHeight rijen hoog en tekende hem daarna op y = BandTop binnen een bitmap die zelf maar BandHeight rijen hoog was. Het resultaat is precies wat je voorspelt zodra je het ziet. Band nul kreeg de hele pagina verticaal samengedrukt tot de bandhoogte. Elke latere band kreeg dezelfde samengedrukte pagina onder de onderrand van zijn bitmap geduwd, dus kwam hij als achtergrondvulling terug. De bug verbergt zich in het ene geval dat de meeste smoke tests gebruiken, een pagina waarvan de renderhoogte kleiner is dan de bandhoogte, omdat er dan één band is en de verkeerde geometrie toevallig met de juiste samenvalt. Alles wat hoger is dan één band maakt hem meteen zichtbaar

Wat garandeert de negatieve offset?

De gerepareerde implementatie stuurt elke band via RenderTile, de ene plek in de component die het onderscheid al begreep. RenderTile neemt een tile-origin in pixelcoördinaten van de volledige pagina plus een afzonderlijke PageWidth en PageHeight, en geeft PDFium -Left en -Top door met de paginagrootte onaangeroerd. De offset negateren schuift de pagina op ware grootte omhoog totdat de aangevraagde band op rij nul van de destination bitmap zit; PDFium clipt daarna native tegen de grenzen van de bitmap, zodat er nooit iets buiten de band wordt gerasterd. De page-to-device-mapping die in ISO 32000-1 clause 8.3.2 wordt beschreven blijft van de eerste tot de laatste band identiek, en dat is precies het punt: band N is bit-identiek aan rijen BandTop tot en met BandTop + h van één full-page-render, en de regressiesuite assert dat exact pixel voor pixel tegen de output van RenderPage op dezelfde afmetingen

// Eén band met de hand. De destination bitmap is maar BandHeight hoog,
// maar de doelgrootte van de pagina blijft de volledige Width x Height
Band := Pdf.RenderTile(0, BandTop,          // tile-origin in paginapixels
                       Width, BandHeight,   // grootte van de destination bitmap
                       Width, Height);      // volledige doelgrootte van de pagina
try
  // Band bevat nu de rijen BandTop .. BandTop + BandHeight - 1 van de pagina
finally
  Band.Free;
end;

De publieke band-API is een callbackloop. RenderPageBanded(Width, Height, BandHeight, BandCallback, Rotation, Options, Color) retourneert het aantal bands dat werkelijk is gerenderd, of 0 wanneer de argumenten worden geweigerd, en houdt de renderlock van de component gedurende de hele pass vast. De callbacksignature is TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object. De bitmap is pf32bit, Width pixels breed en niet hoger dan BandHeight, en wordt vrijgegeven zodra je handler terugkeert, dus kopieer alles wat je wilt behouden. False retourneren stopt de pass na de huidige band, waarmee je hetzelfde coöperatieve cancellationmodel krijgt als bij annuleerbare progressieve PDF-rendering in Delphi, maar op strookniveau in plaats van op het continuationniveau van 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 sterft wanneer deze methode terugkeert - verwerk hem hier
  Inc(FRows, Bitmap.Height);
  Result := not FCancelled;
end;

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

PNG en TIFF streamen zonder een bitmap van een volledige pagina

Renderen in bands helpt alleen als de encoder ook sequentieel is, dus v3.66.0 voegde RenderPageBandedToStream toe, die PNG of TIFF rechtstreeks naar een callerstream schrijft. TPdfBandedImageStreamOptions.Default initialiseert een bandhoogte van 256 rijen, PNG-compressieniveau 6 en een MaxOutputBytes van 0, wat onbeperkt betekent. Het geretourneerde TPdfBandedImageReport bevat Format, Width, Height, BandsRendered, BandsEncoded, RowsEncoded, PeakBandBytes, OutputBytes en Completed. PeakBandBytes is het getal dat je werkelijk nodig hebt bij het dimensioneren van een job: het is Width * BandHeight * 4, dus het A0-vel hierboven piekt op ongeveer 19 MB bandbuffer in plaats van 2 GB pagebuffer

De PNG-encoder is bewust smal. Hij emit fixed RGB8, schrijft een IHDR met bitdiepte 8 en kleurtype 2, bouwt daarna elke scanline met filtertype 0 (ISO/IEC 15948 filter method 0, filter type None) en duwt die door de zlib-compressiestream van het platform. De gecomprimeerde bytes komen terug als CRC-dragende IDAT-chunks die op volgorde worden geschreven. De interessante beperking zit in de stream onder de deflate-laag: die beantwoordt positiequeries omdat de compressiestream ze stelt, maar elke poging tot een echte seek werpt een fout. Dat is opzettelijk. Zodra een IDAT-chunk en zijn CRC op de lijn staan, is er geen weg terug om ze te repareren, en een stille seek zou output beschadigen die er structureel nog geldig uitziet

De TIFF-encoder schrijft little-endian klassieke TIFF, de II-byte-order-mark gevolgd door magic 42, met één strip per band. Pixels worden eerst uitgestreamd en de IFD met tien entries wordt aan het einde gegenereerd, zodra de stripoffsets en bytecounts bekend zijn. Compressie is tag 259 met waarde 1, dus er is helemaal geen entropy coding: de payload is exact Width * Height * 3 bytes, PhotometricInterpretation is RGB, PlanarConfiguration is chunky en RowsPerStrip legt de bandhoogte vast, terwijl de laatste korte strip zijn eigen StripByteCounts-entry krijgt. De bandhoogte verandert dus het piekgeheugen en het aantal strips, maar niet de outputgrootte, wat je moet weten voordat je gaat tunen. Als je kleine bestanden wilt in plaats van lossless bestanden, blijft het per-page-pad in PDF-pagina's naar JPEG-afbeeldingen converteren met de PDFium VCL-component het betere hulpmiddel

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

Waar stopt een banded export?

Twee plafonds begrenzen de output, en ze falen bewust op verschillende plaatsen. Het eerste is het budget van de caller: MaxOutputBytes wordt afgedwongen door een bounded write stream die EPdfError werpt voordat er een write plaatsvindt die de limiet zou overschrijden, zodat het budget een harde cap is en geen rapport achteraf. Het tweede is structureel. Klassieke TIFF slaat stripoffsets op als 32-bit waarden, dus BeginImage valideert Width * Height * 3 plus header en directory tegen die grens en weigert de job voordat één pixel is geschreven; dezelfde check draait vooraf tegen MaxOutputBytes, want een TIFF waarvan het budget zelfs de eigen pixelpayload niet kan dekken, is het starten niet waard. PNG heeft geen equivalente limiet, omdat IDAT-chunks puur sequentieel zijn en er geen 32-bit offsettabel is die kan overlopen

Wees nuchter over wat een gestopte export achterlaat. Wanneer de pass de laatste rij niet bereikt, blijft Completed False en wordt de encoder afgebroken met EndImage(False), dat bewust noch de PNG-IEND-chunk noch de TIFF-IFD schrijft. Het gedeeltelijke bestand is dus ongeldig en elke decoder zal dat zeggen, in plaats van een plausibel beeld met ontbrekende rijen te zijn. Die cleanup is zo omwikkeld dat een tweede fout binnen EndImage de oorspronkelijke exception niet kan vervangen, het verschil tussen een stacktrace die de echte oorzaak noemt en een die de schoonmaker noemt. Als je voortgang wilt die blijft bestaan, checkpoint dan per band binnen je eigen callback; de strip-level cachingtactieken in de PDFium Delphi-rendercache- en zoomgids zijn hier ook van toepassing

Je eigen codec aansluiten

Wanneer PNG en TIFF niet het doel zijn, neemt RenderPageBandedToEncoder een descendant van TPdfBandedImageEncoder aan en stuurt die door dezelfde loop. De lifecycle is expliciet en kort: BeginImage(Width, Height), daarna eenmaal per strip in strikt oplopende volgorde WriteBand(BandIndex, BandTopY, Bitmap), vervolgens EndImage(Completed), waarbij GetBytesWritten Report.OutputBytes voedt. De ingebouwde encoders weigeren een band buiten de volgorde onmiddellijk in plaats van hem te bufferen, en elke encoder die je schrijft moet hetzelfde doen, omdat een codec die strips stilletjes herschikt een bestand produceert dat opent en liegt. Dit is de seam voor JPEG 2000-tiles, een JPEG-writer die één MCU-row-band per keer krijgt of een directe feed naar een printspooler

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;
  // Voer Bitmap.ScanLine[0 .. Bitmap.Height - 1] hier aan de codec door
  Inc(FNextBand);
  Result := True;
end;

Eén cross-compiler-valkuil die je moet kennen

De zlib-unit heet op elke ondersteunde toolchain anders: Delphi XE5 en later gebruiken System.ZLib, FPC gebruikt zstream en oudere Delphi gebruikt gewone ZLib. Dat is routine conditional compilation. De valkuil is dat alle drie compressieniveauconstanten met de namen clNone en clDefault exporteren, die frontaal botsen met de TColor-members met dezelfde naam in de graphicsunit. Zodra de zlib-unit in de implementation uses-clause verschijnt, kan een on-gekwalificeerde clNone in rendercode naar een compressieniveau verwijzen in plaats van naar een kleur, zonder diagnose. PDFiumPas zet dit vast met expliciete kleur-sentinelaliases, PdfGraphicsColorNone en PdfGraphicsColorDefault, die eenmaal aan de volledig gekwalificeerde graphicsconstanten worden gebonden en overal worden gebruikt waar een renderachtergrond of een colorschemasentinel wordt vergeleken. Drie regels code en de symboolresolutie stopt met verschuiven tussen compilers

Banded rendering ziet eruit als een gemaksfunctie tot je een pagina tegenkomt die niet in RAM past, en dan is het de enige route die werkt. De gecorrigeerde bandgeometrie, de sequentiële PNG- en TIFF-encoders en de seam voor een custom encoder worden allemaal meegeleverd als onderdeel van de PDFium Delphi-component, met de volledige band-versus-page-pixelvergelijking in de regressiesuite voor Delphi, Lazarus en C++Builder