HotPDF 2.747.0 декодирует изображения WebP decoder-ом VP8L (WebP lossless), написанным с нуля на Object Pascal, поэтому THotPDF.AddImageFromFile напрямую принимает путь .webp, без DLL libwebp для поставки и без вспомогательного процесса. Decoder полностью реализует RFC 9649 section 3: обход RIFF-контейнера, canonical prefix codes, обратные ссылки LZ77, color cache и все четыре inverse transform. Lossy VP8 frames громко отклоняются, а не декодируются наполовину
Триггер был будничным. Design tool экспортирует каждый asset как WebP, потому что это современный default, assets попадают в генератор счетов или каталогов, который десятилетие без проблем ел PNG и JPEG, и внезапно половина входов отклоняется. Очевидное исправление — привязать libwebp и идти дальше. Очевидное исправление одновременно превращает самодостаточный VCL-компонент в задачу с deployment story
Зачем реализовывать VP8L, а не привязывать libwebp?
HotPDF реализует codec на Pascal, потому что Delphi-компонент, который заказчики компилируют в собственный executable, не может молча приобрести runtime DLL. Нативная зависимость означает отдельные 32-битный и 64-битный binary для сопровождения, версию для фиксации, цепочку code signing, которую нужно объяснить тому, кто запускает deployment, и ещё один файл, который antivirus на заблокированном терминале может решить не принимать. Для компонента, главное преимущество которого — добавить в проект и запустить, это реальная стоимость, а не теоретическая. Другая половина аргумента в том, что VP8L мал: формат prefix-code плюс LZ77 с четырьмя inverse transform и картой расстояний соседства на 120 элементов, а весь decoder в HPDFWebP.pas занимает меньше 900 строк Pascal. Внутри THotPDF.AddImage ветка WebP находится в том же extension dispatch, который уже направляет .jp2, .j2k, .jpt и .jpc через JPEG 2000 path, то есть plumbing уже существовал в том же месте, которое описано в разборе добавления изображений JPEG 2000 в PDF в Delphi. Вызывающие, которым нужны raw pixels вместо PDF image, могут обратиться напрямую к HPDFDecodeWebPLossless, заполняющему TWebPCardinalArray значениями $AARRGGBB в порядке scan line
uses
HPDFDoc, HPDFWebP;
var
Pdf: THotPDF;
Idx: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'catalog.pdf';
Pdf.BeginDoc;
// .webp направляется во встроенный VP8L decoder, DLL не используется
Idx := Pdf.AddImageFromFile('product-shot.webp', icFlate);
Pdf.CurrentPage.ShowImage(Idx, 50, 500, 240, 180, 0);
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Почему VP8L bitstream читается сразу в двух направлениях?
Потому что порядок бит контейнера и порядок бит prefix code заданы независимо, а VP8L выбирает для них противоположные соглашения. RFC 9649 section 3.2 прямо говорит: bitstream читается от младшего значащего бита к старшему, reader начинает с bit 0 байта и идёт вверх. Canonical prefix codes внутри этого потока приходят от старшего значащего бита к младшему, от корня дерева, поэтому decode walk сдвигает accumulator влево и добавляет каждый новый бит снизу. Reader и code walk поэтому идут в противоположных направлениях внутри одного цикла, и каждый раз при перечитывании это выглядит как ошибка
function TWebPBitReader.ReadBit: Integer;
begin
if BytePos >= Length(Data) then
raise EWebPDecode.Create('WebP bitstream exhausted');
Result := (Data[BytePos] shr BitPos) and 1; // LSB first, RFC 9649 3.2
Inc(BitPos);
if BitPos = 8 then
begin
BitPos := 0;
Inc(BytePos);
end;
end;
// Канонический walk идёт в другую сторону: первый бит из потока
// является старшим значащим битом code
for Len := 1 to 15 do
begin
Code := (Code shl 1) or BR.ReadBit;
if Counts[Len] > 0 then
begin
if Code - First < Counts[Len] then
Exit(Symbols[Index + Code - First]);
First := (First + Counts[Len]) shl 1;
Index := Index + Counts[Len];
end
else
First := First shl 1;
end;
Три детали RFC, которые незаметно рассинхронизируют поток
Три семантических правила RFC 9649 сформулированы ровно один раз, их легко пропустить, и каждое добавляет или убирает всего один бит — этого достаточно, чтобы все последующие таблицы превратились в шум. Все три были найдены в VP8L decoder HotPDF, и все три дают один симптом: изображение выглядит правдоподобно, но везде неправильное
- Entropy-coded image в неосновной роли вообще не записывает meta-prefix bit. В ABNF для
entropy-coded-imageтакого элемента просто нет, поэтому его чтение рассинхронизирует поток на один бит. HotPDF передаётAllowMeta = Falseдля самого entropy image, для predictor и color transform data и для color-indexing palette - Prefix code с одним leaf потребляет ноль бит. RFC 9649 section 3.7.2.1 говорит об этом напрямую, а canonical walk с готовностью прочитал бы бит и затем не смог бы его разместить, поэтому
BuildHuffобнаруживает общее число символов 1 и помечает дерево какSingle, декодируя этот единственный символ без обращения к reader - Значение cache_bits 0 означает размер color cache 0, а не
1 shl 0. Удобный shift даёт 1, из-за чего green alphabet256 + 24 + CacheSizeстановится 281 вместо 280, и чтение каждой следующей таблицы prefix code оказывается смещено
CacheBits := 0;
CacheSize := 0; // cache_bits = 0 действительно означает отсутствие cache
if BR.ReadBit = 1 then
begin
CacheBits := Integer(BR.ReadBits(4));
if (CacheBits < 1) or (CacheBits > 11) then
raise EWebPDecode.Create('WebP color cache bits out of range');
CacheSize := 1 shl CacheBits;
end;
// RFC 9649 3.8.3: только пространственно кодированное изображение (ARGB) несёт
// meta prefix bit; entropy-coded roles его никогда не записывают
if AllowMeta then
UseMeta := BR.ReadBit
else
UseMeta := 0;
// ...
ReadHuffCode(256 + 24 + CacheSize, Groups[I].Green); // 280, не 281
ReadHuffCode(256, Groups[I].Red);
ReadHuffCode(256, Groups[I].Blue);
ReadHuffCode(256, Groups[I].Alpha);
ReadHuffCode(40, Groups[I].Dist);
На fixture, использованном при bring-up, эти три случая проявились на bit 47, bit 81 и bit 89 именно в таком порядке. В этом смысл раздела. Ни один из них не объявил себя как off-by-one: каждый выглядел как изображение, которое полностью декодировалось и было похоже на static, а различить их можно было только по точной позиции бита, на которой поток переставал совпадать с reference
Что даёт diff по позиции бита?
Diff по позиции бита превращает бесполезный вопрос в вопрос на одну строку: не почему эта картинка неправильная, а почему поток разошёлся на bit 81. Настройка дёшева. Pillow записывает каждый fixture .webp плюс dump .rgba собственного decode того же изображения; Pascal probe и небольшая Python reference model обе пишут рядом с каждым чтением текущий счётчик бит; первая позиция, на которой журналы расходятся, и есть место ошибки. Начинайте с fixture, который упражняет минимум возможностей: плоское изображение 32x32, использующее только simple-code path. Сделайте его правильным, затем по одному добавляйте градиенты, нечётные размеры и alpha. Угадывать порядок битов — надёжный способ потратить день
Честная оговорка в том, что reference тоже была неправильной. Python model забыла прочитать cache_bits, а её transform loop не доходил до конца, поэтому некоторые точки расхождения означали потерю синхронизации reference decoder, а не Pascal decoder. Ошибка reference implementation не делает реализацию под тестом правильной, и ни одна из сторон не получает benefit of the doubt: каждое расхождение нужно разбирать по тексту RFC. И этот текст тоже извлекайте из источника. Search summaries регулярно искажают числовые таблицы, а карту расстояний из 120 элементов, 14 predictor modes и multiplier color cache $1e35a7bd нужно переписать точно
Где целочисленное деление Pascal расходится с C
Color transform VP8L использует signed delta в fixed point 3.5, и именно здесь Pascal и C перестают совпадать. C сдвигает отрицательные целые арифметически, то есть округляет вниз; div в Pascal обрезает к нулю. Для любого отрицательного произведения результат отличается на единицу, поэтому inverse color transform сдвигается на один шаг канала на каждом пикселе по всему изображению. Поэтому HotPDF явно реализует floor в FloorDiv32, а не полагается на div
// C сдвигает арифметически и округляет отрицательные значения вниз; Pascal div обрезает
// к нулю, поэтому для отрицательного случая нужна явная поправка
function FloorDiv32(V: Integer): Integer;
begin
Result := V div 32;
if (V < 0) and (V mod 32 <> 0) then
Dec(Result);
end;
// Дельта fixed point 3.5 между байтом элемента transform и байтом
// цветового канала, оба значения сначала расширяются со знаком
function ColorDelta(T, C: Integer): Integer;
var
T8, C8: Integer;
begin
T8 := T;
if T8 >= 128 then
Dec(T8, 256);
C8 := C;
if C8 >= 128 then
Dec(C8, 256);
Result := FloorDiv32(T8 * C8);
end;
Этот класс дефектов стоит назвать, потому что он невидим для любого теста, чьи fixture случайно дают неотрицательные произведения, где div и floor совпадают. По этой же причине тесты WebP HotPDF проверяют побайтное равенство пикселей с decode Pillow тех же файлов, а не допуск: градиенты, нечётный размер 100x37, изображение 40x40 с настоящим alpha channel и плоское изображение 32x32 — каждый пиксель сравнивается bit за bit. Одношаговый drift проходит perceptual check и проваливает bitwise check
Что поддержка WebP намеренно отклоняет
HotPDF декодирует первый chunk VP8L в WebP-файле и ничего больше. Lossy VP8 frames, animations и любой контейнер, чей совпадающий chunk не равен VP8L, возвращают False из HPDFDecodeWebPLossless, а AddImage превращает это в exception с именем файла: Failed to decode WebP image (lossless VP8L only). Это намеренная граница, а не недоделка: файл другого формата должен завершиться ошибкой там, где вызывающий код может заранее преобразовать его, вместо появления серого прямоугольника. Поле version обязано быть 0, стек transform ограничен четырьмя entry, а каждое нарушение bounds вызывает EWebPDecode, которое public entry point преобразует в обычный False. Decode при импорте — также обратное направление по сравнению с извлечением картинок из открытого документа; оно проходит через loaded-image path, описанный в статье об извлечении изображений из загруженного PDF и их decode filters. И любой image decoder — это parser, которому подаются файлы, созданные не вами: если WebP assets приходят от клиентов или из публичного интернета, проверки bounds здесь — нижний, а не верхний предел, и более сильный ответ — запуск image codecs в изолированном worker process, чтобы malformed frame не утащил за собой host
Практический результат в том, что приложение Delphi или C++Builder теперь может положить WebP asset в PDF так же, как PNG: один вызов AddImageFromFile, один вызов ShowImage, ничего лишнего в installer. Если нужен остальной image и document pipeline вокруг этой возможности, PDF-компонент HotPDF для Delphi покрывает запись, загрузку и рендеринг из того же набора модулей