מאמר טכני

חילוץ תמונות מ-PDF טעון ב-Delphi: HotPDF

יש לכם PDF על הדיסק, לקוח סרק אותו מערמת חשבוניות, והתפקיד שלכם הוא לחלץ את תמונות העמודים חזרה כמפות סיביות עבור שלב OCR. אתם טוענים את הקובץ, מוצאים את אובייקטי ה-Image XObject, ואז מגלים את החלק שאיש לא מזהיר מפניו: הבתים בזרמים האלה אינם פיקסלים. הם רצף JPEG, או גוש JPEG 2000 דחוס בגלים, או מסלול פקס Group 4, או רסטר מאונדקס מאחורי לוח צבעים ומסנן Flate. אובייקט התמונה יודע מה הרוחב והגובה שלו, אבל הדגימות עצמן חתומות בתוך המסנן שבחר היצרן. כדי לקבל TBitmap שמיש צריך לבטל את המסנן הזה, ו-PDF נותן לכם בערך שמונה דרכים שונות שבהן הבתים יכולים להיות חתומים

זה הפער ש-ExtractLoadedImage ממלא ב-HotPDF, רכיב ה-PDF המקורי ל-VCL עבור Delphi ו-C++Builder. הוא עובר על אובייקטי ה-Image XObject במסמך שטענתם, מדווח מהו כל אחד מהם, ומפענח את אלה שאפשר בחזרה למפת סיביות של 24 ביט. החלק המעניין אינו שטח ה-API, שהוא שלוש שיטות. הוא למה צריך בכלל נתיב פענוח נפרד, ומה הוא יכול ומה הוא לא יכול להחזיר לפיקסלים

למה תמונות טעונות אינן כבר מפוענחות

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

התוצאה היא שהגרף המיובא אינו נושא פיקסלים. Image XObject שבו /Filter הוא /DCTDecode מחזיק בתים של JPEG; HotPDF מעולם לא הפעיל עליו מפענח JPEG, כי שום דבר בנתיב העתקה-ושכתוב לא דרש זאת. לכן כשאתם באמת רוצים פיקסלים, ממשק ה-API של החילוץ חייב לבצע את הפענוח בעצמו, מאפס, עבור כל מסנן שבו התמונה המסוימת משתמשת. זו גם הסיבה שהקודקים בצד הקידוד נפרדים מן הטוען: המאמר על הוספת תמונות JPEG 2000 ל-PDF ב-Delphi מתאר איך מנוע ה-JPX מתחבר לצד היצירה, והמנוע הזה פשוט לא חובר לנתיב הקריאה עד ש-API החילוץ נזקק לו

ממשק ה-API בן שלוש השיטות

השטח קטן. GetLoadedImageCount מחזירה כמה אובייקטי Image XObject יש במסמך הטעון. GetLoadedImageInfo ממלאת רשומת תיאור עבור אחד מהם לפי אינדקס. ExtractLoadedImage מחזירה את מפת הסיביות המפוענחת, או nil כאשר אי אפשר לפענח את התמונה הזאת. המנייה מבוססת אינדקס ויציבה עבור טעינה נתונה: פנימית היא עוברת על טבלת האובייקטים העקיפים ואוספת כל זרם ש-/Subtype שלו נפתר ל-/Image, כך שהאינדקס שאתם מעבירים ל-GetLoadedImageInfo הוא אותו אינדקס שאתם מעבירים ל-ExtractLoadedImage

var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  Bmp: TBitmap;
  I, Count: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-invoices.pdf', '') <= 0 then
      Exit;
    Count := Pdf.GetLoadedImageCount;
    for I := 0 to Count - 1 do
    begin
      if not Pdf.GetLoadedImageInfo(I, Info) then
        Continue;
      if not Info.Decodable then
        Continue;                       // filter or colour space not supported
      Bmp := Pdf.ExtractLoadedImage(I);
      if Bmp <> nil then
      try
        Bmp.SaveToFile(Format('img_%d.bmp', [I]));
      finally
        Bmp.Free;                       // caller owns the bitmap
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

שני פרטי חוזה חשובים כאן. ראשית, TBitmap המוחזר הוא שלכם למחוק; המסמך אינו שומר אותו במטמון ואינו הבעלים שלו. שנית, בדקו Decodable לפני הקריאה, ובדקו את התוצאה מול nil אחריה. השיטה אינה זורקת חריגה על מסנן שאינו נתמך, היא מחזירה nil, ו-nil שקט בלולאת אצווה הוא בדיוק מסוג הדברים שיכולים לבלוע עמוד מתוך משימה של אלפי עמודים בלי שאיש ישים לב

קריאת התיאור לפני הפענוח

THPDFLoadedImageInfo אומרת לכם מהי התמונה בלי להתחייב לפענוח מלא. השדות שלה מגיעים ישירות ממילון התמונה: Width ו-Height בדגימות, BitsPerComponent, ColorComponents ו-ColorSpace שמתארים את הפירוש אחרי הפענוח (1 לאפור, 3 ל-RGB, 4 ל-CMYK), Filter כמסנן הדחיסה הנקוב, IsImageMask למסכות שבלונה, ObjectNumber לאובייקט העקיף הבסיסי, ו-Decodable

הדגל האחרון הוא הכנה ביותר. Decodable הוא True רק כאשר הבנייה הרצה באמת יכולה להפוך את השילוב הספציפי הזה של מסנן ומרחב צבע למפת סיביות. הוא מקודד את מטריצת התמיכה האמיתית, לא משאלה: תמונה ש-Filter שלה הבנייה הנוכחית אינה מבינה מדווחת Decodable = False, ואפשר להתפצל על זה כדי לתעד, לדלג, או לחזור לחילוץ הזרם הגולמי בעצמכם. התייחסו אליו כתנאי מוקדם, לא כרמז

// Triage every image before committing to a decode.
var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  I: Integer;
begin
  // ... Pdf loaded ...
  for I := 0 to Pdf.GetLoadedImageCount - 1 do
  begin
    if not Pdf.GetLoadedImageInfo(I, Info) then
      Continue;
    if Info.Decodable then
      // ExtractLoadedImage(I) will return a TBitmap
    else
      // unsupported filter/colour space: log the object and skip
      Writeln(Format('Image %d obj %d: %dx%d %s/%s not decodable',
        [I, Info.ObjectNumber, Info.Width, Info.Height,
         String(Info.Filter), String(Info.ColorSpace)]));
  end;
end;

פרט מימוש אחד נושך אנשים שבונים רשומות תיאור ביד. THPDFLoadedImageInfo מחזיקה שני שדות AnsiString, Filter ו-ColorSpace. אלה טיפוסים מנוהלים עם מונה הפניות, ולכן האינסטינקט לאפס רשומה עם FillChar(Info, SizeOf(Info), 0) שגוי כאן: הוא דורש את ההפניה למחרוזת בלי להקטין אותה, מה שגורם לדליפה או להשחתה. HotPDF מאתחל את הרשומה שדה אחר שדה בדיוק מהסיבה הזאת, ואם אי פעם תעתיקו את הדפוס הזה לקוד שלכם, עשו אותו הדבר

מפענח אחד, שמונה נתיבי מסנן

הסיבה שהתכונה הזאת הגיעה בסדרה של גרסאות ולא בבת אחת היא של-PDF אין פורמט תמונה. יש לו מסננים, ו-§8.9.5 של ISO 32000-1 מאפשר לאובייקט Image XObject לנקוב בכל אחד מהם ב-/Filter, כאשר פרשנות הדגימות נשלטת בנפרד על ידי /ColorSpace, /BitsPerComponent ו-/Decode אופציונלי. ExtractLoadedImage קוראת את שם המסנן ומנתבת למפענח ייעודי לכל מקרה. הסט הנתמך, שנבנה בין v2.229 ל-v2.231, מכסה כעת שמונה נתיבים נפרדים

  • רסטרים גולמיים (FlateDecode, LZWDecode, או בלי מסנן) ב-DeviceRGB או DeviceGray של 8 ביט. הבתים נפתחים לרסטר ארוז, וההמרה היחידה היא החלפת ערוצים, שמוסברת בהמשך
  • DCTDecode (JPEG). רצף הקידוד נמסר ל-TJPEGImage של VCL, שמחשב גאומטריה וצבע, והתוצאה משובצת למפת סיביות של 24 ביט
  • JPXDecode (JPEG 2000). מפוענח דרך מנוע OpenJPEG, אותו מנוע שמתואר במאמר JPEG 2000, כאשר רכיבים בעומק ביט גבוה נדגמים מחדש למטה ל-8 ביט
  • צבע מאונדקס. לוח הצבעים נקרא מתוך המערך [/Indexed base hival lookup] וכל דגימה מורחבת דרך טבלת החיפוש לצבע אמיתי
  • DeviceCMYK. דגימות בארבעה ערוצים מומרות ל-RGB באמצעות הנוסחה הסטנדרטית של דיו על לבן
  • DeviceGray ו-Indexed מתחת ל-8 ביט ב-1, 2 או 4 ביט לכל רכיב, נפרסים דגימה אחר דגימה ומוקטנים לטווח 0–255
  • CCITTFaxDecode, מסנני הפקס Group 3 ו-Group 4, מפוענחים על ידי מנוע T.4/T.6 ייעודי
  • JBIG2Decode, מסנן דו-ערכי בעל יחס דחיסה גבוה, מפוענח דרך מנוע JBIG2 הרשום, שהמאמר על דחיסת JBIG2 מקורית מתאר מהצד של הקידוד

הכול נוחת באותו מקום: מפת סיביות BGR של 24 ביט, כי זה מה ש-TBitmap של VCL שומר באופן טבעי ומה שכל צרכן במורד הזרם מצפה לקבל

המרות שמשנות פיקסלים בשקט

שני הנתיבים האלה כוללים המרה שקל לטעות בה בעדינות, ושווה להבין גם אם אף פעם לא תיגעו במפענח בעצמכם. הראשונה היא החלפת סדר הצבעים. רסטר DeviceRGB ב-PDF שומר דגימות בסדר אדום-ירוק-כחול, שורה עליונה ראשונה. ScanLine של 24 ביט ב-VCL שומר אותן בסדר כחול-ירוק-אדום. לכן פענוח של תמונת RGB פשוטה אינו memcpy; הבתים הראשון והשלישי של כל פיקסל מוחלפים בדרך אל ה-scanline. טעות בכיוון הזה תחליף בין האדומים לכחולים, מה שייראה תקין על תמונת בדיקת אפור וקטסטרופלי על תמונה צבעונית. סדר השורות, מה שזה שווה, ממופה ישר: הרסטרים מלמעלה-למטה של PDF מסתדרים עם ScanLine[0] של VCL כשורה הוויזואלית העליונה, כך שאין צורך בהיפוך אנכי

השנייה היא CMYK. תמונות DeviceCMYK של PDF נושאות ארבעה דיו, וההמרה ל-RGB היא חישוב לכל ערוץ, לא חיפוש בטבלה: כל ערוץ פלט הוא (255 - ink) * (255 - K) / 255. זוהי קירוב של התקן, לא המרה מנוהלת צבע דרך פרופיל ICC, ולכן התוצאה טובה מספיק לתצוגה ולרה-רסטריזציה, אבל היא לא הנתיב הנכון אם אתם צריכים צבע מדויק לדפוס. אם תהליך העבודה שלכם דורש נאמנות מלאה, התייחסו למפת הסיביות שחילצתם כאל תצוגה מקדימה ושמרו את זרם ה-CMYK המקורי לשרשרת מנוהלת הצבע

לנתיב Indexed יש מלכודת ניתוח משלו. לוח הצבעים במרחב צבע /Indexed יכול להישמר כמחרוזת ליטרלית או כמחרוזת הקסדצימלית, ו-HotPDF שומר את הערך של מחרוזת הקס כטקסט ההקס עצמו, לא כביטים מפוענחים. לכן כשהלוח הוא מחרוזת הקס, טבלת החיפוש חייבת לעבור תחילה פענוח מהקס לבתים; מחרוזת ליטרלית כבר מכילה בתים גולמיים. דלגו על הענף הזה ותמונה מאונדקסת בת ארבעה צבעים תצא כזבל, כי כל רשומת לוח נקראת מהגבול הבייטי הלא נכון

שרשראות מסננים: המסנן האחרון הוא של התמונה עצמה

שם /Filter יחיד הוא המקרה הקל. PDF גם מאפשר שרשרת של מסננים, שבה הזרם עבר כמה שלבים ברצף, הרשומים בסדר במערך /Filter כגון [/ASCII85Decode /FlateDecode] או [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). הסמנטיקה מדויקת: המסננים חלים משמאל לימין בזמן קידוד, ולכן בפענוח אתם מבטלים אותם מימין לשמאל, והמסנן האחרון במערך הוא זה שבאמת מגדיר את פורמט התמונה. המסננים המובילים הם רק קידודי תעבורה שעוטפים אותו

המחלץ מטפל בזה על ידי קילוף. לפני שרץ כל מפענח תמונה, כל מסנן בשרשרת פרט לאחרון מוחל כדי לייצר את הקלט שהמסנן הסופי מצפה לו, ורק אז מתבצעת הניתוב על המסנן האחרון. לכן [/ASCII85Decode /DCTDecode] מבטל תחילה את ASCII85 מהזרם, ואז מנתב את התוצאה לנתיב ה-JPEG; [/FlateDecode] שעוטף רסטר גולמי מנפח ואז מריץ את נתיב הרסטר. זה מה שמאפשר לשמונה המפענחים להישאר פשוטים. אף אחד מהם לא צריך לדעת על עוטפי תעבורת ASCII85 או הקס, כי עד שמפענח רואה את הבתים, העוטפים כבר נעלמו. זה גם אומר ששרשרת שהמסנן הסופי שלה אינו נתמך עדיין נכשלת בצורה נקייה בשלב הניתוב ולא באמצע

איפה החילוץ נעצר, ומה עושים אז

היו כנים עם עצמכם לגבי הגבולות. תמונה שהמסנן הסופי שלה מחוץ לסט הנתמך מחזירה nil, וגם תמונה שמרחב הצבע שלה אינו ניתן לפירוש על ידי הבנייה. מסכות רכות ואלפא לא משוחזרות לתוך מפת הסיביות; אתם מקבלים את תמונת הבסיס, לא תוצאה מורכבת. עומקי ביט מעל 8 מ-JPEG 2000 נדגמים מחדש למטה, מה שהוא אובדן מידע מכוון והמהלך הלא נכון אם אתם משחזרים לארכיון ולא מציגים. ומסכת תמונה, שבלונה של ביט אחד בלי צבע משלה, מתוארת על ידי הרשומה אבל היא דבר שונה מתמונה ציורית; לפענח אותה בציפייה לתצלום יפתיע אתכם

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

ממשק החילוץ של תמונות טעונות, יחד עם כל סט מסנני הפענוח המתואר כאן, מגיע עם רכיב HotPDF עבור Delphi ו-C++Builder