מאמר טכני

החלפת עמודי PDF ב-Delphi בלי לשבור סימניות

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

מדוע סימניות נשברות אחרי החלפת עמוד PDF?

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

יעדים בשם גם הם לא מצילים אתכם. §12.3.2.3 מנתב שם דרך עץ השמות /Dests בקטלוג המסמך, אך העלה שהשם נפתר אליו הוא עדיין מערך יעד מפורש המחזיק את אותה הפניית עמוד. מתן שם מוסיף שכבת עקיפות מעל הפניית העמוד, לא סביבה. אותו היגיון מכסה את שאר שכבת האינטראקציה המתוארת ב-§12.5: הערת קישור נושאת /Dest או פעולת GoTo /A שה-/D שלה הוא אותו מערך, כל הערה עשויה לשאת רשומת /P שהיא הפניה עקיפה לעמוד שלה, ווידג'ט שדה טופס הוא הערה על אותה בדיוק עמדה. החלפת עמוד נאיבית אחת מנתקת ארבע תת-מערכות בבת אחת, ואם רוצים לראות אותן ממופות על קובץ אמיתי, אותו גרף אובייקטים הוא מה שבחינת מבנה, הערות ופעולות עוברת עליו

אילו רשומות עמוד נושאות זהות ואילו נושאות מראה

מילון עמוד מערבב שני סוגי רשומות, והחלפה במקום מצליחה בדיוק כשמפרידים ביניהם. הצד החזותי סופי וניתן לספירה: /Contents, /Resources, חמש תיבות העמוד /MediaBox, /CropBox, /BleedBox, /TrimBox ו-/ArtBox, בתוספת /Rotate, /Group, /UserUnit ו-/BoxColorInfo. אחד עשר הרשומות הללו קובעות הכל שראסטרייזר מפיק עבור העמוד, ושום דבר אחר בקובץ לא מצביע עליהן בשם

צד הזהות הוא מה ששאר המסמך קשר את עצמו אליו: מספר אובייקט העמוד וה-generation, קישור ה-/Parent חזרה לעץ העמודים, ו-/Annots. PDFlibPas שומר על כל אחד מהם ללא נגיעה. ReplacePageRanges מנקה את אחד עשר הרשומות החזותיות ממילון העמוד היעד ומוסיף אותן מחדש מהעמוד המקור המיובא, כך שאובייקט העמוד היעד משתנה במקום במקום להיות מוחלף. מבנה עץ העמודים הנדרש על ידי §7.7.3 גם הוא נשאר זהה byte-by-byte בצורתו: סדר /Kids, /Count, וכל /Parent ששרד זהים לפני ואחרי, כי אף צומת מעולם לא נותק

כיצד PDFlibPas מחליף עמוד בלי למספר מחדש אובייקטים?

הקריאה לוקחת מסמך מקור, עמוד יעד התחלתי מבוסס-1, ביטוי טווח מקור, ודגל אפשרויות. שני המסמכים חייבים להיות פתוחים באותה מופע, ומסמך היעד הוא הנבחר. מכיוון שמספר עמודי היעד לעולם לא משתנה, הטווח שמבקשים חייב להתאים בתוך המסמך החל מ-TargetStartPage, וזה נבדק לפני שנוצר אובייקט כלשהו

var
  Lib: TPDFlib;
  TargetDoc, SourceDoc: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // The document whose bookmarks and links must survive
    if Lib.LoadFromFile('contract-final.pdf', '') <> 1 then
      Exit;
    TargetDoc := Lib.SelectedDocument;

    // The revised clause page, rendered by whatever produced it
    if Lib.LoadFromFile('clause-7-revised.pdf', '') <> 1 then
      Exit;
    SourceDoc := Lib.SelectedDocument;

    Lib.SelectDocument(TargetDoc);
    // Source page 1 overwrites the visuals of target page 3.
    // Page count, page 3 object number, bookmarks and annotations are kept.
    if Lib.ReplacePageRanges(SourceDoc, 3, '1', 0) = 1 then
      Lib.SaveToFile('contract-final.pdf');
  finally
    Lib.Free;
  end;
end;

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

נתיב המחיקה שהיה הורס את מה שזה עתה העברתם

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

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

סדר, כפילויות, וכשל של הכל-או-כלום

דגל האפשרויות בוחר כיצד טווח המקור מתפרש. 0 ממיין את מספרי העמודים המנותחים ומסיר כפילויות, שזו ברירת המחדל השפויה כאשר הקורא מעביר משהו כמו '4-6,2' ופשוט מתכוון לאותם ארבעה עמודים. 1 שומר על הסדר שכתבתם ומאפשר לעמוד לחזור על עצמו, כך ש-'2,1,2' באמת אומר שלוש החלפות שנלקחו משני עמודי מקור. אימות רץ ראשון ורץ במלואו: תחביר הטווח, כל מספר עמוד מול מספר עמודי המקור, ערך האפשרות עצמו, וקיבולת היעד כולם נבדקים לפני שנוצר אובייקט יחיד. קריאה שנדחתה מגדירה את LastErrorCode ל-412, משחזרת את העמוד שנבחר קודם, ומשאירה את המסמך בדיוק כפי שהיה

var
  Replaced: Integer;
begin
  Lib.SelectDocument(TargetDoc);
  // Options = 1: source order is preserved and repeats are allowed, so
  // target pages 5, 6 and 7 receive source pages 2, 1 and 2 respectively
  Replaced := Lib.ReplacePageRanges(SourceDoc, 5, '2,1,2', 1);
  if Replaced = 0 then
    raise Exception.CreateFmt('Replacement rejected, LastErrorCode = %d',
      [Lib.LastErrorCode]);
  // On success the selection is the first replaced page
  Assert(Lib.SelectedPage = 5);
end;

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

// Post-conditions worth asserting in a regression test
Lib.SelectPage(3);
// Geometry now comes from the source page
WriteLn(Format('%.2f x %.2f', [Lib.PageWidth, Lib.PageHeight]));
// Annotations that were already on target page 3 are still attached
WriteLn(Lib.AnnotationCount);
// The bookmark created before the replacement still resolves to page 3
WriteLn(Lib.GetOutlinePage(OutlineID));
// And the document is still the same length
WriteLn(Lib.PageCount);

מה החלפה במקום עדיין לא עושה בשבילכם?

הערות מקור, שדות טופס מקור ומבני outline מקור אינם מיובאים בכוונה. העברת וידג'ט בלי רשומת שדה ה-/AcroForm שלו, או הערה נושאת תוכן-מסומן בלי בעלות עץ המבנה שלה, מייצרת אובייקט אינטראקטיבי מיובא-למחצה שאף צופה לא יכול להבין, כך שהפעולה מעבירה רק מראה. המשמעות המעשית היא שאם עמוד ההחלפה אמור לשאת שדות טופס חדשים או קישורים חדשים, מוסיפים אותם לעמוד היעד לאחר מכן, כנגד אובייקט עמוד היעד שעדיין יושב שם וממתין להם

שני גבולות נוספים כדאי לבדוק על הקבצים שלכם. ראשית, /Annots נשמר אך גיאומטריית העמוד לא, כך שהחלפת עמוד בגודל 220 מ"מ בעמוד בגודל 320 מ"מ משאירה את מלבני ההערות בקואורדינטות הישנות שלהם בתוך /MediaBox בגודל שונה; אם הגיאומטריה משתנה, מחדשים את מיקום ההערות ששמרתם. שנית, רשומות מחוץ לאחד עשר המפתחות החזותיים נשארות עם עמוד היעד בכוונה, מה שנכון עבור /Trans או /AA ומיושן עבור /Thumb, אז מחדשים תמונות ממוזערות (thumbnails) אחרי החלפה. מסמכים מתויגים (tagged) זקוקים למחשבה נוספת אחת: אלמנטי המבנה עדיין מצביעים על אובייקט העמוד הנכון דרך /Pg, אך מזהי התוכן המסומן שלהם מתארים תוכן שכבר לא שם, כך שהחלפת עמוד בתוך זרימת עבודה של PDF/UA היא עריכת עץ מבנה בדיוק כמו שהיא עריכת תוכן. אם המשימה שלכם היא באמת הרכבה (compositing) ולא החלפה, שכבור אמנות על עמודים שאתם שומרים, גישת תפירת עמודים ותבניות היא הכלי הזול יותר

כל מה שתואר כאן, כולל תחביר ביטוי הטווח, ערכי האפשרויות ו-API מניפולציית העמודים הסובב, מגיע בגרסה הסטנדרטית של PDFlibPas Delphi PDF Library עבור Delphi ו-C++Builder, שתיעוד ההפניה שלה נושא את הרשומה המלאה עבור קריאת החלפת העמודים וקודי השגיאה שלה