מאמר טכני

קבצי JBIG2 בגישה אקראית: פענוח שלהם בדלפי

PDFlibPas בגרסה 3.539.23 מפענח קבצי JBIG2 עצמאיים שמשתמשים בארגון הגישה-האקראית מ-ITU-T T.88 נספח D.2, שבו כל כותרת סגמנט באה ראשונה ונתוני הסגמנט באים אחריהם באותו סדר. המפענח ה-Pascal המקורי ב-PDFlibJBIG2.pas עושה אינדוקס של היסטי הכותרות עד כותרת סוף-הקובץ המחייבת, בודק שמספרי הסגמנטים גדלים ושהאורכים המוצהרים של הנתונים מסתכמים בדיוק לבתים שנותרו, ואז מפענח כל גוף לפי סדר הכותרות בלי להעתיק או לסדר מחדש את הנתונים הדחוסים. לפני המהדורה הזאת אותו קובץ העלה שגיאה קצובה של "ארגון גישה אקראית אינו נתמך" ברגע שדגלי הכותרת נקראו

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

מהו ארגון הגישה האקראית של JBIG2?

ארגון הגישה האקראית הוא אחת משלוש הדרכים שבהן נספח D של T.88 מתיר לסדר את אותם הסגמנטים: סדרתי (D.1) שוזר כל כותרת עם הנתונים שלה, גישה אקראית (D.2) שם את כל הכותרות קודם ואת כל הנתונים אחר כך, ומוטמע (D.3) הוא הצורה חסרת הכותרות שמשמשת בתוך מיכלים אחרים כמו PDF. קובץ .jb2 עצמאי מתחיל במזהה בן שמונה הבתים 97 4A 42 32 0D 0A 1A 0A, אחריו בית דגלים אחד, וכשמספר העמודים ידוע — מספר עמודים בן ארבעה בתים. ביט 0 של בית הדגלים בוחר את הארגון, כאשר 1 פירושו סדרתי ו-0 פירושו גישה אקראית; ביט 1 דלוק פירושו שמספר העמודים אינו ידוע והספירה בת ארבעת הבתים נעדרת. PDFlibPas קורא את אלה ב-checkHeader וב-setFileHeaderFlags, וביטים 2 עד 7 השמורים סובלים ולא נדחים

ארגוני קובץ JBIG2 ב-PDFlibPas: בית הדגלים ש-setFileHeaderFlags קורא בוחר סדרתי D.1 עם כותרות שזורות, גישה אקראית D.2 עם כל כותרת לפני בלוק הנתונים, או מוטמע D.3, הצורה חסרת הכותרות שזרם JBIG2Decode משתמש בה עם מילונים ב-JBIG2Globals
שלושת הסידורים נושאים את אותם סגמנטים, אבל רק גישה אקראית גורמת לקורא לראות כל תלות של עמוד ומילון לפני שנוגעים בבית דחוס, ולכן pipelines של ארכוב ביקשו אותה
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = גישה אקראית (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = מספר העמודים מושמט
noOfPagesKnown := pagesKnown = 0;

// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader;                       // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
  // זרם PDF: בלי כותרת קובץ, ארגון מוטמע, עמוד אחד
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

PDF עצמו אף פעם לא נושא את הסידור הזה. זרם תמונה JBIG2Decode, כמתואר ב-ISO 32000-1 §7.4.7, מחזיק רק את סגמנטי העמודים בארגון מוטמע, כשמילוני הסמלים המשותפים עברו לזרם JBIG2Globals נפרד, בלי כותרת קובץ ובלי סגמנטי סוף-עמוד או סוף-קובץ. כש-decodeJBIG2 לא מוצא את המזהה בן שמונת הבתים הוא מניח בדיוק זאת וכופה פענוח סדרתי חד-עמודי. ייצוא התמונות המקורי של JBIG2 הולך לכיוון השני ועוטף את סגמנטי ה-PDF בקובץ עצמאי שבית הדגלים שלו הוא $03, סדרתי עם מספר עמודים לא ידוע, ואחריו כותרת סוף-קובץ מצורפת. כך שהעבודה על גישה אקראית נוגעת בנתיב אחד בלבד: קבצים עצמאיים שנמסרים ישירות ל-TPLJBIG2Decoder, בדרך כלל לפני שהם מומרים או נדחסים מחדש עבור PDF — העבודה שהמנועי קידוד JBIG2 ב-PDFlibPas מבצעים בצד הפלט

למה קובץ בגישה אקראית לא יכול להיקרא לפי סדר הקובץ?

קובץ גישה אקראית לא יכול להיקרא לפי סדר הקובץ כי שום דבר בזרם הבתים לא מסמן איפה בלוק הכותרות נעצר ובלוק הנתונים מתחיל, מלבד כותרת הסגמנט של סוף-הקובץ עצמה. לכותרות סגמנט של JBIG2 יש אורך משתנה: מספר הסגמנטים המוחזקים (referred-to) יכול להיות צורה קצרה של שלוש סיביות או צורה ארוכה עם bitmap שימור, מספרי הסגמנטים המוחזקים תופסים בית אחד, שניים או ארבעה בהתאם למספר של הסגמנט עצמו, ושדה שיוך העמוד הוא בית אחד או ארבעה. קורא סדרתי נאיבי מפענח את הכותרת הראשונה, קורא את אורך הנתונים שלה, ואז מתייחס לבתים הראשונים של הכותרת השנייה כאל הנתונים של אותו סגמנט. המפענח לא יכול לדעת שהשתבש עד הרבה יותר מאוחר, ולכן הקוד הישן סירב לארגון הזה מראש במקום לנסות

איך PDFlibPas עושה אינדוקס לכותרות סגמנט בגישה אקראית?

PDFlibPas עושה אינדוקס לכותרות בגישה אקראית בסריקה מקדימה אחת, IndexRandomHeaders, שמפענחת כל כותרת, רושמת רק את ההיסט שלה בבתים ועוצרת בכותרת סוף-הקובץ הראשונה (סוג סגמנט 51). כל כותרת מפוענחת במלואה ונזרקת, כך שהאינדקס הוא מערך מספרים שלמים ולא רשימת אובייקטים, והסריקה המקדימה מצבירה את האורכים המוצהרים של הנתונים תוך כדי. כשהסריקה מסתיימת, הקורא יושב על הבית הראשון של הנתונים של הסגמנט הראשון, והמיקום הזה הופך ל-NextBodyOffset

הסריקה המקדימה של IndexRandomHeaders ב-PDFlibPas: כל כותרת סגמנט מפוענחת ונזרקת בזמן שרק ההיסט שלה בבתים נשמר, מספרי סגמנטים חייבים לגדול בקפדנות, אורך לא ידוע 0xFFFFFFFF נדחה, הסריקה עוצרת בכותרת סוף-הקובץ מסוג 51, והאורכים המוצהרים חייבים להשתוות בדיוק לבתים שנותרו
החומרה מכוונת: בסידור שבו הכותרות הן המפה היחידה של הנתונים, בית תועה אחד פירושו שכל גוף מאוחר יותר עלול להיות מוסט, ולכן מפענח שסובל את זה לא יכול להבחין בין padding לבין היסט שגוי
// IndexRandomHeaders, מקומי ל-TJBIG2StreamDecoder.readSegments
while not reader.isFinished do
begin
  Offset := reader.bytePointer;
  Header := TSegmentHeader.Create;
  try
    readSegmentHeader(Header);
    if reader.BufferOverrun then
      raise EJBIG2DecodeError.CreateFmt(
        'JBIG2 truncated random-access header at byte %d', [Offset]);
    if (HeaderCount > 0) and
       (Cardinal(Header.getSegmentNumber) <= Cardinal(PreviousNumber)) then
      raise EJBIG2DecodeError.Create('JBIG2 random-access segment numbers must increase');
    PreviousNumber := Header.getSegmentNumber;
    Count := Header.getSegmentDataLength;
    if Count < 0 then
      raise EJBIG2DecodeError.Create('JBIG2 unknown or oversized segment length is not supported');
    Inc(TotalLength, Count);                   // מצבר Int64
    HeaderOffsets[HeaderCount] := Offset;      // גדל בחתיכות
    Inc(HeaderCount);
    if Header.getSegmentType = JBIG2_END_OF_FILE then
    begin
      if Count <> 0 then
        raise EJBIG2DecodeError.Create('JBIG2 invalid end segment length');
      FoundEnd := True;
      Break;
    end;
  finally
    Header.Free;
  end;
end;
if not FoundEnd then
  raise EJBIG2DecodeError.Create('JBIG2 random-access file is missing its end-of-file header');
if TotalLength > Length(reader.Data) - reader.bytePointer then
  raise EJBIG2DecodeError.Create('JBIG2 truncated random-access segment data');
if TotalLength < Length(reader.Data) - reader.bytePointer then
  raise EJBIG2DecodeError.Create('JBIG2 trailing random-access data');
NextBodyOffset := reader.bytePointer;

כל בדיקה בלולאה הזאת קיימת כי לקובץ גישה אקראית יש פחות יתירות מאשר לקובץ סדרתי. מספרי סגמנטים חייבים לגדול בקפדנות, בהשוואה כערכים לא מסומנים, כי שתי כותרות שטוענות לאותו מספר הופכות לעמום איזה גוף רשימת ה-referred-to של אזור מאוחר יותר מתכוונת אליו. שדה אורך הנתונים נקרא על ידי handleSegmentDataLength, שממפה כל ערך עם הסיבית העליונה דלוקה, כולל מחוון ה"אורך לא ידוע" 0xFFFFFFFF, אל -1; בפריסת גישה אקראית אין דרך אחרת למצוא איפה הגוף הבא מתחיל, ולכן PDFlibPas דוחה את האורך הזה מיד במקום לסרוק אחר מחוון סיום. הסכום חייב להתאים לבתים שנותרו בדיוק בשני הכיוונים, ובית עודף אחד אחרי הגוף האחרון נכשל עם "נתוני גישה אקראית נוספים בקצה". החומרה הזאת מכוונת: בפריסה הזאת אי-התאמת אורך פירושה שכל גוף אחרי נקודת השגיאה מוסט, ולמפענח שמתעלם מבית תועה אחד אין דרך לדעת אם זה padding תמים או התסמין הראשון של נתונים מוסטים

למה סגמנט סוף-העמוד האחרון נעלם?

סגמנט סוף-העמוד האחרון נעלם כי הגרסה הראשונה של לולאת הפענוח שמרה על בדיקת הסיום הסדרתית, while not reader.isFinished, ובפריסת גישה אקראית זרם הנתונים נגמר לפני שאינדקס הכותרות נגמר. סגמנטי סוף-עמוד (סוג 49) וסוף-קובץ נושאים אפס בתי נתונים, ובדרך כלל הם הכותרות האחרונות בקובץ. אחרי שגוף האזור האחרון נצרך הקורא יושב בדיוק בסוף החוצץ, ולכן הלולאה יוצאת והסגמנטים באורך אפס האלה אף פעם לא מופעלים, והעמוד נשאר בלתי גמור. התיקון גורם ללולאת הגישה האקראית לספור כותרות במקום בתים. כל איטרציה מקפיצה את הקורא אל הכותרת הבאה באינדקס, מאפסת את bitPointer אל 7 כי הגוף הקודם עלול להסתיים באמצע בית, מפענחת מחדש את הכותרת הזאת, ואז מעבירה את bytePointer אל NextBodyOffset ומקדמת אותו מעבר לגוף. מטפלי הסגמנטים הקיימים, בדיקות הסגמנטים המוחזקים והאבחון של Context רצים ללא שינוי, והודעת שגיאה עדיין מדווחת על ההיסט המקורי של הכותרת בבתים, לא על מיקום הגוף

לולאת הפענוח בגישה אקראית ב-PDFlibPas: כל איטרציה ממקמת את HeaderOffsets של הכותרת הנוכחית, מאפסת את bitPointer אל 7 כדי לבטל זנבות באמצע בית, מקפצת אל NextBodyOffset עבור הגוף, וסופרת כותרות במקום בתים כך שסגמנטי סוף-עמוד באורך אפס מופעלים לפני שהלולאה מסתיימת
כי סגמנטי סוף-עמוד וסוף-קובץ נושאים אפס בתי נתונים, זרם הנתונים נגמר לפני שאינדקס הכותרות נגמר, ורק לולאה שסופרת כותרות יכולה לתת לסגמנטים האחרונים האלה את תורם
// TJBIG2StreamDecoder.readSegments, הלולאה הראשית
if randomAccessOrganisation then
  IndexRandomHeaders;
while (randomAccessOrganisation and (HeaderIndex < HeaderCount)) or
      ((not randomAccessOrganisation) and (not reader.isFinished)) do
begin
  if randomAccessOrganisation then
  begin
    reader.bytePointer := HeaderOffsets[HeaderIndex];
    reader.bitPointer := 7;                    // יישור מחדש אחרי בית חלקי
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // משמש בהקשר שגיאה
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // קפיצה אל הנתונים של הסגמנט הזה
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... הפעלת מטפל הסגמנטים הקיים, ואז מיקום אל DataEnd
end;

מה אימות הגישה האקראית באמת מוכיח?

האימות מוכיח שהבתים המסודרים מחדש מתפענחים לאותם פיקסלים כמו המקורות הסדרתיים שלהם, ומוכיח שקלט גישה אקראית פגום נכשל בנקיון; הוא לא מוכיח כיסוי של קבצי גישה אקראית ממקודדים שרירותיים. רגרסיית ה-Pascal המשותפת משתמשת בקובץ סינתטי בן 235 בתים שנבנה על fixture של טבלה מותאמת אישית שחייב להתפענח לשורה של 7 על 1 פיקסלים שחורים, גם עם מספר עמודים ידוע וגם עם שדה הספירה שהוסר, ואז מזרימה למפענח כל קידומת קטועה של הקובץ הזה, מספר סגמנט כפול, בית אחד מיותר בקצה ואורך נתונים לא ידוע, וטוענת בכל פעם ש-LoadFromByteArray מחזיר False ומשאיר את Width ו-Height על אפס. המקרה של תמונה אמיתית הוא תמונת refinement של טבלה מותאמת אישית בגודל 500 על 473 שהסגמנטים שלה סודרו מחדש לפריסת גישה אקראית כשכל כותרת מקורית ובית דחוס נשמרו; ה-SHA-256 שלה תואם בדיוק ל-baseline הסדרתי שנבדק. הקובץ הזה הוא נגזרת שהופקה על ידי טרנספורמציית ארגון, לא מסמך גישה אקראית טבעי שנמצא בטבע, ולא היה זמין דגימה טבעית כזאת. הסוויטות עברו ב-1,598 בדיקות עבור Delphi Win32, 42 עבור סוויטת התמונות של Delphi Win64, 48 עבור FPC Win32 ו-46 עבור FPC Win64, לצד שלושת מקרי הפיקסלים הסדרתיים הקיימים

טעינת קובץ .jb2 בגישה אקראית והמגבלות שלו

קוד היישום לא משתנה: TPLJBIG2Decoder.LoadFromByteArray מזהה בעצמו את כותרת הקובץ ואת הארגון, מחזיר False על כל קלט נדחה עם הסיבה ב-LastError, וחושף את העמוד המפוענח דרך Width, Height ו-GetScanline, שמחזיר בית אחד לכל פיקסל

uses
  SysUtils, Classes, PDFlibJBIG2;

function ReadJb2(const FileName: string): TJBIG2ByteArray;
var
  FS: TFileStream;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    SetLength(Result, FS.Size);
    if Length(Result) > 0 then
      FS.ReadBuffer(Result[0], Length(Result));
  finally
    FS.Free;
  end;
end;

function CountBlackPixels(const FileName: string): Integer;
var
  Decoder: TPLJBIG2Decoder;
  Row: TJBIG2ByteArray;
  X, Y: Integer;
begin
  Result := 0;
  Decoder := TPLJBIG2Decoder.Create;
  try
    // קבצים עצמאיים סדרתיים ובגישה אקראית לוקחים את אותה קריאה
    if not Decoder.LoadFromByteArray(ReadJb2(FileName)) then
      raise Exception.Create('JBIG2 rejected: ' + Decoder.LastError);
    for Y := 0 to Decoder.Height - 1 do
      if Decoder.GetScanline(Y, Row) then
        for X := 0 to Decoder.Width - 1 do
          if Row[X] = 1 then
            Inc(Result);
  finally
    Decoder.Free;
  end;
end;

הגבולות שווים ניסוח ברור. התמיכה בגישה אקראית היא תכונה של ארגון קובץ, לא API של עמודים אקראיים: TPLJBIG2Decoder עדיין מחזיר את ה-bitmap של העמוד הראשון, ואין קריאה לשלוף עמוד 7 מתוך קובץ בן 40 עמודים או לפענח עמודים בגישת lazy. סגמנטים עם אורך נתונים לא ידוע נדחים בקבצי גישה אקראית, והמגבלות הקיימות על אורכי קידומת של Huffman מותאם ועל מספרי רשומות טבלה ללא שינוי. המגבלות האלה מצומצמות מספיק שיישום דלפי יכול לנתב את המקרים הנדחים הלאה לפי LastError, ואת שאר ה-pipeline של התמונות, מחילוץ תמונות PDF ועד קידוד JBIG2, מכסה עמוד המוצר של PDF Library for Delphi