מאמר טכני

WebP ל-PDF ב-Delphi: מפענח VP8L של HotPDF

HotPDF 2.747.0 מפענחת תמונות WebP באמצעות מפענח VP8L (WebP lossless) שנכתב מאפס ב-Object Pascal, כך ש-THotPDF.AddImageFromFile מקבלת נתיב .webp ישירות, בלי DLL של libwebp שצריך להפיץ ובלי תהליך עזר שצריך להפעיל. המפענח מממש במלואו את סעיף 3 של RFC 9649: מעבר במיכל RIFF, קודי prefix קנוניים, הפניות לאחור של LZ77, cache של צבע וכל ארבעת ה-inverse transforms. פריימים של VP8 lossy נדחים בקול רם במקום לעבור פענוח חלקי

הטריגר היה יומיומי. כלי עיצוב מייצא כל asset כ-WebP מפני שזה ברירת המחדל המודרנית, ה-assets מגיעים למחולל חשבוניות או קטלוג שכבר בלע PNG ו-JPEG במשך עשור, ופתאום חצי מהקלטים נדחים. התיקון המתבקש הוא לקשור את libwebp ולהמשיך. התיקון המתבקש הוא גם זה שהופך רכיב VCL עצמאי למשהו עם סיפור deployment

מדוע לממש VP8L במקום לקשור את libwebp

HotPDF מממשת את ה-codec ב-Pascal מפני שרכיב Delphi שלקוחות מקמפלים לתוך קובץ ההרצה שלהם אינו יכול לרכוש בשקט DLL בזמן ריצה. תלות native פירושה binary של 32 סיביות ושל 64 סיביות למעקב, גרסה שצריך לנעול, שרשרת חתימת קוד שצריך להסביר למי שמריץ את ה-deployment ועוד קובץ שאנטי-וירוס בטרמינל נעול עשוי להחליט שאינו אוהב. עבור רכיב שהטענה המרכזית שלו היא שהוא נכנס לפרויקט ועובד, זה מחיר אמיתי ולא תיאורטי. החצי השני של הטיעון הוא ש-VP8L קטן: פורמט של prefix-code ו-LZ77 עם ארבעה inverse transforms ומפת מרחקים של שכנים בת 120 כניסות, וכל המפענח ב-HPDFWebP.pas קטן מ-900 שורות Pascal. בתוך THotPDF.AddImage ענף ה-WebP יושב באותו dispatch של סיומות שכבר מנתב .jp2, .j2k, .jpt ו-.jpc דרך נתיב JPEG 2000, לכן הצנרת כבר הייתה שם, באותו מקום שמתואר במדריך להוספת תמונות JPEG 2000 ל-PDF ב-Delphi. Callers שרוצים פיקסלים גולמיים במקום תמונת PDF יכולים לפנות ישירות ל-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 המובנה, ללא 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 נקרא בשני כיוונים בו-זמנית

מפני שסדר הביטים של המיכל וסדר הביטים של קוד ה-prefix מוגדרים בנפרד, ו-VP8L בוחר מוסכמות הפוכות עבורם. סעיף 3.2 של RFC 9649 אומר במפורש שזרם הסיביות נקרא מהביט המשמעותי פחות תחילה: הקורא מתחיל בביט 0 של בית ומתקדם כלפי מעלה. קודי ה-prefix הקנוניים שנישאים בתוך הזרם מגיעים מהביט המשמעותי ביותר תחילה, משורש העץ, ולכן מהלך הפענוח מזיז את ה-accumulator שמאלה ומבצע OR של כל ביט חדש בתחתית. לכן הקורא ומהלך הקוד רצים בכיוונים מנוגדים בתוך אותה לולאה, דבר שנראה כמו באג בכל פעם שקוראים את הקוד מחדש

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;

// המהלך הקנוני פועל בכיוון השני: הביט הראשון מהזרם
// הוא הביט המשמעותי ביותר של הקוד
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 של HotPDF, ושלושתן מייצרות אותו תסמין: תמונה שנראית סבירה אך שגויה בכל מקום

  • תמונה מקודדת-entropy בתפקיד שאינו primary אינה כותבת ביט meta-prefix כלל. ה-ABNF של entropy-coded-image פשוט אינו מכיל את האיבר הזה, ולכן קריאת אחד מסנכרנת את הזרם בביט. HotPDF מעבירה AllowMeta = False לתמונת ה-entropy עצמה, לנתוני ה-predictor ול-transform של הצבע ולפלטת ה-color-indexing
  • קוד prefix עם עלה יחיד צורך אפס ביטים. סעיף 3.7.2.1 של RFC 9649 אומר זאת ישירות, והמהלך הקנוני היה קורא בשמחה ביט ואז נכשל במיקום שלו, לכן BuildHuff מזהה ספירת סמלים כוללת של 1 ומסמן את העץ כ-Single, ומפענח את הסמל היחיד בלי לגעת בקורא
  • ערך cache_bits של 0 פירושו שגודל cache הצבע הוא 0, לא 1 shl 0. ההזזה הנוחה נותנת 1, גורמת לאלפבית הירוק 256 + 24 + CacheSize לצאת 281 במקום 280, וכל קריאת טבלת קוד prefix אחריה יוצאת מסונכרנת
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; תפקידי entropy-coded לעולם אינם כותבים אותו
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 שלושת המקרים הופיעו בביט 47, בביט 81 ובביט 89, לפי הסדר הזה. אלה המספרים החשובים בסעיף. אף אחד מהשלושה לא הכריז על עצמו כ-off-by-one; כל אחד הציג תמונה שהפענוח שלה הסתיים ונראתה כמו static, והדבר היחיד שהפריד ביניהם היה מיקום הביט המדויק שבו הזרם הפסיק להסכים עם reference

מה נותן diff לפי מיקום ביט

diff לפי מיקום ביט הופך שאלה חסרת תועלת לשאלה של שורה אחת: לא למה התמונה הזו שגויה אלא מדוע הזרם סטה בביט 81. ההכנה זולה. Pillow כותבת כל fixture של .webp יחד עם dump של .rgba מהפענוח שלה לאותה תמונה; probe של Pascal ומודל reference קטן ב-Python רושמים מונה ביטים רץ לצד כל read; המיקום הראשון שבו שני ה-logs אינם מסכימים הוא המקום שבו הבאג חי. מתחילים ב-fixture שמפעיל כמה שפחות: תמונה שטוחה בגודל 32x32 שמשתמשת רק בנתיב הקוד הפשוט. הופכים אותה לירוקה, ואז מוסיפים gradients, מידות אי-זוגיות ו-alpha, fixture אחד בכל פעם. ניחוש סדר הביטים במקום הוא דרך לשרוף יום

הסתייגות כנה היא שגם ה-reference היה שגוי. מודל ה-Python שכח לקרוא את cache_bits ולולאת ה-transform שלו לא רצה עד הסוף, לכן חלק מנקודות הסטייה היו ה-reference decoder שאיבד סנכרון ולא ה-Pascal. מימוש reference שגוי אינו הופך את המימוש הנבדק לנכון, ואף צד אינו מקבל את יתרון הספק: כל סטייה חייבת להיבחן מול טקסט ה-RFC. גם את הטקסט הזה צריך למשוך מהמקור. סיכומי חיפוש נוטים לשבש טבלאות מספריות, ומפת המרחקים בת 120 הכניסות, 14 מצבי ה-predictor והמכפיל של color cache $1e35a7bd חייבים להיות מתועתקים בדיוק

היכן חלוקת מספרים שלמים ב-Pascal סוטה מ-C

ה-color transform של VP8L הוא fixed point של 3.5 עם deltas חתומים, ושם Pascal ו-C מפסיקות להסכים. C מזיזה מספרים שליליים באופן אריתמטי, כלומר מעגלת כלפי מטה; div של Pascal קוטעת לכיוון אפס. עבור כל מכפלה שלילית השניים שונים באחד, ולכן ה-inverse color transform נסחף בצעד ערוץ אחד לכל פיקסל על פני התמונה כולה. לכן HotPDF מבצעת את העיגול כלפי מטה במפורש ב-FloorDiv32 במקום לסמוך על div

// C מזיזה אריתמטית ומעגלת שליליים כלפי מטה; div של Pascal קוטע
// לכיוון אפס, לכן המקרה השלילי זקוק לתיקון מפורש
function FloorDiv32(V: Integer): Integer;
begin
  Result := V div 32;
  if (V < 0) and (V mod 32 <> 0) then
    Dec(Result);
end;

// delta fixed-point 3.5 בין byte של איבר transform ל-byte של ערוץ צבע,
// כששניהם הורחבו בסימן תחילה
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;

שווה לתת שם לסוג התקלה הזה, מפני שהוא בלתי נראה בכל בדיקה שה-fixtures שלה מייצרות במקרה מכפלות לא שליליות, שבהן div ו-floor מסכימים. זו גם הסיבה שבדיקות ה-WebP של HotPDF טוענות שוויון מדויק ברמת הפיקסל מול פענוחים של Pillow לאותם קבצים ולא tolerance: gradients, גודל אי-זוגי של 100x37, תמונה בגודל 40x40 עם ערוץ alpha אמיתי ותמונה שטוחה בגודל 32x32, כל פיקסל מושווה ביט-לביט. סטייה של צעד אחד עוברת בדיקה perceptual ונכשלת בבדיקה bitwise

מה תמיכת WebP מסרבת לעשות בכוונה

HotPDF מפענחת את chunk ה-VP8L הראשון של קובץ WebP ולא דבר נוסף. פריימי VP8 lossy, אנימציות וכל container שה-chunk התואם שלו אינו VP8L מחזירים False מ-HPDFDecodeWebPLossless, ו-AddImage הופכת זאת לחריגה שמציינת את הקובץ: Failed to decode WebP image (lossless VP8L only). זהו גבול מכוון ולא oversight: קובץ בפורמט הלא נכון צריך להיכשל במקום שבו caller יכול להמיר אותו מראש, ולא להפיק מלבן אפור. שדה הגרסה חייב להיות 0, stack ה-transform מוגבל לארבעה איברים וכל חריגה מגבול מעלה EWebPDecode, שנקודת הכניסה הציבורית הופכת ל-False פשוט. פענוח בזמן import הוא גם הכיוון ההפוך מחילוץ תמונות ממסמך שפתחתם, שעובר בנתיב התמונות הטעונות שמתואר בחילוץ תמונות מ-PDF טעון ומסנני הפענוח שלהן. וכל מפענח תמונות הוא parser שמוזן בקבצים שלא אתם יצרתם: אם נכסי ה-WebP מגיעים מלקוחות או מהאינטרנט הציבורי, בדיקות הגבולות כאן הן הרצפה ולא התקרה, והתשובה החזקה יותר היא להריץ codecs של תמונות בתהליך worker מבודד, כדי ש-frame פגום לא יוריד איתו את ה-host

התוצאה המעשית היא שאפליקציית Delphi או C++Builder יכולה להכניס נכסי WebP ל-PDF באותה דרך שבה היא מכניסה PNG: קריאה אחת ל-AddImageFromFile, קריאה אחת ל-ShowImage ושום דבר נוסף במתקין. אם אתם רוצים את שאר צינור התמונות והמסמכים שסביב זה, רכיב ה-PDF של HotPDF ל-Delphi מכסה את צדדי הכתיבה, הטעינה והרינדור מאותה קבוצת יחידות