قبل v3.539.24 كانت بنية Free Pascal من PDF Library for Delphi تضغط التدفقات الكبيرة على قطع 64 KB وتمنح كل قطعة ترويسة zlib ومجموع تحقق خاصين بها، فيتحول المرفق بحجم 1 MiB إلى ستة عشر عضو zlib ملصوقة طرفاً بطرف. أما FlateDecode في ISO 32000-1 فيتوقع تدفق zlib واحداً بالضبط، وأي فاكّ ضغط مطابق يتوقف عند أول علامة نهاية تدفق، أي أن كل ما بعد أول 65,536 بايت اختفى بصمت. الإصلاح يُبقي حالة zlib واحدة حية عبر كل القطع في DeflateStream و InflateStream معاً
لم يكن الخلل موجوداً إلا في الجانب FPC من الوحدة، وعلى مسار التدفق المقسم وحده، وهو بالضبط ما أبقاه حياً: فرع Delphi كان صحيحاً دائماً، والمساعدات المبنية على السلاسل النصية كانت صحيحة دائماً، وأحمال الاختبار الصغيرة لم تصل إلى الكود المقسم أصلاً. وهو قريب وثيق من عيوب خمسة أخطاء نقل إلى FPC كان Delphi يخفيها، غير أن بنية Delphi هنا لم تكن تطمس شيئاً. فرع FPC كُتب ببساطة وفق نموذج ذهني خاطئ عن ماهية تدفق zlib
لماذا يقطع قارئو PDF الآخرون تدفق zlib المقسم؟
لأن تدفق zlib كما يعرّفه RFC 1950 حاوية واحدة لا سلسلة من الحاويات، وأي فاكّ ضغط مطابق يعامل أول مقطع Adler-32 الختامي بوصفه نهاية البيانات. التكوين ترويسة من بايتين، وتدفق بتات deflate واحد متصل وفق RFC 1951 تحمل كتلته الأخيرة علم الكتلة الأخيرة، ومجموع تحقق Adler-32 من أربعة بايتات فوق كل البايتات غير المضغوطة. ويعرّف §7.4.4 من ISO 32000-1 /FlateDecode بهذه الحدود بالضبط. حين يصل inflate إلى المقطع الختامي يعيد Z_STREAM_END ويترك ما تبقى من مدخل غير مقروء في avail_in. لا شيء خاطئ من وجهة نظره فلا يرفع خطأ، والبايتات بعد المقطع الختامي تُتجاهل بكل بساطة. أعضاء متسلسلة فكرة مشروعة في gzip (يسمح RFC 1952 بعدة أعضاء في ملف واحد) ومنها جاء هذا الحدس غالباً، لكن zlib ليس لديه قاعدة من ذلك ولا PDF طلبها يوماً
كان DeflateStream القديم على FPC يقرأ المصدر على دفعات 64 KB ويسلّم كل قطعة إلى ZFPCCompress، مساعدة تشغّل deflateInit2 خاصتها وتضغط بـ Z_FINISH وتستدعي deflateEnd. فتخرج كل قطعة تدفق zlib كاملاً صحيحاً مُنهياً ذاته، وتسلسل الدالة تلك الأعضاء في AnsiString واحدة قبل كتابتها. بدت النتيجة بيانات Flate سليمة الترويسة، وفُكّت دون خطأ، لكنها فُكّت إلى أول 65,536 بايت فقط. وعلى جانب القراءة كان InflateStream يحمل العيب المعكوس: يستدعي ZFPCInflate مرة عن كل 64 KB من المدخل المضغوط، وكل استدعاء يبدأ inflateInit2 جديداً. القطعة الثانية تبدأ في منتصف تدفق بتات deflate بلا ترويسة zlib، فيرفضها فاكّ جديد، وتدفق عضو واحد عادي تماماً من أي منتج آخر لم يكن يُفك إلا بقدر ما حملته أول 64 KB من بايتاته المضغوطة
// 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 تمر بالمسار المقسم؟
على FPC كان كل ملف مضمّن بحجم 1 MiB أو أكثر يُكتب خطأً، وكل تدفق Flate يُستخرج عبر واجهة التدفق يُقرأ خطأً متى تجاوز حجمه المضغوط قطعة واحدة. تقرر TPDFStream.ReadFromStream كيف تُرمَّز البيانات الداخلة. حين يكون Deflate صحيحاً و Stream.Size >= 1048576 تمرر البيانات عبر DeflateStream؛ ودون ذلك العتبة تقرأ المصدر كله في الذاكرة وتستدعي DeflateStr، المساعدة أحادية الضربة التي لم يلمسها الخلل قط. أما سلسلة مرشحات ASCII85 مع Flate فتتخطى فحص الحجم وتمر دائماً عبر DeflateStream، فعلى ذلك المسار كان أي حمل أكبر من 64 KB ينقسم أصلاً إلى عدة أعضاء. ونقاط الدخول العامة التي تغذي ReadFromStream والضغط مفعَّل فيها هي كاتبات الملفات المضمّنة:
TPDFlib.EmbedFileوTPDFlib.AddEmbeddedFile، تقرآن ملفاً من القرص إلى تدفق/EmbeddedFileTPDFlib.AddAssociatedFileFromStreamوTPDFlib.AddAssociatedFileFromFile، كاتبا الملفات المرافقة PDF/A-3 المستخدمان لـ XML الفواتير الإلكترونية وغيره من بيانات المصدر- وعلى جانب القراءة،
TPDFlib.GetEmbeddedFileContentToStreamوGetEmbeddedFileContentToFile، تفكان عبرTPDFStream.WriteDecodedToStreamثم عبرInflateStream
كان الفشل هادئاً في كل طبقة. يخزن الكاتب /Params /Size ومجموع /CheckSum من نوع MD5 محسوباً من الملف الأصلي، فأعلن قاموس المرفق الحجم الكامل بينما يحمل التدفق ستة عشر عضواً. ووقف مبنى Delphi من المكتبة نفسها حين قرأ ذلك الملف بنظافة عند أول Z_STREAM_END وأعاد 65,536 بايت بالضبط. وأعادت GetEmbeddedFileContentToStream القيمة 1 لأنها تبلّغ هل رفع فك الضغط خطأ، لا هل يطابق الناتج /Size. ومن طارد مشكلة مستندات كبيرة عبر دمج وتقسيم ملفات PDF بحجم غيغابايت يعرف هذا النمط: الملف يفتح، وعدد الصفحات صحيح، والضرر لا يظهر إلا حين يفتح أحد المرفق
حالة deflate واحدة عبر كل القطع
يهيئ DeflateStream المصلح في PDFlibZLib.pas paszlib.TZStream واحداً، ويغذي كل قطعة إلى deflate بـ Z_NO_FLUSH، ولا يفرّغ الضاغط بـ Z_FINISH إلا في النهاية حتى يعيد Z_STREAM_END. النتيجة ترويسة واحدة بالضبط، وتدفق بتات deflate واحد تستطيع إحالاته الخلفية عبور حدود القطع، و Adler-32 واحد فوق المدخل كله. فرع FPC صار بالبنية نفسها التي كان فرع Delphi يمتلكها دائماً. وهو أيضاً يكتب الناتج أثناء إنتاجه بدل تجميع نتيجة الضغط كاملة في AnsiString أولاً، فلم يعد الكاتب يبني نسخة ثانية كاملة من البيانات المضغوطة في الذاكرة قبل نسخها إلى الهدف
// FPC DeflateStream بدءاً من 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); // الحالة نفسها، بلا قطع عضو
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 // مقطع ختامي واحد لكل المدخل
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 حتى تُستهلك القطعة الحالية ولم يعد مخزن الناتج ممتلئاً، وتوقف عند Z_STREAM_END. وهناك أثر جانبي ينبغي أن تعرفه. كان الكاتب المقسم القديم يثبّت المستوى 6 بينما الجديد يحترم PLDeflateLevel، فالمستوى المضبوط عبر TPDFlib.SetCompressionLevel(1..9) ينطبق الآن على الملفات المضمّنة الكبيرة على FPC أيضاً. وهذا يهم إن كنت تضبط الضغط أصلاً للمخرجات الأرشيفية كما في تصغير حجم ملفات PDF في Delphi
كيف تتحقق من أن تدفق Flate عضو zlib واحد؟
فك ضغطه بفاكّ zlib عادي وافحص أمرين حين يعيد Z_STREAM_END: الطول المفكوك يساوي طول المصدر، و avail_in صفر. المدخل المتبقي بعد علامة النهاية هو بصمة التدفق المتسلسل. وتحقق من الإصلاح هكذا: مرت 200 KB من بيانات الاختبار، وهي تمتد على أربع قطع 64 KB، عبر DeflateStream الجديد فخرجت تدفق zlib واحداً بطول 534 بايت، واستعاد فاكّ zlib الجاهز كل الـ 200,000 بايت بلا مدخل متبقٍ، ونجح الفحص نفسه على هدف FPC المترجم تقاطعياً لمعمارية i386. والروتين أدناه هو نسخة 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 بايت عبر 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');
وعلى مستوى التطبيق، التوكيد المفيد هو الذي لا تؤديه لك المكتبة: قارن ما يخرج من مرفق مع /Params /Size المسجل حين دخل. تعيد GetEmbeddedFileIntProperty بالوسم 5 ذلك الحجم المسجل، وفهارس الملفات المضمّنة تبدأ من 1، ويحتاج الحمول إلى 1 MiB على الأقل ليجرّب مسار التدفق. شغّل الاختبار نفسه على كل مترجم تشحن معه، فالخلل الأصلي كان ينجح على 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 أو أكثر يسلك مسار 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;
ماذا لا يغيّره الإصلاح؟
يحتفظ فرع FPC بقواعد فك الضغط المتساهلة لديه، ولا يصلح ملفات كتبتها بنيات FPC الأقدم. عند بلوغ MaxOutput يبتر InflateStream على FPC عند الحد ويعيد، بينما يرفع فرع Delphi ERangeError، وما زال FPC يقبل ناتجاً مفكوكاً جزئياً حين يبلّغ inflate عن خطأ بيانات، لأن بعض منتجي PDF يطلقون تدفقات مبتراة أو فاسدة المجموع. ملف PDF كتبته بنية FPC أقدم من v3.539.24 ما زال يحمل أعضاء متسلسلة، والقارئ المصحح، ككل قارئ آخر، يتوقف عند أول Z_STREAM_END. لا تحاول شفاء مثل ذلك الملف بفك التدفق وإعادة ترميزه داخل المكتبة، فذلك يجعل بتر 64 KB دائماً فقط. أعد تضمين المرفق من مصدره الأصلي بدلاً من ذلك. وحلقة FPC ما زالت تنتهي عند أول قراءة تعيد أقل من قطعة كاملة، وهي إشارة نهاية بيانات لتدفقات مثل TFileStream و TMemoryStream فقط، فأأمن ما تفعله بـ TStream مخصص تمرره إلى AddAssociatedFileFromStream نسخه إلى TMemoryStream أولاً. فرع Delphi، والمساعدتان DeflateStr و InflateStr، وكل تدفق أصغر من 1 MiB على مسار Flate العادي يسلكون كما كانوا تماماً
يصدر DeflateStream و InflateStream المصححان على FPC في v3.539.24 من PDF Library for Delphi، التي تستهدف Delphi و C++Builder و Free Pascal من شجرة مصدر واحدة، ومن المفروض الآن أن يعود المرفق الكبير من بنية FPC بايتاً ببايت، كما كان يعود دائماً من Delphi