מאמר טכני

רנדרר PDF לא מצייר כלום: ארבעה באגים שקטים ב-Delphi

רנדרר PDF שלא מצייר כלום בדרך כלל אין לו שום באג בקוד הציור שלו. ב-HotPDF Component עבור Delphi ו-C++Builder, ארבעה פגמים נפרדים גרמו לעמודים להיות מרונדרים ריקים בעוד כל שורת יומן נשארה נקייה: אופרנדים של שם הנושאים קו נטוי מוביל, קונקטנציית cm הפוכה, ואינדקס טוקן שקרא אפס. אף אחד מהם לא זרק חריגה. אף אחד מהם לא רשם ביומן. זרם התוכן נתפרק לטוקנים כראוי, מפעיל האופרטורים זיהה כל אופרטור, ה-XObject של התמונה פוענח לביטמאפ תקין, ואז העמוד יצא ריק. השילוב הזה — צינור שמדווח הצלחה בכל שלב ומפיק לא כלום נראה — הוא החתימה של חיפוש או אינדקס שמפספסים בשקט במקום להיכשל. זהו ניתוח שלאחר מוות של משפחה אחת כזו, ושל משמעת הבדיקות שאפשרה לה לשרוד 38 גרסאות

מדוע רנדרר PDF לא מצייר כלום בכלל?

כי חיפוש משאב שנכשל ברנדרר PDF אינו ניתן להבחנה מעמוד ריק. אופרנדים של שם בזרם תוכן ומפתחות מילון משאבים הם שני מרחבי מחרוזת שונים, ו-HotPDF השווה ביניהם בלי לנרמל. הטוקנייזר קורא /Im0 ושומר את הקו הנטוי, כי זה מה שהטוקן הוא; מילון /Resources /XObject הטעון מאחסן את המפתח כ-Im0, כי המנתח מסיר את המפריד כשהוא בונה מפתחות מילון. כל FindValue מול שם אופרנד לכן החזיר -1. רדיוס הפיצוץ היה רחב יותר מתמונות. ISO 32000-1 §8.9 מכסה את Do, §8.4 מכסה את gs ואת חיפוש ה-/ExtGState שלו, §8.6 מכסה את cs ו-CS, ו-§8.7.4.3 מכסה את sh. כל חמשת האופרטורים מיפתחו את תת-מילון המשאבים שלהם לפי האופרנד הגולמי, כך שכולם פספסו. מרחבי צבע בשם נפלו בחזרה ל-DeviceGray, מה שהופך 1 scn לדיו לבן על עמוד לבן. XObjects של תמונה מעולם לא צוירו כלל — נתיב תמונת הביטמאפ, בפועל, מעולם לא עבד מהיום שהוא נחת. התיקון הוא helper ברמת היחידה (unit) המיושם בכל חיפוש הממופתח לפי אופרנד, וזו הדרך היחידה למנוע מהמוסכמה לנדוד שוב

// Page content stream, the ordinary image-placement idiom:
//   q
//   /GS0 gs
//   200 0 0 120 60 400 cm
//   /Im0 Do
//   Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.

function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
  Result := N;
  if (Result <> '') and (Result[1] = '/') then
    Delete(Result, 1, 1);
end;

// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
  Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
  FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
  Exit;

פספוס שני, קשור, ישב שכבה אחת למטה. לרנדרר היו resolvers מוקלדים עבור streams ומילונים בלבד, כך שהפניה עקיפה שמצביעה על אובייקט מערך ברמה עליונה — ה-/CS0 5 0 R הנפוץ עם [/Separation ...] בקצה השני — נפתרה ל-nil דרך שניהם ונפלה בחזרה לקישור הבלתי-פתור. הוספת resolver אובייקט גנרי תיקנה מרחבי צבע בשם ומערכי פונקציה במהלך אחד. אם אתם מחווטים מילוני גוון, אותה משמעת פתרון חלה על נתיב הגוון הצירי והרדיאלי, שם הרשומה /Function היא מאוד לעיתים קרובות עקיפה

אופרטור ה-cm וקונקטנציה שנכתבה הפוך

הפגם השני מיקם תמונות בערך מאה אלף פיקסלים מחוץ לעמוד, מה שנראה בדיוק כמו לא לצייר אותן בכלל. ISO 32000-1 §8.3.4 מגדיר טרנספורמציות PDF עם וקטורי שורה, ואופרטור ה-cm משרשר את מטריצת האופרנד שלו M על מטריצת הטרנספורמציה הנוכחית כ-M × CTM — M נכנס לתוקף ראשון, ה-CTM הקיים אחר כך. HotPDF מרכיב מטריצות דרך HPDFMatMul(A, B), שמפעיל את B לפני A. הקריאה הנכונה לכן מעבירה את ה-CTM הישן כ-A. הקוד שנשלח העביר את מטריצת האופרנד כ-A, ומייצר CTM × M

סדר הפוך לא מזיק עבור cm יחיד וקטסטרופלי עבור האידיום הדו-שלבי הסטנדרטי. ממקמים תמונה עם 1 0 0 1 x y cm ואחריו w 0 0 h 0 0 cm והמפל הנכון מקנה את ריבוע היחידה ב-(w, h) ואז מזיז אותו ב-(x, y). תחת המפל ההפוך ההזזה נכנסת קודם והקנה מכפיל אותה, כך שתמונה שנומינלית ב-(60, 400) מוקנה ל-200 על 120 נוחתת ב-(12000, 48000). בדיקת ה-clip בראש ה-blit דוחה אותה, ה-blit מדולג, ושום דבר בשום מקום לא מדווח על בעיה

// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.

// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                    NumAt(3), NumAt(2), NumAt(1)), GS.CTM);

// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                            NumAt(3), NumAt(2), NumAt(1)));

מה שהופך את זה למלמד הוא שאותו קובץ מקור כבר הכיל את הסדר הנכון. לרשומת /Matrix של Form XObject הייתה אותה הרכבה הפוכה, אך נתיב הגליף מסוג Type 3 ונתיב מתאר הגליף המוטמע שניהם קלעו נכון מההתחלה, כי מיקום גליף קורס לראייה אל הראשית כשהופכים אותו ומישהו כבר נאלץ לתקן זאת. שתי מוסכמות התקיימו יחד ביחידה אחת במשך שלוש תריסריות גרסאות, כל אחת נכונה בפונקציה שלה, ואף בודק לא שם לב כי אף אחת מנקודות הקריאה לא נראתה שגויה בבידוד

מה קורה כשאינדקס טוקן שגוי באחד?

מקבלים שנים עשר אופרטורים שמטופלים תחבירית ומתים סמנטית. גישת האופרנד ברנדרר היא NumAt(Back), שקוראת Tokens[OpIndex - Back], ו-OpIndex הוא האינדקס של טוקן האופרטור עצמו. אופרטור בעל אופרנד יחיד לכן מוצא את מספרו ב-back 1. שנים עשר מהם נכתבו כ-NumAt(0), שקוראת את טוקן האופרטור, נכשלת בבדיקת הסוג ctOperandNumber, ומחזירה את ברירת המחדל אפס. הרשימה היא Tc, Tw, Tz, TL, Ts ו-Tr מאופרטורי מצב הטקסט של ISO 32000-1 §9.3, בתוספת w, J, j, M, ri ו-i מאופרטורי מצב הגרפיקה של §8.4.3. ריווח תווים ומילים הפכו ל-no-ops, קנה מידה אופקי לעולם לא הוחל, ה-leading נשאר באפס כך ש-T* לעולם לא התקדם שורה, הרמת טקסט לא עשתה דבר, מצב הרינדור היה תמיד מילוי, וכל stroke בכל מסמך יצא כקו-שיער ברוחב פיקסל אחד ללא קשר לרוחב הקו המוצהר. אופרטורים רבי-אופרנדים כמו m, rg ו-Tm השתמשו ב-NumAt(1..6) והיו כולם נכונים, כך שבודק שסורק את הפונקציה ראה חומת אריתמטיקת אינדקסים סבירה עם שנים עשר רשומות שגויות משובצות בתוכה

function NumAt(Back: Integer): Double;
begin
  Result := 0;
  if (OpIndex - Back >= 0)
    and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
    Result := Tokens[OpIndex - Back].NumValue;
end;

// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1)   // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading   := NumAt(1)   // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w'  then GS.LineWidth      := NumAt(1)   // previously NumAt(0)

מדוע חבילת הבדיקות נשארה ירוקה במשך 38 גרסאות?

כי ה-assertions היו חלשים מכדי להבחין בין עמוד מרונדר לבין עמוד מרונדר חלקית. בדיקות העשן של הרינדור אמרו דברים כמו הביטמאפ הפלט אינו שחור לגמרי, או העמוד אינו ריק, או תקציר התמונה אינו אפס. כל אחת מהן מתקיימת כשטקסט מרונדר ותמונות לא. הטקסט צויר בסדר גמור, כך שה-frame buffer מעולם לא היה אחיד, התקציר מעולם לא היה אפס, וחבילת הבדיקות דיווחה הצלחה בעוד כל צינור התמונה היה קוד מת בפועל. assertions חלשים מפתים עבור גרפיקה בדיוק כי חזקים נראים שבירים. אף אחד לא רוצה בדיקה שנשברת כשקצה anti-aliasing זז בפיקסל אחד, כך שהנסיגה הטבעית היא לאשר משהו ששום שינוי סביר לא היה מפר — והנסיגה הזו מנחיתה אתכם על פרדיקטים ששום שינוי לא-סביר גם לא יכול להפר. בדיקת מרחב-צבע הפרדה אישרה שהפלט ניתן להבחנה משחור; אפור על לבן עברה אותה, וכך גם לבן על לבן. הבדיקה לא מדדה אם הצבע הנכון צויר. היא מדדה אם משהו כלשהו קרה על הקנבס

כיצד כותבים assertion של רינדור שבאמת נכשל?

סופרים פיקסלים בצבע הצפוי, בכמות הצפויה, ונותנים למיקום ולגודל להיגזר מהספירה. משמעת ההחלפה היא PDF מינימלי בנוי-ביד, עובדה חזותית אחת לקובץ, ו-assertion על כמה פיקסלים נוחתים בתוך סבילות של שלישיית RGB ספציפית. תמונה אדומה טהורה בגודל 200 על 120 הממוקמת ב-offset ידוע חייבת להפיק בערך 24000 פיקסלים אדומים. אם החיפוש נכשל, הספירה היא 0. אם מפל ה-cm הפוך, הספירה היא 0. אם התמונה מרונדרת במרחב צבע שגוי, הספירה היא 0. מספר אחד תופס את כל השלושה, ורצועת הסבילות סופגת את רעש ה-anti-aliasing שגרם לאנשים להירתע מהשוואה מדויקת מלכתחילה

function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
  X, Y: Integer;
  C: TColor;
begin
  Result := 0;
  for Y := 0 to Bmp.Height - 1 do
    for X := 0 to Bmp.Width - 1 do
    begin
      C := Bmp.Canvas.Pixels[X, Y];
      if (Abs(GetRValue(C) - R) <= Tol)
        and (Abs(GetGValue(C) - G) <= Tol)
        and (Abs(GetBValue(C) - B) <= Tol) then
        Inc(Result);
    end;
end;

// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
  'image XObject was never drawn');

ארבע בדיקות עשן נכתבו מחדש בדרך הזו — טרנספורמציית גוון Type 4, מיקום Do של תמונה, מקרה נראות תוכן-אופציונלי ומצב stroke של Tr — ויחד הן חשפו את כל המשפחה. זה השיעור האמיתי, והוא מוכלל מעבר לבסיס הקוד הזה: בצינור רינדור, ה-assertion חייב לנקוב בצבע. כל דבר רך יותר הוא בדיקה שהרנדרר רץ, לא בדיקה שהוא צייר. אם אתם בונים harness משלכם מעמוד-לביטמאפ, הסקירה של רסטריזציית עמוד היא המקום הטבעי להצמיד אליו helper לספירת פיקסלים על הרגרסיה הראשונה שלכם

גבולות כנים

שני גבולות שווים ציון ברור. מצבי רינדור clip של טקסט 4 עד 7 מצוירים כמצב המילוי או ה-stroke הבסיסי שלהם, כי הרנדרר לא ממדל נתיבי clip מצטברים ממתארי גליף; מסמכים שמסתמכים על clipping בצורת-טקסט ירנדרו את הטקסט במקום את האמנות המנוקזת שמתחתיו. ומשמעת ספירת הפיקסלים המתוארת כאן היא טכניקת smoke-test, לא חבילת התאמה — היא מוכיחה שעובדה חזותית ספציפית הגיעה ל-frame buffer, שזה רף הרבה יותר נמוך מהוכחת שהפלט תואם רסטריזייזר ייחוס. זה, עם זאת, בדיוק הרף שארבעת הבאגים הללו נכשלו לעבור אותו שלוש שנות גרסאות

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