До v3.539.24 збірка PDF Library for Delphi під Free Pascal стискала великі потоки шматками по 64 КБ і давала кожному шматку власний zlib-заголовок із контрольною сумою, тож вкладення на 1 МіБ перетворювалося на шістнадцять zlib-членів, склеєних впритул. FlateDecode з ISO 32000-1 очікує рівно один zlib-потік, а стандартний декодер зупиняється на першому маркері кінця потоку, тож усе, що йшло після перших 65 536 байтів, тихо зникало. Виправлення тримає один стан zlib живим через усі шматки і в DeflateStream, і в InflateStream
Баг жив лиш у FPC-половині юніта і тільки на chunked-шляху потокового стискання — саме тому він і вижив: Delphi-гілка була завжди коректною, рядкові хелпери теж, а малі тестові payload-и до chunked-коду взагалі не добиралися. Це близький родич дефектів із п'яти FPC-багів портування, які Delphi ховала, з тією різницею, що тут Delphi-збірка нічого не прикривала. FPC-гілку просто написали, спираючись на неправильну модель того, що таке zlib-потік
Чому chunked zlib-потік обрізається іншими PDF-читачами?
Бо zlib-потік за RFC 1950 — це один контейнер, а не послідовність контейнерів, і сумлінний inflater вважає перший трейлер Adler-32 кінцем даних. Формат — це двобайтовий заголовок, один безперервний бітовий потік deflate за RFC 1951, чий останній блок несе прапорець фінального блоку, і чотирьохбайтова сума Adler-32 по всіх нестиснутих байтах. ISO 32000-1 §7.4.4 визначає /FlateDecode саме в цих термінах. Коли inflate добирається до трейлера, він повертає Z_STREAM_END і лишає решту входу непрочитаною в avail_in. З його погляду все гаразд, тож жодної помилки він не піднімає, а байти після трейлера просто ігноруються. Склеєні члени — легітимна ідея в gzip (RFC 1952 дозволяє кілька членів в одному файлі), звідки, скоріш за все, й прийшла ця інтуїція, але zlib такого правила не має, і PDF його ніколи не вимагав
Старий FPC DeflateStream читав джерело порціями по 64 КБ і передавав кожну порцію в ZFPCCompress — хелпер, який сам робить deflateInit2, стискає з Z_FINISH і викликає deflateEnd. Тож кожна порція виходила повним, валідним, самозавершеним zlib-потоком, а функція склеювала їх в один AnsiString, перш ніж записати. Результат виглядав як Flate-дані, мав коректний заголовок і декодувався без помилок — але лише до перших 65 536 байтів. На боці читання в InflateStream був дзеркальний дефект: він викликав ZFPCInflate на кожні 64 КБ стиснутого входу, і кожен виклик стартував свіжий inflateInit2. Другий шматок починається посередині deflate-бітового потоку без zlib-заголовка, тож новий inflater його відкидає, і цілком нормальний одночленний потік від будь-якого іншого продюсера декодувався лише настільки, наскільки вистачало перших 64 КБ стиснутих байтів
// FPC DeflateStream до v3.539.24 (спрощено):
// ZFPCCompress робить deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// тож кожен шматок 64 КБ стає окремим членом 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 сягали chunked-шляху?
На FPC будь-який вбудований файл розміром від 1 МіБ записувався неправильно, а будь-який Flate-потік, витягнутий через потоковий API, читався неправильно, щойно його стиснутий розмір перевищував один шматок. TPDFStream.ReadFromStream вирішує, як кодувати вхідні дані. Коли Deflate істинний і Stream.Size >= 1048576, дані йдуть потоком через DeflateStream; нижче цього порога джерело повністю читається в пам'ять і викликається DeflateStr — одноразовий хелпер, якого дефект не торкнувся взагалі. Ланцюжок фільтрів ASCII85 плюс Flate тест на розмір пропускає і завжди йде через DeflateStream, тож на цьому шляху будь-який payload понад 64 КБ уже розрізався на кілька членів. Публічні точки входу, які годують ReadFromStream зі ввімкненим стисканням, — це записувачі вбудованих файлів:
TPDFlib.EmbedFileіTPDFlib.AddEmbeddedFile, які читають файл із диска в потік/EmbeddedFileTPDFlib.AddAssociatedFileFromStreamіTPDFlib.AddAssociatedFileFromFile— записувачі associated-файлів PDF/A-3, які використовуються для XML електронних рахунків та інших вихідних даних- На боці читання —
TPDFlib.GetEmbeddedFileContentToStreamіGetEmbeddedFileContentToFile, які декодують черезTPDFStream.WriteDecodedToStreamі далі черезInflateStream
Збій мовчав на кожному рівні. Записувач зберігає /Params /Size і MD5-/CheckSum, порахований з оригінального файлу, тож словник вкладення обіцяв повний розмір, поки потік тримав шістнадцять членів. 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-бітовий потік, чиї back-посилання можуть сягати через межі шматків, і один 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. Є один побічний ефект, про який варто знати. Старий chunked-записувач зашивав рівень 6, новий же шанує PLDeflateLevel, тож рівень, заданий через TPDFlib.SetCompressionLevel(1..9), тепер діє на великі вбудовані файли і на FPC. Це важливо, якщо ви вже підкручуєте стискання для архівного виводу, як описано в нотатках про зменшення розміру PDF-файлу в Delphi
Як перевірити, що Flate-потік — це один член zlib?
Роздуйте його звичайним zlib-декодером і, коли той поверне Z_STREAM_END, звірте дві речі: довжина декодованого дорівнює довжині джерела, а avail_in — нуль. Залишковий вхід після маркера кінця — це підпис склеєного потоку. Виправлення перевіряли саме так: 200 КБ тестових даних, що розкидаються на чотири шматки по 64 КБ, пройшли через новий DeflateStream і вийшли одним zlib-потоком на 534 байти, стандартний zlib-декодер відновив усі 200 000 байтів без залишкового входу, а та сама перевірка пройшла і на крос-компільованій цілі 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 байтів через DeflateStream зі стандартним шматком 64 КБ
// і вимагати один член, що декодується назад у повну довжину
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, а payload мусить бути щонайменше 1 МіБ, щоб дістатися потокового шляху. Ганяйте той самий тест на кожному компіляторі, з яким поставляєтеся, бо початковий дефект на 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 МіБ і більше йдуть chunked-шляхом 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, FPC InflateStream обрізає на ліміті й повертається, тоді як Delphi-гілка піднімає ERangeError, і FPC досі приймає частково декодований вивід, коли inflate звітує про помилку даних, бо деякі PDF-продюсери видають обрізані потоки або потоки з побитою контрольною сумою. PDF, записаний FPC-збіркою до v3.539.24, досі містить склеєні члени, і виправлений читач, як і будь-який інший, зупиниться на першому Z_STREAM_END. Не намагайтеся вилікувати такий файл, декодуючи та перекодовуючи потік усередині бібліотеки — це лише консервує обрізаність на 64 КБ. Натомість вкладіть вкладення заново з його початкового джерела. FPC-цикл також досі закінчується на першому читанні, що повернуло менше за повний шматок, а це сигнал кінця даних лише для потоків на кшталт TFileStream і TMemoryStream, тож власний TStream, переданий у AddAssociatedFileFromStream, найбезпечніше спершу скопіювати в TMemoryStream. Delphi-гілка, хелпери DeflateStr і InflateStr і кожен потік менше 1 МіБ на чистому Flate-шляху поводяться рівно як і раніше
Виправлені FPC DeflateStream і InflateStream виходять у v3.539.24 PDF Library for Delphi, яка націлена на Delphi, C++Builder і Free Pascal з одного дерева сирців, і де велике вкладення тепер мусить повертатися з FPC-збірки байт у байт — так, як воно завжди поверталося з Delphi