מאמר טכני

שיתוף מילוני סמלים JBIG2 בין עמודים ב-Delphi

חוזה סרוק בן חמישים עמודים חוזר על אותה אלפבית בכל עמוד, אבל מקודד JBIG2 שבונה מילון סמלים אחד לכל תמונה מאמן מחדש את האלפבית הזה חמישים פעמים נפרדות. HotPDF, רכיב ה-PDF הילידי של Delphi ו-C++Builder, יכול במקום זאת לצבור מילון סמלים משותף אחד לאורך כל המסמך ולקדם אותו לזרם /JBIG2Globals יחיד ברמת-מסמך, כך שזרם ה-JBIG2 של כל עמוד רק מפנה למזהי סמלים במקום לשמור עותק משלו של האלפבית

החלק הזה נשאר צר במכוון ומכסה רק איך HotPDF בונה את השיתוף הבין-עמודי הזה פנימית — יסודות JBIG2, ההשוואה ל-CCITT, ופשרות Lossless מול LossyLevel כבר חיות בהמאמר הנלווה על דחיסה דו-רמתית ילידית של JBIG2 ב-Delphi, שהחלק הזה מניח שקראת

למה דחיסת JBIG2 לכל-עמוד עדיין חוזרת על אותה עלות?

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

איך HotPDF משתפת מילון סמלים יחיד בין עמודים?

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

איך המילון המשותף יושב בתוך זרם /JBIG2Globals

המילון הצבור נכתב כמקטע מילון-סמלים יחיד בתוך זרם ה-/JBIG2Globals, המוחזק במספר מקטע קבוע כך שכל עמוד יכול להצביע על אותו יעד. בתוך ארגון ה-JBIG2 המשובץ ש-ISO 32000-1 סעיף 7.4.7 מגדיר, מקטע אזור-טקסט יכול לקרוא למקטע אחר כמקור הסמלים שלו דרך שדה המקטע-המוצבע-אליו בכותרת המקטע, וזה בדיוק המנגנון ש-HotPDF נשענת עליו: זרם ה-globals נושא את מילון הסמלים הגדול האחד, וזרם ה-JBIG2 של כל עמוד עצמו מצטמצם למקטע מידע-עמוד בתוספת מקטע אזור-טקסט שרשימת ההצבעה שלו מצביעה בחזרה למקטע ה-globals. מה שהיה פעם bitstream עצמאי לכל עמוד הופך לרשימה קצרה של מיקומים ומזהי סמלים, וכל עמוד שנבנה כך מפנה לאותו אובייקט /JBIG2Globals עקיף זהה במקום לעותק שלו. כיסוי הרגרסיה של HotPDF עצמה בודק בדיוק את זה: קידוד מסמך קצר שבו לכל עמוד פריסת גליף שונה, טעינה מחדש שלו, וספירת כמה הפניות אובייקט /JBIG2Globals נבדלות מופיעות בקובץ — מסמך אחד, הפניית אובייקט אחת, לא משנה כמה עמודים תרמו סמלים אליה

הפעלת צבירת מילון סמלים בין-עמודי

המתג יושב על אותה רשומת אפשרויות המתוארת במאמר הנלווה, והוא דורש ארבע הגדרות שמסכימות זו עם זו לפני שהצבירה בפועל מופעלת

var
  Pdf: THotPDF;
  Bmp: TBitmap;
  PageIdx, ImgIdx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.JBIG2Options.Lossless := True;
    Pdf.JBIG2Options.UseSymbolDictionary := True;
    Pdf.JBIG2Options.UseGlobalSegments := True;
    Pdf.JBIG2Options.AccumulateGlobalsAcrossPages := True;  // opt-in, default False
    Pdf.JBIG2Options.UseExternalEncoder := False;            // accumulation needs the native path
    Pdf.JBIG2Options.UseNativeArithmeticFallback := True;
    Pdf.BeginDoc;
    for PageIdx := 0 to ScannedPages.Count - 1 do
    begin
      if PageIdx > 0 then
        Pdf.AddPage;
      Bmp := ScannedPages[PageIdx];             // 1-bit TBitmap for this page
      ImgIdx := Pdf.AddImage(Bmp, icJBIG2);
      Pdf.CurrentPage.ShowImage(ImgIdx, 0, 0, Bmp.Width, Bmp.Height, 0);
    end;
    Pdf.EndDoc;                                  // the shared /JBIG2Globals stream is finalized here
  finally
    Pdf.Free;
  end;
end;

הצימוד הזה אינו קישוט אופציונלי. תפר המקודד החיצוני המתואר במאמר הדחיסה הדו-רמתית — זה שנרשם דרך RegisterJBIG2EncoderBackend עבור יחסי דחיסה ברמת-ייצור — בנוי סביב קידוד לכל-תמונה, וההדגמות והבדיקות הרגרסיה של הצבירה של HotPDF עצמה תמיד מצמידות AccumulateGlobalsAcrossPages עם UseExternalEncoder := False. התייחס לזה כדרישה קשיחה ולא כהצעה: שיתוף בין-עמודי הוא תכונה של מקודד-ילידי, ו-backend חיצוני רשום פשוט אינו חלק מהנתיב שבונה את המילון המשותף

כמה קטנה יותר סריקה רב-עמודית בפועל הופכת?

התשובה הכנה מתחילה במה שלא הזיז את המחט קודם. גרסה קודמת הוסיפה מטמון ממוען-לפי-תוכן עבור זרמי /JBIG2Globals — חיפוש ממופתח לפי hash של 64 סיביות FNV-1a של בייטי הזרם, כך ששתי תמונות שבמקרה ייצרו נתוני globals זהים-בייט יכולות לשתף אובייקט PDF אחד. כשנמדד מול פלט אמיתי, המטמון הזה עזר בקושי, משום שגילוי הכפילויות של HotPDF ברמת התמונה השלמה כבר קרס תמונות זהות-בייט לפני שהמטמון אי-פעם קיבל סיכוי לרוץ. הלקח היה שדה-דופליקציה ברמת-זרם משתלמת רק ברגע ששתי תמונות עמוד שונות באמת עדיין יכולות לשתף מילון גדל אחד, וזה מה שצבירה בין-עמודית אמיתית מספקת

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

procedure RunScenario(const Title: string; AccumulateGlobals: Boolean);
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.JBIG2Options.Lossless := True;
    Pdf.JBIG2Options.UseSymbolDictionary := True;
    Pdf.JBIG2Options.UseGlobalSegments := True;
    Pdf.JBIG2Options.AccumulateGlobalsAcrossPages := AccumulateGlobals;
    Pdf.JBIG2Options.UseExternalEncoder := not AccumulateGlobals;
    // ... encode the same three-page scan here, then compare file sizes.
  finally
    Pdf.Free;
  end;
end;

begin
  RunScenario('Per-image lossless baseline', False);
  RunScenario('Cross-page accumulated globals', True);
end.

איפה צבירה בין-עמודית פוגעת בגבולות שלה

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

תאימות PDF/A היא ההגדרה היחידה שמכבה את המנגנון כולו במקום רק להגביל אותו. HotPDF מחליפה בשקט CCITT Group 4 עבור JBIG2 ברגע ש-PDFACompliance אינו ריק, בכל עמוד, בלתי-תלוי ב-AccumulateGlobalsAcrossPages או בכל דבר אחר ב-JBIG2Options — בחירת תאימות מכוונת, לא באג, אבל זה אומר שפרופיל ארכיוני ושיתוף סמלים בין-עמודי הם היום בלעדיים הדדית. איזו תצורה שתנחת עליה, פענח מה שכתבת לפני שתבטח בו: טען את הקובץ בחזרה עם LoadFromFile ומשוך כל עמוד דרך ExtractLoadedImage, שפותרת עבורך את ה-globals המשותפים באותו אופן שכל קורא תואם היה עושה, והשווה את התוצאה מול ביטמאפי המקור שלך

var
  Loaded: THotPDF;
  PageBmp: TBitmap;
  PageIdx: Integer;
begin
  Loaded := THotPDF.Create(nil);
  try
    Loaded.LoadFromFile('scanned-contract.pdf');
    for PageIdx := 0 to Loaded.PagesCount - 1 do
    begin
      PageBmp := Loaded.ExtractLoadedImage(PageIdx);   // resolves the shared globals for you
      try
        // Compare PageBmp against the source bitmap for this page.
      finally
        PageBmp.Free;
      end;
    end;
  finally
    Loaded.Free;
  end;
end;

שיתוף מילון בין-עמודי נוגע רק בצד תמונת-דו-הרמה של מסמך. אם אותו צינור גם פולט עמודי טקסט שנוצרו לצד הסריקות — דפי שער, דפי אינדקס, שכבת טקסט OCR — זרמי אובייקטים וזרמי xref תוקפים את החצי השני של תקציב גודל הקובץ על ידי דחיסת מבנה המסמך שהעמודים האלה מוסיפים. שיתוף globals בין-עמודי של JBIG2 נשלח כחלק מרכיב HotPDF עבור Delphi ו-C++Builder, לצד אפשרויות ה-JBIG2 לכל-תמונה ושאר צינור הדחיסה