Artikel Teknis

Chunked zlib di FPC: Kenapa FlateDecode Butuh Satu Stream

Sebelum v3.539.24, build Free Pascal dari PDF Library for Delphi mengompres stream besar dalam potongan 64 KB dan memberi setiap potongan header zlib serta checksum miliknya sendiri, jadi lampiran 1 MiB menjadi enam belas zlib member yang direkat ujung ke ujung. FlateDecode di ISO 32000-1 mengharapkan tepat satu zlib stream, dan decoder standar berhenti di penanda end-of-stream pertama, yang berarti semua yang ada setelah 65.536 byte pertama lenyap tanpa suara. Perbaikannya menjaga satu zlib state tetap hidup di seluruh potongan, baik di DeflateStream maupun InflateStream

Bug-nya hanya ada di sisi FPC dari unit ini, dan hanya di jalur streaming chunked, justru itu sebabnya ia lolos begitu lama: cabang Delphi selalu benar, helper berbasis string selalu benar, dan payload uji yang kecil tak pernah sampai ke kode chunked sama sekali. Ia saudara dekat dari cacat di lima bug porting FPC yang selama ini ditutupi Delphi, hanya saja kali ini build Delphi tidak menutupi apa pun. Cabang FPC-nya memang ditulis dengan model mental yang salah soal apa itu sebuah zlib stream

Kenapa zlib stream yang chunked terpotong oleh PDF reader lain?

Karena zlib stream sebagaimana didefinisikan RFC 1950 adalah satu container, bukan rangkaiannya, dan inflater yang patuh memperlakukan trailer Adler-32 pertama sebagai akhir data. Formatnya adalah header dua byte, satu bit stream deflate RFC 1951 yang berkelanjutan dengan blok terakhirnya membawa flag final-block, dan checksum Adler-32 empat byte atas semua byte hasil dekompresi. ISO 32000-1 §7.4.4 mendefinisikan /FlateDecode persis dalam istilah itu. Saat inflate mencapai trailer, ia mengembalikan Z_STREAM_END dan membiarkan sisa input tak terbaca di avail_in. Dari sudut pandangnya tidak ada yang salah, jadi ia tidak melempar error, dan byte setelah trailer itu sekadar diabaikan. Member yang dirangkai memang ide yang sah di gzip (RFC 1952 mengizinkan beberapa member dalam satu file), dan mungkin dari situlah intuisinya berasal, tapi zlib tidak punya aturan seperti itu dan PDF tidak pernah memintanya

Anatomi zlib member RFC 1950 di stream FlateDecode PDFlibPas: header dua byte, satu bit stream deflate yang berkelanjutan dengan blok terakhirnya membawa flag final-block, dan trailer Adler-32 empat byte tempat inflate mengembalikan Z_STREAM_END, membiarkan sisa input tak terbaca di avail_in tanpa error apa pun
Inflater yang patuh memperlakukan trailer Adler-32 pertama sebagai akhir data, jadi batas member adalah pemberhentian mutlak dan semua yang direkat penulis setelahnya adalah beban mati yang tak akan pernah didekode reader mana pun

DeflateStream FPC yang lama membaca sumber 64 KB sekaligus dan menyerahkan setiap potongan ke ZFPCCompress, helper yang menjalankan deflateInit2-nya sendiri, mengompres dengan Z_FINISH, lalu memanggil deflateEnd. Setiap potongan karena itu keluar sebagai zlib stream yang lengkap, valid, dan mengakhiri dirinya sendiri, dan fungsi itu merangkaikan semuanya menjadi satu AnsiString sebelum ditulis keluar. Hasilnya tampak seperti data Flate, punya header yang benar, dan terdekode tanpa error, tapi yang terdekode hanya 65.536 byte pertama. Di sisi baca, InflateStream punya cacat cerminannya: ia memanggil ZFPCInflate sekali per 64 KB input terkompresi, dan setiap panggilan memulai inflateInit2 yang baru. Potongan kedua dimulai di tengah bit stream deflate tanpa header zlib, jadi inflater baru menolaknya, dan satu stream single-member yang sepenuhnya normal dari produser mana pun hanya terdekode sejauh yang dibawa 64 KB byte terkompresi pertamanya

Cacat penulis chunked di DeflateStream FPC milik PDFlibPas: setiap potongan 64 KB melewati ZFPCCompress sebagai zlib member yang lengkap, jadi lampiran 1 MiB memuat enam belas member yang direkat, reader berhenti di trailer pertama setelah 65.536 byte, dan GetEmbeddedFileContentToStream tetap melaporkan sukses
Kerusakannya tetap tak terlihat karena setiap lapisan sukses: dictionary lampiran mengiklankan /Params /Size penuh, dekompresi tidak melempar error, dan hanya orang yang membuka lampirannya yang menyadari pemotongan itu
// FPC DeflateStream sebelum v3.539.24 (disederhanakan):
// ZFPCCompress menjalankan deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// jadi setiap potongan 64 KB menjadi zlib member terpisah
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));

Panggilan PDF Library for Delphi mana yang sampai ke jalur chunked?

Di FPC, semua embedded file berukuran 1 MiB ke atas ditulis salah, dan semua Flate stream yang diekstrak lewat streaming API terbaca salah begitu ukuran terkompresinya melewati satu potongan. TPDFStream.ReadFromStream menentukan cara mengenkode data yang masuk. Ketika Deflate true dan Stream.Size >= 1048576 ia me-stream lewat DeflateStream; di bawah ambang itu ia membaca seluruh sumber ke memori dan memanggil DeflateStr, helper sekali jalan yang tidak pernah terdampak. Rantai filter ASCII85-plus-Flate melewati tes ukuran dan selalu lewat DeflateStream, jadi di jalur itu setiap payload lebih besar dari 64 KB sudah terbelah menjadi beberapa member. Entry point publik yang memberi makan ReadFromStream dengan kompresi aktif adalah para penulis embedded file:

  • TPDFlib.EmbedFile dan TPDFlib.AddEmbeddedFile, yang membaca file dari disk ke dalam stream /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream dan TPDFlib.AddAssociatedFileFromFile, penulis associated-file PDF/A-3 yang dipakai untuk XML e-invoice dan data sumber lainnya
  • Di sisi baca, TPDFlib.GetEmbeddedFileContentToStream dan GetEmbeddedFileContentToFile, yang mendekode lewat TPDFStream.WriteDecodedToStream lalu dari sana lewat InflateStream

Kegagalannya senyap di setiap lapisan. Penulisnya menyimpan /Params /Size dan MD5 /CheckSum yang dihitung dari file aslinya, jadi dictionary lampiran mengiklankan ukuran penuh sementara stream-nya memuat enam belas member. Build Delphi dari library yang sama yang membaca file itu berhenti rapi di Z_STREAM_END pertama dan mengembalikan tepat 65.536 byte. GetEmbeddedFileContentToStream mengembalikan 1, karena yang dilaporkannya adalah apakah dekompresi melempar error, bukan apakah output cocok dengan /Size. Siapa pun yang pernah mengejar masalah dokumen besar lewat merge dan split PDF berukuran gigabyte kenal pola ini: file-nya terbuka, jumlah halamannya benar, dan kerusakannya baru muncul saat seseorang membuka lampirannya

Satu deflate state untuk semua potongan

DeflateStream yang diperbaiki di PDFlibZLib.pas menginisialisasi satu paszlib.TZStream, memberi makan setiap potongan ke deflate dengan Z_NO_FLUSH, dan hanya di akhir menguras kompresornya dengan Z_FINISH sampai ia mengembalikan Z_STREAM_END. Hasilnya tepat satu header, satu bit stream deflate yang back-reference-nya bisa menjangkau lintas batas potongan, dan satu Adler-32 atas seluruh input. Cabang FPC kini punya struktur yang sama dengan yang selama ini dimiliki cabang Delphi. Ia juga menulis output begitu dihasilkan, alih-alih merangkai seluruh hasil kompresi ke satu AnsiString lebih dulu, jadi penulisnya tak lagi membangun salinan penuh kedua dari data terkompresi di memori sebelum menyalinnya ke target

DeflateStream yang diperbaiki di PDFlibPas: satu paszlib.TZStream diinisialisasi sekali, setiap potongan 64 KB diberi makan dengan Z_NO_FLUSH dan pengurasan Z_FINISH terakhir, menghasilkan tepat satu header, satu bit stream deflate berkelanjutan dengan back-reference yang melintasi batas potongan, dan satu Adler-32 atas seluruh input
Satu state juga mengubah cara output ditulis: byte terkompresi keluar begitu setiap buffer penuh alih-alih menumpuk di salinan penuh kedua, dan PLDeflateLevel kini menjangkau embedded file besar di FPC juga
// FPC DeflateStream mulai v3.539.24 (jalur error dipangkas)
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 yang sama, tanpa putus member
        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                                    // satu trailer untuk seluruh input
    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 mendapat penulisan ulang yang simetris: satu inflateInit2, loop dalam yang terus memanggil inflate sampai potongan berjalan habis dikonsumsi dan buffer output tak lagi penuh, dan pemberhentian pada Z_STREAM_END. Ada satu side effect yang layak Anda ketahui. Penulis chunked yang lama meng-hard-code level 6, sementara yang baru menghormati PLDeflateLevel, jadi level yang disetel lewat TPDFlib.SetCompressionLevel(1..9) kini juga berlaku untuk embedded file besar di FPC. Itu penting kalau Anda sudah menyetel kompresi untuk output arsip sebagaimana dijelaskan di memperkecil ukuran file PDF di Delphi

Bagaimana memverifikasi bahwa satu Flate stream adalah satu zlib member?

Inflate stream-nya dengan decoder zlib biasa dan periksa dua hal saat ia mengembalikan Z_STREAM_END: panjang hasil dekode sama dengan panjang sumber, dan avail_in bernilai nol. Input yang tersisa setelah penanda akhir adalah tanda tangan stream yang dirangkai. Perbaikannya diverifikasi begini: 200 KB data uji, yang membentang di empat potongan 64 KB, melewati DeflateStream yang baru dan keluar sebagai satu zlib stream berukuran 534 byte, decoder zlib standar memulihkan seluruh 200.000 byte tanpa sisa input, dan pemeriksaan yang sama lolos di target FPC hasil cross-compile i386. Rutin di bawah adalah versi FPC dari pemeriksaan itu, dibangun langsung di atas paszlib supaya ia tidak memercayai kode yang sedang diuji

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;
    // Satu member berakhir tepat di byte input terakhir
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Dorong 200.000 byte melewati DeflateStream dengan potongan 64 KB bawaan
// dan syaratkan satu member yang terdekode kembali ke panjang penuh
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');

Di level aplikasi, asersi yang berguna adalah yang tidak dibuatkan library untuk Anda: bandingkan apa yang keluar dari sebuah lampiran dengan /Params /Size yang tercatat saat ia masuk. GetEmbeddedFileIntProperty dengan tag 5 mengembalikan ukuran yang tercatat itu, indeks embedded file berbasis 1, dan payload-nya perlu setidaknya 1 MiB agar jalur streaming-nya tersentuh. Jalankan tes yang sama di setiap compiler yang Anda ikutkan, karena cacat aslinya lolos di Delphi dan gagal hanya di 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 ke atas mengambil jalur DeflateStream chunked di 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;

Apa yang tidak diubah oleh perbaikan ini?

Cabang FPC mempertahankan aturan dekodenya yang longgar, dan ia tidak memperbaiki file yang sudah ditulis build FPC sebelumnya. Saat MaxOutput tercapai, InflateStream FPC memotong di batas lalu kembali, sementara cabang Delphi melempar ERangeError, dan FPC masih menerima output yang terdekode sebagian ketika inflate melaporkan data error, karena sebagian produser PDF memang mengeluarkan stream yang terpotong atau checksum-nya rusak. PDF yang ditulis build FPC pra-v3.539.24 masih memuat member yang dirangkai, dan reader yang sudah dikoreksi, seperti reader mana pun, berhenti di Z_STREAM_END pertama. Jangan mencoba menyembuhkan file semacam itu dengan mendekode dan mengenkode ulang stream-nya di dalam library, karena itu hanya membuat pemotongan 64 KB-nya permanen. Tanamkan ulang lampirannya dari sumber aslinya sebagai gantinya. Loop FPC juga masih berakhir pada pembacaan pertama yang mengembalikan kurang dari satu potongan penuh, yang hanya menjadi sinyal akhir data bagi stream seperti TFileStream dan TMemoryStream, jadi TStream kustom yang dikirim ke AddAssociatedFileFromStream paling aman disalin ke TMemoryStream lebih dulu. Cabang Delphi, helper DeflateStr dan InflateStr, serta semua stream lebih kecil dari 1 MiB di jalur Flate polos berperilaku persis seperti sebelumnya

DeflateStream dan InflateStream FPC yang sudah dikoreksi ikut terkirim di v3.539.24 dari PDF Library for Delphi, yang menargetkan Delphi, C++Builder, dan Free Pascal dari satu source tree, dan di mana lampiran besar kini seharusnya kembali dari build FPC byte demi byte, sama seperti yang selama ini selalu terjadi dari Delphi