Технічна стаття

Chunked zlib у FPC: чому FlateDecode потрібен один потік

До 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 його ніколи не вимагав

Анатомія члена zlib за RFC 1950 у потоках FlateDecode PDFlibPas: двобайтовий заголовок, один безперервний бітовий потік deflate, чий останній блок несе прапорець фінального блоку, і чотирьохбайтовий трейлер Adler-32, на якому inflate повертає Z_STREAM_END, лишаючи решту входу непрочитаною в avail_in без жодної помилки
Сумлінний inflater вважає перший трейлер Adler-32 кінцем даних, тож межа членів — це жорсткий стоп, а все, що записувач приклеїв після нього, — мертвий вантаж, який не розкодує жоден читач

Старий FPC DeflateStream читав джерело порціями по 64 КБ і передавав кожну порцію в ZFPCCompress — хелпер, який сам робить deflateInit2, стискає з Z_FINISH і викликає deflateEnd. Тож кожна порція виходила повним, валідним, самозавершеним zlib-потоком, а функція склеювала їх в один AnsiString, перш ніж записати. Результат виглядав як Flate-дані, мав коректний заголовок і декодувався без помилок — але лише до перших 65 536 байтів. На боці читання в InflateStream був дзеркальний дефект: він викликав ZFPCInflate на кожні 64 КБ стиснутого входу, і кожен виклик стартував свіжий inflateInit2. Другий шматок починається посередині deflate-бітового потоку без zlib-заголовка, тож новий inflater його відкидає, і цілком нормальний одночленний потік від будь-якого іншого продюсера декодувався лише настільки, наскільки вистачало перших 64 КБ стиснутих байтів

Дефект chunked-записувача в FPC DeflateStream PDFlibPas: кожен шматок по 64 КБ проходить через ZFPCCompress як повний член zlib, тож вкладення на 1 МіБ несе шістнадцять склеєних членів, читач зупиняється на першому трейлері після 65 536 байтів, а GetEmbeddedFileContentToStream досі звітує про успіх
Пошкодження лишалося невидимим, бо успішним був кожен шар: словник вкладення обіцяв повний /Params /Size, декодування не піднімало помилок, а обрізаність помічав лише той, хто відкрив вкладення
// 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, які читають файл із диска в потік /EmbeddedFile
  • TPDFlib.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, тож записувач більше не будує в пам'яті другу повну копію стиснутих даних, перш ніж скопіювати їх у ціль

Виправлений DeflateStream у PDFlibPas: один paszlib.TZStream, ініціалізований одноразово, кожен шматок 64 КБ згодовується з Z_NO_FLUSH, у фіналі злив із Z_FINISH, що дає рівно один заголовок, один безперервний deflate-бітовий потік із back-посиланнями через межі шматків і один Adler-32 на весь вхід
Один стан змінює і те, як пишеться вивід: стиснуті байти йдуть геть щоразу, коли заповнюється буфер, замість накопичуватися в другій повній копії, а PLDeflateLevel тепер сягає великих вбудованих файлів і на FPC
// 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