Технічна стаття

Banded PDF rendering у Delphi: negative Y offset

Перша band містила весь drawing, стиснутий в одну strip, а п’ять наступних bands поверталися blank. Це був старий banded export, і PDFiumPas виправив його у v3.66.0: RenderPageBanded тепер передає повну target width і height page у FPDF_RenderPageBitmap на кожній band разом із negative vertical offset, тому native clip записує лише rows current band, а page зберігає full-page coordinate geometry. Use case тут нудний і неминучий. Хтось передає вам E-size plot або stitched panorama page і хоче raster у 600 DPI. ISO A0 sheet у 600 DPI — це 19866 x 28086 pixels, а 32-bit destination bitmap такого розміру потребує трохи більше 2 GB contiguous memory. У 32-bit Delphi allocation просто провалюється. У 64-bit вона достатньо часто вдається, щоб failure став customer problem, а не test problem. Banded rendering існує, щоб peak allocation була одна strip, а не одна page

Чому кожна band містила всю page?

Старий code плутав дві різні pairs arguments у PDFium page-rendering call. FPDF_RenderPageBitmap приймає start_x, start_y, size_x і size_y, де size pair каже, до якого розміру масштабувати всю page, а start pair — де ця scaled page опиняється всередині destination bitmap. Pre-v3.66.0 band loop викликав library RenderPage helper із band top як destination offset і band height як page height. Ці два numbers безпосередньо проходили в native call, тому PDFium масштабував усю page в rectangle лише BandHeight rows заввишки, а потім малював її на y = BandTop у bitmap, який сам мав лише BandHeight rows. Результат передбачуваний, щойно його побачити. Band zero отримувала всю page, вертикально стиснуту до band height. Кожна наступна band отримувала ту саму стиснуту page, pushed below bottom edge bitmap, і тому поверталася як background fill. Bug ховається у випадку, який використовують більшість smoke tests: page, чия render height менша за band height, бо тоді band одна і wrong geometry випадково збігається з правильною. Все, що вище однієї band, одразу це показує

Що гарантує negative offset

Fixed implementation спрямовує кожну band через RenderTile, єдине місце в component, яке вже розуміло цю різницю. RenderTile приймає tile origin у full-page pixel coordinates плюс окремі PageWidth і PageHeight, а PDFium передає -Left і -Top, не змінюючи page size. Negating offset зсуває full-size page вгору, доки потрібна band не опиниться в row zero destination bitmap; PDFium потім native clip-ить за межами bitmap, тому нічого поза band ніколи не rasterize-иться. Page-to-device mapping, описаний в ISO 32000-1 clause 8.3.2, лишається однаковим від першої band до останньої, і саме це головне: band N byte-identical до rows BandTop through BandTop + h single full-page render, а regression suite перевіряє саме це pixel by pixel проти RenderPage output із тими самими dimensions

// Одна band вручну. Destination bitmap має лише BandHeight rows,
// але page target size зберігається full Width x Height
Band := Pdf.RenderTile(0, BandTop,          // tile origin у page pixels
                       Width, BandHeight,   // розмір destination bitmap
                       Width, Height);      // розмір full-page target
try
  // Band тепер містить rows BandTop .. BandTop + BandHeight - 1 page
finally
  Band.Free;
end;

Public band API — це callback loop. RenderPageBanded(Width, Height, BandHeight, BandCallback, Rotation, Options, Color) повертає кількість bands, які справді rendered, або 0, коли arguments rejected, і тримає component render lock увесь pass. Callback signature — TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object. Bitmap має pf32bit, width Width pixels і height не більше BandHeight, а звільняється одразу після return вашого handler, тож усе, що хочете залишити, потрібно copy-нути. Return False зупиняє pass після current band і дає ту саму cooperative cancellation model, що використовується в cancellable progressive PDF rendering у Delphi, лише на strip granularity, а не PDFium continuation granularity

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 помирає, коли цей method return-иться — спожити його тут
  Inc(FRows, Bitmap.Height);
  Result := not FCancelled;
end;

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

Streaming PNG і TIFF без full-page bitmap

Rendering bands допомагає лише тоді, коли encoder також sequential, тому v3.66.0 додав RenderPageBandedToStream, який пише PNG або TIFF прямо в caller stream. TPdfBandedImageStreamOptions.Default встановлює band height 256 rows, PNG compression level 6 і MaxOutputBytes 0, що означає unbounded. Returned TPdfBandedImageReport містить Format, Width, Height, BandsRendered, BandsEncoded, RowsEncoded, PeakBandBytes, OutputBytes і Completed. PeakBandBytes — число, яке справді потрібно під час sizing job: це Width * BandHeight * 4, тому наведена A0 sheet має peak приблизно 19 MB band buffer замість 2 GB page buffer

PNG encoder навмисно вузький. Він emit-ить fixed RGB8, записує IHDR із bit depth 8 і color type 2, потім будує кожну scanline з filter type 0 (ISO/IEC 15948 filter method 0, filter type None) і проштовхує її через platform zlib compression stream. Compressed bytes виходять як CRC-bearing IDAT chunks, записані по порядку. Цікаве обмеження — stream під deflate layer: він відповідає на position queries, бо compression stream їх запитує, але будь-яка справжня seek піднімає error. Це навмисно. Після того як IDAT chunk та його CRC опинилися on the wire, назад уже не повернутися для fix, а silent seek зіпсував би output, який усе ще виглядає structurally valid

TIFF encoder записує little-endian classic TIFF, byte-order marker II за magic 42, з однією strip на band. Pixels спочатку stream-яться, а ten-entry IFD генерується в кінці, коли відомі strip offsets і byte counts. Compression — tag 259 value 1, тож entropy coding немає: payload рівно Width * Height * 3 bytes, PhotometricInterpretation — RGB, PlanarConfiguration — chunky, а RowsPerStrip записує band height, тоді як final short strip описується власним StripByteCounts entry. Тому band height змінює peak memory і strip count, але не output size, що варто знати перед tuning. Якщо потрібні small files, а не lossless ones, per-page path у конвертації PDF pages у JPEG images із PDFium VCL component залишається кращим tool

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, а не 19866 * 28086 * 4
end;

Де зупиняється banded export?

Два ceilings обмежують output і навмисно дають різні failures. Перший — caller budget: MaxOutputBytes enforcement відбувається через bounded write stream, який піднімає EPdfError до будь-якого write, що перетнув би limit, тому budget є hard cap, а не after-the-fact report. Другий — structural. Classic TIFF зберігає strip offsets як 32-bit values, тому BeginImage перевіряє Width * Height * 3 плюс header і directory проти цього ceiling і відхиляє job до запису першого pixel; та сама check upfront виконується проти MaxOutputBytes, бо TIFF, чий budget не покриває власний pixel payload, не варто починати. PNG еквівалентного limit не має, оскільки IDAT chunks суто sequential і 32-bit offset table, яка могла б переповнитися, відсутня

Тверезо оцініть, що залишає stopped export. Коли pass не доходить до last row, Completed залишається False, а encoder розбирається через EndImage(False), який навмисно не пише ні PNG IEND chunk, ні TIFF IFD. Тому partial file invalid, і кожен decoder так і скаже, замість створити plausibly-looking image із missing rows. Cleanup обгорнутий так, щоб secondary failure всередині EndImage не замінив original exception, і це різниця між stack trace, який називає справжню причину, та trace, який називає janitor. Якщо потрібен progress, що переживає збій, checkpoint-іть per band у власному callback; strip-level caching tactics з PDFium Delphi render cache та zoom guide також застосовні тут

Підключення власного codec

Коли target — не PNG і не TIFF, RenderPageBandedToEncoder приймає descendant TPdfBandedImageEncoder і керує тим самим loop. Lifecycle explicit і короткий: BeginImage(Width, Height), потім WriteBand(BandIndex, BandTopY, Bitmap) один раз на кожну strip у строго ascending order, потім EndImage(Completed), а GetBytesWritten живить Report.OutputBytes. Built-in encoders відразу відхиляють out-of-order band, а не намагаються buffer-ити її, і будь-який ваш encoder має робити так само, бо codec, який тихо reorder-ить strips, створює file, що відкривається й бреше. Це seam для JPEG 2000 tiles, JPEG writer, який отримує по одній MCU row band, або direct feed у print spooler

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] у codec тут
  Inc(FNextBand);
  Result := True;
end;

Одна cross-compiler trap, про яку варто знати

Zlib unit має різне spelling у кожному supported toolchain: Delphi XE5 і newer використовують System.ZLib, FPC — zstream, а older Delphi — plain ZLib. Це звичайна conditional compilation. Trap у тому, що всі три export-ять compression-level constants із names clNone і clDefault, які head-on collid-ять із TColor members із такими самими names у graphics unit. Щойно zlib unit з’являється в implementation uses clause, unqualified clNone у render code може розв’язатися в compression level замість color, без жодного diagnostic. PDFiumPas фіксує це explicit color sentinel aliases — PdfGraphicsColorNone і PdfGraphicsColorDefault, — які один раз bound до fully qualified graphics constants і використовуються всюди, де порівнюється render background або color-scheme sentinel. Три lines code — і symbol resolution перестає drift-ити між compilers

Banded rendering виглядає як convenience feature, доки не зустрінеш page, яка не влазить у RAM, а потім виявляється єдиним working path. Corrected band geometry, sequential PNG і TIFF encoders та custom encoder seam постачаються як частина PDFium Delphi component, а повне band-versus-page pixel comparison запускається в regression suite на Delphi, Lazarus та C++Builder