Pierwszy pas zawierał cały rysunek ściśnięty do jednego paska, a pięć kolejnych wracało pustych. Tak wyglądał stary eksport pasami, a PDFiumPas naprawił go w v3.66.0: RenderPageBanded przekazuje teraz pełną szerokość i wysokość docelowej strony do FPDF_RenderPageBitmap przy każdym pasie, razem z ujemnym offsetem pionowym, dzięki czemu natywne przycinanie zapisuje tylko wiersze bieżącego pasa, podczas gdy strona zachowuje geometrię układu współrzędnych całej strony. Przypadek użycia jest nudny i nieunikniony. Ktoś przekazuje wykres w rozmiarze E albo zszytą panoramę i chce raster 600 DPI. Arkusz ISO A0 przy 600 DPI ma 19866 x 28086 pikseli, a 32-bitowa bitmapa docelowa o takim rozmiarze potrzebuje nieco ponad 2 GB ciągłej pamięci. W 32-bitowym Delphi taka alokacja po prostu się nie udaje. W 64 bitach udaje się wystarczająco często, aby awaria stała się problemem klienta, a nie testu. Renderowanie pasami istnieje po to, aby szczytowa alokacja obejmowała jeden pas, a nie jedną stronę
Dlaczego każdy pas zawierał całą stronę?
Stary kod pomylił dwie różne pary argumentów w wywołaniu renderowania strony PDFium. FPDF_RenderPageBitmap przyjmuje start_x, start_y, size_x i size_y, gdzie para rozmiaru mówi, do jakiego rozmiaru ma zostać przeskalowana cała strona, a para startowa mówi, gdzie ta przeskalowana strona ląduje wewnątrz bitmapy docelowej. Pętla pasów sprzed v3.66.0 wywoływała pomocnik biblioteki RenderPage z górą pasa jako offsetem docelowym i wysokością pasa jako wysokością strony. Te dwie liczby przechodziły prosto do natywnego wywołania, więc PDFium skalował całą stronę do prostokąta mającego tylko BandHeight wierszy, a następnie rysował ją przy y = BandTop wewnątrz bitmapy, która również miała tylko BandHeight wierszy. Wynik jest dokładnie taki, jak można przewidzieć, gdy już się go zobaczy. Pas zero otrzymywał całą stronę pionowo ściśniętą do wysokości pasa. Każdy kolejny pas otrzymywał tę samą ściśniętą stronę przesuniętą poniżej dolnej krawędzi swojej bitmapy, więc wracał jako wypełnienie tła. Błąd ukrywa się w przypadku używanym przez większość smoke testów, czyli stronie, której wysokość renderowania jest mniejsza niż wysokość pasa, bo wtedy istnieje jeden pas i zła geometria przypadkiem pokrywa się z właściwą. Wszystko wyższe niż jeden pas ujawnia go natychmiast
Co gwarantuje ujemny offset
Poprawiona implementacja prowadzi każdy pas przez RenderTile, jedyne miejsce w komponencie, które już rozumiało to rozróżnienie. RenderTile przyjmuje początek kafelka w pełnostronicowych współrzędnych pikselowych oraz osobne PageWidth i PageHeight, a do PDFium przekazuje -Left i -Top z niezmienionym rozmiarem strony. Negacja offsetu przesuwa stronę w pełnym rozmiarze w górę, aż żądany pas znajdzie się w wierszu zero bitmapy docelowej; PDFium natywnie przycina wtedy do granic bitmapy, więc nic poza pasem nie jest rasteryzowane. Mapowanie strona-urządzenie opisane w ISO 32000-1 w punkcie 8.3.2 pozostaje identyczne od pierwszego do ostatniego pasa, i właśnie o to chodzi: pas N jest identyczny bit po bicie z wierszami od BandTop do BandTop + h pojedynczego renderowania całej strony, a zestaw regresji sprawdza to dokładnie, piksel po pikselu, względem wyjścia RenderPage przy tych samych wymiarach
// Jeden pas ręcznie. Bitmapa docelowa ma tylko BandHeight wierszy,
// ale rozmiar docelowy strony pozostaje pełnym Width x Height
Band := Pdf.RenderTile(0, BandTop, // początek kafelka w pikselach strony
Width, BandHeight, // rozmiar bitmapy docelowej
Width, Height); // rozmiar docelowy całej strony
try
// Band zawiera teraz wiersze BandTop .. BandTop + BandHeight - 1 strony
finally
Band.Free;
end;
Publiczne API pasów to pętla callbacków. RenderPageBanded(Width, Height, BandHeight, BandCallback, Rotation, Options, Color) zwraca liczbę faktycznie wyrenderowanych pasów albo 0, gdy argumenty zostaną odrzucone, i trzyma blokadę renderowania komponentu przez cały przebieg. Sygnatura callbacka to TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object. Bitmapa ma format pf32bit, szerokość Width pikseli i wysokość nie większą niż BandHeight, a jest zwalniana natychmiast po powrocie twojego handlera, więc skopiuj wszystko, co chcesz zachować. Zwrócenie False zatrzymuje przebieg po bieżącym pasie, zapewniając ten sam kooperacyjny model anulowania co w anulowalnym progresywnym renderowaniu PDF w Delphi, tylko na poziomie pasa zamiast granularności kontynuacji 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 znika po powrocie z tej metody - użyj jej tutaj
Inc(FRows, Bitmap.Height);
Result := not FCancelled;
end;
// ...
Pdf.PageNumber := 1;
Bands := Pdf.RenderPageBanded(19866, 28086, 256, Sink.HandleBand);
Strumieniowanie PNG i TIFF bez bitmapy całej strony
Renderowanie pasami pomaga tylko wtedy, gdy encoder również działa sekwencyjnie, dlatego v3.66.0 dodało RenderPageBandedToStream, zapisujące PNG albo TIFF bezpośrednio do strumienia wywołującego. TPdfBandedImageStreamOptions.Default ustawia wysokość pasa na 256 wierszy, poziom kompresji PNG na 6 i MaxOutputBytes na 0, co oznacza brak limitu. Zwracany TPdfBandedImageReport zawiera Format, Width, Height, BandsRendered, BandsEncoded, RowsEncoded, PeakBandBytes, OutputBytes i Completed. PeakBandBytes to liczba, która naprawdę ma znaczenie przy wymiarowaniu zadania: wynosi Width * BandHeight * 4, więc arkusz A0 powyżej osiąga szczytowo około 19 MB bufora pasa zamiast 2 GB bufora strony
Encoder PNG jest celowo wąski. Emituje stałe RGB8, zapisując IHDR z głębią bitową 8 i typem koloru 2, a następnie buduje każdą linię skanowania z typem filtra 0 (metoda filtra ISO/IEC 15948 0, typ filtra None) i przepuszcza ją przez platformowy strumień kompresji zlib. Skompresowane bajty wychodzą jako zawierające CRC chunki IDAT zapisywane po kolei. Ciekawym ograniczeniem jest strumień leżący pod warstwą deflate: odpowiada na zapytania o pozycję, bo prosi o nią strumień kompresji, ale każda próba prawdziwego seeka zgłasza błąd. To celowe. Gdy chunk IDAT i jego CRC są już na przewodzie, nie ma powrotu, aby je naprawić, a cichy seek uszkodziłby wyjście, które nadal wyglądałoby poprawnie strukturalnie
Encoder TIFF zapisuje klasyczny TIFF little-endian, znacznik kolejności bajtów II po którym następuje magiczne 42, z jednym pasem na każdy band. Najpierw strumieniowane są piksele, a dziesięciowpisowy IFD powstaje na końcu, gdy znane są offsety i liczby bajtów pasów. Kompresja to tag 259 o wartości 1, więc nie ma żadnego kodowania entropijnego: payload to dokładnie Width * Height * 3 bajtów, PhotometricInterpretation ma wartość RGB, PlanarConfiguration jest chunky, a RowsPerStrip zapisuje wysokość pasa, podczas gdy końcowy krótki pas opisuje własny wpis StripByteCounts. Wysokość pasa zmienia więc pamięć szczytową i liczbę pasów, ale nie rozmiar wyjścia, co warto wiedzieć przed strojeniem. Jeśli chcesz małe pliki zamiast bezstratnych, lepszym narzędziem pozostaje ścieżka per-page opisana w artykule o konwertowaniu stron PDF do obrazów JPEG komponentem PDFium VCL
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;
Gdzie zatrzymuje się eksport pasami?
Dwa limity ograniczają wyjście i celowo kończą się w różnych miejscach. Pierwszy to budżet wywołującego: MaxOutputBytes jest wymuszany przez ograniczony strumień zapisu, który zgłasza EPdfError przed każdym zapisem przekraczającym limit, więc budżet jest twardym maksimum, a nie raportem po fakcie. Drugi jest strukturalny. Klasyczny TIFF przechowuje offsety pasów jako wartości 32-bitowe, więc BeginImage sprawdza Width * Height * 3 wraz z nagłówkiem i katalogiem względem tego limitu i odrzuca zadanie przed zapisaniem pojedynczego piksela; to samo sprawdzenie wykonuje z góry względem MaxOutputBytes, ponieważ TIFF, którego budżet nie pokrywa własnego payloadu pikseli, nie jest wart rozpoczynania. PNG nie ma równoważnego limitu, ponieważ chunki IDAT są czysto sekwencyjne i nie ma 32-bitowej tabeli offsetów, która mogłaby się przepełnić
Trzeźwo oceń, co pozostawia po sobie zatrzymany eksport. Gdy przebieg nie dociera do ostatniego wiersza, Completed pozostaje False, a encoder jest zamykany przez EndImage(False), które celowo nie zapisuje ani chunka PNG IEND, ani IFD TIFF. Częściowy plik jest więc niepoprawny i każdy dekoder to powie, zamiast pokazać wiarygodny obraz z brakującymi wierszami. To sprzątanie jest opakowane tak, aby wtórna awaria wewnątrz EndImage nie zastąpiła pierwotnego wyjątku, co odróżnia stack trace wskazujący prawdziwą przyczynę od tego, który wskazuje sprzątacza. Jeśli potrzebujesz zachować postęp, twórz checkpoint po każdym pasie we własnym callbacku; taktyki cache’owania na poziomie pasa z przewodnika po cache renderowania i powiększeniu PDFium Delphi mają tu również zastosowanie
Podpinanie własnego kodeka
Gdy celem nie jest PNG ani TIFF, RenderPageBandedToEncoder przyjmuje potomka TPdfBandedImageEncoder i prowadzi tę samą pętlę. Cykl życia jest jawny i krótki: BeginImage(Width, Height), następnie WriteBand(BandIndex, BandTopY, Bitmap) raz na każdy pas w ściśle rosnącej kolejności, na końcu EndImage(Completed), a GetBytesWritten zasila Report.OutputBytes. Wbudowane encodery odrzucają pas w złej kolejności, zamiast próbować go buforować, i każdy napisany przez ciebie encoder powinien robić to samo, bo kodek po cichu zmieniający kolejność pasów produkuje plik, który się otwiera i kłamie. To miejsce służy do obsługi kafelków JPEG 2000, writera JPEG karmionego po jednym pasie wierszy MCU albo bezpośredniego podawania danych do spoolera wydruku
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;
// Przekaż tutaj Bitmap.ScanLine[0 .. Bitmap.Height - 1] do kodeka
Inc(FNextBand);
Result := True;
end;
Jedna pułapka między kompilatorami, którą warto znać
Unit zlib ma inną nazwę w każdym obsługiwanym toolchainie: Delphi XE5 i nowsze używają System.ZLib, FPC używa zstream, a starsze Delphi używa zwykłego ZLib. To rutynowa kompilacja warunkowa. Pułapka polega na tym, że wszystkie trzy eksportują stałe poziomu kompresji nazwane clNone i clDefault, które zderzają się czołowo ze składowymi TColor o tych samych nazwach w unicie graphics. Gdy unit zlib pojawi się w klauzuli uses implementacji, niekwalifikowane clNone w kodzie renderowania może rozwiązać się do poziomu kompresji zamiast do koloru, bez żadnego komunikatu. PDFiumPas przypina to przez jawne aliasy sentinelów koloru, PdfGraphicsColorNone i PdfGraphicsColorDefault, związane raz z w pełni kwalifikowanymi stałymi graphics i używane wszędzie tam, gdzie porównywany jest kolor tła renderowania albo sentinel schematu kolorów. Trzy linie kodu i rozwiązywanie symboli przestaje dryfować między kompilatorami
Renderowanie pasami wygląda jak wygodna funkcja aż do chwili, gdy trafisz na stronę, która nie mieści się w RAM-ie, a wtedy jest jedyną działającą ścieżką. Poprawiona geometria pasów, sekwencyjne encodery PNG i TIFF oraz miejsce na własny encoder są częścią komponentu PDFium dla Delphi, a pełne porównanie piksel po pikselu pasa ze stroną działa w zestawie regresji dla Delphi, Lazarus i C++Builder