stream המסומן /Predictor 12 לא אומר שכל שורה משתמשת בפילטר PNG 2. HotPDF, רכיב ה-VCL הילידי ל-PDF עבור Delphi ו-C++Builder, מתייחס לערכי predictor 10 עד 15 כמשפחה אחת: תג הפילטר האמיתי, 0 עד 4, הוא הבייט הראשון של כל שורה מקודדת, ו-HPDFDecodePredictor קורא ומאמת את התג הזה שורה אחר שורה. ההבחנה הזו היא הצורה של כמעט כל באג בפינה הזו של PDF, כי שום דבר לא מתריע כשטועים בה. שרשרת הפילטר רצה, ה-raster בגודל שציפיתם לו, והתמונה יוצאת כרעש אלכסוני או כגרדיאנט שנודד רחוק יותר עם כל scanline. חמשת המספרים ב-/DecodeParms (ISO 32000-1 §7.4.4) בעיקר משנים את משמעות הבייטים ולא את אורכם, כך שערך שגוי מפיק זבל סביר במקום שגיאה
מדוע /Predictor 12 לא אומר פילטר PNG 2 בכל שורה?
כי מספר ה-predictor רק אומר "חיזוי PNG בשימוש", לא איזה פילטר. מקודדי PNG בוחרים פילטר לכל scanline והפילטר של PDF יורש זאת, כך שערכי predictor 10 (None), 11 (Sub), 12 (Up), 13 (Average), 14 (Paeth) ו-15 (Optimum) כולם מפוענחים זהה: בייט התג המוביל של כל שורה הוא מה שהמפענח חייב לציית לו. השלכת הפריסה חשובה כמו הסמנטיקה. כל שורה מקודדת אורכה 1 + RowBytes בייט, הקלט לכן עולה על הפלט בדיוק במספר השורות, ו-stream שאורכו אינו כפולה שלמה של RowBytes + 1 נקטע בהגדרה. HotPDF בודק את הגבול הזה לפני שהוא נוגע בבייט, דוחה כל תג מעל 4 עם Invalid PNG predictor row tag, וקורא את השורה הקודמת ישירות מתוך ה-buffer פלט היחיד במקום להמחיש מערך שורות דו-ממדי. פילטרים 1 ו-3 חוזרים אחורה BytesPerPixel בתוך השורה הנוכחית, פילטר 2 קורא ישר למעלה, פילטר 4 מריץ את בחירת Paeth על פני שמאל, מעלה, ומעלה-שמאל — וכל הארבעה פועלים על פלט שכבר שוחזר, וזו הסיבה ששורת-המעלה חייבת להיות השורה המפוענחת ולעולם לא הקלט המסונן
uses
HPDFPredictor;
var
Filtered, Raster: AnsiString;
ErrorText: string;
begin
// /DecodeParms << /Predictor 12 /Colors 3 /BitsPerComponent 8 /Columns 1024 >>
if HPDFTryDecodePredictor(Filtered, 12, 3, 8, 1024,
Int64(1024) * 3 * 8192, Raster, ErrorText) then
ConsumeRaster(Raster)
else
LogStreamDefect('predictor', ErrorText); // no exception, no partial raster
end;
הארגומנט MaxOutputBytes אינו קישוט. שלב predictor הוא שלב פענוח דחיסה במסווה, וערך /Columns עוין או פשוט שבור הופך כמה קילו-בייט של קלט לבקשת הקצאה של מספר גיגה-בייט. HotPDF מחשב ביטים לשורה, בייטים לשורה וגודל raster כולל ב-Int64 תחילה, מסרב לגיאומטריה שגולשת, ומכבד את התקרה שסופקה על ידי הקורא. מעבירים גבול אמיתי הנגזר ממילון התמונה, ומצב הכשל הופך להודעה מתועדת במקום דיאלוג אזל-הזיכרון במחשב של לקוח
מדוע TIFF Predictor 2 משחית תמונות 4-סיביות?
כי Predictor 2 הוא הבדלה אופקית (horizontal differencing) לכל דגימה, לא לכל בייט, ובביט אחד, שניים או ארבעה לרכיב, כמה דגימות חולקות בייט. המימוש הנפוץ מוסיף בייט N-Colors לבייט N, מה שקורה להיות נכון ב-8 ביט לרכיב ושגוי בשקט בכל מקום אחר. סריקת RGB 8-ביט מפוענחת בצורה מושלמת, ואז אותו קוד הורס תמונה מאונדקסת 4-ביט בפעם הראשונה שאחת כזו מופיעה בייצור
האריתמטיקה הנכונה עובדת בתוך שדה הביטים. HotPDF עובר על דגימות מאינדקס Colors עד Colors * Columns - 1, מחלץ את הדגימה ואת שכנתה השמאלית מאותו רכיב עם מסכה של (1 shl BitsPerComponent) - 1 בהזזה המתאימה, מוסיף אותן מודולו אותה מסכה, וכותב את התוצאה בחזרה בלי להפריע לדגימות האחרות הארוזות באותו בייט. הזנב חשוב גם הוא: שורה מרופדת לגבול בייט, כך שביטי הריפוד אחרי הדגימה האחרונה חייבים לשרוד ללא נגיעה במקום להתקפל לתוך האריתמטיקה. ב-16 ביט לרכיב כל דגימה היא זוג בייטים big-endian וההוספה עוטפת ב-$FFFF על פני הזוג במקום לשאת בין בייטים באופן עצמאי; ב-8 ביט הרקורסיה הפשוטה של בייט נכונה, מתקדמת לפי Colors כך שאדום מצטבר מול אדום ואלפא מול אלפא. בכל וריאנט הפיקסל הראשון בשורה הוא literal, לעולם לא הפרש, והרקורסיה מתחילה מחדש בכל גבול שורה — חיזוי TIFF לעולם לא קורא את השורה שמעל, וזה כל ההבדל בינו לבין משפחת PNG
מה EarlyChange באמת שולט בו ב-LZWDecode?
הוא שולט מתי הקורא מרחיב את גודל הקוד שלו בביט אחד, והיות קוד אחד לא בסנכרון משחית כל מה שבא אחריו. HotPDF מבטא את הכלל כאינווריאנט יחיד: אחרי הוספת רשומת מילון, הקריאה הבאה מתרחבת כאשר NextCode מגיע ל-(1 shl CodeSize) - Ord(EarlyChange). עם /EarlyChange 1, ברירת המחדל של ISO 32000-1 §7.4.4, המעבר קורה קוד אחד מוקדם; עם /EarlyChange 0 הוא קורה בדיוק בגבול. שניהם מופיעים בקבצים אמיתיים ושום דבר ב-bitstream לא אומר לכם באיזה מהם המקודד השתמש. שאר מכונת המצבים חייבת לנוע בצעד אחיד: קוד clear מאפס גודל קוד, מסכת ביטים, קוד חופשי הבא ואחסון הביטוי ביחד, וקוד end-of-information נקרא ברוחב שנוכחי באותו רגע, לא ב-9 הביטים ההתחלתיים. HotPDF מתחיל ב-InitialCodeSize 9, מכסה את גודל הקוד ב-12 ואת המילון ב-4096 רשומות, וברירת המחדל של FillOrder היא foTop כי PDF אורז קודים עם הביט הגבוה-ראשון — foBottom קיים עבור streams בסגנון TIFF שלא עושים זאת
uses
HPDFLZW;
var
Decoder: TPDFLZWDecompressor;
Parms: TPDFLZWParms;
Plain: AnsiString;
begin
Decoder := TPDFLZWDecompressor.Create;
try
Decoder.EarlyChange := True; // /EarlyChange 1 is the PDF default
Decoder.FillOrder := foTop; // high-order bit first
Decoder.MaxOutputBytes := 256 * 1024 * 1024;
Decoder.RequireInitialClear := False;
Decoder.RequireEndOfInformation := False;
Parms.Predictor := 12;
Parms.Colors := 3;
Parms.BitsPerComponent := 8;
Parms.Columns := 1024;
Parms.ExpandedTo8Bit := False;
Parms.ColorSpace := 'DeviceRGB';
if Decoder.TryDecompress(RawStreamBytes, Parms, Plain) then
LogDecodeStats(Decoder.PeakCodeSize, Decoder.DictionaryAdds,
Decoder.KwKwKExpansions, Decoder.OutputBytes)
else
LogStreamDefect('lzw', Decoder.LastError);
finally
Decoder.Free;
end;
end;
הסטטיסטיקות קיימות למיון (triage), לא ליהירות. כשקובץ מפוענח לאורך הנכון אך לפיקסלים השגויים, PeakCodeSize ו-DictionaryAdds אומרים לכם מיד אם הקורא אי פעם התרחב במקום שהכותב עשה זאת. מחליפים את EarlyChange, מפענחים שוב, משווים את השניים: אם המספרים זזים, יש לכם תשובה בהרצה אחת במקום לצעוד דרך קורא ביטים
הענף KwKwK, ומתי stream צריך פשוט להיכשל
המקרה החוקי היחיד שנראה בלתי חוקי הוא Code = NextCode, ו-HotPDF מטפל בו על ידי בניית הרשומה לפני פליטתה. מקודד עשוי לפלוט את הקוד עבור ביטוי שהוא מגדיר באותו צעד, מה שקורה בכל פעם שהקלט מכיל תבנית מהצורה K w K w K; המפענח לא יכול לחפש את הקוד הזה כי הוא עדיין לא קיים, כך שהוא חייב לבנות Previous + First(Previous), להוסיף אותו כרשומה חדשה, ולפלוט את הרשומה שהוא זה עתה יצר. HotPDF סופר את אלה ב-KwKwKExpansions ומצליב-בודק שהקוד שהוא הוסיף הוא הקוד שהתבקש. כל מה שמעל NextCode הוא השחתה, ושם מפענח צריך לעצור במקום לאלתר: HotPDF מעלה חריגה על קוד עתידי, על קידומת מילון שמצביעה מחוץ לזירת הביטוי, על מילון מלא, ועל קוד ראשון שאינו literal. שני מתגי קפדנות כבויים בכוונה כברירת מחדל, RequireInitialClear ו-RequireEndOfInformation, כי הרבה קבצי PDF בייצור משמיטים את קוד ה-clear המוביל או נגמרים להם הנתונים ללא סיומת. מדליקים אותם בעת אימות הפלט שלכם, משאירים אותם כבויים בעת צריכת קבצים מהעולם החיצון
איפה /DecodeParms באמת נקרא בצד המסמך הטעון
HotPDF פותר את /DecodeParms או את קיצורו /DP על מילון זרם התמונה, מקבל או מילון או מערך ולוקח את האיבר האחרון כשזה מערך, ואז נושא את Predictor, Colors, BitsPerComponent, Columns ו-EarlyChange לתוך נתיב ה-raster. מקרה המערך הוא זה שאנשים שוכחים: stream המסונן על ידי [/ASCII85Decode /FlateDecode] נושא מערך פרמטרים מקביל, וההגדרות של predictor שייכות לפילטר האחרון, לא לראשון
var
Pdf: THotPDF;
Info: THPDFLoadedImageInfo;
Bmp: TBitmap;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('scanned.pdf', '') > 0 then
for I := 0 to Pdf.GetLoadedImageCount - 1 do
if Pdf.GetLoadedImageInfo(I, Info) then
begin
Bmp := Pdf.ExtractLoadedImage(I); // nil when the raster is unusable
if Bmp <> nil then
try
Bmp.SaveToFile(Format('image-%d.bmp', [I]));
finally
Bmp.Free;
end;
end;
finally
Pdf.Free;
end;
end;
פגם היסטורי אחד בנתיב הזה שווה למנות, כי קטגוריית הבאג חוזרת על עצמה. שגרת ה-Flate-עם-פרמטרים הישנה יצרה stream של פענוח דחיסה ואז העתיקה מהקלט הדחוס המקורי, כך ששלב ה-predictor קיבל בייטים דחוסים וביטל-חיזוי אותם בנאמנות: תמיד שגוי, לעולם לא מעלה חריגה. הקוד הנוכחי קורא רק מהמפענח לפני שהוא מעביר את התוצאה ל-predictor המשותף, והוא דוחה raster קצר יותר מהגודל המחושב במקום ליפול חזרה לבייטים שעדיין דחוסים — fallback שהיה הופך כשל פענוח לבמפ מושחת. אותו מימוש predictor משרת עכשיו גם cross-reference streams, שזו עקביות מועילה אם גם עובדים עם object streams ועדכונים מצטברים, ומנגנון החילוץ הסובב מכוסה במאמר הנלווה על חילוץ תמונות טעונות ופילטרי הפענוח שלהן. תמונות שמגיעות כ-DCTDecode או JPXDecode לעולם לא מגיעות ל-predictor כלל; הן נושאות מודל פיקסל דחוס משלהן
תפוקה: זירת ביטוי רציפה מול מחרוזות לכל רשומה
החלפת מילון המחרוזות לכל-רשומה בזירת ביטוי רציפה מדדה בערך פי 1.61 מהיר יותר על קלט פתולוגי: 1558 MiB/s מול 969 MiB/s על benchmark שהביטוי הבודד הארוך ביותר שלו מגיע ל-7,370,880 בייט. הצורה של הקלט הזה מסבירה את הפער, כי המימושים הקלאסיים בוחרים אחת משתי עסקאות רעות. מילון של ערכי AnsiString מקצה ומעתיק מחרוזת חדשה עבור כל אחת מעד 4096 רשומות, כל רשומה חדשה מעתיקה את הוריה במלואו; מחסנית prefix/suffix נמנעת מהזיכרון הזה לגמרי אך בונה מחדש כל ביטוי על ידי מעבר על השרשרת אחורה בייט-בייט והיפוכה, מה שבסדר עבור טקסט רגיל וכואב כשביטוי אחד מגיע למגה-בייטים. HotPDF מצרף כל ביטוי ברציפות לזירה שגדלה גיאומטרית, מאנדקס רשומות לפי offset ואורך, ופולט ביטוי עם Move יחיד לתוך ה-buffer פלט. העלות הכנה היא זיכרון: זירה המחזיקה כל ביטוי במלואו מוגבלת על ידי סכום כל אורכי הביטוי ולא על ידי ספירת הרשומות, וזו בדיוק הסיבה ש-MaxOutputBytes קיים גם על המפענח וגם על ה-predictor. גוזרים את הגבול הזה ממה שמילון התמונה טוען שה-raster צריך להיות, ו-stream שמשקר נכשל מהר
מפענח ה-LZW, ה-predictor המשותף ונתיב חילוץ התמונה הטעונה המוצגים כאן מגיעים כחלק מהגרסה הסטנדרטית של HotPDF Component עבור Delphi ו-C++Builder, עם הפניית הפילטרים וה-DecodeParms המלאה בדף המוצר