مقاله فنی

رندر banded PDF در Delphi: offset منفی Y

band اول کل drawing را فشرده‌شده در یک strip نگه می‌داشت و پنج band بعدی خالی برمی‌گشتند. این export banded قدیمی بود و PDFiumPas آن را در v3.66.0 اصلاح کرد: RenderPageBanded حالا در هر band، width و height هدف مربوط به کل page را همراه با vertical offset منفی به FPDF_RenderPageBitmap می‌دهد؛ در نتیجه native clip فقط rowهای band فعلی را می‌نویسد، در حالی که page هندسه coordinate کامل خود را حفظ می‌کند. use case پشت این کار کسل‌کننده و اجتناب‌ناپذیر است. کسی یک plot از نوع E یا page پانورامای stitch‌شده تحویل شما می‌دهد و rasterی با 600 DPI می‌خواهد. یک sheet از نوع ISO A0 در 600 DPI برابر 19866 در 28086 pixel است و bitmap مقصد 32-bit با این اندازه کمی بیشتر از 2 GB memory contiguous لازم دارد. در Delphi سی‌ودوبیتی این allocation به‌سادگی fail می‌شود. در 64-bit آن‌قدر اغلب موفق می‌شود که failure به مشکل customer تبدیل شود نه مشکل test. rendering banded وجود دارد تا peak allocation یک strip باشد، نه یک page

چرا هر band کل page را در خود داشت؟

code قدیمی دو جفت parameter متفاوت در call مربوط به rendering page در PDFium را با هم اشتباه کرده بود. FPDF_RenderPageBitmap، start_x، start_y، size_x و size_y را می‌گیرد؛ جفت size می‌گوید کل page باید به چه اندازه scale شود و جفت start می‌گوید page scale‌شده داخل bitmap مقصد کجا قرار بگیرد. loop مربوط به band پیش از v3.66.0، top مربوط به band را با offset مقصد و height مربوط به band را با page height به helper از نوع RenderPage library می‌داد. این دو number مستقیم به native call می‌رفتند؛ بنابراین PDFium کل page را در rectangleای فقط به بلندی BandHeight scale می‌کرد و بعد آن را با y = BandTop داخل bitmapی draw می‌کرد که خودش فقط BandHeight row داشت. وقتی این را ببینید، نتیجه دقیقاً قابل پیش‌بینی است. band صفر کل page را به‌صورت عمودی تا ارتفاع band فشرده می‌گرفت. هر band بعدی همان page فشرده را پایین‌تر از لبه پایینی bitmap خود دریافت می‌کرد و در نتیجه به‌صورت background fill برمی‌گشت. bug در تنها caseای پنهان می‌شود که بیشتر smoke testها استفاده می‌کنند: pageی که ارتفاع render آن از ارتفاع band کوچک‌تر باشد، چون در آن صورت فقط یک band داریم و geometry اشتباه اتفاقاً با geometry درست منطبق می‌شود. هر چیزی بلندتر از یک band، bug را فوراً آشکار می‌کند

offset منفی چه چیزی را تضمین می‌کند؟

implementation اصلاح‌شده هر band را از مسیر RenderTile عبور می‌دهد که تنها نقطه component است که از قبل این تفاوت را می‌فهمید. RenderTile یک origin مربوط به tile در coordinateهای pixel کل page به‌اضافه PageWidth و PageHeight جداگانه می‌گیرد و -Left و -Top را با اندازه page دست‌نخورده به PDFium می‌دهد. منفی کردن offset، page کامل را به سمت بالا می‌لغزاند تا band درخواست‌شده در row صفر bitmap مقصد قرار بگیرد؛ سپس PDFium به‌صورت native بر اساس bounds bitmap clip می‌کند و هیچ چیزی خارج از band rasterize نمی‌شود. mapping مربوط به page به device که در ISO 32000-1 clause 8.3.2 توضیح داده شده از band اول تا آخر یکسان می‌ماند؛ تمام نکته همین است: band N باید از نظر byte با rowهای BandTop تا BandTop + h از یک full-page render یکسان باشد و regression suite دقیقاً همین را pixel به pixel در برابر output مربوط به RenderPage با همان dimensionها assert می‌کند

// یک band به‌صورت دستی. bitmap مقصد فقط BandHeight ارتفاع دارد،
// اما اندازه هدف page روی Width در Height کامل باقی می‌ماند
Band := Pdf.RenderTile(0, BandTop,          // origin مربوط به tile در pixelهای page
                       Width, BandHeight,   // اندازه bitmap مقصد
                       Width, Height);      // اندازه هدف کل page
try
  // Band اکنون rowهای BandTop تا BandTop + BandHeight - 1 از page را دارد
finally
  Band.Free;
end;

band API عمومی یک callback loop است. RenderPageBanded(Width, Height, BandHeight, BandCallback, Rotation, Options, Color) تعداد bandهایی را که واقعاً render کرده برمی‌گرداند یا وقتی argumentها رد شوند 0 می‌دهد و در کل pass، render lock component را نگه می‌دارد. signature مربوط به callback برابر TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object است. bitmap از نوع pf32bit، به عرض Width pixel و حداکثر به ارتفاع BandHeight است و به‌محض اینکه handler شما برگردد free می‌شود؛ پس هر چیزی را که قصد نگه‌داری دارید copy کنید. برگرداندن False pass را بعد از band فعلی متوقف می‌کند و همان مدل cooperative cancellation را می‌دهد که در rendering تدریجی و قابل لغو PDF در Delphi استفاده می‌شود، فقط در granularity مربوط به strip نه granularity ادامه 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 با برگشت این method از بین می‌رود؛ همین‌جا آن را مصرف کنید
  Inc(FRows, Bitmap.Height);
  Result := not FCancelled;
end;

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

PNG و TIFF را بدون bitmap کامل page چگونه stream کنیم؟

رندر کردن به‌صورت band فقط وقتی کمک می‌کند که encoder نیز sequential باشد؛ به همین دلیل v3.66.0، RenderPageBandedToStream را اضافه کرد که PNG یا TIFF را مستقیم در stream caller می‌نویسد. TPdfBandedImageStreamOptions.Default ارتفاع band برابر 256 row، compression level برابر 6 برای PNG و MaxOutputBytes برابر 0 را seed می‌کند که معنای آن unbounded است. TPdfBandedImageReport برگشتی، Format، Width، Height، BandsRendered، BandsEncoded، RowsEncoded، PeakBandBytes، OutputBytes و Completed را حمل می‌کند. هنگام sizing یک job، عددی که واقعاً مهم است PeakBandBytes است: برابر Width * BandHeight * 4 است، بنابراین sheet مربوط به A0 بالا به‌جای 2 GB page buffer، حدود 19 MB band buffer در peak مصرف می‌کند

PNG encoder عمداً محدود است. RGB8 ثابت emit می‌کند و IHDR را با bit depth برابر 8 و color type برابر 2 می‌نویسد، سپس هر scanline را با filter type 0 می‌سازد (ISO/IEC 15948 filter method 0 یعنی filter type None) و آن را از compression stream مربوط به zlib در platform عبور می‌دهد. byteهای فشرده به‌صورت chunkهای IDAT دارای CRC و به ترتیب بیرون می‌آیند. constraint جالب stream زیر لایه deflate است: به queryهای position پاسخ می‌دهد چون compression stream از آن‌ها سؤال می‌کند، اما هر تلاش برای seek واقعی error می‌دهد. این عمدی است. وقتی یک IDAT chunk و CRC آن روی wire قرار گرفت، دیگر راهی برای برگشت و اصلاحشان وجود ندارد و seek خاموش outputی را corrupt می‌کند که از نظر ساختاری هنوز معتبر به نظر می‌رسد

TIFF encoder، classic TIFF با endianness کوچک را می‌نویسد؛ marker مربوط به ترتیب byte یعنی II و سپس magic برابر 42، با یک strip برای هر band. ابتدا pixelها stream می‌شوند و IFD ده‌entry در انتها تولید می‌شود، زمانی که offset و byte count مربوط به stripها معلوم شده‌اند. compression برابر tag 259 و value برابر 1 است، پس اصلاً entropy coding وجود ندارد: payload دقیقاً Width * Height * 3 byte است، PhotometricInterpretation برابر RGB، PlanarConfiguration برابر chunky و RowsPerStrip ارتفاع band را ثبت می‌کند، در حالی که short strip نهایی با entry مستقل StripByteCounts توصیف می‌شود. بنابراین ارتفاع band، peak memory و تعداد strip را تغییر می‌دهد اما اندازه output را نه؛ این را پیش از tuning بدانید. اگر file کوچک‌تر می‌خواهید نه file lossless، مسیر per-page در تبدیل pageهای PDF به imageهای JPEG با PDFium VCL component هنوز ابزار بهتر است

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;

یک export banded کجا متوقف می‌شود؟

دو ceiling خروجی را محدود می‌کنند و عمداً در نقاط متفاوت fail می‌شوند. اولی budget caller است: MaxOutputBytes توسط write stream محدودشده enforce می‌شود و EPdfError را پیش از هر writeای که از limit عبور کند raise می‌کند؛ بنابراین budget یک hard cap است نه report بعد از واقعیت. دومی structural است. TIFF کلاسیک offset مربوط به strip را به‌صورت value سی‌ودوبیتی نگه می‌دارد، پس BeginImage، مقدار Width * Height * 3 به‌اضافه header و directory را در برابر این ceiling validate می‌کند و پیش از نوشته شدن حتی یک pixel، job را reject می‌کند. همین check از ابتدا در برابر MaxOutputBytes هم اجرا می‌شود، چون شروع کردن TIFFی که budget آن حتی payload pixel خودش را پوشش نمی‌دهد ارزش ندارد. PNG محدودیت معادلی ندارد، چون IDAT chunkها کاملاً sequential هستند و table offset سی‌ودوبیتی برای overflow شدن وجود ندارد

باید شفاف بدانید export متوقف‌شده چه چیزی پشت سر می‌گذارد. وقتی pass به آخرین row نمی‌رسد، Completed روی False می‌ماند و encoder با EndImage(False) teardown می‌شود؛ این call عمداً نه PNG IEND chunk و نه TIFF IFD را می‌نویسد. پس file ناقص invalid است و هر decoder این را اعلام می‌کند، به‌جای اینکه imageای ظاهراً معقول با rowهای گمشده به نظر برسد. این cleanup طوری wrap شده که failure ثانویه داخل EndImage نتواند exception اصلی را جایگزین کند؛ تفاوت میان stack traceای که علت واقعی را نام می‌برد و stack traceای که janitor را نام می‌برد همین است. اگر progressی می‌خواهید که زنده بماند، داخل callback خودتان به‌ازای هر band checkpoint کنید؛ تاکتیک‌های strip-level caching در راهنمای render cache و zoom در PDFium برای Delphi اینجا هم به کار می‌آیند

codec خودتان را وصل کنید

وقتی مقصد PNG و TIFF نیست، RenderPageBandedToEncoder یک descendant از TPdfBandedImageEncoder می‌گیرد و همان loop را drive می‌کند. lifecycle صریح و کوتاه است: BeginImage(Width, Height)، سپس WriteBand(BandIndex, BandTopY, Bitmap) یک بار برای هر strip و دقیقاً به ترتیب صعودی، سپس EndImage(Completed)؛ در این میان GetBytesWritten مقدار Report.OutputBytes را تغذیه می‌کند. encoderهای built-in، band خارج از order را بی‌درنگ reject می‌کنند و تلاش نمی‌کنند آن را buffer کنند و هر encoderی که می‌نویسید باید همین کار را بکند، چون codecی که stripها را بی‌سروصدا reorder کند fileی تولید می‌کند که باز می‌شود و دروغ می‌گوید. این seam برای tileهای JPEG 2000، یک JPEG writer که هر بار یک MCU row band می‌گیرد یا 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 که ارزش دانستن دارد

نام unit مربوط به zlib در هر toolchain پشتیبانی‌شده فرق می‌کند: Delphi XE5 و جدیدتر از System.ZLib استفاده می‌کنند، FPC از zstream و Delphi قدیمی از ZLib ساده. این مقدار conditional compilation معمولی است. دام این است که هر سه، constantهای compression level با نام clNone و clDefault export می‌کنند که با memberهای TColor به همین نام در graphics unit مستقیم collision دارند. به‌محض اینکه zlib unit در clause مربوط به implementation uses ظاهر شود، clNone بدون qualification در render code ممکن است به‌جای color به compression level resolve شود و هیچ diagnosticی ندهد. PDFiumPas این موضوع را با aliasهای صریح color sentinel یعنی PdfGraphicsColorNone و PdfGraphicsColorDefault محکم کرده است؛ این aliasها یک بار به constantهای کاملاً qualify‌شده graphics bind می‌شوند و هرجا background render یا color-scheme sentinel مقایسه شود استفاده می‌شوند. سه line code است و symbol resolution دیگر بین compilerها drift نمی‌کند

rendering banded تا وقتی با pageی روبه‌رو نشوید که در RAM جا نمی‌گیرد شبیه یک convenience feature است و بعد از آن تنها مسیری است که کار می‌کند. geometry اصلاح‌شده band، encoderهای sequential برای PNG و TIFF و seam مربوط به custom encoder، همگی بخشی از PDFium Delphi component هستند و مقایسه کامل pixelهای band در برابر page در regression suite روی Delphi، Lazarus و C++Builder اجرا می‌شود