Τεχνικό Άρθρο

Banded PDF rendering στο Delphi: το αρνητικό Y offset

Το πρώτο band κρατούσε ολόκληρο το drawing συμπιεσμένο σε μία λωρίδα και τα πέντε bands μετά από αυτό επέστρεφαν blank. Αυτό ήταν το παλιό banded export και το PDFiumPas το διόρθωσε στο v3.66.0: το RenderPageBanded περνά πλέον το full-page target width και height στο FPDF_RenderPageBitmap σε κάθε band, μαζί με αρνητικό vertical offset, ώστε το native clip να γράφει μόνο τις rows του τρέχοντος band ενώ η page κρατά την πλήρη coordinate geometry της. Η χρήση πίσω από όλα αυτά είναι βαρετή και αναπόφευκτη. Κάποιος σου δίνει 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 πετυχαίνει αρκετά συχνά ώστε η αποτυχία να γίνει πρόβλημα customer και όχι test. Το banded rendering υπάρχει ώστε το peak allocation να είναι ένα strip και όχι μία page

Γιατί κάθε band περιείχε ολόκληρη τη σελίδα

Ο παλιός code μπέρδευε δύο διαφορετικά ζεύγη arguments στο PDFium page-rendering call. Το FPDF_RenderPageBitmap παίρνει start_x, start_y, size_x και size_y, όπου το size pair λέει πόσο θα scaled γίνει ολόκληρη η page και το start pair λέει πού θα τοποθετηθεί εκείνη η scaled page μέσα στο destination bitmap. Ο band loop πριν από το v3.66.0 καλούσε το helper RenderPage της library με το band top ως destination offset και το band height ως page height. Αυτοί οι δύο αριθμοί περνούσαν απευθείας στο native call, οπότε το PDFium έκανε scale ολόκληρη τη page σε rectangle ύψους μόνο BandHeight rows και μετά τη σχεδίαζε στο y = BandTop μέσα σε bitmap που ήταν και το ίδιο μόνο BandHeight rows ψηλό. Το αποτέλεσμα ήταν ακριβώς αυτό που προβλέπεις όταν το δεις. Το band zero έπαιρνε ολόκληρη τη page κάθετα συμπιεσμένη στο band height. Κάθε μεταγενέστερο band έπαιρνε την ίδια crushed page σπρωγμένη κάτω από το bottom edge του bitmap του, άρα επέστρεφε background fill. Το bug κρύβεται στη μοναδική περίπτωση που χρησιμοποιούν τα περισσότερα smoke tests, page της οποίας το render height είναι μικρότερο από το band height, επειδή τότε υπάρχει ένα μόνο band και η λάθος geometry τυχαίνει να συμπίπτει με τη σωστή. Οτιδήποτε ψηλότερο από ένα band το αποκαλύπτει αμέσως

Τι εγγυάται το αρνητικό offset

Η fixed υλοποίηση δρομολογεί κάθε band μέσω του RenderTile, που είναι το ένα σημείο του component το οποίο ήδη καταλάβαινε τη διάκριση. Το RenderTile παίρνει tile origin σε full-page pixel coordinates μαζί με ξεχωριστά PageWidth και PageHeight, και δίνει στο PDFium -Left και -Top με το page size ανέγγιχτο. Η άρνηση του offset γλιστρά τη full-size page προς τα πάνω μέχρι το ζητούμενο band να βρίσκεται στη row zero του destination bitmap· το PDFium μετά κάνει native clip πάνω στα bitmap bounds, άρα τίποτε έξω από το band δεν γίνεται rasterize. Το page-to-device mapping που περιγράφει το ISO 32000-1 clause 8.3.2 παραμένει ίδιο από το πρώτο band έως το τελευταίο, και αυτό είναι όλη η ουσία: το band N είναι bit-identical με τις rows BandTop έως BandTop + h ενός single full-page render, και η regression suite το ελέγχει ακριβώς αυτό, pixel προς pixel, απέναντι σε output του RenderPage στις ίδιες διαστάσεις

// Ένα band με το χέρι. Το destination bitmap έχει ύψος μόνο BandHeight,
// αλλά το page target size παραμένει το πλήρες Width x Height
Band := Pdf.RenderTile(0, BandTop,          // προέλευση tile σε 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 που έκανε πραγματικά render ή 0 όταν τα arguments απορρίπτονται και κρατά το component render lock σε ολόκληρο το pass. Το callback signature είναι TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object. Το bitmap είναι pf32bit, έχει πλάτος Width pixels και ύψος όχι μεγαλύτερο από BandHeight, ενώ ελευθερώνεται αμέσως μόλις επιστρέψει ο handler σου, οπότε κάνε copy οτιδήποτε θέλεις να κρατήσεις. Επιστροφή 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 — κατανάλωσέ το εδώ
  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 βοηθά μόνο αν sequential είναι και ο encoder, γι’ αυτό το v3.66.0 πρόσθεσε το RenderPageBandedToStream, που γράφει PNG ή TIFF απευθείας σε stream του caller. Το 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 παραπάνω φτάνει περίπου 19 MB band buffer αντί για 2 GB page buffer

Ο PNG encoder είναι σκόπιμα στενός. Εκδίδει 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 του βρεθούν στο wire, δεν υπάρχει επιστροφή για διόρθωση και ένα silent seek θα κατέστρεφε output που θα έμοιαζε ακόμη structurally valid

Ο TIFF encoder γράφει little-endian classic TIFF, το II byte order mark ακολουθούμενο από magic 42, με ένα strip ανά band. Τα pixels βγαίνουν πρώτα και το 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, ενώ το τελευταίο short strip περιγράφεται από το δικό του StripByteCounts entry. Το band height επομένως αλλάζει peak memory και strip count αλλά όχι output size, κάτι που πρέπει να ξέρεις πριν το ρυθμίσεις. Αν θέλεις μικρά files αντί για lossless, το per-page path στο converting PDF pages σε JPEG images με το 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;

Πού σταματά ένα banded export

Δύο ceilings περιορίζουν το output και αποτυγχάνουν σκόπιμα σε διαφορετικά σημεία. Το πρώτο είναι το caller budget: το MaxOutputBytes επιβάλλεται από 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· ο ίδιος έλεγχος γίνεται upfront απέναντι στο MaxOutputBytes, επειδή TIFF του οποίου το budget δεν καλύπτει ούτε το δικό του pixel payload δεν αξίζει να ξεκινήσει. Το PNG δεν έχει αντίστοιχο limit, επειδή τα IDAT chunks είναι καθαρά sequential και δεν υπάρχει 32-bit offset table για overflow

Χρειάζεται καθαρή εικόνα για ό,τι αφήνει πίσω ένα stopped export. Όταν το pass δεν φτάσει την τελευταία row, το Completed μένει False και ο encoder γκρεμίζεται με EndImage(False), το οποίο σκόπιμα δεν γράφει ούτε PNG IEND chunk ούτε TIFF IFD. Το partial file είναι επομένως invalid και κάθε decoder θα το πει, αντί να είναι plausible-looking image με missing rows. Αυτό το cleanup τυλίγεται έτσι ώστε secondary failure μέσα στο EndImage να μην αντικαταστήσει το original exception, και αυτή είναι η διαφορά ανάμεσα σε stack trace που ονομάζει την πραγματική αιτία και σε stack trace που ονομάζει τον janitor. Αν χρειάζεσαι progress που επιβιώνει, κάνε checkpoint ανά band μέσα στο δικό σου callback· οι strip-level caching tactics στον οδηγό PDFium Delphi render cache και zoom ισχύουν και εδώ

Σύνδεση δικού σου codec

Όταν PNG και TIFF δεν είναι ο στόχος, το RenderPageBandedToEncoder παίρνει descendant TPdfBandedImageEncoder και οδηγεί το ίδιο loop. Ο lifecycle είναι explicit και σύντομος: BeginImage(Width, Height), μετά WriteBand(BandIndex, BandTopY, Bitmap) μία φορά για κάθε strip σε strictly 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 κάθε φορά ή άμεσο 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;
  // Τροφοδότησε τον codec με Bitmap.ScanLine[0 .. Bitmap.Height - 1] εδώ
  Inc(FNextBand);
  Result := True;
end;

Μία cross-compiler παγίδα που αξίζει να γνωρίζεις

Η zlib unit έχει διαφορετικό spelling σε κάθε supported toolchain: Delphi XE5 και νεότερα χρησιμοποιούν System.ZLib, το FPC χρησιμοποιεί zstream και τα παλιότερα Delphi το απλό ZLib. Αυτό είναι συνηθισμένο conditional compilation. Η παγίδα είναι ότι και οι τρεις εξάγουν constants compression level με ονόματα clNone και clDefault, που συγκρούονται μετωπικά με τα TColor members με τα ίδια ονόματα στη graphics unit. Μόλις η zlib unit εμφανιστεί στο implementation uses clause, ένα unqualified clNone σε render code μπορεί να επιλυθεί σε compression level αντί για color, χωρίς diagnostic. Το PDFiumPas το σταθεροποιεί με explicit color sentinel aliases, PdfGraphicsColorNone και PdfGraphicsColorDefault, που συνδέονται μία φορά με τα fully qualified graphics constants και χρησιμοποιούνται παντού όπου συγκρίνεται render background ή color-scheme sentinel. Τρεις γραμμές code και το symbol resolution σταματά να μετακινείται ανάμεσα σε compilers

Το banded rendering μοιάζει με convenience feature μέχρι να συναντήσεις page που δεν χωρά στη RAM, και μετά είναι η μόνη διαδρομή που λειτουργεί. Η διορθωμένη band geometry, οι sequential PNG και TIFF encoders και το custom encoder seam διατίθενται στο PDFium Delphi component, με την πλήρη σύγκριση band-versus-page pixels να τρέχει στη regression suite σε Delphi, Lazarus και C++Builder