기술 문서

Delphi에서 밴드 PDF 렌더링: 음수 Y 오프셋

첫 번째 band는 전체 drawing을 하나의 strip에 눌러 담았고 그 뒤의 다섯 band는 빈 상태로 돌아왔습니다. 이것이 예전 banded export였으며 PDFiumPas는 v3.66.0에서 수정했습니다. RenderPageBanded는 이제 모든 band에서 full-page target width와 height를 FPDF_RenderPageBitmap에 넘기고 negative vertical offset도 함께 전달하므로 native clip이 현재 band의 row만 쓰면서 page는 full-page coordinate geometry를 유지합니다. 배경은 지루하지만 피할 수 없습니다. 누군가 E-size plot 또는 stitched panorama page를 넘기며 600 DPI raster를 원합니다. 600 DPI의 ISO A0 sheet는 19866 x 28086 pixel이고 32-bit destination bitmap은 contiguous memory 2 GB를 조금 넘습니다. 32-bit Delphi에서는 allocation 자체가 실패합니다. 64-bit에서는 충분히 자주 성공해 test 문제가 아니라 customer 문제가 됩니다. banded rendering은 peak allocation을 page 하나가 아니라 strip 하나로 만들기 위해 존재합니다

모든 band에 전체 page가 들어간 이유

예전 code가 PDFium page-rendering call의 서로 다른 두 argument pair를 혼동했습니다. FPDF_RenderPageBitmapstart_x, start_y, size_x, size_y를 받으며 size pair는 전체 page를 얼마나 크게 scale할지, start pair는 그 scaled page가 destination bitmap 안 어디에 놓일지를 말합니다. v3.66.0 이전의 band loop는 band top을 destination offset으로, band height를 page height로 사용해 library RenderPage helper를 호출했습니다. 두 숫자가 native call까지 그대로 갔으므로 PDFium은 전체 page를 BandHeight row밖에 안 되는 rectangle으로 scale한 다음 그 page를 자체가 BandHeight row밖에 안 되는 bitmap 안 y = BandTop에 그렸습니다. 결과는 보이는 그대로입니다. band zero는 전체 page를 band height로 수직 압축한 것을 받습니다. 뒤의 모든 band는 같은 압축 page가 bitmap 아래로 밀려 background fill만 돌아옵니다. smoke test가 주로 사용하는 page render height가 band height보다 작은 한 band case에서는 bug가 숨습니다. wrong geometry가 우연히 right geometry와 겹치기 때문입니다. 한 band보다 큰 것은 즉시 드러냅니다

negative offset이 보장하는 것

수정된 implementation은 모든 band를 RenderTile로 보냅니다. 이 component에서 이미 distinction을 이해하는 유일한 곳입니다. RenderTile은 full-page pixel coordinate의 tile origin과 별도의 PageWidth, PageHeight를 받고 PDFium에는 page size를 건드리지 않은 채 -Left-Top을 넘깁니다. offset을 negate하면 full-size page가 위로 미끄러져 요청한 band가 destination bitmap의 row zero에 놓입니다. PDFium은 bitmap boundary에 대해 native clip하므로 band 밖은 rasterize되지 않습니다. ISO 32000-1 clause 8.3.2에서 말하는 page-to-device mapping은 첫 band부터 마지막까지 동일합니다. 이것이 핵심이며 band N은 같은 dimension의 단일 full-page render에서 row BandTop부터 BandTop + h까지와 pixel 단위로 동일하고 regression suite가 RenderPage output과 비교해 이를 assert합니다

// 직접 한 band를 렌더합니다. destination bitmap은 BandHeight만큼만 높지만
// page target size는 full Width x Height로 유지됩니다
Band := Pdf.RenderTile(0, BandTop,          // page pixel의 tile origin
                       Width, BandHeight,   // destination bitmap 크기
                       Width, Height);      // full-page target 크기
try
  // Band에는 page의 BandTop .. BandTop + BandHeight - 1 row가 들어 있습니다
finally
  Band.Free;
end;

public band API는 callback loop입니다. RenderPageBanded(Width, Height, BandHeight, BandCallback, Rotation, Options, Color)은 실제로 render한 band 수를 반환하고 argument가 거부되면 0을 반환하며 전체 pass 동안 component render lock을 잡습니다. callback signature는 TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object입니다. bitmap은 pf32bit이고 width는 Width pixel, height는 BandHeight 이하이며 handler가 return하는 즉시 free되므로 보관하려는 것은 복사해야 합니다. False를 반환하면 current band 뒤에서 pass가 멈춥니다. Delphi의 cancellable progressive PDF rendering과 같은 cooperative cancellation model이지만 PDFium continuation 단위가 아니라 strip 단위입니다

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
  // 이 method가 return하면 Bitmap은 사라지므로 여기서 소비합니다
  Inc(FRows, Bitmap.Height);
  Result := not FCancelled;
end;

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

full-page bitmap 없이 PNG와 TIFF stream하기

band render가 도움이 되려면 encoder도 sequential이어야 하므로 v3.66.0은 PNG 또는 TIFF를 caller stream에 직접 쓰는 RenderPageBandedToStream도 추가했습니다. TPdfBandedImageStreamOptions.Default는 band height 256 row, PNG compression level 6, MaxOutputBytes 0을 seed하며 0은 unbounded입니다. 반환되는 TPdfBandedImageReport에는 Format, Width, Height, BandsRendered, BandsEncoded, RowsEncoded, PeakBandBytes, OutputBytes, Completed가 들어갑니다. job size를 잡을 때 실제로 중요한 수는 PeakBandBytes입니다. Width * BandHeight * 4이므로 앞서의 A0 sheet는 2 GB page buffer가 아니라 약 19 MB band buffer에서 peak합니다

PNG encoder는 의도적으로 좁습니다. fixed RGB8을 emit하며 bit depth 8, color type 2인 IHDR을 쓰고 모든 scanline을 filter type 0(ISO/IEC 15948 filter method 0, filter type None)으로 만든 뒤 platform zlib compression stream에 넣습니다. compressed byte는 CRC가 포함된 IDAT chunk로 순서대로 나옵니다. 흥미로운 제약은 deflate layer 아래의 stream입니다. compression stream이 position query를 하므로 query에는 응답하지만 실제 seek를 시도하면 error를 raise합니다. IDAT chunk와 CRC가 wire에 올라간 뒤에는 고칠 방법이 없고 silent seek는 구조적으로는 유효해 보이는 output을 corrupt하기 때문입니다

TIFF encoder는 little-endian classic TIFF를 쓰며 II byte order mark 뒤에 magic 42가 오고 band마다 하나의 strip을 둡니다. pixel이 먼저 stream으로 나가고 strip offset과 byte count를 알게 되는 마지막에 ten-entry IFD를 생성합니다. compression은 tag 259 value 1이므로 entropy coding이 전혀 없습니다. payload는 정확히 Width * Height * 3 byte이고 PhotometricInterpretation은 RGB, PlanarConfiguration은 chunky이며 RowsPerStrip은 band height를 기록하고 마지막 short strip은 자체 StripByteCounts entry로 설명합니다. 따라서 band height는 peak memory와 strip count를 바꾸지만 output size는 바꾸지 않습니다. lossless가 아니라 작은 file을 원한다면 PDFium VCL component로 PDF page를 JPEG image로 변환하는 per-page path가 더 알맞습니다

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가 멈추는 곳

두 ceiling이 output을 제한하며 의도적으로 서로 다른 곳에서 실패합니다. 첫째는 caller budget입니다. MaxOutputBytes는 limit을 넘기는 write가 일어나기 전에 EPdfError를 raise하는 bounded write stream으로 강제되므로 budget은 사후 report가 아니라 hard cap입니다. 둘째는 structural limit입니다. classic TIFF는 strip offset을 32-bit value로 저장하므로 BeginImage는 한 pixel도 write하기 전에 Width * Height * 3에 header와 directory를 더한 값이 그 ceiling 안에 있는지 확인하고 job을 거부합니다. 같은 check는 MaxOutputBytes에도 upfront로 수행됩니다. 자체 pixel payload를 budget이 감당하지 못하는 TIFF는 시작할 가치가 없기 때문입니다. PNG에는 동등한 limit이 없습니다. IDAT chunk는 순차적이고 overflow할 32-bit offset table이 없기 때문입니다

stopped export가 남기는 것을 정확히 봐야 합니다. pass가 마지막 row까지 도달하지 못하면 CompletedFalse로 남고 encoder가 EndImage(False)로 teardown됩니다. 이때 PNG IEND chunk나 TIFF IFD는 일부러 쓰지 않습니다. 따라서 partial file은 invalid하고 decoder는 모두 이를 말하게 됩니다. row가 빠진 그럴듯한 image를 만들지 않습니다. 이 cleanup은 EndImage 내부의 secondary failure가 original exception을 덮어쓰지 못하도록 감싸져 있습니다. 진짜 원인을 말하는 stack trace와 janitor를 말하는 stack trace의 차이입니다. 유지되는 progress가 필요하면 자체 callback에서 band마다 checkpoint하세요. PDFium Delphi render cache와 zoom guide의 strip-level caching tactic도 여기 적용됩니다

자체 codec 연결하기

PNG와 TIFF가 target이 아닐 때는 RenderPageBandedToEncoderTPdfBandedImageEncoder descendant를 넘겨 같은 loop를 구동할 수 있습니다. lifecycle은 명시적이고 짧습니다. BeginImage(Width, Height), 그 다음 strip마다 정확히 한 번씩 strictly ascending order로 WriteBand(BandIndex, BandTopY, Bitmap), 마지막으로 EndImage(Completed)를 호출하며 GetBytesWrittenReport.OutputBytes를 공급합니다. built-in encoder는 out-of-order band를 buffer하려 하지 않고 즉시 거부하며 직접 작성하는 encoder도 같은 일을 해야 합니다. strip을 조용히 reorder하는 codec은 열리기는 하지만 거짓말하는 file을 만들기 때문입니다. JPEG 2000 tile, 한 번에 MCU row band를 받는 JPEG writer 또는 print spooler로 직접 feed할 때 사용할 seam입니다

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에 feed합니다
  Inc(FNextBand);
  Result := True;
end;

알아 둘 만한 cross-compiler trap 하나

지원되는 모든 toolchain에서 zlib unit 이름이 다릅니다. Delphi XE5 이후에는 System.ZLib, FPC에서는 zstream, 오래된 Delphi에서는 평범한 ZLib을 사용합니다. 이것은 일반적인 conditional compilation입니다. trap은 세 unit 모두 clNoneclDefault라는 compression-level constant를 export하며 graphics unit의 같은 이름인 TColor member와 정면으로 충돌한다는 점입니다. zlib unit이 implementation uses clause에 나타난 뒤 render code의 한정하지 않은 clNone이 diagnostic 없이 color가 아니라 compression level로 resolve될 수 있습니다. PDFiumPas는 fully qualified graphics constant에 한 번 bind한 explicit color sentinel alias PdfGraphicsColorNonePdfGraphicsColorDefault로 이를 고정하며 render background나 color-scheme sentinel을 비교하는 곳에서 어디나 이를 사용합니다. 세 줄의 code로 compiler 사이에서 symbol resolution이 흔들리지 않습니다

banded rendering은 RAM에 들어가지 않는 page를 만나기 전까지는 convenience feature처럼 보이지만 그때는 작동하는 유일한 path입니다. 수정된 band geometry, sequential PNG와 TIFF encoder, custom encoder seam은 Delphi, Lazarus와 C++Builder 전체에서 band와 page pixel comparison을 regression suite로 실행하는 PDFium Delphi component에 포함되어 있습니다