Odborný článok

Pásové renderovanie PDF v Delphi: záporný Y offset

Prvý pás obsahoval celý výkres stlačený do jedného pruhu a päť pásov za ním sa vrátilo prázdnych. Tak vyzeral starý pásový export a PDFiumPas ho vo v3.66.0 opravil: RenderPageBanded teraz pri každom jednom páse odovzdá FPDF_RenderPageBitmap úplnú cieľovú šírku aj výšku stránky spolu so záporným vertikálnym offsetom, takže natívny clip zapíše iba riadky aktuálneho pásu, zatiaľ čo stránka si ponechá geometriu celej strany. Použitie za tým všetkým je nudné a nevyhnutné. Niekto vám odovzdá plot veľkosti E alebo zošitú panoramatickú stránku a chce z nej raster v 600 DPI. Hárok ISO A0 pri 600 DPI má 19866 × 28086 pixelov a 32-bitová cieľová bitmapa takej veľkosti potrebuje o niečo viac než 2 GB súvislej pamäte. V 32-bitovom Delphi táto alokácia jednoducho zlyhá. V 64-bitovom uspeje dosť často na to, aby bol problémom zákazníka, nie testu. Pásové renderovanie existuje preto, aby peak alokácia bola jeden pruh, nie jedna stránka

Prečo každý pás obsahoval celú stránku?

Starý kód si pomýlil dve rôzne dvojice argumentov vo volaní renderovania stránky PDFium. FPDF_RenderPageBitmap prijíma start_x, start_y, size_x a size_y, pričom dvojica size hovorí, na akú veľkosť sa má škálovať celá stránka, a dvojica start hovorí, kam táto škálovaná stránka dopadne v cieľovej bitmap. Slučka pásov pred v3.66.0 volala helper knižnice RenderPage s vrchom pásu ako destination offsetom a výškou pásu ako výškou stránky. Tieto dve čísla prešli priamo do natívneho volania, takže PDFium škáloval celú stránku do obdĺžnika vysokého iba BandHeight riadkov a potom ju kreslil na y = BandTop v bitmap, ktorá sama mala iba BandHeight riadkov. Výsledok je presne taký, aký predpovedáte, keď ho raz uvidíte. Pás nula dostal celú stránku vertikálne stlačenú na výšku pásu. Každý neskorší pás dostal tú istú stlačenú stránku posunutú pod spodnú hranu svojej bitmapy, takže sa vrátil ako výplň pozadia. Bug sa skrýva v jedinom prípade, ktorý používajú najbežnejšie smoke testy, teda stránke, ktorej renderovacia výška je menšia než výška pásu, pretože vtedy existuje jeden pás a nesprávna geometria náhodou splýva so správnou. Čokoľvek vyššie než jeden pás ho odhalí okamžite

Čo garantuje záporný offset

Opravená implementácia vedie každý pás cez RenderTile, jediné miesto v komponente, ktoré už rozdiel chápalo. RenderTile prijíma pôvod tile v pixelových súradniciach celej stránky plus samostatné PageWidth a PageHeight a do PDFium odovzdá -Left a -Top s nedotknutou veľkosťou stránky. Negácia offsetu posunie stránku plnej veľkosti nahor, až kým požadovaný pás nesedí na riadku nula cieľovej bitmapy; PDFium potom natívne oreže podľa hraníc bitmapy, takže nič mimo pásu sa nikdy nerasterizuje. Mapovanie stránky na zariadenie opísané v ISO 32000-1 clause 8.3.2 zostáva rovnaké od prvého pásu po posledný, čo je celý point: pás N je bitovo identický s riadkami BandTopBandTop + h jediného renderu celej stránky a regresná suite to overuje presne takto, pixel po pixeli, oproti výstupu RenderPage s rovnakými rozmermi

// Jeden pás ručne. Cieľová bitmapa je vysoká iba BandHeight,
// ale cieľová veľkosť stránky zostáva plná Width x Height
Band := Pdf.RenderTile(0, BandTop,          // pôvod tile v pixeloch stránky
                       Width, BandHeight,   // veľkosť cieľovej bitmapy
                       Width, Height);      // cieľová veľkosť celej stránky
try
  // Band teraz drží riadky BandTop .. BandTop + BandHeight - 1 stránky
finally
  Band.Free;
end;

Verejné band API je callback loop. RenderPageBanded(Width, Height, BandHeight, BandCallback, Rotation, Options, Color) vráti počet pásov, ktoré skutočne vyrenderovalo, alebo 0 pri odmietnutí argumentov, a počas celého prechodu drží render lock komponentu. Signatúra callbacku je TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object. Bitmapa je pf32bit, široká Width pixelov a nie vyššia než BandHeight, pričom sa uvoľní hneď po návrate vášho handlera, preto si skopírujte všetko, čo si chcete ponechať. Návrat False zastaví prechod po aktuálnom páse a dá vám rovnaký kooperatívny cancellation model, aký používa kooperatívne zrušiteľné progresívne renderovanie PDF v Delphi, iba na granularite pruhov namiesto granularitu 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
  // Bitmapa zanikne, keď sa táto metóda vráti - spotrebuj ju tu
  Inc(FRows, Bitmap.Height);
  Result := not FCancelled;
end;

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

Streamovanie PNG a TIFF bez bitmapy celej stránky

Renderovanie v pásoch pomáha iba vtedy, keď je sekvenčný aj encoder, preto v3.66.0 pribudol RenderPageBandedToStream, ktorý zapisuje PNG alebo TIFF priamo do streamu volajúceho. TPdfBandedImageStreamOptions.Default nastaví výšku pásu 256 riadkov, úroveň PNG kompresie 6 a MaxOutputBytes na 0, čo znamená bez limitu. Vrátený TPdfBandedImageReport nesie Format, Width, Height, BandsRendered, BandsEncoded, RowsEncoded, PeakBandBytes, OutputBytes a Completed. PeakBandBytes je číslo, na ktorom vám pri dimenzovaní jobu skutočne záleží: je to Width * BandHeight * 4, takže hárok A0 vyššie vrcholí približne na 19 MB bufferu pásu namiesto 2 GB bufferu stránky

PNG encoder je zámerne úzky. Emittuje fixed RGB8, zapíše IHDR s bit depth 8 a color type 2, potom každý scanline zostaví s filter type 0 (ISO/IEC 15948 filter method 0, filter type None) a pretlačí ho cez platformový zlib compression stream. Komprimované bajty vyjdú ako IDAT chunky nesúce CRC a zapísané v poradí. Zaujímavé obmedzenie je stream pod deflate vrstvou: odpovedá na position queries, pretože si ich kompresný stream pýta, ale každý skutočný seek vyvolá chybu. Je to zámerné. Keď je IDAT chunk a jeho CRC na wire, niet cesty späť na ich opravu a tichý seek by poškodil výstup, ktorý stále vyzerá štrukturálne platne

TIFF encoder zapisuje little-endian classic TIFF, byte-order mark II nasledovaný magic 42, s jedným pásom na strip. Pixely streamujú von najprv a desaťprvkový IFD sa vygeneruje na konci, keď už sú známe offsety stripov a počty bajtov. Kompresia je tag 259 value 1, takže tu vôbec nie je entropy coding: payload je presne Width * Height * 3 bajtov, PhotometricInterpretation je RGB, PlanarConfiguration je chunky a RowsPerStrip zaznamenáva výšku pásu, zatiaľ čo posledný krátky strip opisuje vlastná položka StripByteCounts. Výška pásu preto mení peak memory a počet stripov, nie veľkosť výstupu, čo je dobré vedieť pred ladením. Ak chcete malé súbory namiesto bezstratových, lepším nástrojom zostáva per-page path v článku o prevode stránok PDF na obrázky JPEG pomocou PDFium VCL komponentu

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

Kde sa pásový export zastaví?

Výstup ohraničujú dva stropy a zámerne zlyhávajú na rôznych miestach. Prvým je rozpočet volajúceho: MaxOutputBytes vynucuje bounded write stream, ktorý vyvolá EPdfError pred každým zápisom, ktorý by prekročil limit, takže rozpočet je tvrdý strop, nie dodatočný report. Druhý je štrukturálny. Classic TIFF ukladá offsety stripov ako 32-bitové hodnoty, takže BeginImage overí Width * Height * 3 plus hlavičku a directory voči tomuto stropu a job odmietne skôr, než sa zapíše jediný pixel; rovnaká kontrola sa vopred vykoná voči MaxOutputBytes, pretože TIFF, ktorého rozpočet nepokryje vlastný pixel payload, nemá zmysel spúšťať. PNG ekvivalentný limit nemá, pretože IDAT chunky sú čisto sekvenčné a neexistuje 32-bitová offsetová tabuľka, ktorá by pretiekla

Majte jasno v tom, čo po sebe zastavený export zanechá. Keď prechod nedôjde k poslednému riadku, Completed zostane False a encoder sa rozoberie s EndImage(False), ktoré zámerne nezapíše ani PNG IEND chunk, ani TIFF IFD. Čiastočný súbor je teda neplatný a každý dekóder to povie namiesto vierohodne vyzerajúceho obrazu s chýbajúcimi riadkami. Toto čistenie je zabalené tak, aby sekundárne zlyhanie vnútri EndImage nemohlo nahradiť pôvodnú výnimku, čo je rozdiel medzi stack trace, ktorý pomenuje skutočnú príčinu, a stack trace, ktorý pomenuje upratovača. Ak potrebujete progress, ktorý prežije, checkpointujte po pásoch vo vlastnom callbacku; taktiky cache na úrovni stripov z príručky PDFium Delphi render cache a zoom platia aj tu

Zapojenie vlastného kodeku

Keď cieľom nie je PNG ani TIFF, RenderPageBandedToEncoder prijíma potomka TPdfBandedImageEncoder a poháňa rovnakú slučku. Životný cyklus je explicitný a krátky: BeginImage(Width, Height), potom raz na každý strip v striktne vzostupnom poradí WriteBand(BandIndex, BandTopY, Bitmap), potom EndImage(Completed), pričom GetBytesWritten zásobuje Report.OutputBytes. Vstavané encodery out-of-order band priamo odmietnu namiesto pokusu o jeho bufferovanie a rovnako by ste mali postupovať pri vlastnom, pretože kodek, ktorý potichu preusporiada stripy, vyrobí súbor, ktorý sa otvorí a klame. Toto je seam pre JPEG 2000 tiles, JPEG writer zásobovaný jedným MCU row bandom naraz alebo priamy feed do print spoolera

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;
  // Feedni Bitmap.ScanLine[0 .. Bitmap.Height - 1] do kodeku tu
  Inc(FNextBand);
  Result := True;
end;

Jedna pasca cross-compilera, ktorú sa oplatí poznať

Unit zlib sa v každom podporovanom toolchaine píše inak: Delphi XE5 a neskôr používajú System.ZLib, FPC používa zstream a staršie Delphi používa obyčajný ZLib. To je bežná conditional compilation. Pasca je v tom, že všetky tri exportujú konštanty úrovne kompresie s názvom clNone a clDefault, ktoré čelne kolidujú s členmi TColor rovnakého mena v graphics unite. Keď sa zlib unit objaví v implementation uses clause, nekvalifikované clNone v render kóde sa môže vyriešiť na compression level namiesto color bez diagnostiky. PDFiumPas to pripína explicitnými aliasmi farebných sentinelov PdfGraphicsColorNone a PdfGraphicsColorDefault, ktoré sa raz naviažu na plne kvalifikované graphics constants a používajú všade, kde sa porovnáva render background alebo color-scheme sentinel. Tri riadky kódu a resolving symbolov prestane driftovať medzi kompilátormi

Pásové renderovanie vyzerá ako pohodlná funkcia až do chvíle, keď stretnete stránku, ktorá sa nezmestí do RAM, a potom je to jediná cesta, ktorá funguje. Opravená geometria pásov, sekvenčné PNG a TIFF encodery aj seam pre vlastný encoder sa dodávajú ako súčasť PDFium Delphi component, pričom úplné porovnanie pixelov pásu a stránky beží v regresnej suite naprieč Delphi, Lazarus a C++Builder