Техническая статья

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

До v3.539.24 сборка PDF Library for Delphi под Free Pascal сжимала большие потоки чанками по 64 KB и выдавала каждому чанку собственный zlib-заголовок и контрольную сумму, так что вложение размером 1 MiB превращалось в шестнадцать zlib-членов, склеенных встык. FlateDecode по ISO 32000-1 ожидает ровно один zlib-поток, и стандартный декодер останавливается на первом маркере конца потока, а значит, всё после первых 65 536 байт молча пропадало. Исправление держит одно состояние zlib живым через все чанки — и в DeflateStream, и в InflateStream

Баг жил только в FPC-ветке юнита и только на чанковом потоковом пути — именно поэтому он и выжил: Delphi-ветка всегда была корректной, строковые хелперы всегда были корректными, а маленькие тестовые данные до чанкового кода вообще не добирались. Это близкий родственник дефектов из статьи о пяти FPC-багах портирования, которые Delphi прятала, с той разницей, что здесь сборка Delphi ничего не прикрывала. FPC-ветку просто написали, отталкиваясь от неверной модели того, что такое zlib-поток

Почему чанковый zlib-поток обрезается другими PDF-ридерами?

Потому что zlib-поток по определению RFC 1950 — это один контейнер, а не их последовательность, и корректный инфлейтер считает первый трейлер 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 и не поднимая ошибки
Корректный инфлейтер считает первый трейлер Adler-32 концом данных, поэтому граница члена — жёсткий стоп, и всё, что писатель приклеил после него, — мёртвый груз, который не декодирует ни один ридер

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

Дефект чанковой записи в FPC DeflateStream библиотеки PDFlibPas: каждый чанк 64 KB проходит через ZFPCCompress как полный zlib-член, поэтому вложение 1 MiB несёт шестнадцать склеенных членов, ридер останавливается на первом трейлере после 65 536 байт, а GetEmbeddedFileContentToStream по-прежнему сообщает успех
Повреждение оставалось невидимым, потому что каждый слой отрабатывал успешно: словарь вложения анонсировал полный /Params /Size, декодирование не поднимало ошибки, и только тот, кто открыл вложение, замечал обрезку
// DeflateStream на FPC до 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-поток, извлечённый через потоковый API, читался неправильно, как только его сжатый размер переваливал за один чанк. TPDFStream.ReadFromStream решает, как кодировать входящие данные. Когда Deflate равен true и Stream.Size >= 1048576, данные идут потоком через DeflateStream; ниже порога источник целиком читается в память и вызывается DeflateStr — одноразовый хелпер, которого дефект никогда не касался. Цепочка фильтров ASCII85 плюс Flate тест на размер пропускает и всегда идёт через DeflateStream, так что на этом пути любые данные больше 64 KB уже разбивались на несколько членов. Публичные точки входа, которые кормят ReadFromStream со включённым сжатием, — это писатели встроенных файлов:

  • TPDFlib.EmbedFile и TPDFlib.AddEmbeddedFile, читающие файл с диска в поток /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream и TPDFlib.AddAssociatedFileFromFile, писатели связанных файлов 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, чьи обратные ссылки дотягиваются через границы чанков, и один Adler-32 по всему входу. FPC-ветка теперь имеет ту же структуру, которую Delphi-ветка имела всегда. Заодно вывод пишется по мере производства, вместо того чтобы сначала склеивать весь сжатый результат в AnsiString, так что писатель больше не строит в памяти вторую полную копию сжатых данных перед копированием в цель

Исправленный DeflateStream в PDFlibPas: один paszlib.TZStream, инициализированный единожды, каждый чанк 64 KB подаётся с Z_NO_FLUSH и финальным дренированием Z_FINISH, что даёт ровно один заголовок, один непрерывный битовый поток deflate с обратными ссылками через границы чанков и один Adler-32 по всему входу
Единое состояние меняет и способ записи вывода: сжатые байты уходят по мере заполнения каждого буфера вместо накопления во второй полной копии, а PLDeflateLevel теперь добирается и до больших встроенных файлов на FPC
// DeflateStream на FPC начиная с 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 байт без остатка входа, и та же проверка прошла на кросс-компилированной цели 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 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 FPC-InflateStream обрезает по лимиту и возвращается, тогда как 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-пути ведут себя ровно как раньше

Исправленные FPC DeflateStream и InflateStream выходят в v3.539.24 PDF Library for Delphi, которая собирает Delphi, C++Builder и Free Pascal из одного исходного дерева, и там большое вложение теперь должно возвращаться из FPC-сборки байт в байт — так же, как оно всегда возвращалось из Delphi