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

Декодування Predictor і LZWDecode PDF у Delphi з HotPDF

Потік, позначений /Predictor 12, не означає, що кожен рядок використовує PNG-фільтр 2. HotPDF, нативний VCL-компонент PDF для Delphi та C++Builder, трактує значення предиктора від 10 до 15 як одну родину: справжній тег фільтра, від 0 до 4, — це перший байт кожного закодованого рядка, і HPDFDecodePredictor читає й валідує цей тег рядок за рядком. Ця відмінність — форма майже кожного багу в цьому куточку PDF, бо ніщо не сигналізує, коли ти помилився. Ланцюжок фільтрів виконується, растр має очікуваний розмір, а зображення виходить як діагональні перешкоди чи градієнт, що дедалі більше зсувається з кожним сканрядком. П'ять чисел у /DecodeParms (ISO 32000-1 §7.4.4) здебільшого змінюють значення байтів, а не їхню довжину, тож помилка в одному з них дає правдоподібне сміття замість помилки

Чому /Predictor 12 не означає PNG-фільтр 2 на кожному рядку?

Тому що число предиктора лише каже "використовується PNG-передбачення", а не який саме фільтр. PNG-кодери обирають фільтр для кожного сканрядка, і PDF-фільтр успадковує це, тож значення предиктора 10 (None), 11 (Sub), 12 (Up), 13 (Average), 14 (Paeth) та 15 (Optimum) усі декодуються ідентично: перший байт-тег кожного рядка — те, чого декодер повинен слухатися. Наслідок для розкладки має не менше значення, ніж семантика. Кожен закодований рядок має довжину 1 + RowBytes байтів, тож вхід перевищує вихід рівно на кількість рядків, а потік, чия довжина не є цілим кратним RowBytes + 1, за визначенням обірваний. HotPDF перевіряє цю межу до того, як торкнеться байта, відхиляє будь-який тег понад 4 з повідомленням Invalid PNG predictor row tag і читає попередній рядок прямо з єдиного вихідного буфера замість матеріалізації двовимірного масиву рядків. Фільтри 1 і 3 сягають назад на BytesPerPixel у межах поточного рядка, фільтр 2 читає прямо вгору, фільтр 4 виконує вибір Paeth над лівим, верхнім та верхньо-лівим — і всі чотири оперують уже реконструйованим виводом, тому верхній рядок мусить бути декодованим рядком і ніколи відфільтрованим вводом

uses
  HPDFPredictor;

var
  Filtered, Raster: AnsiString;
  ErrorText: string;
begin
  // /DecodeParms << /Predictor 12 /Colors 3 /BitsPerComponent 8 /Columns 1024 >>
  if HPDFTryDecodePredictor(Filtered, 12, 3, 8, 1024,
       Int64(1024) * 3 * 8192, Raster, ErrorText) then
    ConsumeRaster(Raster)
  else
    LogStreamDefect('predictor', ErrorText);   // no exception, no partial raster
end;

Аргумент MaxOutputBytes — не декорація. Стадія предиктора — це прихована стадія декомпресії, і ворожа чи просто зламана величина /Columns перетворює кілька кілобайтів вводу на запит виділення пам'яті на кілька гігабайтів. HotPDF обчислює біти на рядок, байти на рядок та загальний розмір растра спочатку в Int64, відмовляється від геометрії, що переповнюється, і поважає стелю, задану викликачем. Передай реальну межу, виведену зі словника зображення, і режим відмови стане залогованим повідомленням, а не діалогом браку пам'яті на машині клієнта

Чому TIFF Predictor 2 псує 4-бітні зображення?

Тому що Predictor 2 — це горизонтальне різницювання по зразку, а не по байту, а при 1, 2 чи 4 бітах на компонент кілька зразків ділять один байт. Поширена реалізація додає байт N-Colors до байта N, що виявляється коректним при 8 бітах на компонент і тихо неправильним усюди інде. 8-бітне RGB-сканування декодується ідеально, а потім той самий код руйнує 4-бітне індексоване зображення при першій же появі в продакшені

Коректна арифметика працює всередині бітового поля. HotPDF обходить зразки від індексу Colors до Colors * Columns - 1, витягує зразок та його лівого сусіда того самого компонента з маскою (1 shl BitsPerComponent) - 1 на відповідному зсуві, додає їх за модулем цієї маски і записує результат назад, не турбуючи інші зразки, запаковані в той самий байт. Хвіст теж має значення: рядок доповнюється до межі байта, тож біти доповнення після останнього зразка мусять пережити недоторканими, а не бути згорнутими в арифметику. При 16 бітах на компонент кожен зразок — пара байтів у порядку big-endian, і додавання переповнюється на $FFFF у межах пари, а не переноситься між байтами незалежно; при 8 бітах проста побайтова рекурентність правильна, з кроком Colors, тож червоний накопичується проти червоного, а альфа проти альфи. У кожному варіанті перший піксель рядка — літерал, ніколи не різниця, і рекурентність перезапускається на кожній межі рядка — TIFF-передбачення ніколи не читає рядок вище, а це і є вся різниця між ним та родиною PNG

Що насправді контролює EarlyChange у LZWDecode?

Він контролює, коли читач розширює розмір коду на один біт, і збій на один код не в такт псує все, що йде далі. HotPDF виражає це правило як єдиний інваріант: після додавання запису в словник наступне читання розширюється, коли NextCode досягає (1 shl CodeSize) - Ord(EarlyChange). При /EarlyChange 1, значенні за замовчуванням ISO 32000-1 §7.4.4, перемикання стається на один код раніше; при /EarlyChange 0 воно стається рівно на межі. Обидва трапляються в реальних файлах, і ніщо в бітовому потоці не повідомляє, який з них використав кодер. Решта скінченного автомата мусить рухатися синхронно: код очищення скидає розмір коду, бітову маску, наступний вільний код та сховище фраз разом, а код кінця інформації читається на тій ширині, що поточна в той момент, а не на початкових 9 бітах. HotPDF починає з InitialCodeSize 9, обмежує розмір коду на 12, а словник — на 4096 записів, і за замовчуванням встановлює FillOrder у foTop, бо PDF пакує коди старшим бітом уперед — foBottom існує для потоків у стилі TIFF, які цього не роблять

uses
  HPDFLZW;

var
  Decoder: TPDFLZWDecompressor;
  Parms: TPDFLZWParms;
  Plain: AnsiString;
begin
  Decoder := TPDFLZWDecompressor.Create;
  try
    Decoder.EarlyChange := True;      // /EarlyChange 1 is the PDF default
    Decoder.FillOrder := foTop;       // high-order bit first
    Decoder.MaxOutputBytes := 256 * 1024 * 1024;
    Decoder.RequireInitialClear := False;
    Decoder.RequireEndOfInformation := False;

    Parms.Predictor := 12;
    Parms.Colors := 3;
    Parms.BitsPerComponent := 8;
    Parms.Columns := 1024;
    Parms.ExpandedTo8Bit := False;
    Parms.ColorSpace := 'DeviceRGB';

    if Decoder.TryDecompress(RawStreamBytes, Parms, Plain) then
      LogDecodeStats(Decoder.PeakCodeSize, Decoder.DictionaryAdds,
        Decoder.KwKwKExpansions, Decoder.OutputBytes)
    else
      LogStreamDefect('lzw', Decoder.LastError);
  finally
    Decoder.Free;
  end;
end;

Статистика існує для тріажу, не для марнославства. Коли файл декодується до правильної довжини, але з неправильними пікселями, PeakCodeSize та DictionaryAdds одразу повідомляють, чи читач взагалі колись розширювався там, де розширювався писач. Переверни EarlyChange, декодуй знову, порівняй два результати: якщо числа рухаються, ти маєш відповідь за один прогін замість покрокового проходу через побітовий читач

Гілка KwKwK і коли потоку варто просто провалитися

Єдиний легальний випадок, що виглядає нелегальним, — це Code = NextCode, і HotPDF обробляє його, будуючи запис перед його видачею. Кодер може видати код для фрази, яку саме визначає на цьому кроці, що трапляється щоразу, коли ввід містить шаблон форми K w K w K; декодер не може знайти цей код, бо його ще не існує, тож мусить побудувати Previous + First(Previous), додати це як новий запис і видати саме той запис, який щойно створив. HotPDF рахує їх у KwKwKExpansions і перевіряє навхрест, що доданий код — саме той, що був запитаний. Усе понад NextCode — пошкодження, і тут декодеру слід зупинитися, а не імпровізувати: HotPDF викликає помилку на майбутньому коді, на префіксі словника, що вказує за межі арени фраз, на повному словнику та на першому коді, що не є літералом. Два перемикачі строгості навмисно вимкнені за замовчуванням, RequireInitialClear та RequireEndOfInformation, бо чимало продакшн-PDF пропускають провідний код очищення чи закінчуються без термінатора. Вмикай їх при валідації власного виводу, лишай вимкненими при споживанні файлів з дикої природи

Де насправді читається /DecodeParms на боці завантаженого документа

HotPDF розв'язує /DecodeParms чи його скорочення /DP у словнику потоку зображення, приймає або словник, або масив, і бере останній елемент, коли це масив, потім переносить Predictor, Colors, BitsPerComponent, Columns та EarlyChange у шлях растра. Випадок з масивом — той, що люди забувають: потік, відфільтрований [/ASCII85Decode /FlateDecode], несе паралельний масив параметрів, і налаштування предиктора належать останньому фільтру, а не першому

var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  Bmp: TBitmap;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned.pdf', '') > 0 then
      for I := 0 to Pdf.GetLoadedImageCount - 1 do
        if Pdf.GetLoadedImageInfo(I, Info) then
        begin
          Bmp := Pdf.ExtractLoadedImage(I);   // nil when the raster is unusable
          if Bmp <> nil then
          try
            Bmp.SaveToFile(Format('image-%d.bmp', [I]));
          finally
            Bmp.Free;
          end;
        end;
  finally
    Pdf.Free;
  end;
end;

Один історичний дефект на цьому шляху варто назвати, бо цей клас багу повторюється. Стара процедура Flate з параметрами створювала потік декомпресії, а потім копіювала з оригінального стисненого вводу, тож стадія предиктора отримувала стиснені байти й сумлінно де-передбачала їх: завжди неправильно, ніколи не сигналізуючи. Поточний код читає лише з декодера, перш ніж передати результат спільному предиктору, і відхиляє растр, коротший за обчислений розмір, замість відкату до все ще стиснених байтів — відкату, який раніше перетворював збій декодування на пошкоджений растр. Та сама реалізація предиктора тепер обслуговує й потоки перехресних посилань, що корисна узгодженість, якщо ти також працюєш з потоками об'єктів та інкрементальними оновленнями, а навколишній механізм екстракції розглянуто в супровідній статті про екстракцію завантажених зображень та їхніх фільтрів декодування. Зображення, що приходять як DCTDecode чи JPXDecode, взагалі не досягають предиктора; вони несуть власну стиснену модель пікселів

Пропускна здатність: суцільна арена фраз проти рядків на кожен запис

Заміна словника з рядком на запис суцільною ареною фраз виміряно приблизно у 1,61 раза швидшою на патологічному вводі: 1558 МіБ/с проти 969 МіБ/с на бенчмарку, чия єдина найдовша фраза сягає 7 370 880 байтів. Форма цього вводу пояснює розрив, бо класичні реалізації обирають один з двох поганих компромісів. Словник значень AnsiString виділяє й копіює свіжий рядок для кожного з до 4096 записів, причому кожен новий запис копіює весь свій батьківський запис; стек префікс/суфікс уникає цієї пам'яті повністю, але реконструює кожну фразу, обходячи ланцюжок назад побайтово і реверсуючи його, що добре для звичайного тексту й болісно, коли одна фраза сягає мегабайтів. HotPDF дописує кожну фразу суцільно в геометрично зростаючу арену, індексує записи за зсувом і довжиною і видає фразу єдиним Move у вихідний буфер. Чесна ціна — пам'ять: арена, що тримає кожну фразу повністю, обмежена сумою всіх довжин фраз, а не кількістю записів, і саме тому MaxOutputBytes існує і в декомпресорі, і в предикторі. Виведи цю межу з того, що словник зображення стверджує про растр, і брехливий потік провалюється швидко

LZW-декомпресор, спільний предиктор та шлях екстракції завантажених зображень, показані тут, постачаються як частина стандартного HotPDF Component для Delphi та C++Builder, з повним довідником фільтрів та DecodeParms на сторінці продукту