Πριν το v3.539.24, το Free Pascal build του PDF Library for Delphi συμπίεζε τα μεγάλα streams σε κομμάτια των 64 KB και έδινε σε κάθε κομμάτι δικό του zlib header και checksum, οπότε ένα attachment του 1 MiB γινόταν δεκαέξι μέλη zlib κολλημένα το ένα στην άκρη του άλλου. Το FlateDecode του ISO 32000-1 περιμένει ακριβώς ένα zlib stream, και ένας κανονικός decoder σταματά στον πρώτο δείκτη end-of-stream, που σημαίνει ότι όλα όσα έρχονταν μετά τα πρώτα 65.536 bytes χάνονταν σιωπηλά. Το fix κρατά ένα zlib state ζωντανό σε όλα τα κομμάτια, τόσο στο DeflateStream όσο και στο InflateStream
Το bug υπήρχε μόνο στην πλευρά FPC της μονάδας, και μόνο στο chunked streaming μονοπάτι, που είναι ακριβώς ο λόγος που επέζησε: το Delphi branch ήταν πάντα σωστό, τα string-based helpers ήταν πάντα σωστά, και τα μικρά test payloads δεν έφταναν ποτέ ως τον chunked κώδικα. Είναι στενός συγγενής των ελαττωμάτων στο πέντε FPC porting bugs που το Delphi κάλυπτε, με τη διαφορά ότι εδώ το Delphi build δεν κάλυπτε τίποτα. Το FPC branch είχε απλώς γραφτεί πάνω σε λάθος νοητικό μοντέλο του τι είναι ένα zlib stream
Γιατί ένα chunked zlib stream κόβεται από τα άλλα PDF readers;
Επειδή ένα zlib stream όπως το ορίζει το RFC 1950 είναι ένα container, όχι μια αλληλουχία από αυτά, και ένας conforming inflater μεταχειρίζεται το πρώτο trailer Adler-32 ως τέλος των δεδομένων. Η μορφή είναι ένα header δύο bytes, ένα συνεχές deflate bit stream κατά RFC 1951 του οποίου το τελευταίο block κουβαλά τη σημαία final-block, και ένα checksum Adler-32 τεσσάρων bytes πάνω σε όλα τα ασυμπίεστα bytes. Το ISO 32000-1 §7.4.4 ορίζει το /FlateDecode ακριβώς με αυτούς τους όρους. Όταν το inflate φτάσει το trailer, επιστρέφει Z_STREAM_END και αφήνει ό,τι υπόλοιπη είσοδο υπάρχει αναπαυσμένη μέσα στο avail_in. Από τη δική του σκοπιά δεν τρέχει τίποτα, οπότε δεν πετάει error, και τα bytes μετά το trailer απλώς αγνοούνται. Τα κολλημένα μέλη είναι νόμιμη ιδέα στο gzip (το RFC 1952 επιτρέπει αρκετά μέλη σε ένα αρχείο), που μάλλον εκεί γεννήθηκε η διαίσθηση, αλλά το zlib δεν έχει τέτοιο κανόνα και το PDF δεν ζήτησε ποτέ
Το παλιό FPC DeflateStream διάβαζε την πηγή 64 KB τη φορά και έδινε κάθε κομμάτι στο ZFPCCompress, έναν helper που τρέχει δικό του deflateInit2, συμπιέζει με Z_FINISH, και καλεί deflateEnd. Κάθε κομμάτι λοιπόν βγαίνει πλήρες, έγκυρο, αυτοτερματιζόμενο zlib stream, και η συνάρτηση τα ένωνε σε ένα AnsiString πριν το γράψει. Το αποτέλεσμα έμοιαζε με Flate δεδομένα, είχε σωστό header, και αποκωδικοποιούνταν χωρίς error, αλλά αποκωδικοποιούσε μόνο τα πρώτα 65.536 bytes. Στην ανάγνωση, το InflateStream είχε το καθρεφισμένο ελάττωμα: καλούσε το ZFPCInflate μία φορά ανά 64 KB συμπιεσμένης εισόδου, και κάθε κλήση ξεκινούσε φρέσκο inflateInit2. Το δεύτερο κομμάτι αρχίζει στη μέση ενός deflate bit stream χωρίς zlib header, οπότε ένας νέος inflater το απορρίπτει, και ένα απόλυτα κανονικό single-member stream από οποιονδήποτε άλλο παραγωγό αποκωδικοποιούνταν μόνο όσο το κουβαλούσαν τα πρώτα 64 KB συμπιεσμένων bytes
// FPC DeflateStream πριν το v3.539.24 (απλοποιημένο):
// Το ZFPCCompress κάνει deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// οπότε κάθε κομμάτι 64 KB γίνεται ξεχωριστό μέλος zlib
Repeat
ReadCount:= Source.Read(Input[1], ChunkSize);
If (ReadCount> 0) Then
Compressed:= Compressed+ ZFPCCompress(Copy(Input, 1, ReadCount), 6, False);
Until (ReadCount< ChunkSize);
If (Compressed<> '') Then
Target.WriteBuffer(PAnsiChar(Compressed)^, Length(Compressed));
Ποιες κλήσεις του PDF Library for Delphi έφταναν το chunked μονοπάτι;
Σε FPC, κάθε embedded file του 1 MiB και άνω γραφόταν λάθος, και κάθε Flate stream που εξαγόταν μέσω του streaming API διαβαζόταν λάθος μόλις το συμπιεσμένο του μέγεθος ξεπερνούσε ένα κομμάτι. Το TPDFStream.ReadFromStream αποφασίζει πώς κωδικοποιούνται τα εισερχόμενα δεδομένα. Όταν το Deflate είναι true και Stream.Size >= 1048576, κάνει streaming μέσω DeflateStream· κάτω από αυτό το όριο διαβάζει όλη την πηγή στη μνήμη και καλεί το DeflateStr, τον single-shot helper που δεν επηρεάστηκε ποτέ. Η αλυσίδα φίλτρων ASCII85-plus-Flate παρακάμπτει το τεστ μεγέθους και περνά πάντα από DeflateStream, οπότε σε εκείνο το μονοπάτι κάθε payload μεγαλύτερο από 64 KB είχε ήδη κοπεί σε αρκετά μέλη. Τα δημόσια σημεία εισόδου που τροφοδοτούν το ReadFromStream με αναμμένη τη συμπίεση είναι οι writers embedded file:
TPDFlib.EmbedFileκαιTPDFlib.AddEmbeddedFile, που διαβάζουν αρχείο από δίσκο σε stream/EmbeddedFileTPDFlib.AddAssociatedFileFromStreamκαιTPDFlib.AddAssociatedFileFromFile, οι writers associated-file του PDF/A-3 που χρησιμοποιούνται για e-invoice XML και άλλα source δεδομένα- Στην ανάγνωση, τα
TPDFlib.GetEmbeddedFileContentToStreamκαιGetEmbeddedFileContentToFile, που αποκωδικοποιούν μέσωTPDFStream.WriteDecodedToStreamκαι από εκεί μέσωInflateStream
Η αποτυχία ήταν ήσυχη σε κάθε στρώμα. Ο writer αποθηκεύει /Params /Size και ένα MD5 /CheckSum υπολογισμένο από το πρωτότυπο αρχείο, οπότε το attachment dictionary διαφήμιζε το πλήρες μέγεθος ενώ το stream κρατούσε δεκαέξι μέλη. Ένα Delphi build της ίδιας βιβλιοθήκης που διάβαζε το αρχείο σταματούσε καθαρά στο πρώτο Z_STREAM_END και επέστρεφε ακριβώς 65.536 bytes. Το GetEmbeddedFileContentToStream επέστρεφε 1, επειδή αναφέρει αν η αποκωδικοποίηση πέταξε error, όχι αν η έξοδος ταιριάζει το /Size. Όποιος έχει κυνηγήσει πρόβλημα μεγάλου εγγράφου μέσα από τον συγχώνευση και διαχωρισμό PDF gigabyte ξέρει αυτό το μοτίβο: το αρχείο ανοίγει, ο αριθμός σελίδων είναι σωστός, και η ζημιά φαίνεται μόνο όταν κάποιος ανοίξει το attachment
Ένα deflate state σε όλα τα κομμάτια
Το διορθωμένο DeflateStream στο PDFlibZLib.pas αρχικοποιεί ένα paszlib.TZStream, τροφοδοτεί κάθε κομμάτι στο deflate με Z_NO_FLUSH, και μόνο στο τέλος στραγγίζει τον compressor με Z_FINISH μέχρι να επιστρέψει Z_STREAM_END. Αυτό παράγει ακριβώς ένα header, ένα deflate bit stream του οποίου οι back-references φτάνουν πέρα από τα όρια κομματιών, και ένα Adler-32 πάνω σε όλη την είσοδο. Το FPC branch πλέον έχει την ίδια δομή που είχε πάντα το Delphi branch. Γράφει και την έξοδο καθώς παράγεται, αντί να ενώνει πρώτα όλο το συμπιεσμένο αποτέλεσμα σε ένα AnsiString, οπότε ο writer δεν χτίζει πλέον δεύτερο πλήρες αντίγραφο των συμπιεσμένων δεδομένων στη μνήμη πριν το αντιγράψει στον στόχο
// FPC DeflateStream από το v3.539.24 (τα error μονοπάτια κόφτηκαν)
If (deflateInit2(strm, Level, Z_DEFLATED, 15, 8, Z_DEFAULT_STRATEGY)<> Z_OK) Then
Exit;
Try
Repeat
ReadCount:= Source.Read(Input[0], ChunkSize);
If (ReadCount> 0) Then
Begin
strm.next_in:= Pointer(Input);
strm.avail_in:= ReadCount;
While (strm.avail_in> 0) Do
Begin
strm.next_out:= Pointer(Output);
strm.avail_out:= ChunkSize;
Status:= deflate(strm, Z_NO_FLUSH); // ίδιο state, χωρίς διακοπή μέλους
Produced:= ChunkSize- strm.avail_out;
If (Produced> 0) Then
Target.WriteBuffer(Output[0], Produced);
If (Status<> Z_OK) Then
Break;
End;
End;
Until (ReadCount< ChunkSize);
Repeat // ένα trailer για όλη την είσοδο
strm.next_out:= Pointer(Output);
strm.avail_out:= ChunkSize;
Status:= deflate(strm, Z_FINISH);
Produced:= ChunkSize- strm.avail_out;
If (Produced> 0) Then
Target.WriteBuffer(Output[0], Produced);
Until (Status= Z_STREAM_END);
Finally
deflateEnd(strm);
End;
Το InflateStream πήρε το συμμετρικό ξαναγράψιμο: ένα inflateInit2, μια εσωτερική λούπα που συνεχίζει να καλεί inflate μέχρι το τρέχον κομμάτι να καταναλωθεί και ο output buffer να μην είναι πια γεμάτος, και μια στάση στο Z_STREAM_END. Υπάρχει ένα side effect που αξίζει να το ξέρετε. Ο παλιός chunked writer είχε hard-code level 6, ενώ ο νέος σέβεται το PLDeflateLevel, οπότε ένα level που ορίζεται μέσω TPDFlib.SetCompressionLevel(1..9) πλέον ισχύει και για τα μεγάλα embedded files σε FPC. Έχει σημασία αν ήδη ρυθμίζετε τη συμπίεση για archival έξοδο όπως περιγράφεται στο μείωση του μεγέθους PDF αρχείων στο Delphi
Πώς επαληθεύετε ότι ένα Flate stream είναι ένα μόνο μέλος zlib;
Αποσυμπιέστε το με έναν σκέτο zlib decoder και ελέγξτε δύο πράγματα όταν επιστρέψει Z_STREAM_END: το αποκωδικοποιημένο μήκος ισούται με το μήκος της πηγής, και το avail_in είναι μηδέν. Είσοδος που περίσσεψε μετά τον δείκτη τέλους είναι η υπογραφή ενός αλυσιδωτού stream. Το fix επαληθεύτηκε έτσι: 200 KB test δεδομένων, που απλώνονται σε τέσσερα κομμάτια των 64 KB, πέρασαν από το νέο DeflateStream και βγήκαν ως ένα zlib stream 534 bytes, ένας stock zlib decoder ανέκτησε και τα 200.000 bytes χωρίς υπόλοιπη είσοδο, και ο ίδιος έλεγχος πέρασε και στον cross-compiled στόχο i386 του FPC. Η ρουτίνα παρακάτω είναι η εκδοχή FPC του ελέγχου, χτισμένη απευθείας πάνω στο paszlib ώστε να μην εμπιστεύεται τον κώδικα που τεστάρεται
uses Classes, SysUtils, paszlib, PDFlibZLib;
function IsSingleZlibMember(Packed: TMemoryStream; out Decoded: Int64): Boolean;
var
strm: TZStream;
Buf: array[0..65535] of Byte;
Status: Integer;
begin
Result:= False;
Decoded:= 0;
FillChar(strm, SizeOf(strm), 0);
if inflateInit2(strm, 15) <> Z_OK then
Exit;
try
strm.next_in:= Packed.Memory;
strm.avail_in:= Cardinal(Packed.Size);
repeat
strm.next_out:= @Buf;
strm.avail_out:= SizeOf(Buf);
Status:= inflate(strm, Z_NO_FLUSH);
until Status <> Z_OK;
Decoded:= strm.total_out;
// Ένα μέλος τελειώνει ακριβώς στο τελευταίο byte εισόδου
Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
finally
inflateEnd(strm);
end;
end;
// Περάστε 200.000 bytes από το DeflateStream με το προεπιλεγμένο κομμάτι 64 KB
// και απαιτήστε ένα μέλος που αποκωδικοποιείται ξανά στο πλήρες μήκος
Source.Position:= 0;
DeflateStream(Source, Packed);
if not (IsSingleZlibMember(Packed, Decoded) and (Decoded = Source.Size)) then
raise Exception.Create('DeflateStream produced more than one zlib member');
Σε επίπεδο εφαρμογής, ο χρήσιμος έλεγχος είναι εκείνος που η βιβλιοθήκη δεν κάνει για εσάς: συγκρίνετε ό,τι βγαίνει από ένα attachment με το /Params /Size που καταγράφηκε τη στιγμή που μπήκε. Το GetEmbeddedFileIntProperty με tag 5 επιστρέφει το καταγεγραμμένο μέγεθος, οι δείκτες embedded file είναι 1-based, και το payload θέλει τουλάχιστον 1 MiB για να ενεργοποιηθεί το streaming μονοπάτι. Τρέξτε το ίδιο τεστ σε κάθε compiler που παραδίδετε, αφού το αρχικό ελάττωμα περνούσε στο Delphi και αποτύγχανε μόνο σε FPC
procedure CheckLargeAttachmentRoundTrip(const PayloadFile, OutFile: string);
var
PDF: TPDFlib;
Extracted: TMemoryStream;
I, Declared: Integer;
begin
PDF:= TPDFlib.Create;
try
PDF.NewDocument;
PDF.AddStandardFont(4);
PDF.DrawText(80, 100, 'Large attachment round trip');
// 1 MiB και άνω παίρνει το chunked μονοπάτι DeflateStream στο ReadFromStream
if PDF.EmbedFile('Payload', PayloadFile, 'application/octet-stream') <> 1 then
raise Exception.Create('EmbedFile failed');
if PDF.SaveToFile(OutFile) <> 1 then
raise Exception.Create('SaveToFile failed');
finally
PDF.Free;
end;
PDF:= TPDFlib.Create;
Extracted:= TMemoryStream.Create;
try
if PDF.LoadFromFile(OutFile, '') = 0 then
raise Exception.Create('LoadFromFile failed');
for I:= 1 to PDF.EmbeddedFileCount do
begin
Extracted.Clear;
if PDF.GetEmbeddedFileContentToStream(I, Extracted) <> 1 then
raise Exception.CreateFmt('Attachment %d could not be decoded', [I]);
Declared:= PDF.GetEmbeddedFileIntProperty(I, 5); // /Params /Size
if Extracted.Size <> Declared then
raise Exception.CreateFmt('Attachment %d truncated: %d of %d bytes',
[I, Extracted.Size, Declared]);
end;
finally
Extracted.Free;
PDF.Free;
end;
end;
Τι δεν αλλάζει το fix;
Το FPC branch κρατά τους επιεικείς κανόνες αποκωδικοποίησής του, και δεν επιδιορθώνει αρχεία που παλαιότερα FPC builds είχαν ήδη γράψει. Όταν φτάσει το MaxOutput, το FPC InflateStream κόβει στο όριο και επιστρέφει, ενώ το Delphi branch πετάει ERangeError, και το FPC εξακολουθεί να δέχεται μερικώς αποκωδικοποιημένη έξοδο όταν το inflate αναφέρει data error, επειδή κάποιοι PDF παραγωγοί βγάζουν κομμένα ή με χαλασμένο checksum streams. Ένα PDF γραμμένο από FPC build πριν το v3.539.24 εξακολουθεί να περιέχει αλυσιδωτά μέλη, και ο διορθωμένος reader, όπως κάθε άλλος reader, σταματά στο πρώτο Z_STREAM_END. Μην προσπαθήσετε να θεραπεύσετε τέτοιο αρχείο αποκωδικοποιώντας και ξανακωδικοποιώντας το stream μέσα στη βιβλιοθήκη, αφού αυτό μόνο μονιμοποιεί το κόψιμο των 64 KB. Ξαναενσωματώστε το attachment από την αρχική του πηγή. Η λούπα FPC επίσης τελειώνει ακόμα στην πρώτη ανάγνωση που επιστρέφει λιγότερο από ένα πλήρες κομμάτι, που είναι σήμα τέλους δεδομένων μόνο για streams όπως TFileStream και TMemoryStream, οπότε ένα custom TStream που περνάτε στο AddAssociatedFileFromStream είναι πιο ασφαλές να αντιγραφεί πρώτα σε TMemoryStream. Το Delphi branch, οι helpers DeflateStr και InflateStr, και κάθε stream μικρότερο από 1 MiB στο σκέτο Flate μονοπάτι συμπεριφέρονται ακριβώς όπως πριν
Ο διορθωμένος FPC DeflateStream και InflateStream κυκλοφορούν στο v3.539.24 του PDF Library for Delphi, που στοχεύει Delphi, C++Builder, και Free Pascal από ένα δέντρο πηγαίου κώδικα, και όπου ένα μεγάλο attachment πρέπει πλέον να γυρίζει από FPC build byte προς byte, όπως πάντα γύριζε από Delphi