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

PNG Predictor row filter тагове в PDF xref stream на Delphi

Стойност на /Predictor от 10 до 14 в PDF речник /DecodeParms не казва на декодера кой PNG филтър да приложи. losLab PDF Library, от v3.539.21 нататък, третира всяка стойност от 10 до 15 еднакво в ApplyInflatePredictor: всеки ред от раздутия данни започва с един байт filter-type, и този байт, не речникът, избира None, Sub, Up, Average или Paeth за реда. Разликата между 10, 11, 12, 13, 14 и 15 е съвет към енкодера кой филтър да записва; от страната на декодирането тя е безсмислена, и декодер, който я уважи, дава изход, изместен с по един байт на ред. Това звучи като бележка под линия в таблица 8 на ISO 32000-1, докато не пристигне реален файл, чийто cross-reference stream е записан с /Predictor 12. Тогава цялата таблица с обекти се декодира до правдоподобно изглеждащ боклук, коренът на документа не се намира, а публичният loader докладва грешка, сочеща файловая система, а не filter параметър. Три неща трябва да се разплетат: какво всъщност обещава спецификацията, защо старата имплементация и нейните собствени регресионни тестове се съгласяваха помежду си и с нищо друго, и какво прави коригираният редов цикъл в PDFlibZLib.pas сега и за Flate, и за LZW stream-ове

Защо /Predictor 12 не значи Up на всеки ред?

Защото ISO 32000-1 §7.4.4.4, таблица 8, дефинира стойностите от 10 до 15 като PNG predictor семейство, а самият PNG формат, ISO/IEC 15948 §9, дефинира филтрирането като решение за всеки scanline, записано в байт filter-type в началото на всеки scanline. Таблица 8 изброява отделните стойности с описания като „PNG Sub prediction on all rows" и „PNG optimum prediction", но тези описания казват на енкодера какво да записва. Таблицата е изрична, че PNG predictor-ите позволяват различен филтър на всеки ред точно защото всеки ред носи собствения си таг. Файл, записан с /Predictor 12 от добре възпитан енкодер, следователно съдържа байт 2 пред всеки ред, а декодерът, който прочете този байт, получава един и същ отговор дали му е казано 12 или 15

Клопката е, че описанията се четат като набор инструкции. Predictor 2 в същата таблица наистина е фиксиран алгоритъм — TIFF horizontal differencing predictor — и изобщо не носи маркер за ред, така че разработчик, който имплементира първо Predictor 2 и после продължи надолу по таблицата, естествено приема, че 10 до 14 работят по същия начин, с различна формула всеки. Точно това предположение излезе в по-ранните версии на losLab PDF Library: редовият цикъл третираше Predictor 15 като единствената tagged разновидност, и мапваше 10 до 14 към Parms.Predictor - 10 като постоянен филтър. Четенето на редовия таг е единственото коректно поведение, а подсказката от страната на енкодера е най-добре изобщо да се игнорира, защото producer, който е ползвал PNG optimum филтриране, но е написал /Predictor 11 в речника, дори не е malformed по букваря на стандарта

Как XFA фактура превърна Flate параметър в грешка 401

Провалът излезе наяве в XFA фактура, разрешена от клиента, регистрирана в локалния корпус като xfa-invoice-1 с SHA-256 1b65f07d…46de1e — едностраничен документ, чийто cross-reference stream е Flate-компресиран с /Predictor 12. При старото четене с фиксиран алгоритъм SourceRowLength беше равно на TargetRowLength вместо на TargetRowLength + 1, така че цикълът поглъщаше байта filter-type като първи данни-байт на всеки ред и започваше всеки следващ ред с един байт закъснение. Всеки cross-reference запис след първия беше разместван, полето type се четеше от това, което всъщност беше offset байт, а resolve-натите offset-и сочеха в средата на несвързани обекти

Разместване в xref stream при фиксирано четене на PNG predictor в PDFlibPas: с /Predictor 12 всеки ред започва с таг байт, старият цикъл го поглъщаше като данни и старташе всеки следващ ред с един байт закъснение, така че полетата type се четяха от offset байтове, /Root resolve-ваше до празен обект, а LastErrorCode докладваше 401
Четенето на тага прави SourceRowLength равно на TargetRowLength плюс едно, така че всеки декодиран ред е точно един cross-reference запис за /W [1 3 1], а /Root resolve-ва до Catalog вместо до празен обект

Това, което извикващият видя, беше LoadFromFile, връщащ 0, и LastErrorCode, докладващ 401, „Could not open input file", върху файл, който беше четим, нешифрован и се отваряше без проблем във всеки viewer. Отвътре веригата е кратка: cross-reference stream-ът се декодира, записите се парснаха до глупости, справката /Root resolve-на до празен обект вместо Catalog, изграждането на документа се провали, а мапването на резултата от зареждането падна до кода си по подразбиране. Понеже пробата беше XFA форма, първият заподозрян беше XFA машинерията, която нямаше нищо общо; документът никога не стигна дотам да погледне шаблон. Урокът, който си заслужава да се носи, е диагностичен: когато съвременен файл с компресиран cross-reference stream се провали при зареждане с генеричен код, декодирайте cross-reference stream-а на ръка, преди да гледате другаде, и проверете дали /DecodeParms му споменава predictor

Как Columns, Colors и BitsPerComponent оразмеряват ред

Геометрията на реда идва изцяло от три параметъра в таблица 8, и заблуда в който и да е от тях дава същия симптом като заблуда в тага. losLab PDF Library изчислява дължината на декодирания ред като ceil(Columns × Colors × BitsPerComponent / 8) — това е TargetRowLength в ApplyInflatePredictor — а за PNG семейството добавя един байт, за да получи SourceRowLength. Стойностите по подразбиране, приложени от TPDFStream.GetDecodedStream, когато ключ липсва, са тези от таблица 8: Colors 1, BitsPerComponent 8, Columns 1, Predictor 1. За cross-reference stream producer-ът задава /Columns на сумата от ширините в /W, така че /W [1 3 1] върви с /Columns 5 и всеки декодиран ред е точно един запис; самата аритметика на /W е покрита в статията за object stream-овете и cross-reference stream-овете, която спира на момента, в който payload-ът на stream-а се подава на филтъра. Филтрите Sub, Average и Paeth допълнително трябва да знаят колко назад е „байтът вляво", и PNG дефинира това като броя байтове на пълен пиксел, закръглен нагоре до поне един; losLab PDF Library го извежда като PixelSize := ceil(BitsPerComponent × Colors / 8), така че RGB 8-битово изображение предсказва всеки байт спрямо байта три позиции по-рано, докато 1-битово изображение — и всеки cross-reference stream — предсказва спрямо непосредствено предходния байт. Predictor 2 е друго животно, обработвано от отделен клон: той възстановява всеки sample, добавяйки предишния sample на същия color компонент, работи на ниво компонент за 1, 2, 4, 8 и 16 бита на компонент, и няма редов таг, така че SourceRowLength му е точно TargetRowLength. Двете predictor семейства споделят само аритметиката на дължината на реда

Геометрия на реда за PNG predictor-и в PDFlibPas: TargetRowLength е ceil от Columns по Colors по BitsPerComponent над 8, PNG семейството добавя един таг байт за SourceRowLength, PixelSize е ceil от BitsPerComponent по Colors над 8, стойностите по подразбиране са Colors 1, BitsPerComponent 8, Columns 1, а Predictor 2 изобщо не носи таг
Речниковата стойност отваря вратата към PNG семейството и оразмерява реда, cross-reference stream сдвоява /W [1 3 1] с Columns 5 за по един запис на ред, а двете predictor семейства споделят само аритметиката на дължината на реда

Какво прави коригираният редов цикъл в ApplyInflatePredictor

Декодерът е една-единствена функция, ApplyInflatePredictor(Const Input: AnsiString; Var Parms: TPDFFlateParms; MaxOutput: Integer = 0): AnsiString, а InflateStrParms е просто ApplyInflatePredictor(InflateStr(Input), Parms). Записът TPDFFlateParms носи Predictor, Colors, BitsPerComponent, Columns, плюс ExpandedTo8Bit и ColorSpace, които image декодерът ползва след предсказването. Поправката в v3.539.21 махна изцяло клона с фиксиран алгоритъм; извадката по-долу е редовият цикъл в сегашния му вид, с пропуснат TIFF клон

TargetRowLength:= Integer((BitsPerRow+ 7)div 8);
If Parms.Predictor>= 10 Then
  SourceRowLength:= TargetRowLength+ 1     // PNG семейство: таг байт на ред
Else If Parms.Predictor= 2 Then
  SourceRowLength:= TargetRowLength        // TIFF: без таг
Else
  Exit;

RowCount:= (SourceLength+ SourceRowLength- 1)div SourceRowLength;
SetLength(Result, RowCount* TargetRowLength);
SourcePos:= 1;
For Row:= 0 To RowCount- 1 Do
Begin
  TargetPos:= Row* TargetRowLength+ 1;
  If Parms.Predictor>= 10 Then
  Begin
    If SourcePos<= SourceLength Then
      Predictor:= Byte(Source[SourcePos])  // 0..4 избира row филтъра
    Else
      Predictor:= 0;
    Inc(SourcePos);
    If Predictor> 4 Then
    Begin
      Result:= '';                         // непознат таг: отказ на stream-а
      Exit;
    End;
  End
  Else
    Predictor:= 202;                       // вътрешен маркер за TIFF
  FillChar(Result[TargetPos], TargetRowLength, 0);
  // ... копирай до TargetRowLength сурови байта в Result[TargetPos] ...
  If Row> 0 Then
    PreviousRowPos:= TargetPos- TargetRowLength
  Else
    PreviousRowPos:= 0;
  Case Predictor Of
  1: // Sub: добави байта на един пиксел наляво
    For Col:= PixelSize To TargetRowLength- 1 Do
      Result[TargetPos+ Col]:= AnsiChar((Byte(Result[TargetPos+ Col])+
        Byte(Result[TargetPos+ Col- PixelSize]))and $FF);
  2: // Up: добави байта директно отгоре, първият ред няма такъв
    If Row> 0 Then
      For Col:= 0 To TargetRowLength- 1 Do
        Result[TargetPos+ Col]:= AnsiChar((Byte(Result[TargetPos+ Col])+
          Byte(Result[PreviousRowPos+ Col]))and $FF);
  3: // Average: (left + up) div 2, липсващите съседи се четат като 0
    ...
  4: // Paeth: PaethPredictor(left, up, upper-left)
    ...
  End;
End;

Четирите филтърни клона следват ISO/IEC 15948 §9 буквално, включително правилото, че липсващ ляв съсед на първия пиксел и липсващ горен ред на първия scanline се четат като нула, а PaethPredictor(A, b, C: Byte): Byte имплементира наредбата при равенство, която PNG спецификацията изисква: ляв печели над горния, горният печели над горния-ляв. Цялата аритметика е по модул 256 върху байтове. Нищо от това не зависи от Parms.Predictor, щом редовият цикъл тръгне — и това е целият смисъл: речниковата стойност отваря вратата към PNG семейството и оразмерява реда, а оттам нататък данните поемат. LZW stream-овете минават през същия код: TPDFLZWDecompressor.ApplyPredictor копира четирите полета за геометрия от TPDFLZWParms в запис TPDFFlateParms и вика ApplyInflatePredictor с тавана за изход на декомпресора, така че една-единствена поправка вдигна и двата филтъра наведнъж, а LZW регресионната фикстура се смени в същия commit. LZW-специфичните опасности — ширината на кода и параметърът /EarlyChange — са отделна тема, покрита в статията за LZW EarlyChange; обработката на predictor е надолу по веригата от всичко това и е еднаква за двата филтъра

Защо старите регресионни тестове минаваха?

Минаваха, защото фикстурите бяха строени на ръка да пасват на имплементацията, а не от енкодер, чел стандарта. Тест, именуван за Predictor 12, хранеше декодера осем сурови байта, #1#2#3#4#10#10#10#10, с Columns 4, и очакваше Up филтърът да е приложен на втория ред. Никакъв реален producer не излъчва тази байтова последователност под /Predictor 12; реален producer излъчва десет байта, #2#1#2#3#4#2#10#10#10#10, с таг 2 в началото на всеки ред. Тестът закова погрешното четене и даде зелена светлина на всеки следващ рефакторинг. Коригираните фикстури в Tests.ZLib.pas и Tests.LZW.pas вече носят таг байта, а случай с None филтър е най-поучителният от набора: входът #0#1#2#3#0#4#5#6 с Columns 3 се декодира до #1#2#3#4#5#6 — шест байта от осем, защото два от входните байтове никога не са били данни. По-силният guard е споделен тест в Tests/PNGPredictorSemantics.inc, който върви и на Delphi, и на FPC: той строи stream, чийто раздутий payload е 15 байта — три реда Columns 4 плюс по един таг, с тагове 0, 2 и 1 в тази подредба — после декодира идентичните байтове шест пъти с /Predictor настройван на всяка от 10 до 15, и всеки пас трябва да даде същите дванайсет байта. Ако някой някога върне идеята, че речниковата стойност избира алгоритъма, пасът с Predictor 11 или 13 дава различен отговор и тестът се проваля със съобщение, назоваващо грешката

procedure RunPNGPredictorSemantics;
const
  // zlib payload, който се inflate-ва до: 0 1 2 3 4 | 2 10 10 10 10 | 1 5 5 5 5
  Encoded: AnsiString = #$78#$9C#$63#$60#$64#$62#$66#$61#$E2#$02#$02#$46#$56#$20#$00#$00#$01#$FE#$00#$4A;
  Expected: AnsiString = #1#2#3#4#11#12#13#14#5#10#15#20;
var
  Structure: TPDFStructure;
  Stream: TPDFStream;
  Parms: TPDFDictionary;
  Predictor: Integer;
begin
  for Predictor:= 10 to 15 do
  begin
    Structure:= TPDFStructure.Create;
    try
      Parms:= Structure.NewDictionary;
      Parms.Add('Predictor', Structure.NewNumeric(Predictor));
      Parms.Add('Columns', Structure.NewNumeric(4));
      Stream:= Structure.NewStream(Encoded, False);
      Stream.Dictionary.Add('Filter', Structure.NewName('FlateDecode'));
      Stream.Dictionary.Add('DecodeParms', Parms);
      if Stream.GetDecodedStream<> Expected then
        raise Exception.Create('PNG predictor ignored per-row algorithm tags');
    finally
      Structure.Free;
    end;
  end;
end;

Ред едно има таг 0 и минава през като 1 2 3 4. Ред два има таг 2, така че 10 10 10 10 става 11 12 13 14 чрез добавяне на реда отгоре. Ред три има таг 1, така че 5 5 5 5 се натрупва отляво надясно в 5 10 15 20. Фикстура с три различни филтъра в три реда е минимумът, отличаващ „чете тага" от „случайно позна", а общото правило, което тя налага, заслужава възприемане: всяка predictor фикстура трябва да е произведена от реален енкодер или поне ръчно сглобена с редовите ѝ тагове на място. Синтетични данни, прескачащи тага, не са опростяване — те са друг формат

Фикстура с редови тагове в PDFlibPas: три реда с тагове 0, 2 и 1 при Columns 4 се декодират като None, пропускащ 1 2 3 4, Up, добавящ реда отгоре за 11 12 13 14, и Sub, натрупващ отляво надясно в 5 10 15 20, а същите байтове под /Predictor от 10 до 15 дават дванайсет идентични байта
Три различни филтъра в три реда са минимумът, отличаващ четенето на тага от познаването, непознат таг над 4 отказва целия stream, а фикстура без редови тагове описва друг формат

Извикване на декодера директно и границите, които той налага

Повечето извикващи никога не пипат ApplyInflatePredictor; LoadFromFile, четецът на cross-reference, image декодерът и TPDFStream.GetDecodedStream всички го достигат вътрешно. Все пак е полезно да знаете как да го управлявате, когато stream е изваден от файл за форензичен оглед, а извадката по-долу декодира payload на cross-reference stream по начина, по който го прави loader-ът за речник с /W [1 3 1] /Columns 5 /Predictor 12

uses
  PDFlibZLib;

function DecodeXRefPayload(const RawStreamBytes: AnsiString): AnsiString;
var
  Parms: TPDFFlateParms;
begin
  FillChar(Parms, SizeOf(Parms), 0);
  Parms.Predictor:= 12;          // всяка стойност 10..15 се държи еднакво
  Parms.Colors:= 1;
  Parms.BitsPerComponent:= 8;
  Parms.Columns:= 5;             // сумата на /W [1 3 1]
  Result:= InflateStrParms(RawStreamBytes, Parms);
  // Result вече държи N * 5 байта: type(1) offset(3) gen(1) на запис.
  // Празен Result значи, че payload-ът е отхвърлен, а не само празен
end;

Границите са нарочни и си заслужава да се изкажат директно. Таг на ред, по-голям от 4, прекъсва цялото декодиране и връща празен стринг вместо частичен резултат, защото stream, нарушаващ PNG диапазона на таговете, много по-вероятно е бил параметризиран зле, отколкото да съдържа един-единствен лош байт, и тихото продължаване би възпроизвело оригиналния бъг в друг костюм. Отрязан последен ред се толерира: RowCount закръгля нагоре, липсващите байтове се четат като нула, а липсващ таг се чете като филтър 0, което позволява на stream, отрязан от грешен /Length, пак да даде непокътнатите си редове. Colors под 1, Columns под 1, BitsPerComponent извън 1, 2, 4, 8 и 16, или Predictor извън 2 и 10 до 15 се отхвърлят всички, преди да е пипнат който и да е ред, а когато е подаден таван за изхода, декодерът връща празно вместо да пропусне входа непроменен. Нищо от това не ремонтира повреден cross-reference stream; файл, чийто /Columns не се съгласява с ширините му в /W, пак се декодира до глупости, а възстановяването от това е работата на offset скенера, описан в статията за възстановяване на повреден xref, не на predictor-а. Коригираната обработка на редови тагове излезе в losLab PDF Library v3.539.21 за Delphi, C++Builder и Free Pascal, и беше валидирана срещу осем corpus набора от общо 1472 страници на FPC Win64 билда заедно с пълния Delphi headless пакет; продуктовата страница изброява възможностите на декодера и поддържаните комбинации от филтър и predictor