מאמר טכני

העתקת אובייקטים בין מסמכי PDF ב־Delphi: קריסות מחזור

ממזגים שני PDF ביד, מעבירים אובייקט עמוד אחד למסמך היעד, וההעתקה נתקלת ישר בהפרת גישה. PDFlibPas פותר זאת ב־CopyForeignObject: הוא מעתיק בעומק אובייקט עקיף אחד ובנוסף את כל סגר ההפניות שלו, ומפענח הפניות חוזרות מחזוריות כמו /Parent ל־null במקום להיכנס לרקורסיה

למה העתקת עמוד אחד בין מסמכים קורסת?

כי עץ העמודים של PDF הוא עץ רק אם קוראים אותו כלפי מטה. הולכים בו כפי שעושה מעתיק רקורסיבי, עוקבים אחרי כל ערך בכל מילון, ומילון העמוד מוסר לכם את /Parent, שמצביע בחזרה על צומת ה־/Pages ממנו הגעתם, והצומת הזה מוסר את /Kids, שמצביע בחזרה על העמוד. ISO 32000-1 §7.7.3 קובע ש־/Parent הוא חובה בכל צומת של עץ עמודים פרט לשורש, ולכן זה לא קובץ פגום שאפשר לדחות — זו הצורה הרגילה של כל מסמך שתקבלו אי פעם

החצי השני של הבעיה הוא מספור. אובייקטים עקיפים מזוהים במספר אובייקט שהוא מקומי לקובץ אחד (ISO 32000-1 §7.3.10), ולכן אובייקט שנגרר ממסמך A למסמך B חייב מספור מחדש, וכל הפניה אליו בתוך הסגר המועתק חייבת מספור מחדש זהה, אחרת שתי הפניות שהצביעו בעבר על גופן משותף אחד יצביעו על שני דברים זרים זה לזה. מספור מחדש זהו אותו תפקיד שמיזוג מהיר עושה ברמת הבייט, ושווה לקרוא את שניהם זה לצד זה: הזזת הפניות ברמת בייט למיזוג PDF מהיר פותרת זאת בתרגום קבצים שלמים, ואילו העתקה ברמת אובייקט חייבת לפתור זאת קשת אחת בכל פעם

למה העתקה חוצת מסמכים של PDF ב־Delphi דורשת זהירות: מילון העמוד וצומת ה־/Pages שלו סוגרים מחזור דרך /Parent ו־/Kids, סגר של גופן יורד כלפי מטה ומסתיים, ו־PDFlibPas ממפה מחדש כל מספר אובייקט מקומי לקובץ
עץ העמודים סוגר לולאה דרך /Parent ו־/Kids בעוד שסגרי התוכן מסתיימים, וכל מספר אובייקט מועתק חייב מיפוי מחדש בדרך

מה CopyForeignObject של PDFlibPas מעתיק בפועל

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) משכפל אובייקט עקיף אחד ואת כל מה שניתן להגיע אליו ממנו — מילונים מקוננים, מערכים, מחרוזות, שמות, מספרים, וזרמים עם המילונים שלהם שלמים — אל המסמך הנבחר הנוכחי, ומחזיר handle שאינו אפס להפניה העקיפה החדשה. מספרי האובייקטים של המקור ממופים מחדש דרך מפה חיה שנשמרת למשך הקריאה, כך שאובייקט שמגיעים אליו פעמיים בסגר משוכפל פעם אחת ומשותף פעמיים. הפונקציה מחזירה אפס, בלי להעלות חריגה, כשמזהה מסמך המקור אינו מוכר, כשהמקור הוא המסמך הנבחר עצמו, או כש־ObjectNumber קטן מ־1

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFile מחזיר 1 על הצלחה
    SourceDoc := Lib.SelectedDocument;   // הטעינה בחרה את מה שטענה
    Lib.SelectDocument(TargetDoc);       // ההעתקה מטריחה את המסמך הנבחר
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

שני פרטים נושכים בריצה הראשונה. LoadFromFile משיב 1 או 0 ולא מזהה מסמך, ולכן ה־handle שצריך מגיע מ־SelectedDocument מיד אחרי הטעינה; וההעתקה תמיד כותבת אל מה ש־SelectDocument הפך לנוכחי אחרון, ולעולם לא אל המסמך שממנו טענתם. בפנים, הרקורסיה נושאת גם תקרת עומק קשיחה של 64, שהיא רשת ביטחון מפני קינון פתולוגי ולא המנגנון שמטפל במחזורים — טיפול המחזורים נפרד ומכוון

למה שמירת מיפוי Nil לא שוברת את המחזור?

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

// שבור: יעד Nil שמור בלתי ניתן להבחנה מ"טרם מופה"
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // שמור, עדיין Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // מושלם רק בדרך החוצה
end;

עוקבים אחרי זה דרך לולאת העמודים. השכפול של העמוד מגיע ל־/Parent, נכנס לרקורסיה בצומת ה־/Pages, שמגיע ל־/Kids, שנכנס בחזרה לעמוד — שהרשומה השמורה שלו עדיין קוראת Nil, ולכן הוא משוכפל בפעם השנייה, ואז בשלישית, כל רמה דוחפת מסגרת טרייה ואובייקט חצי־בנוי חדש. מה שרואים הוא אפילו לא גלישת מחסנית נקייה: המסגרות החיצוניות יושבות על הפניות שהיעדים שלהן מעולם לא הוקצו, ולכן הכתיבה הראשונה דרך אחד המשבצות האלה היא הפרת גישה במקום שנראה לא קשור כלל להעתקת העמוד שגרמה לה

למה שמירת יעד Nil במפה לא עוצרת את המחזור בהעתקה חוצת מסמכים של PDFlibPas: החיפוש אינו יכול להבחין בין רשומה שמורה לרשומה שלא מופתה, ולכן המעתיק צולל דרך מסגרות חצי־בנויות הולכות ומעמיקות עד שכתיבה קורסת
כי יעד Nil עונה על שתי שאלות שונות בבת אחת, הקשת החוזרת מעולם לא מזוהה והעמוד משוכפל שוב בכל מעבר

התיקון: מצב InProgress מפורש

התיקון הוא לחדול מלהעמיס על Nil ולשאול את השאלה ישירות. רשומת מפה שהיעד שלה עדיין לא הוקצה פירושה אובייקט זה נמצא כרגע בעיצומו של שכפול, והתנאי InProgress בודק בדיוק זאת לפני שהחיפוש הרגיל רץ. כשהוא אמת, הקשת היא מחזור חזרה אל אב קדמון של השכפול הנוכחי, ו־PDFlibPas פולט עבורה אובייקט null במקום לעקוב אחריה

// רשומת מפה עם יעד Nil מסמנת שכפול בעיצומו
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... בתוך CloneObject, עבור הפניה עקיפה:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // קשת חוזרת מחזורית, אין להיכנס לרקורסיה
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // הפניית מקור תלויה
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // לשמור, ואז להיכנס לרקורסיה
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // השלמה לאחור
end;
Exit(NewRef);

זה בטוח להכליל רק בזכות עובדה מבנית על PDF: מחזורים בגרף האובייקטים מופיעים בקישורים חוזרים, לא בקשתות התוכן. /Parent בעץ העמודים ו־/Prev בשרשרת outline מצביעים כלפי מעלה או אחורה אל משהו שכבר ביקרתם בו; הסגר של גופן, של XObject תמונה או של XObject טופס יורד כלפי מטה ומסתיים. לכן העתקה של מתאר גופן, של מרחב צבע או של מילון הצללה אינה מושפעת מהחלפת ה־null — שום דבר בסגרים האלה לא פוגע ב־InProgress. המחיר, כפי שהוא, הוא שהקשת המחזורית אינה שורדת את ההעתקה. מילון עמוד שמשוכפל כך מגיע עם /Parent כאובייקט null, ו־ISO 32000-1 §7.3.9 קובע שזה שקול לערך חסר, ולכן העמוד המועתק הוא אובייקט תקין שאינו שייך לשום עץ עמודים עד שתקשרו אותו בעצמכם אל צומת ה־/Pages של היעד ותתקנו את /Count. פריט outline שהועתק מאבד את /Prev שלו באותו אופן ודורש בנייה מחדש של שרשרת האחים. זהו המסחר הכן: CopyForeignObject נותן לכם סגר נכון ומשאיר את הקישור המבני מחדש לקורא, וזהו אותו גבול שבו עובדת גם החלפת עמודים תוך שימור מספרי אובייקטים

התיקון ב־CopyForeignObject של PDFlibPas עבור Delphi: בדיקת InProgress מפורשת רצה לפני חיפוש המפה, קשת חוזרת מחזורית הופכת לאובייקט null, והקורא מקשר מחדש את העמוד המועתק אל עץ העמודים של היעד לאחר מכן
מצב InProgress מפורש מחליף את ה־Nil העמוס, הקשת החוזרת מפוענחת ל־null ונותר לקורא תיקון מבני אחד לבצע

למה רשומת המפה חייבת להישמר לפני NewObjRef

חלופה מובנת מאליה הייתה עוקפת את כל ריקוד ה־in-progress: מקצים קודם אובייקט קליפה ריק, רושמים את מספרו האמיתי במפה, ואז ממלאים את הקליפה אחרי שהצאצאים משוכפלים. זה לא עובד כאן, כי TPDFIndObj.Obj הוא לקריאה בלבד ותוכנו אינו ניתן להחלפה אחרי הבנייה — אין קליפה למלא. המספר והתוכן מוחלטים יחד על ידי NewObjRef, ומכאן שרשומת המפה חייבת להיווצר לפני הקריאה הרקורסיבית ולהשלם אחריה, והמרווח בין שני הרגעים האלה הוא בדיוק מה ש־InProgress צריך לכסות. תוצאה אחת שכדאי לדעת לפני שעורכים דיפ של הפלט: כיוון ש־NewObjRef רץ אחרי שסגר הצאצאים נכתב, המספור ביעד יוצא מלמטה למעלה, ומספרי האובייקטים לא ישקפו את סדר המקור. שום דבר בפורמט הקובץ לא אכפת לו, אבל השוואת בייטים מול ציפייה שנבנתה ביד כן אכפת לה. אם ריצה משאירה אובייקטים שהחלטתם שלא לקשר לשום דבר, הם בלתי מופנים ולא פגומים, ואיסוף mark-and-sweep של אובייקטי PDF שאינם נגישים הוא הכלי שמנקה אותם לפני השמירה

הרגרסיה שמכסה זאת זקוקה לפרט אחד שמפתיע כותבי בדיקות מול TPDFlib: הבנאי כבר מחזיק מסמך ברירת מחדל, ולכן DocumentCount מתחיל ב־1 ופיקסטורה של שני מסמכים חייבת לטעון >= 2 ולא = 2. לצד ההעתקה המוצלחת, הבדיקה נועצת את שלוש הדחיות — מזהה מקור לא מוכר, המסמך הנבחר כמקור של עצמו, ומספר אובייקט אפס — שכולן מחזירות 0 במקום להעלות חריגה, כי לולאת מיזוג היא מקום רע לגלות שסעיף שמירה זורק חריגה

איפה זה משתלב בצינור מיזוג

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