HotPDF 2.747.0は、Object Pascalでゼロから書かれたVP8L(WebP lossless)decoderでWebP imageをdecodeします。そのためTHotPDF.AddImageFromFileは.webp pathを直接受け取り、同梱するlibwebp DLLも起動するhelper processも必要ありません。decoderはRFC 9649 section 3を完全に実装します。RIFF container walk、canonical prefix code、LZ77 backward reference、color cache、4つのinverse transformです。lossy VP8 frameは中途半端にdecodeせず、明確に拒否します
きっかけはありふれたものでした。design toolがassetをすべてWebPでexportするようになり、assetは10年間PNGとJPEGを問題なく処理してきたinvoiceまたはcatalog generatorへ入り、突然inputの半分がrejectされます。明白なfixはlibwebpをbindして終わらせることです。しかしそれは、self-contained VCL componentをdeployment storyのあるものへ変えるfixでもあります
libwebpをbindせずVP8Lを実装した理由
HotPDFがcodecをPascalで実装したのは、customerが自分のexecutableへcompileするDelphi componentがruntime DLLを静かに要求できないためです。native dependencyには32-bitと64-bitのbinaryを追跡し、versionをpinし、deploymentを実行する人へcode-signing chainを説明し、locked-down terminalのantivirusが嫌うかもしれないfileをもう1つ増やすコストがあります。projectへdropすれば動くことが主な価値のcomponentにとって、これは理論上のcostではありません。もう半分の理由はVP8Lが小さいことです。prefix-codeとLZ77、4つのinverse transform、120-entryのneighborhood distance mapを持つformatで、HPDFWebP.pasのdecoder全体は900行未満のPascalです。THotPDF.AddImage内のWebP branchは、すでに.jp2、.j2k、.jpt、.jpcをJPEG 2000 pathへrouteするextension dispatchと同じ場所にあります。plumbingはすでにあり、DelphiでPDFへJPEG 2000 imageを追加する方法で説明した場所です。PDF imageではなくraw pixelが欲しいcallerは、HPDFDecodeWebPLosslessを直接呼べます。これはscan-line orderの$AARRGGBB valueからなるTWebPCardinalArrayをfillします
uses
HPDFDoc, HPDFWebP;
var
Pdf: THotPDF;
Idx: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'catalog.pdf';
Pdf.BeginDoc;
// .webpはbuilt-in VP8L decoderへdispatchされ、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が同時に2方向へ読む理由
container bit orderとprefix code bit orderが独立して規定され、VP8Lがそれぞれ反対のconventionを選ぶためです。RFC 9649 section 3.2はbitstreamをleast significant bit firstで読むと明記します。readerはbyteのbit 0から始め、上へ進みます。stream内に含まれるcanonical prefix codeはmost significant bit first、treeのrootから届くため、decode walkはaccumulatorをleft shiftし、新しいbitをbottomでORします。同じloop内でreaderとcode walkが逆方向へ走るので、読み返すたびにbugのように見えます
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;
// canonical walkは逆方向に進む。streamから最初に取り出すbitが
// codeのmost significant bitになる
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;
streamを黙ってdesynchronizeするRFC detail 3つ
RFC 9649で一度だけ正確に書かれ、読み飛ばしやすく、それぞれ1 bitをcostまたはsaveするsemanticsが3つあります。それだけで後続のtable全体がnoiseになります。HotPDF VP8L decoderでは3つとも見つかり、どれも同じ症状を出します。もっともらしいimageにdecodeが完了するのに、全体が間違っています
- non-primary roleのentropy-coded imageにはmeta-prefix bitがありません。
entropy-coded-imageのABNFにそのitemがないため、1つ読むとstreamが1 bitずれます。HotPDFはentropy image自身、predictorとcolor transform data、color-indexing paletteにAllowMeta = Falseを渡します - single-leaf prefix codeは0 bitをconsumeします。RFC 9649 section 3.7.2.1に直接書かれており、canonical walkはbitを読んでから配置できずに失敗します。そのため
BuildHuffはtotal symbol countが1であることを検出してtreeをSingleとmarkし、readerに触れずにその1 symbolをdecodeします - cache_bits valueが0なら、color cache sizeは
1 shl 0ではなく0です。便利なshiftは1を作り、green alphabetの256 + 24 + CacheSizeを280ではなく281にします。その後に読むすべてのprefix code tableがずれます
CacheBits := 0;
CacheSize := 0; // cache_bits = 0は本当にnoneを意味する
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:spatially coded(ARGB)imageだけが
// meta prefix bitを持ち、entropy-coded roleにはない
if AllowMeta then
UseMeta := BR.ReadBit
else
UseMeta := 0;
// ...
ReadHuffCode(256 + 24 + CacheSize, Groups[I].Green); // 281ではなく280
ReadHuffCode(256, Groups[I].Red);
ReadHuffCode(256, Groups[I].Blue);
ReadHuffCode(256, Groups[I].Alpha);
ReadHuffCode(40, Groups[I].Dist);
bring-upで使ったfixtureでは、3つがその順にbit 47、bit 81、bit 89で表面化しました。この数字がこのsectionの要点です。3つともoff-by-oneとは告げず、streamがreferenceと一致しなくなった正確なbit positionだけが、それらを区別しました。decodeが完了し、staticのように見えるimageとして現れたのです
bit-position diffingがもたらすもの
bit-position diffingは役に立たない質問を1行の質問へ変えます。「なぜこのpictureが間違っているのか」ではなく「なぜstreamはbit 81で分岐したのか」です。setupは安価です。Pillowが各.webp fixtureと、同じimageを自身でdecodeした.rgba dumpを書きます。Pascal probeと小さなPython reference modelは、すべてのreadの横にrunning bit counterをlogします。2つのlogが初めて一致しないpositionがbugの場所です。最小限しかexerciseしないfixtureから始めてください。simple-code pathだけを通るflat 32×32 imageです。それがgreenになってから、gradient、odd dimension、alphaを一度に1 fixtureずつ追加します。bit orderを当てずっぽうで試すと1日使います
正直な注意点は、referenceも間違っていたことです。Python modelはcache_bitsを読むのを忘れ、transform loopも最後まで走りませんでした。そのため一部のdivergence pointはPascalではなくreference decoderがsyncを失った場所でした。reference implementationが間違っているからといって、test対象のimplementationが正しいことにはなりません。どちらにも疑いの利益はなく、各divergenceはRFC textに照らして判定する必要があります。そのtextもsourceから取得してください。search summaryはnumeric tableを頻繁に壊します。120-entry distance map、14 predictor mode、color cache multiplier $1e35a7bdはすべて正確に転記しなければなりません
Pascalのinteger divisionがCからずれる場所
VP8L color transformはsigned deltaを持つ3.5 fixed pointで、ここでPascalとCが一致しなくなります。Cはnegative integerをarithmeticにshiftしてfloorしますが、Pascalのdivはzeroへtruncateします。negative productでは両者が1ずつ異なるため、inverse color transformがimage全体でpixelごとに1 channel stepずつずれます。そこでHotPDFはdivに頼らず、FloorDiv32でfloorを明示します
// Cはarithmetic shiftでnegativeをfloorする。Pascalのdivは
// zeroへtruncateするため、negative caseには明示的な補正が必要
function FloorDiv32(V: Integer): Integer;
begin
Result := V div 32;
if (V < 0) and (V mod 32 <> 0) then
Dec(Result);
end;
// transform element byteとcolor channel byteの間の3.5 fixed-point delta
// どちらも先にsign-extendする
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;
この種類のdefectは名前を付ける価値があります。fixtureがたまたまnon-negative productを生成するtestでは見えないためです。その場合はdivとfloorが一致します。また、HotPDF WebP testがtoleranceではなくPillowによる同じfileのdecodeとのpixel-exact equalityをassertする理由でもあります。gradient、奇数サイズの100×37、real alpha channelの40×40、flat 32×32 imageを、すべてのpixelについてbit単位で比較します。1 stepのdriftはperceptual checkを通り、bitwise checkに落ちます
WebP supportが意図的に拒否するもの
HotPDFはWebP fileの最初のVP8L chunkだけをdecodeし、それ以外は扱いません。lossy VP8 frame、animation、matching chunkがVP8LでないcontainerはHPDFDecodeWebPLosslessからFalseを返し、AddImageはfile名を示すexceptionへ変換します。Failed to decode WebP image (lossless VP8L only)です。これは見落としではなく意図したboundaryです。wrong-format fileはgray rectangleを出すのではなく、callerがpre-convertできる場所で失敗すべきだからです。version fieldは0でなければならず、transform stackは4 entryに制限され、すべてのbounds violationはEWebPDecodeをraiseします。public entry pointはそれをplain Falseへ変換します。import時にdecodeすることは、openしたdocumentからpictureを取り出す方向とは逆です。後者はloaded PDFとそのdecode filterからimageをextractする処理を通ります。またimage decoderは、自分で作っていないfileを入力にするparserです。WebP assetがcustomerやpublic internetから来るなら、ここにあるbounds checkはfloorであってceilingではありません。より強い答えはimage codecをisolated worker processで実行する方法です。malformed frameがhostごと倒すのを防ぎます
実用上の結果として、DelphiまたはC++Builder applicationはPNGをPDFへ入れるのと同じ方法でWebP assetを置けます。AddImageFromFileを1回、ShowImageを1回呼ぶだけで、installerに追加するものはありません。その周囲のimageとdocument pipelineも必要なら、HotPDF Delphi PDF componentが同じunit setでwrite、load、renderをカバーします