מאמר טכני

למה כמה זרמי אובייקט PDF מפוענחים לזבל ב-Delphi

זרם אובייקט PDF שמתפרק (inflate) ללא שגיאה אבל עדיין נקרא כרעש בדרך כלל מפספס שלב אחד: היפוך ה-Predictor של ISO 32000-1. כאשר מילון ה-/DecodeParms של זרם נושא /Predictor 2 ומעלה, הבייטים ש-FlateDecode מוסרת בחזרה אינם הנתונים המקוריים — הם ערכים מבודלים-בסגנון-PNG לפי שורה או מבודלים-אופקית-בסגנון-TIFF שזקוקים למעבר שחזור שני לפני שחיפוש מילון כלשהו הגיוני. PDFiumPas, ספריית רכיב ה-PDF הילידית מסוג VCL עבור Delphi ו-C++Builder, הוסיפה את מעבר השחזור ההוא ב-v2.16.0, ספציפית משום שזרמי אובייקט PDF 1.5+ הורחבו לבייטים מבודלים שאף מפענח מילון לא יכול היה לקרוא

למה FlateDecode לבדו לא מספיק

‏FlateDecode עצמה היא רק פענוח דחיסת DEFLATE (‏ISO 32000-1 סעיף 7.4.4.1): היא משחזרת כל בייט שהקודן מסר לדחסן, שום דבר מעבר לזה. ה-Predictor חי שכבה אחת מעל, במילון ה-/DecodeParms של הזרם, והוא מתאר טרנספורמציה שהקודן יישם לפני דחיסה — הבדלה הופכת ריצות ארוכות של ערכים מובנים דומים, כמו המספרים השלמים ארוזים-בצפיפות בתוך זרם הפניה-צולבת או זרם אובייקט, לריצות ארוכות של מספרים קטנים ש-DEFLATE דוחסת הרבה יותר טוב. ‏ISO 32000-1 סעיף 7.4.4.3 (טבלה 8) מפורש בכך שביטול הטרנספורמציה הזו הוא חלק מפענוח זרם מסונן, לא מעבר ניקוי אופציונלי, ובכל זאת קל לכתוב עוזר FlateDecode שרק קורא ל-inflate ונעצר שם

הסימפטום ברור ברגע שאתה יודע לחפש אותו. בייטים מבודלים-Predictor אינם רעש אקראי — הם עדיין נושאים את הצורה של זרם דחוס, כך שמפענח תמים לעיתים קרובות עובר על פני כמה טוקנים שנראים תקפים לפני שהוא פוגע ברצף בייטים שלא יכול להיות שם, מספר, או מפריד PDF, ושורות שונות נכשלות בהיסטים שונים בהתאם לכמה הערכים הבסיסיים במקרה הבדילו מהשכנים שלהם. חוסר-העקביות הזה הוא מה שהופך את הבאג לקשה לאיתור מקובץ בודד שנכשל: שני קובצי PDF מאותו יצרן יכולים להיבדל רק בכך אילו ערכים במקרה חוזרים, כך שאחד מפוענח כמעט במקרה בעוד השני נכשל לחלוטין

מה פרמטר ה-Predictor של PDF בפועל עושה?

רשומת ה-/Predictor ב-/DecodeParms אומרת לקורא תואם איזה היפוך להריץ, ו-ISO 32000-1 טבלה 8 מגדירה את הערכים שחשובים בפועל: 1 אומר שלא יושמה חיזוי, 2 בוחר TIFF Predictor 2 (הבדלה אופקית), וכל ערך מ-10 עד 15 בוחר חיזוי בסגנון-PNG. שלושה מפתחות נוספים נוסעים לצידו — /Colors, ‏/BitsPerComponent, ו-/Columns — ויחד הם מתארים את גיאומטריית השורה שההבדלה חושבה מולה, אפילו כשהזרם לא מחזיק שום נתוני תמונה בכלל: זרם אובייקט אינו תמונה, אבל כותבי PDF עושים שימוש חוזר באותו מנגנון predictor מבוסס-שורה עבורו משום שדלתא-ואז-דפלייט דוחסת מספרים שלמים והיסטי אובייקט ארוזים-בצפיפות טוב יותר מדפלייט גולמי שלהם

‏TIFF Predictor 2 הוא הפשוט משתי הסכימות: כל רכיב נשמר כהפרש מאותו רכיב בפיקסל הקודם באותה שורה, וכל שורה מתאפסת בקצה השמאלי שלה במקום לשאת הפרש מהשורה שמעליה. חיזוי PNG מדוקדק יותר, משום שהמסנן בפועל יכול להשתנות משורה לשורה: כל שורה מתחילה בבייט תג בודד — 0 עבור None, 1 עבור Sub, 2 עבור Up, 3 עבור Average, 4 עבור Paeth — והתג ההוא, לא הערך המוצהר /Predictor, מחליט איך השורה הספציפית ההיא משוחזרת. ‏/Predictor של 12 הוא באמת רק הרמז של הקודן שהוא העדיף את מסנן ה-Up, שבו כל בייט משוחזר על ידי הוספת הבייט ישירות מעליו בשורה הקודמת, אבל מפענח נכון עדיין חייב לקרוא את התג בכל שורה ולא להניח Up לאורך כל הדרך

למה זרמי אובייקט הופכים Predictor שהוחמץ לבלתי-נראה?

זרמי אובייקט מכפילים את הבעיה במקום סתם לחזור עליה. ‏ISO 32000-1 סעיף 7.5.7 מאפשר לכותב PDF 1.5+ לארוז כמה אובייקטים עקיפים לתוך מכולה דחוסה יחידה, ‏/ObjStm, ונפוץ שבדיוק האובייקטים שמאמת הכי זקוק להם — הקטלוג, ‏/OutputIntents, או זרם /Metadata של XMP — נוסעים דרך המכולה ההיא עם /Predictor 12 מצורף, משום שהאובייקטים האלה קצרים וחוזרים-על-עצמם מספיק כדי להרוויח מהבדלה לפי-שורה. כאשר שלב ה-predictor חסר, הרחבת זרם האובייקט לא מעלה שגיאה: היא מייצרת רצף בייטים שנראה סביר במבט שטחי אבל לא מתפרק לטוקנים לתוך האובייקטים המצופים, כך שמה שהיה ארוז בפנים פשוט לא מופיע. עיבוד לעיתים רחוקות שם לב, משום שמנוע עיבוד תואם כבר משחזר נתונים מבודלי-predictor לפני שהם אי-פעם מגיעים לפריסה; הקוד ששם לב הוא בדיוק הסוג שהבאג הזה התחבא בתוכו — מאמת, חותם, או בודק-גרסה שעובר על בייטי ה-PDF הגולמיים בעצמו כדי לענות על שאלה מבנית, ללא נפילה-לאחור ברגע שהתצוגה שלו עצמו של זרם האובייקט חוזרת שגויה

PDFiumPas פגעה בדיוק בכשל הזה לפני v2.16.0. זרמי אובייקט שנבנו עם /Predictor 12, המקרה הנפוץ עבור כותבי PDF 1.5+, הורחבו דרך PdfExpandObjectStreams לבייטים מבודלים שהסורק המבני לא יכול היה לפענח, כך שכל אובייקט קטלוג, ‏/OutputIntents, או /Metadata שנארז בפנים היה בלתי-נראה למעשה לסריקות תאימות — ללא חריגה, ללא אזהרה, סתם סריקה שבשקט התנהגה כאילו האובייקטים ההם נעדרים. המכניקה העמוקה יותר של איך PDFiumPas פותרת זרם אובייקט מול טבלת ההפניה-הצולבת הפעילה, כולל מקרי zref ההיברידיים והטהורים, מכוסה בנפרד בהמאמר על אימות זרמי אובייקט וxref עם PDFiumPas; שלב ה-predictor המתואר כאן רץ אחרי הפתרון ההוא, על הבייטים שכל אובייקט דחוס בפועל מכיל

היפוך שורות Predictor מסוג PNG ו-TIFF ב-Pascal

PDFiumPas הופכת את ההבדלה בפונקציה בודדת, ‏PdfApplyPredictor, וחשבון הגיאומטריה שלה שווה לדעת בין אם אתה קורא לה ובין אם אתה מממש מחדש את הרעיון בקוד Delphi שלך עצמך. רוחב השורה בבייטים הוא ceil(Columns × Colors × BitsPerComponent ÷ 8) ורוחב הבייט לפיקסל ששני האלגוריתמים משתמשים בו הוא ceil(Colors × BitsPerComponent ÷ 8) — טעה באחד מהעיגולים והשחזור קורא על פני גבול שורה במקום בתוך אחת. ‏/Predictor מתחת ל-2 נשאר ללא נגיעה, שכן 1 אומר שהקודן לא יישם שום טרנספורמציה בכלל; 2 בוחר בענף TIFF שמוצג להלן, וכל דבר מ-10 ומעלה נופל לשחזור מסנן-שורה של PNG, שבו בייט התג בתחילת כל שורה — לא ערך ה-/Predictor המוצהר — מחליט איך השורה הספציפית ההיא מבוטלת

function PdfApplyPredictor(const Src: TBytes;
  Predictor, Colors, Bpc, Columns: Integer): TBytes;
var
  RowLen, Bpp, R, I: Integer;
begin
  Result:= Src;
  if Predictor< 2 then
    Exit;                                   // 1 = no prediction, nothing to undo
  if Colors<= 0 then Colors:= 1;
  if Bpc<= 0 then Bpc:= 8;
  if Columns<= 0 then Columns:= 1;
  if (Colors> 64)or (Bpc> 32)or (Columns> 1 shl 24) then
    Exit;                                   // reject hostile row geometries
  RowLen:= (Columns* Colors* Bpc+ 7) div 8;  // ceil(), per ISO 32000-1 Table 8
  Bpp:= (Colors* Bpc+ 7) div 8;
  if Predictor= 2 then
  begin
    if Bpc<> 8 then
      Exit;                                 // only the 8-bit layout is reconstructed
    Result:= Copy(Src, 0, Length(Src));
    R:= 0;
    while R+ RowLen<= Length(Result) do
    begin
      for I:= R+ Bpp to R+ RowLen- 1 do
        Result[I]:= Byte(Result[I]+ Result[I- Bpp]);
      Inc(R, RowLen);
    end;
    Exit;
  end;
  // Predictor >= 10 falls through to PNG row-filter reconstruction below
end;
// Continues inside PdfApplyPredictor once Predictor>= 10 (PNG row filters).
// Rows:= Length(Src) div (RowLen+ 1); each row is a 1-byte filter tag
// followed by RowLen data bytes, decoded left to right.
for R:= 0 to Rows- 1 do
begin
  SrcOfs:= R* (RowLen+ 1);
  DstOfs:= R* RowLen;
  Tag:= Src[SrcOfs];
  Inc(SrcOfs);
  for I:= 0 to RowLen- 1 do
  begin
    if I>= Bpp then A:= Result[DstOfs+ I- Bpp] else A:= 0;   // byte to the left
    if R> 0 then B:= Result[DstOfs+ I- RowLen] else B:= 0;   // byte above
    case Tag of
    1: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ A);            // Sub
    2: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ B);            // Up
    3: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ (A+ B) div 2); // Average
    // Paeth (tag 4) adds whichever of A, B or the byte above-left sits
    // closest to the linear predictor A+ B- C; tag 0 (None) copies the
    // filtered byte through unchanged
    else Result[DstOfs+ I]:= Src[SrcOfs+ I];
    end;
  end;
end;

מה PDFiumPas שינתה ב-v2.16.0

התיקון שיצא ב-PDFiumPas v2.16.0 יושב בתוך PdfReadAndDecodeStream, הפונקציה שקוראת את הבייטים הגולמיים של זרם ומפענחת אותם עבור כל קוד קורא שזקוק לבדוק מבנה PDF ברמת-הבייט, כולל הרחבת זרם אובייקט; היא מנסה שחזור רק אחרי אישור ש-/Filter הוא FlateDecode ערום, אף פעם לא שרשרת (cascade), משום שמסנן משורשר לא יכול להיות מתוקן-predictor בבטחה בשכבה הזו. קריאת /Predictor, ‏/Colors, ‏/BitsPerComponent, ו-/Columns בחזרה ממילון הזרם גם לא זקוקה למפענח מילון כללי: ‏PdfDictRefNum מוצאת כל מפתח על ידי חיפוש טוקן-שם ישיר בתוך טווח הבייט של אותו מילון בודד, שבטוח כאן בדיוק משום שארבעת המפתחות האלה לא יכולים לחזור על עצמם או להיות מקוננים בתוך מילון זרם בודד. אותו חיפוש טוקן-שם הרבה יותר מסוכן ברגע שהוא מכוון לאזור גדול יותר או פחות-תחום של קובץ PDF, שזה נושא המאמר הנלווה על פענוח מילוני PDF בבטחה

// Inside PdfReadAndDecodeStream, right after PdfInflate() has already run:
if PdfFilterIsPureFlate(DictTxt) then
begin
  Inflated:= PdfInflate(Raw);
  Predictor:= PdfDictRefNum(Data, DS, DE, 'Predictor');
  if Predictor>= 2 then
  begin
    PColors:= PdfDictRefNum(Data, DS, DE, 'Colors');
    PBpc:= PdfDictRefNum(Data, DS, DE, 'BitsPerComponent');
    PColumns:= PdfDictRefNum(Data, DS, DE, 'Columns');
    Result:= PdfApplyPredictor(Inflated, Predictor, PColors, PBpc, PColumns);
  end
  else
    Result:= Inflated;
end;

לפני v2.16.0, זרם אובייקט שנבנה עם /Predictor 12 התרחב לבייטים מבודלים ללא שגיאה שהועלתה, כך שכל אובייקט קטלוג, ‏/OutputIntents, או /Metadata שנארז בתוכו נעדר מסריקות המבנה של PDFiumPas ללא שום אזהרה. אחרי התיקון, אותו זרם אובייקט מתפרק ואז משוחזר נכון, והאובייקטים שנארזו בתוכו הופכים נראים לסריקות ההן שוב. גבולות הגנתיים נסעו יחד עם התיקון: ‏PdfApplyPredictor עכשיו דוחה /Colors מעל 64, ‏/BitsPerComponent מעל 32, ו-/Columns מעל 2^24 לחלוטין, משום שהצירופים האלה מתארים גיאומטריות שורה שאף יצרן PDF אמיתי לא זקוק להן וקיימים בעיקר כדי לגרום למפענח להקצות הרבה יותר זיכרון ממה שבייטי הקלט מצדיקים

var
  Pdf: TPdf;
  Report: TPdfAValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'incoming.pdf';
    Pdf.Active := True;
    Report := Pdf.ValidatePdfA;
    if not Report.IsCompliant then
      LogNonCompliance(Report); // caller-supplied handler
  finally
    Pdf.Free;
  end;
end;

מגבלות ששוות ידיעה

לשחזור ה-predictor של PDFiumPas יש שני קצוות ששווים ידיעה לפני שאתה נשען עליו. שחזור TIFF Predictor 2 מכסה רק את מקרה 8-סיביות-לרכיב; PDF מתיר אריזות צרות יותר, אבל נתונים מבודלי-TIFF תת-בייט עוברים בלתי-משוחזרים במקום להינחש, כך שזרם שמצהיר /Predictor 2 עם /BitsPerComponent 1, ‏2, או 4 לא יפוענח נכון דרך הנתיב הזה היום. לחיזוי PNG אין הגבלה כזו — כל שורה מספקת את תג המסנן שלה עצמה, וכל חמשת הסוגים המוגדרים משוחזרים ללא קשר למה שערך ה-/Predictor המוצהר בין 10 ל-15 במקרה, מה שתואם איך שסינון-בסגנון-PNG בפועל עובד: הערך המוצהר קרוב יותר לרמז על מה שהקודן ברובו השתמש בו מאשר הבטחה על כל שורה

מנוע העיבוד הילידי של PDFium כבר משחזר נתוני תמונה וזרם-תוכן מבודלי-predictor נכון, וזו בדיוק הסיבה שקובץ יכול להיות מעובד בצורה מושלמת בכל מציג רגיל בעוד מאמת, חותם, או בודק-גרסה ברמת-בייט שבנוי מעליו קורא את אותם בייטים לא-נכון. הפענוח מודע-ה-predictor המתואר כאן תומך בתכונות אימות PDF/A, סריקה מבנית, וחתימה של PDFiumPas, רכיב ה-PDFium הילידי מסוג VCL עבור Delphi ו-C++Builder