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

Chunked zlib σε FPC: Γιατί το FlateDecode θέλει ένα stream

Πριν το 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 δεν ζήτησε ποτέ

Ανατομία μέλους zlib κατά RFC 1950 σε streams FlateDecode του PDFlibPas: header δύο bytes, ένα συνεχές deflate bit stream με τη σημαία final-block στο τελευταίο block, και trailer Adler-32 τεσσάρων bytes όπου το inflate επιστρέφει Z_STREAM_END και αφήνει υπόλοιπη είσοδο στο avail_in χωρίς κανένα error
Ένας conforming inflater βάζει τέλος στα δεδομένα στο πρώτο trailer Adler-32, οπότε το όριο μέλους είναι σκληρή στάση και ό,τι κολλήσει ο writer μετά είναι νεκρό βάρος που κανένας reader δεν θα αποκωδικοποιήσει

Το παλιό 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

Ελάττωμα του chunked writer στο FPC DeflateStream του PDFlibPas: κάθε κομμάτι 64 KB περνά από το ZFPCCompress ως πλήρες μέλος zlib, οπότε ένα attachment 1 MiB κουβαλά δεκαέξι κολλημένα μέλη, ο reader σταματά στο πρώτο trailer μετά από 65.536 bytes, και το GetEmbeddedFileContentToStream αναφέρει ακόμα επιτυχία
Η ζημιά έμενε αόρατη επειδή κάθε στρώμα πέτυχε: το attachment dictionary διαφήμιζε το πλήρες /Params /Size, η αποκωδικοποίηση δεν πετούσε error, και μόνο όποιος άνοιγε το attachment έβλεπε το κόψιμο
// 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 /EmbeddedFile
  • TPDFlib.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 δεν χτίζει πλέον δεύτερο πλήρες αντίγραφο των συμπιεσμένων δεδομένων στη μνήμη πριν το αντιγράψει στον στόχο

Διορθωμένο DeflateStream στο PDFlibPas: ένα paszlib.TZStream αρχικοποιημένο μία φορά, κάθε κομμάτι 64 KB τροφοδοτείται με Z_NO_FLUSH και ένα τελικό στράγγισμα Z_FINISH, παράγοντας ακριβώς ένα header, ένα συνεχές deflate bit stream με back-references που διασχίζουν όρια κομματιών και ένα Adler-32 για όλη την είσοδο
Ένα state αλλάζει και τον τρόπο γραφής της εξόδου: τα συμπιεσμένα bytes φεύγουν καθώς γεμίζει κάθε buffer αντί να μαζεύονται σε δεύτερο πλήρες αντίγραφο, και το PLDeflateLevel πλέον φτάνει και τα μεγάλα embedded files σε FPC
// 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