مقاله فنی

Chunked zlib روی FPC: چرا FlateDecode به یک stream نیاز دارد

پیش از v3.539.24، بیلد Free Pascal از PDF Library for Delphi استریم‌های بزرگ را در chunkهای 64 KB فشرده می‌کرد و به هر chunk هدر و checksum مخصوص خودش را می‌داد، پس یک پیوست 1 MiBایی می‌شد شانزده عضو zlib که ته‌به‌ته به هم چسبیده بودند. FlateDecode در ISO 32000-1 دقیقاً یک zlib stream انتظار دارد و یک decoder استاندارد روی اولین نشانگر end-of-stream می‌ایستد، یعنی هرچه بعد از 65,536 بایت اول بود بی‌سروصدا گم می‌شد. fix یک zlib state واحد را در هر دو DeflateStream و InflateStream سراسر همهٔ chunkها زنده نگه می‌دارد

این باگ فقط در سمت FPC این unit و فقط روی مسیر استریم chunkشده وجود داشت، و دقیقاً به همین دلیل زنده مانده بود: شاخهٔ Delphi همیشه درست بود، هلپرهای رشته‌محور همیشه درست بودند و payloadهای تست کوچک هرگز اصلاً به کد chunkشده نمی‌رسیدند. خویشاوند نزدیک نقص‌های پنج باگ پورت FPC که Delphi پنهانشان می‌کرد است، با این تفاوت که اینجا بیلد Delphi سرپوش چیزی نبود. شاخهٔ FPC صرفاً با یک مدل ذهنی غلط از اینکه zlib stream چیست نوشته شده بود

چرا readerهای PDF دیگر یک zlib stream chunkشده را نصفه می‌خوانند؟

چون zlib stream طبق تعریف RFC 1950 یک container است، نه دنباله‌ای از آن‌ها، و یک inflater قانون‌مند اولین trailer آدلر-32 را پایان داده می‌داند. قالب یک هدر دو بایتی است، یک بیت‌استریم پیوستهٔ deflate طبق RFC 1951 که بلوک آخرش فلگ final-block را حمل می‌کند، و یک checksum چهاربایتی Adler-32 روی همهٔ بایت‌های غیرفشرده. ISO 32000-1 §7.4.4 /FlateDecode را دقیقاً با همین اصطلاحات تعریف می‌کند. وقتی inflate به trailer می‌رسد Z_STREAM_END برمی‌گرداند و باقی‌ماندهٔ ورودی را نخوانده در avail_in رها می‌کند. از دید او هیچ چیز غلط نیست، پس خطایی raise نمی‌شود و بایت‌های بعد از trailer صرفاً نادیده گرفته می‌شوند. اعضای چسبانده‌شده در gzip ایدهٔ مشروعی‌اند (RFC 1952 چند عضو در یک فایل را مجاز می‌داند) و احتمالاً شهود از همان‌جا آمده، اما zlib چنین قاعده‌ای ندارد و PDF هم هرگز خواستارش نبوده

کالبدشکافی عضو zlib طبق RFC 1950 در استریم‌های FlateDecode در PDFlibPas: یک هدر دو بایتی، یک بیت‌استریم پیوستهٔ deflate که بلوک آخرش فلگ final-block را حمل می‌کند، و یک trailer چهاربایتی Adler-32 که inflate در آن Z_STREAM_END برمی‌گرداند و باقی‌ماندهٔ ورودی را بی‌خطا نخوانده در avail_in رها می‌کند
یک inflater قانون‌مند اولین trailer آدلر-32 را پایان داده می‌داند، پس مرز عضو یک توقف سخت است و هرچه نویسنده بعدش چسبانده باشد بار مرده‌ای است که هیچ readerای decode‌اش نخواهد کرد

DeflateStream قدیمی FPC منبع را هر بار 64 KB می‌خواند و هر chunk را به ZFPCCompress می‌داد، هلپری که deflateInit2 خودش را اجرا می‌کند، با Z_FINISH فشرده می‌کند و deflateEnd را صدا می‌زند. پس هر chunk از آب درمی‌آمد یک zlib stream کامل، معتبر و خودخاتمه، و تابع آن‌ها را پیش از نوشتن در یک AnsiString به هم می‌چسباند. نتیجه شبیه دادهٔ Flate بود، هدر درستی داشت و بی‌خطا decode می‌شد، اما فقط به 65,536 بایت اول decode می‌شد. در سمت خواندن، InflateStream نقص آینه‌ای داشت: به‌ازای هر 64 KB ورودی فشرده یک بار ZFPCInflate صدا زده می‌شد و هر فراخوانی inflateInit2 تازه‌ای شروع می‌کرد. chunk دوم از میانهٔ یک بیت‌استریم deflate و بدون هدر zlib شروع می‌شود، پس inflater تازه آن را رد می‌کند، و یک stream تک‌عضوی کاملاً معمولی از هر تولیدکنندهٔ دیگری فقط تا جایی decode می‌شد که 64 KB اول بایت‌های فشرده‌اش پیش می‌برد

نقص writer چانک‌شده در DeflateStream سمت FPC در PDFlibPas: هر chunk 64 KB به‌عنوان یک عضو کامل zlib از ZFPCCompress می‌گذرد، پس یک پیوست 1 MiBایی شانزده عضو چسبیده دارد، reader بعد از 65,536 بایت روی اولین trailer می‌ایستد و GetEmbeddedFileContentToStream باز هم موفقیت گزارش می‌کند
آسیت نامرئی ماند چون همهٔ لایه‌ها موفق بودند: dictionary پیوست اندازهٔ کامل /Params /Size را اعلان می‌کرد، decode خطایی raise نمی‌کرد و فقط کسی که پیوست را باز می‌کرد بریدگی را می‌دید
// DeflateStream سمت FPC پیش از v3.539.24 (ساده‌شده):
// ZFPCCompress کار deflateInit2 / deflate(Z_FINISH) / deflateEnd را می‌کند،
// پس هر chunk 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 به مسیر chunkشده می‌رسند؟

روی FPC هر فایل embedded یک MiB یا بیشتر غلط نوشته می‌شد و هر Flate stream که از طریق API استریمینگ استخراج می‌شد، به‌محض عبور اندازهٔ فشرده‌اش از یک chunk، غلط خوانده می‌شد. TPDFStream.ReadFromStream تصمیم می‌گیرد دادهٔ ورودی چطور encode شود. وقتی Deflate true باشد و Stream.Size >= 1048576، داده از DeflateStream عبور می‌کند؛ زیر آن آستانه، کل منبع در حافظه خوانده و DeflateStr صدا زده می‌شود، همان هلپر تک‌مرحله‌ای که هرگز آسیبی ندید. زنجیرهٔ فیلتر ASCII85 به‌علاوهٔ Flate آزمون اندازه را رد می‌کند و همیشه از DeflateStream می‌گذرد، پس در آن مسیر هر payload بزرگ‌تر از 64 KB از قبل به چند عضو تقسیم می‌شد. نقطه‌های ورود عمومی که با فشرده‌سازی روشن به ReadFromStream غذا می‌دهند writerهای فایل embedded هستند:

  • TPDFlib.EmbedFile و TPDFlib.AddEmbeddedFile که یک فایل را از دیسک خوانده و در یک stream /EmbeddedFile می‌نویسند
  • TPDFlib.AddAssociatedFileFromStream و TPDFlib.AddAssociatedFileFromFile، writerهای فایل مرتبط PDF/A-3 که برای XML فاکتور الکترونیکی و دیگر داده‌های مبدأ به کار می‌روند
  • در سمت خواندن، TPDFlib.GetEmbeddedFileContentToStream و GetEmbeddedFileContentToFile که از طریق TPDFStream.WriteDecodedToStream و از آنجا از طریق InflateStream decode می‌کنند

شکست در هر لایه بی‌صدا بود. writer /Params /Size و یک /CheckSum از جنس MD5 را که از فایل اصلی محاسبه شده ذخیره می‌کند، پس dictionary پیوست اندازهٔ کامل را اعلان می‌کرد در حالی که stream شانزده عضو در خود داشت. بیلد Delphi همان کتابخانه که چنین فایلی را می‌خواند تمیز روی اولین Z_STREAM_END می‌ایستاد و دقیقاً 65,536 بایت برمی‌گرداند. GetEmbeddedFileContentToStream مقدار 1 برمی‌گرداند، چون گزارش می‌کند decode خطا داده یا نه، نه اینکه خروجی با /Size بخواند یا نه. هر کسی که یک مسئلهٔ سند بزرگ را تا ادغام و تفکیک PDFهای گیگابایتی دنبال کرده این الگو را می‌شناسد: فایل باز می‌شود، تعداد صفحه‌ها درست است و آسیت فقط وقتی خودش را نشان می‌دهد که یکی پیوست را باز کند

یک deflate state سراسر همهٔ chunkها

DeflateStream اصلاح‌شده در PDFlibZLib.pas یک paszlib.TZStream واحد را مقداردهی اولیه می‌کند، هر chunk را با Z_NO_FLUSH به deflate می‌دهد و فقط در پایان کمپرسور را با Z_FINISH تخلیه می‌کند تا Z_STREAM_END برگرداند. نتیجه دقیقاً یک هدر است، یک بیت‌استریم deflate که back-referenceهایش می‌توانند از مرز chunkها بگذرند، و یک Adler-32 روی کل ورودی. شاخهٔ FPC حالا همان ساختاری را دارد که شاخهٔ Delphi همیشه داشت. خروجی هم همان‌طور که تولید می‌شود نوشته می‌شود، به‌جای آنکه تمام نتیجهٔ فشرده اول در یک AnsiString جمع شود، پس writer دیگر پیش از کپی کردن به مقصد، یک کپی کامل دوم از دادهٔ فشرده در حافظه نمی‌سازد

DeflateStream اصلاح‌شده در PDFlibPas: یک paszlib.TZStream که یک بار مقداردهی اولیه می‌شود، هر chunk 64 KB با Z_NO_FLUSH تغذیه و در پایان با یک Z_FINISH تخلیه می‌شود، که دقیقاً یک هدر، یک بیت‌استریم پیوستهٔ deflate با back-referenceهایی از مرز chunkها گذشته و یک Adler-32 روی کل ورودی تحویل می‌دهد
یک state بودن طرز نوشتن خروجی را هم عوض می‌کند: بایت‌های فشرده با پر شدن هر بافر بیرون می‌روند به‌جای آنکه در یک کپی کامل دوم جمع شوند، و PLDeflateLevel حالا به فایل‌های embedded بزرگ روی FPC هم می‌رسد
// DeflateStream سمت FPC از v3.539.24 (مسیرهای خطا حذف شده)
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، حلقهٔ داخلی که تا مصرف شدن chunk جاری و خالی شدن بافر خروجی به صدا زدن inflate ادامه می‌دهد، و یک توقف روی Z_STREAM_END. یک عارضهٔ جانبی هم هست که بدانش بد نیست. writer چانک‌شدهٔ قدیمی سطح 6 را هاردکد کرده بود، در حالی که نسخهٔ جدید به PLDeflateLevel احترام می‌گذارد، پس سطحی که از طریق TPDFlib.SetCompressionLevel(1..9) ست شده حالا روی فایل‌های embedded بزرگ در FPC هم اعمال می‌شود. اگر برای خروجی آرشیوی فشرده‌سازی را تنظیم کرده‌ای — همان‌طور که در کاهش حجم فایل PDF در Delphi آمده — این برایت مهم است

چطور بررسی کنیم یک Flate stream تک‌عضویِ zlib است؟

با یک decoder zlib ساده inflate‌اش کن و وقتی Z_STREAM_END برگرداند دو چیز را بررسی کن: طول decode‌شده با طول منبع برابر باشد و avail_in صفر باشد. ورودی باقی‌مانده بعد از نشانگر پایان، امضای یک stream چسبانده‌شده است. fix همین‌طور راستی‌آزمایی شد: 200 KB دادهٔ تست که چهار chunk 64 KB را پوشش می‌دهد از DeflateStream جدید گذشت و یک zlib stream تک‌عضوی 534 بایتی از آب درآمد، یک decoder zlib معمولی هر 200,000 بایت را بدون ورودی باقی‌مانده بازیابی کرد و همین بررسی روی target کراس‌کامپایل‌شدهٔ 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;
    // یک عضو دقیقاً روی آخرین بایت ورودی تمام می‌شود
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// 200,000 بایت را با chunk پیش‌فرض 64 KB از DeflateStream عبور بده
// و یک عضو بخواه که به طول کامل decode شود
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');

در سطح اپلیکیشن، ادعای مفید همان است که کتابخانه به‌جایت نمی‌سازد: خروجی پیوست را با /Params /Size ثبت‌شده هنگام ورودش مقایسه کن. GetEmbeddedFileIntProperty با tag 5 همان اندازهٔ ثبت‌شده را برمی‌گرداند، شاخص‌های فایل embedded یک‌مبنایند و payload باید دست‌کم 1 MiB باشد تا مسیر استریمینگ را بچرخانی. همان تست را روی هر کامپایلری که با آن ship می‌کنی اجرا کن، چون نقص اصلی روی 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');
    // یک MiB یا بیشتر مسیر 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 قواعد آسان‌گیر decode کردنش را نگه می‌دارد و فایل‌هایی را که بیلدهای قبلی FPC نوشته‌اند ترمیم نمی‌کند. وقتی به MaxOutput برسیم، InflateStream سمت FPC روی همان حد بریده و برمی‌گردد، در حالی که شاخهٔ Delphi ERangeError می‌دهد؛ و FPC همچنان وقتی inflate خطای داده گزارش می‌کند خروجی نیمه‌decode‌شده را می‌پذیرد، چون بعضی تولیدکننده‌های PDF استریم‌های بریده یا checksum‌شکسته صادر می‌کنند. PDFی که یک بیلد FPC پیش از v3.539.24 نوشته هنوز اعضای چسبیده در خود دارد و reader اصلاح‌شده، مثل هر reader دیگری، روی اولین Z_STREAM_END می‌ایستد. سعی نکن چنین فایلی را با decode و encode دوبارهٔ stream داخل کتابخانه مداوا کنی، چون فقط بریدگی 64 KB را دائمی می‌کند. به‌جایش پیوست را از منبع اصلی‌اش دوباره embed کن. حلقهٔ FPC همچنان روی اولین خواندنی که کمتر از یک chunk کامل برگرداند تمام می‌شود، که فقط برای استریم‌هایی مثل TFileStream و TMemoryStream نشانهٔ پایان داده است، پس یک TStream سفارشی که به AddAssociatedFileFromStream می‌دهی بهتر است اول در یک TMemoryStream کپی شود. شاخهٔ Delphi، هلپرهای DeflateStr و InflateStr و هر stream کوچک‌تر از 1 MiB در مسیر Flate ساده دقیقاً مثل قبل رفتار می‌کنند

DeflateStream و InflateStream اصلاح‌شدهٔ FPC در v3.539.24 از PDF Library for Delphi عرضه می‌شوند، کتابخانه‌ای که Delphi و C++Builder و Free Pascal را از یک source tree هدف می‌گیرد، و جایی که حالا باید یک پیوست بزرگ از بیلد FPC هم بایت‌به‌بایت برگردد، همان‌طور که همیشه از Delphi برمی‌گشت