מאמר טכני

חיזוק מפענח ה־TIFF ב־Delphi: BigTIFF ו־Tiled TIFF

PDFlibPas מפענח TIFF באמצעות מנתח Object Pascal שנכתב ביד ולא דרך קישור ל־libtiff, וגרסה 3.534.1 הידקה בדיוק את הנקודות שבהן המנתח הזה מסרב לקלט. magic 43 של BigTIFF נדחה עתה לפי שמו, TileOffsets ו־TileByteCounts נפסלים כבר בזמן פענוח התגיות, וכל חוצץ ממודד בחשבון Int64 תחת תקרת פענוח של 256 MiB

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

למה II או MM לא הוכחה שיש לכם TIFF קלאסי?

כי סמן סדר הבתים משותף לשני הניבים. גם TIFF קלאסי וגם BigTIFF נפתחים ב־II או MM, והשדה שבאמת מבדיל ביניהם הוא ה־magic בן 16 הסיביות שמיד אחריו: 42 עבור TIFF קלאסי כפי שמוגדר במפרט TIFF 6.0, ו־43 עבור BigTIFF עם ההיסטים בני 64 הסיביות שלו. טוען שנכתב כ־FValidTIFF := PopWord = 42 אינו טועה לגבי TIFF קלאסי, אבל הוא מכווץ שתי דחיות שונות לחלוטין לבוליאן שקט אחד, כך שקובץ BigTIFF נבלע באותה קטגוריה עם JPEG קטוע שמישהו שינה לו את הסיומת. PDFlibPas מפריד עתה בין המקרים ורושם כל אחד מהם ב־TPDFTIFF.LastError: כותרת קצרה מארבעה בתים, סמן סדר בתים לא חוקי, magic 43 וכל ערך magic אחר מניבים כל אחד טקסט נפרד משלו. הספרייה עדיין לא מפענחת BigTIFF, ואמירת זאת במפורש היא בדיוק העניין. הקורא מקבל את ההבדל בין "זה לא TIFF" לבין "זה TIFF שאת פריסת ההיסטים בני 64 הסיביות שלו המפענח המובנה לא מממש" — וזה ההבדל בין פניית תמיכה שאפשר להשיב עליה בתשובה אחת לבין כזו שהופכת לשבוע של ניחושים

טוען ה־TIFF של PDFlibPas קורא את סמן סדר הבתים ואת ה־magic בן 16 הסיביות בנפרד, כך שכותרת קצרה, סמן לא חוקי, magic 43 של BigTIFF וכל ערך magic אחר מניבים טקסט LastError נפרד ולא בוליאן שקט אחד
TIFF קלאסי ו־BigTIFF נפתחים באותו סמן סדר בתים, ולכן PDFlibPas מפריד ארבעה מקרי דחייה וקורא לכל אחד מהם בשמו ב־LastError
var
  Tiff: TPDFTIFF;
  Page: Integer;
begin
  Tiff := TPDFTIFF.Create;
  try
    Tiff.LoadFromFile('inbox\scan-0417.tif');
    if not Tiff.ValidTIFF then
      raise Exception.Create('TIFF rejected: ' + Tiff.LastError);
    if Tiff.PageCount < 1 then
      raise Exception.Create('TIFF carries no decodable page');
    for Page := 1 to Tiff.PageCount do
      Writeln(Format('page %d: %dx%d, %d spp',
        [Page,
         Tiff.PageInfo[Page].Width,
         Tiff.PageInfo[Page].Height,
         Tiff.PageInfo[Page].SamplesPerPixel]));
  finally
    Tiff.Free;
  end;
end;

טיילים הם גיאומטריה אחרת, לא עוד מערך היסטים

PDFlibPas דוחה tiled TIFF כבר בזמן פענוח התגיות, בטרם נגיעה בנתוני פיקסלים כלשהם. הקיצור הדרך שמזמין את הבאג קל לראות: תגית 324 (TileOffsets) ותגית 325 (TileByteCounts) הן מערכי היסטים ומספרי בתים בקובץ, מבנית זהות למערכי הפסים, ולכן הצבעת השדות הקיימים של הפסים אליהן עולה שתי שורות קוד ומתקמפלת בנקיון. היא גם שגויה. טיילים יוצרים רשת דו־ממדית עם בלוקי קצה מרופדים, מרווח שורה משלהם בתוך כל טייל, ובלי שום סמנטיקה של RowsPerStrip, כפי שהקטע על תמונות מטופלות במפרט TIFF 6.0 מפרט. הזנת מטענים של טיילים למפענח פסים לכן אינה נכשלת ברעש. SimpleExtract ו־CompDecode הולכים על הנתונים עם מרווח השורה השגוי ומפיקים תמונה עם הממדים הנכונים והפיקסלים השגויים. הקוד הישן החמיר זאת כשהשאיר את StripsAreTiles, ColumnsPerTile ו־RowsPerTile בתוך TTIFFPage: גיאומטריית טיילים שנרשמה על ידי מפענח שאין מאחד טיילים מאחוריו. ב־3.534.1 המטפלים של תגיות 324 ו־325 מעלים את שגיאת הטיילים ונוטשים את ה־IFD מיד, כך שהדחייה נושאת את המילה "tiled" במקום להתגלות שבועות אחר כך כתלונת תצוגה

PDFlibPas משווה פריסת פסים, שבה רצועות ברוחב מלא חולקות מרווח שורה אחד, עם פריסת טיילים, רשת דו־ממדית של בלוקי קצה מרופדים, ודוחה את תגיות 324 ו־325 בזמן פענוח התגיות בטרם נגיעה בנתוני פיקסלים
מערכי הטיילים נראים מבנית זהים למערכי הפסים, ומכאן שהזנתם למפענח פסים מניבה את הממדים הנכונים ואת הפיקסלים השגויים

הגבלת ממדים אחת אינה תקציב זיכרון

הצמדת רוחב וגובה ל־65,535 כל אחד היא הכרחית ורחוקה מלהספיק, כי הגודל שמניע את ההקצאה הוא מכפלה. RowsPerStrip * Width * SamplesPerPixel עלול לגלוש מעבר לאריתמטיקה בת 32 הסיביות עוד לפני שאחד הגורמים מגיע לגבול שלו, וגם בלי גלישה הוא עלול לתאר הקצאה שאף שירות לא צריך לנסות. PDFlibPas מחשב את בייטים לשורה ב־Int64 ואוכף שלוש תקרות יחד: 65,535 לממד, 32 רכיבי צבע, ו־256 MiB של בייטים מפוענחים

const
  PDFLIB_TIFF_MAX_IMAGE_DIM = 65535;
  PDFLIB_TIFF_MAX_COLOR_COMPONENTS = 32;
  PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES = 256 * 1024 * 1024;

// בתוך TPDFTIFF.ValidatePageForDecode
BitsPerPixel := Int64(P.BitsPerSample) * P.SamplesPerPixel;
RowBytes := (Int64(P.Width) * BitsPerPixel + 7) div 8;
if (RowBytes < 1) or
   (RowBytes > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES) or
   (Int64(P.Height) > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES div RowBytes) then
  Exit(False);

DecodedBytes := RowBytes * P.RowsPerStrip;
if (DecodedBytes < 1) or
   (DecodedBytes > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES) or
   (DecodedBytes > MaxInt) then
  Exit(False);

שלושה פרטים שם חשובים יותר מהקבועים עצמם. בדיקת הגובה נכתבה כחלוקה ולא ככפל, כך שהמכפלה העצומה אף פעם לא נוצרת בפועל. RowsPerStrip קטן מ־1 או גדול מגובה התמונה מנורמל קודם לגובה, וזו הקריאה לפס יחיד שמפרט TIFF 6.0 כבר רומז עליה, והיא גם מונעת מתגית עוינת לנפח את חוצץ הפס. והשגרה היא משותפת: ValidatePageForDecode רצה בסוף פענוח התגיות ושוב בכניסה לשני המפענחים SimpleExtract ו־CompDecode, כך שקוד שמגיע ישירות למפענח אינו יכול לעקוף את התקציב. זהו אותו כלל שמיישם PDFlibPas בעת פענוח גרפי אובייקטים של PDF לא מהימנים, כי מגבלה שנאכפת בדלת אחת מתוך שלוש אינה מגבלה

PDFlibPas ממדד כל חוצץ TIFF באריתמטיקת Int64, בודק את גובה התמונה בחלוקה כך שהמכפלה העצומה אף פעם לא נוצרת, ומריץ את אותה שגרת ValidatePageForDecode בפענוח התגיות ובשתי כניסות המפענחים
שלוש תקרות, אריתמטיקת Int64 לבייטים לשורה ושגרת ולידציה משותפת אחת שאליה מגיעים משלוש דלתות, כי מגבלה שנאכפת בדלת אחת מתוך שלוש אינה מגבלה

מה חייב קורא לבדוק לפני שהוא קורא מ־PageInfo?

בודקים קודם את ValidTIFF, אחר כך את PageCount, ורק אז מאנדקסים את PageInfo. קובץ נדחה יכול להשאיר את PageCount על אפס, ו־GetPageInfo משיב לאינדקס מחוץ לטווח רשומת TTIFFPage לא מאותחלת, כך שנתיב שגיאה שקורא רזולוציה או מספרי דגימות בדרכו לדווח על הכשל נגמר בקריאת רעש. גרסה 3.534.1 תיקנה את שני הקוראים בתוך הספרייה עצמה: נתיב ייבוא התמונות קורא את XRes ו־YRes רק בתוך הענף התקין, ו־TPDFlib.GetImagePageCount דורש ValidTIFF במקום לסמוך על מספר עמודים שאינו אפס כשלעצמו. בהמשך השרשרת, הארגומנט Options של AddImageFromFile הוא מספר העמוד מבוסס 1 עבור TIFF מרובה עמודים, ולכן GetImagePageCount צריך להיות אמין עוד לפני תחילת הלולאה ולא אחריה. אפס עמודים הוא עתה תשובה אמיתית שפירושה "אין כאן משהו שאפשר לפענח" ולא תוצר לוואי של return מוקדם, וזה חשוב במיוחד כשמבצעים מיון ושזירה של אצוות סריקה דו־צדדיות ודף אחד שפוענח שגוי בשקט ינחת במקום הלא נכון

var
  Pdf: TPDFlib;
  Pages, I, ImageID: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    Pages := Pdf.GetImagePageCount('inbox\scan-0417.tif');
    if Pages < 1 then
      Exit;  // כותרת פגומה, BigTIFF, פריסת טיילים או חריגה מהתקרה
    Pdf.NewDocument;
    for I := 1 to Pages do
    begin
      Pdf.NewPage;
      ImageID := Pdf.AddImageFromFile('inbox\scan-0417.tif', I);
      if ImageID > 0 then
      begin
        Pdf.SelectImage(ImageID);
        Pdf.DrawImage(0, 0, 595, 842);
      end;
    end;
    Pdf.SaveToFile('scan-0417.pdf');
  finally
    Pdf.Free;
  end;
end;

לבנות את המפענח או לקשר את libtiff?

PDFlibPas מחזיק במפענח המובנה, והגורם המכריע הוא טווח הפלטפורמות ולא בעלות על הקוד. כ־1,873 שורות של Object Pascal מתקמפלות בכל מקום שאליו מגיע הקומפיילר: Win32, Win64, macOS, iOS, Android, ו־FPC על Linux. libtiff 4.7.1 הוא כ־30,000 שורות C המפוזרות על פני 34 יחידות תרגום של tif_*.c, וקובצי האובייקט המוכנים מראש שקיימים כיום מכסים את Windows בלבד. אימוץ שלו היה ממיר כיסוי TIFF מלא ברשימת פלטפורמות נתמכות שמתכווצת לכל מכונה שמסוגלת להריץ את כלי ה־C, ובנוסף מעבר לינקר שאף אחד עוד לא עבר עליו

מה שזה עולה כדאי לומר בלי קישוט. המפענח המובנה מטפל במה שעבודת מסמכים סרוקים באמת מייצרת: CCITT Group 3 חד־ממדי ודו־ממדי, Group 4, LZW, Deflate, PackBits ו־JPEG בתוך TIFF, על פני הפוטומטריות WhiteIsZero, BlackIsZero, RGB, פלטה ו־CMYK עם Predictor 1 ו־2. המטענים האלה תואמים למסנני ה־PDF ב־ISO 32000-1 §7.4.4 ו־§7.4.6, ומכאן משקלה הרב של החזית של TIFF בצינור סריקה. מה שהוא לא מטפל בו הוא BigTIFF, טיילים, Predictor 3 בנקודה צפה, PixarLog ו־SGILog, דחיסת JPEG בסגנון הישן 6, ופירמידות תת־IFD. החל מ־3.534.1 כל אחד מאלה הוא סירוב בעל שם ולא תמונה שגויה, והספרייה שומרת רשימת טריגרים כתובה לפתיחה מחדש של ההחלטה על libtiff:

  • לקוח מדווח על קובץ BigTIFF וזקוק לתמיכה טבעית ולא לשלב המרה
  • לקוח מדווח על tiled TIFF ממקורות רפואיים, GIS או תעשייתיים וזקוק לפענוח שלו במקום
  • לקוח מדווח על TIFF עם Predictor 3 בנקודה צפה
  • פרצת אבטחה שפורסמה פוגעת בנתיבי הפענוח המובנים של CCITT או LZW
  • הטיעון חוצה הפלטפורמות מפסיק לחול, בין אם בגלל הפסקת התמיכה ב־macOS, iOS ו־Android ובין אם בגלל אינטגרציית libtiff לשימוש חוזר שכבר מכסה את macOS ו־Linux

ההגירה עצמה מתוחמת ולא היפותטית: תנאי קימפול בשם USE_LIBTIFF היה שומר על פני השטח הציבוריים של TPDFTIFF כפי שהם, מנתב את LoadFromStream דרך TIFFClientOpen עם פונקציות קריאה חוזרת של זרם, ומשאיר את המנתח בפסקל כגיבוי לפלטפורמות שאינן Windows. עד שאחד הטריגרים האלה באמת יקרה, תחזוקה של שני מפענחים ומטריצת בדיקות מוכפלת לא קונה שום דבר שלקוח ירגיש בו. דחיית עלות כשנתיב המילוט כבר כתוב על הנייר היא דבר אחר מהתעלמות ממנה

מה זה משאיר לצינור של מסמכים סרוקים

מתייחסים ל־TPDFTIFF כשער ולא כממיר. טוענים את הקובץ, קוראים את ValidTIFF, ורושמים ביומן את LastError מילה במילה בכל פעם שהוא שקרי, כי המחרוזת הזו היא עתה הדרך הקצרה ביותר מדיווח שטח לאבחנה. קבצים שנכשלים בשער נותרים ברי הצלה באמצעות המרה במעלה הזרם, וזו התשובה המעשית למקורות BigTIFF וטיילים כיום. לקלט שאינו TIFF כלל, PDFlibPas נוקט נתיב נפרד דרך קליטת תמונות AVIF, HEIF ו־JPEG XL שלו, כך שהשאלה איזה מפענח מחזיק באיזה פורמט נשארת מפורשת ולא מתגלה בדיעבד

כל זה יושב מאחורי ממשק התמונות הרגיל, כך שצינור מסמכים מקבל את הגבול המהודק יותר בלי לשנות שורת קוד קריאה אחת מעבר לבדיקת מספר העמודים שהיה עליו לבדוק ממילא. אם אתם שוקלים נתיב טבעי מ־TIFF ל־PDF עבור Delphi או C++Builder, הרכיב המלא וטיפול התמונות שלו מתועדים בעמוד PDF Library for Delphi