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

Chunked zlib на FPC: защо FlateDecode иска един stream

Преди v3.539.24 Free Pascal build-ът на PDF Library for Delphi компресираше големите потоци на парчета по 64 KB и даваше на всяко парче собствен zlib header и checksum, така че attachment от 1 MiB се превръщаше в шестнайсет залепени един към друг zlib членове. ISO 32000-1 FlateDecode очаква точно един zlib stream, а стандартен decoder спира на първия end-of-stream маркер, което означава, че всичко след първите 65 536 байта изчезваше тихо. Поправката поддържа едно zlib state живо през всички парчета и в DeflateStream, и в InflateStream

Бъгът съществуваше само във FPC частта на unit-а и само по chunked streaming пътя, което е точно причината той да оцелее: Delphi клонът беше винаги коректен, string базираните helper-и бяха винаги коректни, а малките тестови payload-и изобщо не стигаха до chunked кода. Той е близък роднина на дефектите в петте FPC porting бъга, които Delphi криеше, само че тук Delphi build-ът не прикриваше нищо. FPC клонът просто беше написан срещу грешна представа какво всъщност е zlib stream

Защо други PDF четци режат chunked zlib stream?

Защото zlib stream по RFC 1950 е един контейнер, а не поредица от тях, а коректен inflater приема първия Adler-32 trailer за край на данните. Форматът е двубайтов header, един непрекъснат deflate бит stream по RFC 1951, чийто последен блок носи final-block флага, и четирибайтов Adler-32 checksum върху всички некомпресирани байтове. ISO 32000-1 §7.4.4 дефинира /FlateDecode точно в тези термини. Когато inflate стигне до trailer-а, връща Z_STREAM_END и оставя остатъка от входа непрочетен в avail_in. От негова гледна точка нищо не е наопаки, затова не вдига грешка, а байтовете след trailer-а просто се игнорират. Слепените членове са легитимна идея в gzip (RFC 1952 позволява няколко члена в един файл), откъдето вероятно идва и интуицията, но zlib няма такова правило, а PDF никога не го е искал

Анатомия на RFC 1950 zlib член във FlateDecode потоци на PDFlibPas: двубайтов header, един непрекъснат deflate бит stream, чийто последен блок носи final-block флага, и четирибайтов Adler-32 trailer, при който inflate връща Z_STREAM_END и оставя остатъка от входа непрочетен в avail_in, без да вдига грешка
Коректен inflater приема първия Adler-32 trailer за край на данните, така че границата на члена е твърда стъпка, а всичко, което writer-ът е залепил след него, е мъртва тежест, която никой четец няма да декодира

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

Дефект на chunked writer-а в FPC DeflateStream на PDFlibPas: всяко парче от 64 KB минава през ZFPCCompress като пълен zlib член, така че attachment от 1 MiB носи шестнайсет слепени члена, четец спира на първия trailer след 65 536 байта, а GetEmbeddedFileContentToStream и пак съобщава успех
Щетите оставаха невидими, защото всеки слой успяваше: речникът на attachment-а обявяваше пълния /Params /Size, декодирането не вдигаше грешка, а съкращението забелязваше само този, който отвореше attachment-а
// FPC DeflateStream преди 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 стигаха до chunked пътя?

На FPC всеки вграден файл от 1 MiB нагоре се записваше зле, а всеки Flate stream, извлечен чрез streaming API, се четеше зле щом компресираният му размер минеше едно парче. TPDFStream.ReadFromStream решава как да кодира входящите данни. Когато Deflate е true и Stream.Size >= 1048576, предава ги през DeflateStream; под този праг чете целия източник в паметта и вика DeflateStr — едношатовия helper, който никога не е бил засегнат. ASCII85-plus-Flate филтърната верига прескача теста за размер и винаги минава през DeflateStream, така че по този път всеки payload над 64 KB вече се разпадаше на няколко члена. Публичните входни точки, които захранват ReadFromStream с включена компресия, са writer-ите за вградени файлове:

  • TPDFlib.EmbedFile и TPDFlib.AddEmbeddedFile, които четат файл от диска в /EmbeddedFile stream
  • TPDFlib.AddAssociatedFileFromStream и TPDFlib.AddAssociatedFileFromFile, writer-ите за PDF/A-3 associated file, ползвани за e-фактура XML и други изходни данни
  • На страната на четенето, TPDFlib.GetEmbeddedFileContentToStream и GetEmbeddedFileContentToFile, които декодират през TPDFStream.WriteDecodedToStream и оттам през InflateStream

Отказът беше тих на всеки слой. Writer-ът записва /Params /Size и MD5 /CheckSum, сметнат от оригиналния файл, така че речникът на attachment-а обявяваше пълния размер, докато stream-ът държеше шестнайсет члена. Delphi build на същата библиотека, четящ такъв файл, спираше чисто на първия Z_STREAM_END и връщаше точно 65 536 байта. GetEmbeddedFileContentToStream връщаше 1, защото съобщава дали декодирането е вдигнало грешка, а не дали изходът съвпада с /Size. Който е гонил проблем с голям документ през сливането и разделянето на гигабайтни PDF-и, познава този модел: файлът се отваря, броят страници е верен, а щетите се показват чак когато някой отвори attachment-а

Едно deflate state през всички парчета

Поправеният DeflateStream в PDFlibZLib.pas инициализира един paszlib.TZStream, подава всяко парче на deflate с Z_NO_FLUSH и едва накрая изпразва компресора с Z_FINISH, докато върне Z_STREAM_END. Така излизат точно един header, един deflate бит stream, чиито back-references могат да прескачат границите между парчетата, и един Adler-32 върху целия вход. FPC клонът вече има същата структура, която Delphi клонът е имал винаги. Той също записва изхода веднага щом се произведе, вместо първо да сглоби целия компресиран резултат в AnsiString, така че writer-ът вече не строи второ пълно копие на компресираните данни в паметта, преди да ги копира в целта

Поправен DeflateStream в PDFlibPas: един paszlib.TZStream, инициализиран веднъж, всяко парче от 64 KB подадено с Z_NO_FLUSH и финално изпразване с Z_FINISH, което дава точно един header, един непрекъснат deflate бит stream с back-references през границите на парчетата и един Adler-32 върху целия вход
Едното state сменя и как се записва изходът: компресирани байтове излизат с всяко запълване на буфера вместо да се трупат във второ пълно копие, а 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);   // същото state, без членна граница
        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 за целия вход
    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 writer беше закодил ниво 6, а новият уважава PLDeflateLevel, така че ниво, зададено чрез TPDFlib.SetCompressionLevel(1..9), вече важи и за големите вградени файлове на FPC. Това има значение, ако вече настройвате компресията за архивен изход, както е описано в намаляването на размера на PDF файлове в Delphi

Как проверявате дали Flate stream е един-единствен zlib член?

Надуйте го с обикновен zlib decoder и при връщане на Z_STREAM_END проверете две неща: декодираната дължина да е равна на дължината на източника и avail_in да е нула. Остатък от вход след крайния маркер е подписът на слепен stream. Поправката е проверена точно така: 200 KB тестови данни, обхващащи четири парчета по 64 KB, минаха през новия DeflateStream и излязоха като 534-байтов едночленен zlib stream, стандартен zlib decoder възстанови всички 200 000 байта без остатъчен вход, а същата проверка мина и на i386 cross-компилираната 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');

На ниво приложение полезният assert е този, който библиотеката не прави вместо вас: сравнете какво излиза от attachment-а с /Params /Size, записан при вкарването му. GetEmbeddedFileIntProperty с tag 5 връща този записан размер, индексите на вградените файлове са от 1, а payload-ът трябва да е поне 1 MiB, за да влезе в streaming пътя. Пуснете същия тест на всеки компилатор, с който доставяте, щом първоначалният дефект минаваше на 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 или повече влиза в 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 build-ове вече са написали. При достигнат MaxOutput FPC InflateStream съкращава на лимита и се връща, докато Delphi клонът вдига ERangeError, а FPC и нататък приема частично декодиран изход, когато inflate съобщава data error, защото някои PDF производители излъчват съкратени или с развален checksum stream-ове. PDF, написан от FPC build преди v3.539.24, и нататък съдържа слепени членове, а поправеният четец, като всеки друг, спира на първия Z_STREAM_END. Не се опитвайте да излекувате такъв файл, като декодирате и прекодирате stream-а в библиотеката — това само вковава трайно съкращението до 64 KB. Вградете attachment-а наново от оригиналния му източник. FPC цикълът също завършва на първото четене, върнало по-малко от пълно парче, а това е сигнал за край на данните само за stream-ове като TFileStream и TMemoryStream, така че собствен TStream, подаден на AddAssociatedFileFromStream, е най-безопасно първо да се копира в TMemoryStream. Delphi клонът, helper-ите DeflateStr и InflateStr и всеки stream под 1 MiB по обикновения Flate път се държат точно както преди

Поправеният FPC DeflateStream и InflateStream излизат в v3.539.24 на PDF Library for Delphi, която таргети Delphi, C++Builder и Free Pascal от едно кодово дърво, и където голям attachment вече би трябвало да се върне от FPC build байт по байт, точно както винаги се е връщал от Delphi