Trước v3.539.24, bản Free Pascal của PDF Library for Delphi nén các stream lớn theo từng khối 64 KB và cho mỗi khối một zlib header lẫn checksum riêng, nên một attachment 1 MiB biến thành mười sáu zlib member nối đuôi nhau. FlateDecode của ISO 32000-1 chỉ chấp nhận đúng một zlib stream, và bộ giải mã chuẩn dừng ngay ở marker kết thúc stream đầu tiên, nghĩa là mọi thứ sau 65.536 byte đầu tiên lặng lẽ biến mất. Bản sửa giữ một zlib state sống xuyên suốt mọi khối trong cả DeflateStream lẫn InflateStream
Lỗi này chỉ tồn tại ở phía FPC của unit, và chỉ trên đường streaming chia khối, chính vì thế mà nó sống sót: nhánh Delphi luôn đúng, các helper dựa trên chuỗi luôn đúng, còn payload kiểm thử nhỏ chẳng bao giờ chạm tới đoạn code chia khối. Nó là họ hàng gần của những lỗi trong năm lỗi porting FPC mà Delphi đã che giấu, chỉ khác là lần này bản build Delphi không bênh chỗ nào cả. Nhánh FPC đơn giản được viết ra từ một mô hình tinh thần sai bét về zlib stream là gì
Vì sao một zlib stream chia khối bị các trình đọc PDF khác cắt cụt?
Vì một zlib stream theo định nghĩa của RFC 1950 là một container duy nhất chứ không phải một chuỗi các container, và một inflater tuân thủ chuẩn coi trailer Adler-32 đầu tiên là điểm kết thúc dữ liệu. Định dạng gồm một header hai byte, một deflate bit stream liên tục mà khối cuối mang cờ final-block, và một checksum Adler-32 bốn byte tính trên toàn bộ byte chưa nén. ISO 32000-1 §7.4.4 định nghĩa /FlateDecode đúng theo những thuật ngữ đó. Khi inflate chạm tới trailer nó trả về Z_STREAM_END và bỏ phần input còn lại chưa đọc trong avail_in. Từ góc nhìn của nó chẳng có gì sai, nên nó không báo lỗi, và các byte sau trailer đơn giản bị bỏ qua. Ghép nối member là một ý tưởng chính đáng trong gzip (RFC 1952 cho phép nhiều member trong một tệp), có lẽ trực giác này xuất phát từ đó, nhưng zlib không có luật nào như vậy và PDF chưa từng đòi hỏi
DeflateStream cũ phía FPC đọc nguồn từng lượt 64 KB rồi đưa từng khối cho ZFPCCompress, một helper tự chạy deflateInit2 của riêng mình, nén với Z_FINISH và gọi deflateEnd. Mỗi khối vì thế trở thành một zlib stream hoàn chỉnh, hợp lệ, tự kết thúc, và hàm nối chúng lại thành một AnsiString duy nhất trước khi ghi ra. Kết quả nhìn như dữ liệu Flate, có header chuẩn, giải mã không báo lỗi, nhưng chỉ giải mã được đúng 65.536 byte đầu tiên. Ở phía đọc, InflateStream mang lỗi đối xứng: nó gọi ZFPCInflate một lần cho mỗi 64 KB input nén, và mỗi lần gọi lại khởi động một inflateInit2 mới. Khối thứ hai bắt đầu ngay giữa một deflate bit stream không có zlib header, nên một inflater mới sẽ từ chối nó, còn một single-member stream hoàn toàn bình thường từ bất kỳ producer nào khác cũng chỉ được giải mã hết mức mà 64 KB byte nén đầu tiên cho phép
// DeflateStream phía FPC trước v3.539.24 (đã lược):
// ZFPCCompress chạy deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// nên mỗi khối 64 KB trở thành một zlib member riêng
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));
Những lệnh gọi nào của PDF Library for Delphi chạm tới đường chia khối?
Trên FPC, mọi embedded file từ 1 MiB trở lên đều bị ghi sai, và mọi Flate stream trích qua streaming API đều bị đọc sai một khi kích thước nén vượt quá một khối. TPDFStream.ReadFromStream quyết định cách mã hóa dữ liệu đến. Khi Deflate là true và Stream.Size >= 1048576 nó stream dữ liệu qua DeflateStream; dưới ngưỡng đó nó đọc toàn bộ nguồn vào bộ nhớ rồi gọi DeflateStr, helper one-shot chưa bao giờ bị ảnh hưởng. Chuỗi filter ASCII85 cộng Flate bỏ qua phép kiểm tra kích thước và luôn đi qua DeflateStream, nên trên đường đó mọi payload lớn hơn 64 KB đã bị tách thành nhiều member ngay từ đầu. Các entry point công khai nạp dữ liệu cho ReadFromStream với chế độ nén bật là các hàm ghi embedded-file:
TPDFlib.EmbedFilevàTPDFlib.AddEmbeddedFile, đọc một tệp từ đĩa vào stream/EmbeddedFileTPDFlib.AddAssociatedFileFromStreamvàTPDFlib.AddAssociatedFileFromFile, các hàm ghi associated-file PDF/A-3 dùng cho XML hóa đơn điện tử và dữ liệu nguồn khác- Ở phía đọc,
TPDFlib.GetEmbeddedFileContentToStreamvàGetEmbeddedFileContentToFile, giải mã quaTPDFStream.WriteDecodedToStreamrồi từ đó quaInflateStream
Sự thất bại im lặng ở mọi tầng. Writer lưu /Params /Size và một /CheckSum MD5 tính từ tệp gốc, nên dictionary của attachment quảng cáo đúng kích thước đầy đủ trong khi stream bên trong chứa mười sáu member. Bản build Delphi của cùng thư viện đọc tệp đó sẽ dừng gọn gàng ở Z_STREAM_END đầu tiên và trả về đúng 65.536 byte. GetEmbeddedFileContentToStream trả về 1, vì nó báo việc giải mã có lỗi hay không, chứ không báo output có khớp /Size hay không. Ai từng đuổi theo một vấn đề tài liệu lớn qua bài gộp và tách PDF cỡ gigabyte đều nhận ra khuôn mẫu này: tệp mở được, số trang đúng, và thiệt hại chỉ lộ ra khi ai đó mở attachment
Một deflate state xuyên suốt mọi khối
DeflateStream đã sửa trong PDFlibZLib.pas khởi tạo một paszlib.TZStream duy nhất, đưa từng khối cho deflate với Z_NO_FLUSH, và chỉ đến cuối mới rút cạn bộ nén bằng Z_FINISH cho tới khi nó trả về Z_STREAM_END. Kết quả là đúng một header, một deflate bit stream mà các back-reference vươn được qua ranh giới khối, và một Adler-32 tính trên toàn bộ input. Nhánh FPC giờ có cùng cấu trúc mà nhánh Delphi vẫn luôn có. Nó còn ghi output ngay khi dữ liệu được tạo ra, thay vì nối toàn bộ kết quả nén vào một AnsiString trước, nên writer không còn dựng một bản sao nén thứ hai đầy đủ trong bộ nhớ trước khi copy sang đích
// DeflateStream phía FPC từ v3.539.24 (đã lược các nhánh lỗi)
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); // cùng một state, không ngắt 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 // một trailer duy nhất cho toàn bộ 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 được viết lại đối xứng: một inflateInit2, một vòng lặp trong cứ gọi inflate cho tới khi khối hiện tại được tiêu thụ hết và output buffer không còn đầy, cùng một điểm dừng ở Z_STREAM_END. Có một tác dụng phụ đáng biết. Writer chia khối cũ hardcode level 6, còn bản mới tôn trọng PLDeflateLevel, nên level đặt qua TPDFlib.SetCompressionLevel(1..9) giờ áp dụng cả với embedded file lớn trên FPC. Điều đó có ý nghĩa nếu bạn đã tinh chỉnh nén cho output lưu trữ như bài giảm dung lượng tệp PDF trong Delphi đã nói
Làm sao xác minh một Flate stream chỉ là một zlib member duy nhất?
Inflate nó bằng một bộ giải mã zlib thường rồi kiểm tra hai điều khi nó trả về Z_STREAM_END: độ dài sau giải mã bằng độ dài nguồn, và avail_in bằng 0. Input còn sót sau marker kết thúc chính là dấu vân tay của một stream ghép nối. Bản sửa được xác minh theo đúng cách đó: 200 KB dữ liệu kiểm thử, trải qua bốn khối 64 KB, đi qua DeflateStream mới và ra một zlib stream đơn 534 byte, một bộ giải mã zlib stock thu hồi đủ 200.000 byte mà không còn input dư, và phép kiểm tra tương tự đạt luôn trên đích FPC cross-compile i386. Thủ tục dưới đây là phiên bản FPC của phép kiểm tra ấy, dựng thẳng trên paszlib để không phải tin vào chính code đang được kiểm thử
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;
// Một member kết thúc đúng ở byte input cuối cùng
Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
finally
inflateEnd(strm);
end;
end;
// Đẩy 200.000 byte qua DeflateStream với khối 64 KB mặc định
// và đòi hỏi một member duy nhất giải mã ra đủ độ dài ban đầu
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');
Ở cấp ứng dụng, phép khẳng định hữu ích là thứ thư viện không làm sẵn cho bạn: so sánh thứ chảy ra từ attachment với /Params /Size được ghi lại lúc nạp vào. GetEmbeddedFileIntProperty với tag 5 trả về kích thước đã ghi đó, chỉ số embedded-file đếm từ 1, và payload cần tối thiểu 1 MiB để chạm tới đường streaming. Hãy chạy cùng một phép kiểm thử trên mọi compiler bạn phát hành, vì lỗi gốc đạt trên Delphi và chỉ thất bại trên 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');
// Từ 1 MiB trở lên đi qua đường DeflateStream chia khối trong 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;
Bản sửa không đổi điều gì?
Nhánh FPC giữ nguyên các luật giải mã khoan dung của nó, và nó không sửa chữa các tệp mà các bản build FPC trước đây đã ghi. Khi chạm tới MaxOutput, FPC InflateStream cắt cụt tại giới hạn rồi trả về, trong khi nhánh Delphi ném ERangeError, và FPC vẫn chấp nhận output giải mã một phần khi inflate báo lỗi dữ liệu, vì một số producer phát ra stream bị cắt cụt hoặc hỏng checksum. Một PDF do bản build FPC trước v3.539.24 ghi ra vẫn chứa các member nối nhau, và bộ đọc đã sửa, y như mọi trình đọc khác, dừng ở Z_STREAM_END đầu tiên. Đừng cố chữa tệp ấy bằng cách giải mã rồi mã hóa lại stream ngay trong thư viện, vì điều đó chỉ biến phần cắt cụt 64 KB thành vĩnh viễn. Hãy nhúng lại attachment từ nguồn gốc của nó. Vòng lặp FPC cũng vẫn kết thúc ở lần đọc đầu tiên trả về ít hơn một khối đầy, thứ chỉ là tín hiệu hết dữ liệu với các stream như TFileStream và TMemoryStream, nên một TStream tùy chỉnh đưa vào AddAssociatedFileFromStream thì an toàn nhất là copy vào một TMemoryStream trước. Nhánh Delphi, các helper DeflateStr và InflateStr, và mọi stream nhỏ hơn 1 MiB trên đường Flate thuần đều hành xử hệt như trước
DeflateStream và InflateStream phía FPC đã sửa có mặt trong v3.539.24 của PDF Library for Delphi, nhắm tới Delphi, C++Builder và Free Pascal từ một cây mã nguồn duy nhất, và một attachment lớn giờ đây phải trở về từ bản build FPC nguyên vẹn từng byte, đúng như cách nó vẫn luôn trở về từ Delphi