מאמר טכני

רקורסיה של Form XObject: זיהוי מעגלים ב-PDFlibPas ב-Delphi

PDFlibPas פותרת קריאות Form XObject רקורסיביות בזרמי תוכן PDF ב-Delphi על ידי מעקב אחר שרשרת הקריאה הפעילה, לא קבוצת-ביקורים גלובלית, כך ש-TPDFlib.EnumPageContentStatesEx יכולה לעבור על אותו Form שנקרא כמה פעמים על עמוד אחד בלי לבלבל שימוש-חוזר לגיטימי עם מעגל. Form XObject של חותמת בתבנית חשבונית הוא המקרה הטיפוסי: אותו אובייקט נקרא מהכותרת העליונה, הכותרת התחתונה, ושכבת סימן-מים על עמוד אחד, ורק שרשרת קריאה שלולאת בחזרה על עצמה היא מעגל אמיתי

‏ISO 32000-1 סעיף 8.10 מגדיר Form XObject כזרם תוכן עצמאי שעמוד, או Form אחר, קורא לו עם אופרטור ה-Do, שלם עם מערכת קואורדינטות משלו ב-/Matrix, גבול גזירה במערכת הקואורדינטות ההיא ב-/BBox, ובאופן אופציונלי מילון משאב משלו. שום דבר במפרט לא מגביל כמה פעמים Form אחד יכול להיקרא או כמה עמוק Forms יכולים לקרוא זה לזה, כך שמפענח תואם חייב לקבל שימוש-חוזר לגיטימי וקינון לגיטימי בעוד עדיין מגן על עצמו מפני הסידור האחד שהמפרט כן אוסר: Form שזרם התוכן שלו, ישירות או טרנזיטיבית, קורא לעצמו. PDFlibPas מדווחת על ההבחנה הזו דרך ערכי TPDFlibContentFormTraversalStatus שמחוברים לכל תמונת-מצב Do, בעיקר ftsEnumerated עבור ירידה מוצלחת ו-ftsCycle עבור המקרה היחיד שבאמת לולאה

למה שימוש חוזר באותו Form XObject לא מפעיל מעגל-כוזב?

הפניה חוזרת ל-Form XObject אינה, בפני עצמה, הוכחה שמשהו לא בסדר. ‏ISO 32000-1 מאפשר לאותו אובייקט Form להיקרא ממקומות רבים ככל שהמחבר רוצה בזרם תוכן, וזו בדיוק הדרך שחותמת לוגו, תבנית נייר-מכתבים, או כותרת-מספר-עמוד עושה שימוש חוזר על פני עמוד בלי לשכפל את זרם התוכן שלה כמה פעמים. השמירה התמימה מפני רקורסיה בורחת היא קבוצת-ביקורים בודדת ממופתחת לפי מספר אובייקט: בפעם הראשונה שסייר רואה אובייקט Form 12, הוא מסמן 12 כנראה ומסרב להיכנס אליו שוב בכל מקום אחר בעץ. הגישה הזו נשברת ברגע שאותה חותמת מופיעה בשתי פינות בלתי-קשורות של עמוד אחד, משום שהקריאה השנייה, הלגיטימית לחלוטין, מגיעה אחרי שמספר האובייקט כבר מסומן-נראה ונדחית כאילו הייתה לולאה

PDFlibPas נמנעת מהחיוב-הכוזב ההוא על ידי הגדרת היקף זיהוי-מעגל לשרשרת הקריאה הנוכחית ולא כל המסמך. ‏EnumPageContentStatesEx דוחפת את זרם ה-Form שנפתר לתוך שרשרת הקריאה הפעילה מיד לפני שהיא יורדת לתוכו, ואז מקפיצה (pop) את אותה רשומה בחזרה החוצה ברגע שהירידה חוזרת, בהצלחה או לא. הפעלה-אחות של אותו זרם בדיוק מתחילה רק אחרי שהראשונה כבר קפצה החוצה, כך ששרשרת הקריאה נקייה מהזרם ההוא עד שהקריאה האחות בודקת אותו, והסייר עובר עליו בדיוק כמו כל Form אחר. מעגל אמיתי נראה שונה על אותה שרשרת בדיוק: Form A קורא ל-Form B, ‏B עדיין פתוח בשרשרת כשהתוכן שלו עצמו קורא בחזרה ל-A, ו-A עדיין יושב בשרשרת מהקריאה החיצונית שעדיין לא חזרה — זו הצורה היחידה ש-ftsCycle מדווחת, זרם Form שעדיין פתוח איפשהו מוקדם יותר בשרשרת הקריאה הנוכחית, לא רק נוכח איפשהו אחר בעמוד

עד כמה עמוקה רקורסיית Form XObject יכולה להגיע לפני ש-PDFlibPas עוצרת אותה?

זיהוי-מעגל והגבלת-עומק פותרים שתי בעיות שונות, ו-PDFlibPas שומרת עליהן כשתי תוצאות TPDFlibContentFormTraversalStatus שונות בדיוק מהסיבה הזו. שרשרת של עשרים Forms נבדלים, כל אחד קורא לבא, ואף אחד מהם לא חוזר, אינה מעגל בשום הגדרה — בדיקת השרשרת-הפעילה אף פעם לא מוצאת זרם חוזר — אבל עשרים רמות כנות של קינון עדיין עשרים רמות של פענוח, שרשור מטריצה, ופתרון משאב שקובץ PDF פגום או עוין יכול היה לדחוף גבוה יותר באופן שרירותי אם שום דבר אחר לא היה עוצר אותו. ‏EnumPageContentStatesEx מקבלת פרמטר MaxFormDepth בדיוק מהסיבה הזו וכובלת כל ערך שנמסר למקסימום של 64, ללא קשר למה שקוד קורא מבקש. עומק אפס הוא מקרה מיוחד ששווה לדעת בפני עצמו: הוא מבטל רקורסיית Form לחלוטין ומשחזר את ההתנהגות השטוחה, עמוד-בלבד, של הפונקציה הישנה יותר EnumPageContentStates, וזו הסיבה שכל תמונת-מצב Do באותו מצב מדווחת ftsNotRequested במקום לנסות משהו

var
  Lib: TPDFlib;
  States: array of TPDFlibContentGraphicsState;
  Count, I: Integer;
begin
  Lib:= TPDFlib.Create;
  try
    if Lib.LoadFromFile('invoice-batch.pdf', '')<> 1 then
      Exit;
    Lib.SelectPage(1);
    Count:= Lib.EnumPageContentStatesEx(True, 8, States);  // count only
    SetLength(States, Count);
    Lib.EnumPageContentStatesEx(True, 8, States);          // fill
    for I:= 0 to Count- 1 do
      if States[I].FormTraversalStatus= ftsCycle then
        LogSuspectForm(States[I].XObjectResource, States[I].ContentDepth);
  finally
    Lib.Free;
  end;
end;

מעקב-משנה אחד לכל הפעלה: בידוד מצב-גרפיקה

כל ירידה לתוך Form XObject מקבלת מעקב מצב-גרפיקה משלה במקום לחלוק את זה שכבר עובר על העמוד, משום שזרם התוכן של Form נדרש להשאיר את מצב הגרפיקה בדיוק כפי שהוא מצא אותו, ו-PDFlibPas לא יכולה להניח שכל PDF שהיא פותחת בפועל מכבד את הדרישה הזו. מעקב הבן מתחיל מתמונת-מצב של איזה CTM, מצב צבע, ופרמטרי טקסט שהיו פעילים בהוראת ה-Do הקוראת, ואז מאפס את מחסנית השמירה-ושחזור שלו עצמו ומעקב-הנתיב-הנוכחי לריק לפני ביצוע הוראה בודדת של ה-Form. ‏q בלתי-מאוזן ללא Q מתאים בתוך Form רשלני או פגום, לא דבר נדיר למצוא בקובצי PDF שנוצרו על ידי כלים ישנים יותר, נשאר מוכל בתוך מעקב ההפעלה ההיא ואף פעם לא דולף לתוך מעקב העמוד או לתוך הפעלה-אחות של אותה חותמת שיושבת שורה אחת מאוחר יותר בזרם התוכן

/Matrix של Form משתלב עם ה-CTM שבתוקף ב-Do באותו אופן שאופרטור cm עושה, מוכפל-משמאל מול הטרנספורמציה הנוכחית ולא מוחלף בה, ו-PDFlibPas משתמשת מחדש במכוון באותו נתיב-קוד אחד במקום לתחזק נוסחה שנייה, שכן שני מימושים עצמאיים של אותה אלגברת-מטריצה הם בדיוק סוג הכפילות שבשקט סוחפת מזה מזה אחרי כמה סבבים של קנה-מידה, סיבוב, וקומפוזיציית-הטיה. ‏/BBox אז גוזרת במרחב הקואורדינטות של ה-Form עצמו אחרי שהמטריצה כבר יושמה, וכל ארבע פינות התיבה ההיא מתורגמות בנפרד ולא רק הפינות המנוגדות, שכן Form מסובב או מוטה יכול אחרת לדווח תיבת-גבול שמפספסת תוכן אמיתי שיושב במה שהיה פינה קיצונית לפני שהטרנספורמציה הזיזה אותו למקום אחר. הרחבת הלולאה מהדוגמה הקודמת על פני אותו מערך States קוראת את השדות האלה ישירות

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Do')and (States[I].XObjectKind= cxkForm)and
    States[I].FormBBoxKnown then
    Writeln('Form ', States[I].XObjectResource, ' matrix ',
      States[I].FormMatrix.M11:0:3, ',', States[I].FormMatrix.M12:0:3,
      ' bbox ', States[I].FormBBoxLeft:0:1, '..', States[I].FormBBoxRight:0:1);

האם שני Forms עם אותו שם משאב חולקים גופן אחד?

לא. שם משאב כמו /F1 אומר משהו רק יחסית למילון המשאב הפעיל בנקודה שבה הוא משמש, ושני Form XObject שונים חופשיים להגדיר שני גופנים שונים לחלוטין תחת אותו שם זהה. PDFlibPas פותרת את זה על ידי מעקב אחר היקף-משאב לצד כל שם משאב: כאשר Form נושא מילון /Resources משלו, המילון ההוא הופך להיקף-המשאב השלם עבור כל מה שבתוכו, ללא נפילה-לאחור לפי-מפתח לעמוד או למילון-הקורא עבור כל מה שהמילון של ה-Form עצמו במקרה משמיט. רק Form ללא מפתח /Resources בכלל, תבנית שעדיין מיוצרת על ידי כמה מחוללי PDF ישנים יותר, יורש את מילון הקריאה במלואו, וזו החרגת-תאימות מכוונת ולא כלל כללי ששווה להישען עליו בפלט חדש. זהות גופן בתמונת-מצב TPDFlibContentGraphicsState היא לכן הזוג של FontResource ו-FontResourceScope, לא השם לבדו, עם FontObjectNumber זמין לאשר בדיוק לאיזה אובייקט עקיף /F1 נתון נפתר בהיקף הספציפי ההוא

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

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Tj')and States[I].TextAdvanceResolved then
    RecordGlyphRun(States[I].FontResource, States[I].FontResourceScope,
      States[I].FontObjectNumber, States[I].ContentDepth);

קריאת FormTraversalStatus בצינור שלך עצמך

FormTraversalStatus הופכת כל תמונת-מצב Do לדוח אבחון קטן בזכות עצמה, וצינור שמתעלם ממנה זורק בדיוק את המידע שהיה מסביר חילוץ בלתי-שלם. ‏ftsNotApplicable אומר שההוראה אף פעם לא הייתה הפעלת-Form שנפתרה מלכתחילה; ‏ftsNotRequested אומר שרקורסיה כובתה עבור הקריאה הזו; ‏ftsEnumerated אומר שה-Form פוענח ונסקר בהצלחה; ‏ftsDepthLimit ו-ftsCycle מסמנים את שתי הדרכים שירידה נחתכת בכוונה; ו-ftsMalformed מכסה כל דבר אחר שעצר את הסיור — הפניית זרם בלתי-ניתנת-לפתרון, ‏/Matrix או /BBox שנכשלו בפענוח, או חריגה שהועלתה במהלך ביצוע התוכן העצמי של ה-Form. המקרה האחרון ההוא חשוב תפעולית, משום שסיור מקונן שנכשל מבטל כל פלט חלקי שכבר ייצר עבור הענף ההוא, כך שקוד קורא אף פעם לא צריך לנחש אם Form היה ריק באמת או פשוט התפוצץ שתי הוראות לתוך זרם התוכן שלו

var
  Tally: array[TPDFlibContentFormTraversalStatus] of Integer;
  Status: TPDFlibContentFormTraversalStatus;
begin
  for Status:= Low(Tally) to High(Tally) do
    Tally[Status]:= 0;
  for I:= 0 to Count- 1 do
    Inc(Tally[States[I].FormTraversalStatus]);
  if (Tally[ftsCycle]> 0)or (Tally[ftsMalformed]> 0) then
    FlagForManualReview(SourceFileName, Tally[ftsCycle], Tally[ftsMalformed]);
end;

גבולות, עלויות, ואיפה זה משתלב

זרם התוכן של Form מפוענח ומנותח בדיוק פעם אחת לכל קריאת ספירה (enumeration) ללא קשר לכמה פעמים ה-Form נקרא, משום ש-PDFlibPas שומרת במטמון את רשימת ההוראות המפוענחת מול אובייקט הזרם הבסיסי במקום לפענח אותה מחדש בכל קריאה-אחות — חותמת שלוש-הפינות מהדוגמה הפתיחה מפוענחת פעם אחת ונסקרת שלוש פעמים, לא מפוענחת שלוש פעמים. מה שכן נבנה מחדש בכל הפעלה בודדת הוא כל מה שבאמת נבדל בין אתר-קריאה אחד לבא: מעקב-הבן, ה-CTM המשורשר, הגזירה המצטלבת, והיקף-המשאב. חשבונאות CTM וגזירה לפי-הפעלה ההיא היא אותו מנגנון מאחורי מעקב מצב CTM וגזירה של זרם-תוכן ב-PDFlibPas, ששווה קריאה לצד המאמר הזה עבור כל סיור בזרם-תוכן שחורג מעבר לרקורסיית-Form עצמה

שתי מגבלות שוות קביעת ציפיות סביבן לפני שה-API הזה נכנס לצינור גדול יותר. תקרת-העומק בת 64-הרמות אינה כפתור-כוונון עבור מסמכים עמוקים לגיטימית, שכן חשבוניות, דוחות, ותבניות דוח אמיתיים למעשה אף פעם לא מקננים Forms יותר משלוש או ארבע רמות עמוק — מסמך שבפועל פוגע ב-ftsDepthLimit הרבה יותר סביר שיהיה פגום או עוין מאשר מפורט באופן חריג, ושווה לרשום ביומן כאיתות איכות-נתונים ולא לנסות מחדש בשקט עם מספר גדול יותר. ‏EnumPageContentStatesEx היא גם API ניתוח בצד-קריאה: היא מדווחת מה זרם תוכן עושה, לא אם Form צריך להיות גלוי בכלל, שאלה נפרדת שנענית על ידי מצב נראות קבוצת תוכן אופציונלי כאשר Form של חותמת או סימן-מים יושב מאחורי שכבה שמציג אולי כיבה. זיהוי-מעגל שרשרת-קריאה, בידוד לפי-הפעלה, והגדרת-היקף משאב יחד מרכיבים פינה אחת של משטח בדיקת זרם-התוכן ברכיב PDFlibPas עבור Delphi ו-C++Builder