מאמר טכני

הוספת תמונות JPEG 2000 ל-PDF ב-Delphi עם HotPDF

שקופית רפואית סרוקה, אריח של סקר אווירי, פריימים של סרט שארכבו בטווח דינמי מלא. אלו התמונות שמגיעות כ-JPEG 2000, והן מגיעות כך מסיבה מסוימת. הפורמט שומר על 12 או 16 סיביות לערוץ, דוחס באמצעות התמרת wavelet במקום ה-block DCT ש-JPEG משתמש בו, ויכול לקודד את אותה תמונה ללא אובדן נתונים (lossless) או עם אובדן נתונים (lossy) מאותו זרם קוד. כאשר מסמך שנבנה ממקורות אלה צריך להפוך ל-PDF, התמונה חייבת לעבור דרך מסנן שמפרט ה-PDF שומר במיוחד עבור קודק זה

HotPDF v2.228.0 שחזר מנוע פענוח פעיל של JPEG 2000 עבור מסלול זה. בנייה (build) מוקדמת יותר סיפקה את היחידה עם פונקציות stub שהחזירו nil, כך שה-API היה קיים אך לא פענח דבר. המנוע הנוכחי מקשר (binds) את OpenJPEG 2.5.4 באופן סטטי והופך מקור של JP2 או J2K לפיקסלים ש-HotPDF יכול להציב על דף

מסנן ה-JPXDecode ב-PDF

תקן ISO 32000-1 מגדיר את מסנן JPXDecode בסעיף 7.4.9. אובייקט XObject של תמונת PDF מציין את הדחיסה שלו בערך /Filter של מילון הזרם, ו-JPXDecode הוא הערך שאומר שנתוני הזרם הם זרם קוד של JPEG 2000 ולא ה-JPEG הבסיסי ש-/DCTDecode נושא. המסנן הוא מה שמאפשר ל-PDF להכיל נתוני תמונה בדחיסת wavelet עם עומק סיביות גבוה, והוא מתיר גם את המצב ללא אובדן נתונים וגם את המצב עם אובדן נתונים של הקודק, משום שהמצב הוא מאפיין של זרם הקוד עצמו ולא של העטיפה שסביבו

הנקודה האחרונה היא זו ששווה להיאחז בה. JPEG 2000 הוא אלגוריתם יחיד עם מקרה מיוחד של ללא אובדן נתונים (lossless), ולא שני פורמטים נפרדים. ה-wavelet ההפיך 5/3 משחזר את הדגימות המקוריות במדויק; ה-wavelet הבלתי הפיך 9/7 מוותר על הדיוק הזה בתמורה לקובץ קטן יותר. מפענח מתייחס לשניהם באותה הדרך בזמן קריאה, וזו הסיבה ש-HotPDF זקוק רק למסלול פענוח אחד כדי לקבל כל מה שזרם JPXDecode זורק לעברו

מה המפענח עושה לפיקסלים

אובייקטי XObject של תמונות PDF במקרה הנפוץ מצפים ל-8 סיביות לרכיב ב-DeviceGray או DeviceRGB. ב-JPEG 2000 חורגים באופן שגרתי מכך, ומודל הרכיבים שלו הוא כללי יותר מאשר רסטר דחוס (packed raster), ולכן למפענח יש שלוש משימות לבצע לפני שניתן להשתמש בנתונים כתמונה רגילה

ראשית, רכיבים בעלי עומק סיביות גבוה נדגמים מחדש (resampled) ל-8 סיביות. דגימה של 12 סיביות או 16 סיביות מוקטנת לטווח שבין 0 ל-255 כך שהתוצאה היא רסטר רגיל של 8 סיביות. רכיבים בעלי סימן מוסטים לטווח ללא סימן תחילה. הפרט הזה חשוב משום שהוא כרוך באובדן נתונים בפני עצמו: סריקה בגווני אפור של 16 סיביות מאבדת את הטווח הטונאלי העמוק שלה ברגע שהיא הופכת לתמונת PDF של 8 סיביות, מה שמהווה פשרה נכונה עבור פלט למסך או להדפסה, אך לא עבור ארכוב מחדש

שנית, מרחב צבעים YCbCr (הקודק מכנה אותו SYCC) מומר ל-RGB. ה-JPEG 2000 מאחסן לעתים קרובות צבע במרחב luma-chroma למען יעילות דחיסה, אותו רעיון בו משתמש ה-JPEG הבסיסי, והמפענח מחיל את ההתמרה ההפוכה התקנית כך שהדף מקבל RGB אמיתי

שלישית, רכיבים בדגימת חסר (subsampled) עוברים upsampling על ידי שכפול שכן קרוב (nearest-neighbor replication). ערוצי הכרומה מאוחסנים לעתים קרובות בחצי רזולוציה, ולכן המפענח קורא כל רכיב בממדים שלו ובגורם הדגימה שלו, ואז משכפל דגימות כדי להביא כל ערוץ לגודל התמונה המלא לפני השילוב (interleaving). אלגוריתם "שכן קרוב" שומר על כך שהפעולה תהיה "זולה" מחשובית; הכרומה שהוא ממלא הייתה בתדר נמוך מלכתחילה, כך שהעלות הנראית לעין היא קטנה

תיבות JP2 לעומת זרם קוד גולמי של J2K

קובץ JPEG 2000 מגיע בשתי צורות, ו-HotPDF מזהה איזו מהן הוא קורא מהבתים הראשונים ולא מסיומת הקובץ. קובץ JP2 הוא קונטיינר (container) הבנוי מתיבות: הוא נפתח עם תיבת חתימה של שנים-עשר בתים 00 00 00 0C 6A 50 20 20 ועוטף את זרם הקוד לצד תיבות שמתארות מרחב צבעים, רזולוציה ומטא נתונים. זרם קוד גולמי של J2K אינו נושא שום קונטיינר כלל ומתחיל בסמן ה-SOC FF 4F FF 51. המפענח קורא את הבתים המובילים הללו, מזהה את החתימה, ובוחר את קודק ה-OpenJPEG התואם לכל מקרה

שתי הצורות מטופלות מכיוון ששתיהן מתרחשות ב"טבע". מכשירי לכידה וארכיונים שזקוקים למטא הנתונים הצדדיים פולטים JP2; כלים שרוצים את העומס (payload) הקטן ביותר האפשרי פולטים את זרם הקוד החשוף. סוג הפורמט ממודל כ-enum, TJpeg2000FileType, עם החברים jtInvalid, jtJP2, jtJ2K ו-jtJPT. החבר JPT מזהה את גרסת ההזרמה JPIP; גלאי חתימת הבתים מפענח את שתי הצורות שהוא מסוגל לפענח, JP2 ו-J2K, ומדווח על כל דבר אחר כ-jtInvalid כך שקלט שאינו נתמך נכשל בצורה נקייה במקום לייצר זבל

uses
  HPDFJpeg2000;

var
  Decoder: THPDFJpeg2000Decoder;
  Pixels: TJpeg2000ByteArray;
begin
  Decoder := THPDFJpeg2000Decoder.Create;
  try
    if Decoder.LoadFromStream(Input) then          // JP2 or J2K, auto-detected
      if Decoder.GetImageData(Pixels) then
        // Pixels is 8-bit interleaved, ColorComponents channels wide,
        // row-major top to bottom: ready for a DeviceGray/DeviceRGB XObject.
        ProcessRaster(Decoder.Width, Decoder.Height,
                      Decoder.ColorComponents, Pixels);
  finally
    Decoder.Free;
  end;
end;

ללא אובדן נתונים ועם אובדן נתונים בצד הקידוד

המפענח קורא את שני המצבים מבלי שיגידו לו באיזה מהם מדובר. הבחירה הופכת לפרמטר רק כאשר אתה הולך בכיוון ההפוך ומייצר קובץ JPEG 2000, ש-HotPDF יכול גם לעשות דרך מחלקת TJpeg2000Bitmap, יורשת של TBitmap שטוענת ושומרת נתוני רסטר כ-JP2. שני מאפיינים (properties) שולטים בפלט. LosslessCompression הוא ערך בוליאני שבוחר את ה-wavelet ההפיך כאשר הוא True; CompressionQuality הוא TJpeg2000QualityRange, מספר שלם מ-1 עד 100 שבו 1 הוא קטן ומכוער ו-100 הוא גדול ונאמן למקור. ברירות המחדל חיות בקבועים בעלי שם: Jpeg2000DefaultLosslessCompression הוא False ו-Jpeg2000DefaultLossyQuality הוא 80

ההחלטה היא החלטת תוכן. מצב ללא אובדן נתונים (Lossless) מתאים לעותק אב, סריקה רפואית או משפטית, כל דבר שעשוי להיות מקודד מחדש מאוחר יותר ושאסור לו לצבור ירידה באיכות מדור לדור. מצב אובדן נתונים (Lossy) באיכות 80 מתאים לתמונה שמיועדת למסך או להדפסה, כאשר הירידה החיננית באיכות של ה-wavelet מעניקה קובץ קטן יותר באופן ניכר ללא שום ארטיפקט (artifact) שקורא היה תופס. יש סייג אחד לגבי CMYK שיש לסמן: מפת הסיביות חושפת את SetCMYK כדי לסמן נתונים של ארבעה ערוצים כ-CMYK ולא כ-RGBA, מה שחשוב עבור מסלולי הדפסה ששומרים על הפרדות (separations) שלמות

uses
  HPDFJpeg2000;

var
  Bmp: TJpeg2000Bitmap;
begin
  Bmp := TJpeg2000Bitmap.Create;
  try
    Bmp.LoadFromStream(Source);              // decode an existing JP2/J2K
    Bmp.LosslessCompression := True;         // reversible 5/3 wavelet
    // or, for a smaller lossy file:
    // Bmp.LosslessCompression := False;
    // Bmp.CompressionQuality := 80;         // matches the default
    Bmp.SaveToStream(Output);                // always writes a JP2 file
  finally
    Bmp.Free;
  end;
end;

מדוע אין מסלול מסנני פענוח-בטעינה

עובדה ארכיטקטונית אחת מעצבת את האופן שבו אתה משתמש בכל זה, וקל להניח את ההפך. ל-HotPDF אין מסנן תמונה כללי של פענוח-בטעינה. כאשר אתה פותח PDF שכבר מכיל תמונת JPXDecode, המנוע אינו מפענח את הזרם הזה. הוא שומר על בתי ה-JPEG 2000 בדיוק כפי שהם, כך שהעתקת דף או מיזוג מסמך מעבירים את התמונה ללא מגע, בית אחר בית. למפענח יש נקודת כניסה יחידה, והיא בצד היצירה: פונקציית AddImage המבוססת על קובץ, המשוגרת לפי סיומת קובץ כדי לטפל במקורות .jp2, .j2k, .jpt ו-.jpc

פיצול זה הוא העיצוב הנכון ולא מגבלה. פענוח זרם JPX מובנה בזמן טעינה, רק כדי לקודד אותו מחדש בשמירה, יהפוך תמונת ארכיון ללא אובדן נתונים לתמונה עם אובדן נתונים וינפח כל מיזוג, כל זאת עבור תמונה שרק התכוונת להעביר מ-PDF אחד למשנהו. העברת הזרם כלשונו (verbatim) היא פעולה ללא אובדן נתונים ופעולה מהירה. הפענוח נדחה לרגע היחיד שבו הוא נדרש באמת: כאשר אתה מוסר למנוע קובץ JPEG 2000 מהדיסק ומבקש ממנו לרנדר (rasterize) את התמונה הזו לצורך הצבה בדף חדש. בנקודה זו הקובץ חייב להפוך לפיקסלים, והמפענח רץ

רישום תמיכה והצבת תמונה

רישום תמונת JPEG 2000 הוא אופציונלי (opt-in) מאחורי מתג ההידור HPDF_REGISTER_JPEG2000_PICTURE, שכובה כברירת מחדל. הסיבה לכך היא התנגשות אמיתית, לא זהירות: רישום פורמטי הקבצים jp2, j2k ו-jpc באופן גלובלי עם TPicture יכול להפריע לזיהוי פורמט ה-BLOB שעליו מסתמך TppDBImage של ReportBuilder. הגדר את המתג כאשר אינטגרציה זו אינה מעורבת, ופורמטי הקבצים יירשמו כך ש-TPicture יזהה אותם; השאר אותו לא מוגדר ושיגור (dispatch) הסיומת AddImage עדיין יפענח קבצי JPEG 2000 ישירות, כי מסלול זה אינו עובר דרך TPicture כלל

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

var
  Pdf: THotPDF;
  ImgIndex: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginDoc;
    Pdf.AddPage;
    // The .jp2 source is decoded through the OpenJPEG backend, then
    // re-embedded with the compression you request here.
    ImgIndex := Pdf.AddImage('Scan_16bit.jp2', icJpeg);
    // x, y, width, height in points; final 0 is the rotation angle.
    Pdf.ShowImage(ImgIndex, 72, 72, 400, 300, 0);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

הדחיסה שאתה מעביר ל-AddImage שולטת באופן שבו התמונה המפוענחת מאוחסנת מחדש, לא באופן שבו היא נקראה. קובץ JPEG 2000 שפוענח למפת סיביות (bitmap) יכול לצאת בחזרה כ-DCTDecode JPEG, כרסטר (raster) Flate, או מסנן נתמך אחר, לפי מה שמתאים למסמך. הפענוח מ-JP2 או J2K מתרחש קודם לכן ללא קשר, כך שאותה קריאה מקבלת מקור דחוס wavelet ומשלבת אותו בכל צורה ששאר מסלול העבודה שלך מצפה לה

לתמונה רחבה יותר של האופן שבו תמונות וגופנים מגיעים לפלט שנוצר, עיין בהערות שלנו על פלט דוחות עם גופנים ותמונות. כאשר המסמך שאתה מרכיב עושה שימוש חוזר בתוכן מ-PDF קיימים, התנהגות ה"מעבר דרך" (passthrough) המתוארת כאן משתלבת עם מכניקת המיזוג והעדכון בזרמי אובייקטים (object streams) ועדכונים מצטברים. מנוע פענוח ה-JPEG 2000 מסופק כחלק מ-HotPDF Component עבור Delphi ו-C++Builder, לצד ממשקי ה-API של תמונה, גופנים ומסמכים הנסקרים במקומות אחרים בבלוג זה