ก่อน v3.539.24 บิลด์ Free Pascal ของ PDF Library for Delphi บีบอัด stream ขนาดใหญ่ทีละชิ้นละ 64 KB และให้ทุกชิ้นมี zlib header กับ checksum ของตัวเอง ไฟล์แนบ 1 MiB หนึ่งไฟล์จึงกลายเป็น zlib member สิบหกตัวต่อกันเรื่อย ๆ ISO 32000-1 FlateDecode คาดหวัง zlib stream แบบเดียวเท่านั้น และ decoder มาตรฐานหยุดที่ end-of-stream marker ตัวแรก ซึ่งหมายความว่าทุกอย่างหลัง 65,536 ไบต์แรกหายไปเงียบ ๆ การแก้คือรักษา zlib state หนึ่งตัวให้มีชีวิตข้ามทุกชิ้นทั้งใน DeflateStream และ InflateStream
bug นี้มีอยู่แค่ฝั่ง FPC ของ unit เดียว และอยู่แค่เส้นทาง streaming แบบ chunked ซึ่งเป็นเหตุผลที่มันรอดมาได้พอดี: สาขา Delphi ถูกมาตลอด helper ที่ทำงานบน string ก็ถูกมาตลอด และ payload ทดสอบขนาดเล็กไม่เคยแตะโค้ด chunked เลย มันเป็นญาติสนิทของข้อผิดพลาดในbug พอร์ต FPC ห้าตัวที่ Delphi ปิดบังไว้เงียบ ๆ ต่างกันที่ที่นี่บิลด์ Delphi ไม่ได้ช่วยปิดบังอะไรเลย สาขา FPC แค่ถูกเขียนขึ้นจากภาพจำที่ผิดว่า zlib stream คืออะไร
ทำไม zlib stream แบบ chunked ถึงถูกตัดสั้นโดย PDF reader ตัวอื่น
เพราะ zlib stream ตามนิยามของ RFC 1950 เป็น container เดียว ไม่ใช่ลำดับของหลายตัว และ inflater ที่ทำตามสเปกถือว่า Adler-32 trailer ตัวแรกคือจุดจบของข้อมูล รูปแบบคือ header สองไบต์ ต่อด้วย deflate bit stream ต่อเนื่องหนึ่งสายที่บล็อกสุดท้ายติดธง final-block และจบด้วย checksum Adler-32 สี่ไบต์ครอบไบต์ที่คลายแล้วทั้งหมด ISO 32000-1 §7.4.4 นิยาม /FlateDecode ในแง่นี้พอดี เมื่อ inflate ไปถึง trailer มันคืน Z_STREAM_END แล้วทิ้ง input ที่เหลือไว้ใน avail_in โดยไม่อ่าน จากมุมมองของมันไม่มีอะไรผิด มันจึงไม่โยน error และไบต์หลัง trailer ก็ถูกเมินไปเฉย ๆ การต่อ member เข้าด้วยกันเป็นแนวคิดที่ชอบธรรมใน gzip (RFC 1952 ยอมให้หลาย member อยู่ในไฟล์เดียว) ซึ่งน่าจะเป็นที่มาของสัญชาตญาณนี้ แต่ zlib ไม่มีกฎแบบนั้น และ PDF ก็ไม่เคยขอด้วย
DeflateStream เดิมของฝั่ง FPC อ่านต้นทางทีละ 64 KB แล้วส่งแต่ละชิ้นให้ ZFPCCompress ซึ่งเป็น helper ที่รัน deflateInit2 ของตัวเอง บีบอัดด้วย Z_FINISH แล้วเรียก deflateEnd ทุกชิ้นจึงออกมาเป็น zlib stream ที่สมบูรณ์ ถูกต้อง และปิดตัวเองได้ แล้วฟังก์ชันก็เอามาต่อกันเป็น AnsiString เดียวก่อนเขียนออก ผลลัพธ์หน้าตาเหมือนข้อมูล Flate มี header ถูกต้อง decode ได้โดยไม่โยน error แต่คลายออกมาได้แค่ 65,536 ไบต์แรกเท่านั้น ฝั่งอ่าน InflateStream มีความผิดพลาดแบบสะท้อนกลับ: มันเรียก ZFPCInflate หนึ่งครั้งต่อ input ที่บีบอัดแล้ว 64 KB และแต่ละครั้งเริ่ม inflateInit2 ใหม่เอี่ยม ชิ้นที่สองเริ่มกลาง deflate bit stream โดยไม่มี zlib header ดังนั้น inflater ตัวใหม่จึงปฏิเสธมัน และ stream single-member ที่ปกติดีมาจาก producer อื่นก็ถูก decode ได้เท่าที่ไบต์บีบอัด 64 KB แรกพาไปถึง
// DeflateStream ฝั่ง FPC ก่อน v3.539.24 (ย่อ):
// ZFPCCompress ทำ deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// ทุกชิ้น 64 KB จึงกลายเป็น zlib member แยกต่างหาก
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));
call ไหนของ PDF Library for Delphi บ้างที่แตะเส้นทาง chunked
บน FPC embedded file ที่ตั้งแต่ 1 MiB ขึ้นไปถูกเขียนผิดทั้งหมด และ Flate stream ที่ดึงออกผ่าน streaming API ถูกอ่านผิดทันทีที่ขนาดหลังบีบอัดข้ามหนึ่งชิ้น TPDFStream.ReadFromStream เป็นตัวตัดสินวิธีเข้ารหัสข้อมูลที่เข้ามา เมื่อ Deflate เป็น true และ Stream.Size >= 1048576 มันจะ stream ผ่าน DeflateStream ถ้าต่ำกว่าเกณฑ์นี้จะอ่านต้นทางทั้งหมดเข้าหน่วยความจำแล้วเรียก DeflateStr helper แบบครั้งเดียวจบที่ไม่เคยได้รับผลกระทบ ส่วน filter chain ASCII85 บวก Flate ข้ามการทดสอบขนาดและผ่าน DeflateStream เสมอ เส้นทางนั้น payload อะไรที่ใหญ่กว่า 64 KB จึงถูกแยกเป็นหลาย member ไปแล้ว จุดเข้าสาธารณะที่ป้อน ReadFromStream โดยเปิดการบีบอัดอยู่คือกลุ่ม writer ของ embedded file:
TPDFlib.EmbedFileกับTPDFlib.AddEmbeddedFileที่อ่านไฟล์จากดิสก์เข้าสู่ stream/EmbeddedFileTPDFlib.AddAssociatedFileFromStreamกับTPDFlib.AddAssociatedFileFromFileกลุ่ม writer ของ associated file สำหรับ PDF/A-3 ที่ใช้กับ e-invoice XML และข้อมูลต้นทางอื่น ๆ- ฝั่งอ่าน
TPDFlib.GetEmbeddedFileContentToStreamกับGetEmbeddedFileContentToFileที่ decode ผ่านTPDFStream.WriteDecodedToStreamแล้วต่อด้วยInflateStream
ความล้มเหลวนี้เงียบในทุกชั้น writer เก็บ /Params /Size กับ /CheckSum แบบ MD5 ที่คำนวณจากไฟล์ต้นฉบับ ดังนั้น dictionary ของไฟล์แนบจึงประกาศขนาดเต็มขณะที่ใน stream มี member อยู่สิบหกตัว บิลด์ Delphi ของ library เดียวกันอ่านไฟล์นั้นแล้วหยุดเรียบร้อยที่ Z_STREAM_END ตัวแรกและคืนมาพอดี 65,536 ไบต์ GetEmbeddedFileContentToStream คืน 1 เพราะมันรายงานว่าการ decode โยน error หรือไม่ ไม่ได้รายงานว่าผลลัพธ์ตรงกับ /Size ไหม ใครที่เคยไล่ปัญหาเอกสารใหญ่ผ่านการ merge และ split PDF ขนาดกิกะไบต์จะรู้จักรูปแบบนี้ดี: ไฟล์เปิดได้ จำนวนหน้าถูกต้อง และความเสียหายโผล่ตอนที่มีคนเปิดไฟล์แนบเท่านั้น
deflate state เดียวข้ามทุกชิ้น
DeflateStream ที่แก้แล้วใน PDFlibZLib.pas initialize paszlib.TZStream หนึ่งตัว ป้อนทุกชิ้นให้ deflate ด้วย Z_NO_FLUSH แล้วจึงค่อยระบาย compressor ด้วย Z_FINISH ตอนจบจนกว่าจะคืน Z_STREAM_END ผลที่ได้คือ header หนึ่งตัวพอดี deflate bit stream หนึ่งสายที่ back-reference เอื้อมข้ามเขตแดนชิ้นได้ และ Adler-32 หนึ่งตัวครอบ input ทั้งหมด สาขา FPC ตอนนี้จึงมีโครงสร้างแบบเดียวกับที่สาขา Delphi เป็นมาตลอด และมันยังเขียน output ออกไประหว่างทำงานด้วย แทนที่จะเอาผลบีบอัดทั้งก้อนไปต่อรวมใน AnsiString ก่อน writer จึงไม่ต้องสร้างสำเนาเต็มที่สองของข้อมูลบีบอัดไว้ในหน่วยความจำอีกต่อไปก่อนคัดลอกไปที่ปลายทาง
// DeflateStream ฝั่ง FPC ตั้งแต่ 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 เดิม ไม่มีการเบรก 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 // trailer เดียวสำหรับ 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 ถูกเขียนใหม่แบบสมมาตร: inflateInit2 หนึ่งครั้ง loop ข้างในคอยเรียก inflate จนกว่าชิ้นปัจจุบันถูกกินหมดและ output buffer ไม่เต็มอีก แล้วหยุดที่ Z_STREAM_END มี side effect หนึ่งอย่างที่ควรรู้ writer แบบ chunked เดิม hardcode level ไว้ที่ 6 ส่วนตัวใหม่เคารพ PLDeflateLevel ดังนั้น level ที่ตั้งผ่าน TPDFlib.SetCompressionLevel(1..9) ตอนนี้มีผลกับ embedded file ขนาดใหญ่บน FPC ด้วย เรื่องนี้สำคัญถ้าคุณ tune การบีบอัดไว้สำหรับ output เชิง archive อยู่แล้วตามที่พูดถึงในการลดขนาดไฟล์ PDF ใน Delphi
จะตรวจยังไงว่า Flate stream เป็น zlib member เดียว
คลายมันด้วย zlib decoder ธรรมดาแล้วเช็กสองอย่างตอนที่มันคืน Z_STREAM_END: ความยาวที่คลายได้เท่ากับความยาวต้นทาง และ avail_in เป็นศูนย์ input ที่เหลือค้างหลัง end marker คือลายเซ็นของ stream ที่ถูกต่อกัน การแก้ได้รับการยืนยันด้วยวิธีนี้: ข้อมูลทดสอบ 200 KB ซึ่งกินสี่ชิ้นแบบ 64 KB ผ่าน DeflateStream ใหม่แล้วออกมาเป็น zlib stream เดี่ยวขนาด 534 ไบต์, zlib decoder สำเร็จรูปกู้คืนได้ครบ 200,000 ไบต์โดยไม่เหลือ input และการเช็กเดียวกันผ่านบน target FPC ที่ cross-compile เป็น i386 routine ด้านล่างคือเวอร์ชัน 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;
// member เดียวต้องจบพอดีที่ไบต์ input สุดท้าย
Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
finally
inflateEnd(strm);
end;
end;
// ดันไบต์ 200,000 ตัวผ่าน DeflateStream ด้วยชิ้น default 64 KB
// แล้วบังคับว่ามี member เดียวที่คลายกลับมาได้ครบความยาว
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');
ระดับแอปพลิเคชัน assertion ที่ใช้ประโยชน์ได้จริงคืออันที่ library ไม่ได้ทำให้คุณ: เทียบสิ่งที่ดึงออกจากไฟล์แนบกับ /Params /Size ที่บันทึกไว้ตอนเอาเข้า GetEmbeddedFileIntProperty ด้วย tag 5 คืนขนาดที่บันทึกไว้นั้น index ของ embedded file เริ่มที่ 1 และ payload ต้องใหญ่อย่างน้อย 1 MiB เสียก่อนถึงจะแตะเส้นทาง streaming รัน test เดียวกันบน 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 ขึ้นไปเข้าเส้นทาง DeflateStream แบบ chunked ใน 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 ยังคงกฎการ decode แบบเชื่องของตัวเอง และไม่ได้ซ่อมไฟล์ที่บิลด์ FPC รุ่นก่อนเขียนไว้แล้ว เมื่อถึง MaxOutput InflateStream ฝั่ง FPC จะตัดที่เพดานแล้วคืนค่า ขณะที่ฝั่ง Delphi โยน ERangeError และฝั่ง FPC ยังยอมรับ output ที่คลายมาเพียงบางส่วนเมื่อ inflate รายงาน data error เพราะ producer บางตัวปล่อย stream ที่ถูกตัดหรือ checksum พังออกมา PDF ที่เขียนโดยบิลด์ FPC ก่อน v3.539.24 ยังมี member ต่อกันอยู่ข้างใน และ reader ที่แก้แล้วก็เหมือน reader ทุกตัว คือหยุดที่ Z_STREAM_END ตัวแรก อย่าพยายามเยียวยาไฟล์แบบนั้นด้วยการ decode แล้ว encode ใหม่ภายใน library เพราะมันแค่เปลี่ยนการตัด 64 KB ให้ถาวรขึ้นเท่านั้น ให้เอาไฟล์แนบจากแหล่งต้นฉบับมา embed ใหม่แทน loop ฝั่ง FPC ยังจบที่การอ่านแรกที่ได้มาไม่เต็มชิ้นด้วย ซึ่งเป็นสัญญาณจบข้อมูลเฉพาะกับ stream อย่าง TFileStream กับ TMemoryStream เท่านั้น TStream ที่เขียนเองส่งเข้า AddAssociatedFileFromStream จึงปลอดภัยสุดถ้า copy ลง TMemoryStream ก่อน สาขา Delphi, helper DeflateStr กับ InflateStr และทุก stream ที่เล็กกว่า 1 MiB บนเส้นทาง Flate ธรรมดาทำงานเหมือนเดิมทุกอย่าง
DeflateStream และ InflateStream ฝั่ง FPC ที่แก้แล้ว ship มากับ v3.539.24 ของPDF Library for Delphi ที่รองรับทั้ง Delphi, C++Builder และ Free Pascal จาก source tree เดียว และไฟล์แนบขนาดใหญ่ควรกลับมาจากบิลด์ FPC ได้ไบต์ต่อไบต์ อย่างเดียวกับที่เคยเป็นจาก Delphi มาตลอด