מאמר טכני

MovePage ב-PDFlibPas: כשתיבות בירושה חולקות מופעים

ב-PDFlibPas, ספריית ה-PDF ל-Delphi, עמוד שהועבר עם MovePage נהג לקבל בדיוק את אותם אובייקטי MediaBox, CropBox ו-Resources שצומת ה-Pages הישן שלו החזיק, כך ש-SetPageBox או DrawText מאוחר יותר על העמוד המועבר שכתב בשקט את אותו צומת ואת כל אח שעדיין יורש ממנו. מאז v3.539.36 העמוד המועבר מקבל עותקים משלו, והפניה עקיפה נשארת הפניה. אותה מהדורה סוגרת שני נתיבים נלווים: SetPageBox על תיבה עקיפה שכמה עמודים חולקים, ו-CopyPageRanges שהשאיר עמודים במסמך המקור קשורים לצומת ה-Pages שלהם, כשה-CropBox קשור ל-MediaBox

הדוחות שמגיעים לכאן לעולם לא מזכירים זהות אובייקט. הם אומרים דברים כמו "חתכתי את עמוד 7 וגם עמודים 8 עד 12 נחתכו", או "צירתי את ה-CropBox וה-MediaBox זז איתו", או, המבלבל מכולם, "העתקתי עמוד למסמך חדש והקובץ המקורי השתנה". שום דבר לא קורס, שום דבר לא דולף, והקובץ השמור הוא PDF תקין לחלוטין. הוא פשוט מכיל גאומטריה שאף אחד לא ביקש

למה SetPageBox על עמוד אחד משנה את גודל האחים שלו?

SetPageBox שינה גודל אחים כי שתי רשומות עץ עמודים הצביעו על מערך אחד בזיכרון, ו-SetPageBox עורך את מערך היעד שלו במקום. כל עמוד או צומת Pages שהחזיק את אותו מופע ראה את העריכה. שלושה נתיבי קוד ב-PDFlibPas ייצרו את השיתוף הזה לפני v3.539.36:

  • MovePage ממחיש את התכונות הניתנות לירושה על העמוד לפני ניתוקו מההורה שלו, והוא חיבר את האובייקטים של האב עצמו ולא עותקים, כך שהעמוד המועבר ואחיו לשעבר חלקו מערך תיבה ומילון Resources
  • SetPageBox עקב אחר הפניות עקיפות וערך את המערך המופנה, כך שבקובץ שבו כמה עמודים מצביעים על אובייקט /MediaBox 11 0 R אחד, קריאה אחת שינתה את גודל כל העמודים האלה, בין אם MovePage היה מעורב ובין לאו
  • CopyPageRanges ממחיש ערכים בירושה על עמוד המקור לפני שיבוטו למסמך היעד, והוא חיבר את מופעי צומת ה-Pages אל עמוד המקור, ובנוסף את מופע ה-MediaBox עצמו בתור ברירת המחדל של ה-CropBox
כינוי מופע ב-MovePage של PDFlibPas שבו עמוד מועבר ואחיו לשעבר החזיקו שניהם את מופע מערך ה-MediaBox של האב עצמו, כך ש-SetPageBox ערך עמוד אחד ושינה את גודל השני; מאז v3.539.36 ההמחשה מחברת עותקים מפוענחים ועריכות נשארות מקומיות לעמוד שנוגעים בו
שתי רשומות עץ עמודים שהצביעו על מערך אחד בזיכרון גרמו לכל עריכה לנחות אצל כל מחזיק, וה-PDF השמור נשאר תקין כל הזמן

למקרה של MovePage יש היסטוריה קצרה. לפני v3.539.27, MovePage העביר רק את /Resources, כך שעמוד שהועבר תחת הורה אחר אימץ בשקט את הגודל והסיבוב של אותו הורה. v3.539.27 תיקן את ה-MediaBox, ה-CropBox וה-Rotate החסרים, שעליהם נשען גם CollateDocumentsEx כשהוא מסדר עמודים מחדש, אבל חיבר את ערכי האב בתור מופעים משותפים. זה החלון ש-v3.539.36 סוגר. הנתיבים של SetPageBox ו-CopyPageRanges ישנים יותר; כל build לפני v3.539.36 מחזיק אותם

ערכים ישירים, הפניות עקיפות וירושת תכונות עמוד

עותק נכון של תכונת עמוד בירושה משכפל ערכים ישירים ומשאיר הפניות עקיפות בתור הפניות, כי זו ההבחנה ש-ISO 32000-1 עצמו מצייר. אובייקט ישיר כמו [0 0 400 300] שנכתב בתוך מילון שייך למילון הזה לבדו. אובייקט עקיף, שמוגדר פעם אחת בתור 11 0 obj ומצוטט בתור 11 0 R, משותף בתכנון: ISO 32000-1 §7.3.10 הופך אותו לניתן לפנייה מכל מקום בקובץ, וכל 11 0 R פירושו אותו אובייקט

ירושת תכונות עמוד, ISO 32000-1 §7.7.3.4, מוסיפה מקרה שלישי. Resources, MediaBox, CropBox ו-Rotate רשאים לשבת על צומת Pages ולחול על כל עמוד צאצא שאינו מגדיר משלו. העמוד לא מחזיק את הערך; הוא מאתר את הערך דרך /Parent. שרשרת האיתור הזאת נשברת ברגע שעמוד מחליף הורה, ולכן MovePage ו-BalancePageTree חייבים קודם לכתוב את הערכים האפקטיביים על העמוד עצמו. השאלה היא רק איך לכתוב אותם

למה מאגר אובייקטים מסתיר את הטעות

ב-PDFlibPas כל אובייקט PDF שנפרסר או נוצר בבעלות מאגר ה-TPDFStructure של המסמך, ומילונים ומערכים מאחסנים מצביעים פשוטים אל הרשומות שלהם. TPDFDictionary.Add רושם את המצביע ושום דבר מעבר. הוספת מופע אחד לשתי קונטיינרי הורה היא אפוא חוקית בכל רמה שהראנטיים יכול לבדוק: אין free כפול בפירוק, אין מונה הפניות שעלול להשתבש, אין חריגה. הסריאליזציה סלחנית באותה מידה, כי כל קונטיינר כותב את הערך הנוכחי של המופע המשותף inline, ולפני כל עריכה הפלט הוא בייט בבייט מה שעותק נכון היה מייצר

הכינוי מתגלה רק כשמישהו משנה את המופע המשותף במקום. SetPageBox עושה בדיוק את זה דרך wrapper של מלבן מעל המערך הקיים, וציור על עמוד עושה את זה למילון ה-Resources כשגופן או תמונה נרשמים. העריכה נוחתת, בשקט, בכל קונטיינר אחר שמחזיק את המצביע

איך PDFlibPas v3.539.36 מעתיקה במקום לשתף

PDFlibPas v3.539.36 מתקנת את הבעיה בשני הקצוות: ההמחשה מחברת עכשיו עותקים, וכתיבת תיבות עורכת עכשיו רק מערך שהעמוד בבעלותו. כל תיקון מכסה מקרה שהשני אינו יכול

פונקציית ההמחשה, PLInheritPageAttributes, מחברת עכשיו את Page.Owner.Decode(Value.Output) במקום את Value. מעבר עגול דרך הסריאליזר הוא דרך גסה אך מדויקת לקבל בחינם את הסמנטיקה של PDF. מערך או מילון ישירים ממוספרים לטקסט הליטרלי שלהם ומפוענחים למופע טרי ובלתי תלוי. הפניה עקיפה ממוספרת ל-11 0 R ומפוענחת לאובייקט הפניה חדש שמצביע על אותו אובייקט 11, כך שהעמוד עדיין מתייחס לאובייקט המשותף במקום לקבל עותק משובץ, מה שמשמר את התנהגות ההפניה שהוצגה ב-v3.539.27. העותק עמוק בדיוק כמו המבנה הישיר: כל מה שמגיעים אליו דרך הפניה בתוך מילון מועתק נשאר משותף, כפי שפורמט הקובץ מתכוון. BalancePageTree קורא לאותו helper עבור כל עמוד שהוא משייך מחדש להורה, כך שעמודים שמומחשים שם מקבלים גם הם מופעים נפרדים

מעבר עגול של המחשה ב-PDFlibPas שבו PLInheritPageAttributes מחבר את Page.Owner.Decode(Value.Output): מערך ישיר ממוספר לטקסט ליטרלי ומפוענח למופע טרי, בזמן שהפניה עקיפה 11 0 R ממוספרת ומפוענחת להפניה חדשה שעדיין מצביעה על האובייקט המשותף 11
סריאליזציה ופרסור מחדש מקבלים את סמנטיקת האובייקטים של PDF בחינם: ערכים ישירים מועתקים, הפניות נשארות הפניות, בדיוק כפי ש-ISO 32000-1 מתכוון

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

החלטת ה-copy-on-write של SetPageBox ב-PDFlibPas: כשהרשומה של העמוד עצמו היא מערך ישיר היא נערכת במקום, וכשהיא הפניה עקיפה או חסרה הכותב מחליף אותה במערך ישיר חדש כך שהאובייקט המשותף 11 שומר על ערכו עבור כל עמוד אחר שמצטט אותו
העתקה בהמחשה אינה מספיקה כל עוד הפניות עדיין מצביעות על אובייקטים משותפים, ולכן כותב התיבות עורך רק מה שהעמוד בבעלותו
נתיב קודלפני v3.539.36מאז v3.539.36
MovePage המחשההעמוד מחזיק את המופעים הישירים של האב עצמוהעמוד מחזיק עותקים מפוענחים; הפניות נשארות הפניות
SetPageBoxעוקב אחר הפניה ועורך את המערך המשותףעורך רק מערך ישיר על העמוד, אחרת כותב חדש
עמוד המקור ב-CopyPageRangesחולק תיבות של צומת Pages; ה-CropBox הוא מופע ה-MediaBoxכל ערך ממוחש על עמוד המקור הוא עותק
תיבות ברירת מחדל בשיבוט משאבי עמודCropBox, BleedBox, TrimBox ו-ArtBox חולקים מערך אחדלכל תיבת ברירת מחדל יש מערך משלה

השורה האחרונה היא הרדומה. כשהספרייה משבטת את משאבי עמוד לצורך לכידת עמוד או מיזוג, היא ממלאת רשומות CropBox, BleedBox, TrimBox ו-ArtBox חסרות, ואלה היו פעם אותו מופע מערך. אף קורא אקטואלי לא השאיר את הכינוי הזה לחיות מספיק זמן כדי שייערך, אבל הקורא הבא היה כן. איך ערכי תיבות ברירת המחדל האלה נבחרים הוא נושא בפני עצמו, שמכוסה ב-המדריך של PDFlibPas לברירות המחדל של TrimBox, BleedBox ו-CropBox

שחזור הכינוי של MovePage עם PDF שנבנה ביד

הדרך המהירה לבדוק כל build של PDFlibPas היא PDF קטן שנכתב ביד ונטען עם LoadFromString, שבו כל מספר אובייקט ידוע מראש. ה-helper להלן כותב טבלת cross-reference קלאסית עם היסטי בייטים מחושבים נכון, כך שהבדיקה לא נשענת על התנהגות ההתאוששות של הפרסר לקבצים פגומים

uses
  System.SysUtils, PDFlibrary;

function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
  Offsets: array of Integer;
  I, XRefPos: Integer;
begin
  Result := '%PDF-1.4'#10;
  SetLength(Offsets, Length(Objects));
  for I := 0 to High(Objects) do
  begin
    Offsets[I] := Length(Result);   // היסט בייטים מאפס של "N 0 obj"
    Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
      Objects[I] + #10'endobj'#10;
  end;
  XRefPos := Length(Result);
  Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
    #10'0000000000 65535 f '#10;
  for I := 0 to High(Offsets) do      // כל רשומה היא בדיוק 20 בייטים
    Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
  Result := Result + 'trailer'#10'<< /Size ' +
    AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
    'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;

function StreamObj(const Content: AnsiString): AnsiString;
begin
  Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
    ' >>'#10'stream'#10 + Content + #10'endstream';
end;

למסמך הבדיקה שני צומתי Pages ביניים. צומת 3 נושא MediaBox עקיף (אובייקט 11, 400 על 300 נקודות), CropBox ישיר ומילון Resources ישיר, ובבעלותו שני עמודים. לצומת 4 יש MediaBox בגודל Letter ובבעלותו העמוד השלישי. העברת עמוד 1 למקום 3 משייכת אותו מחדש תחת צומת 4, וזה בדיוק המעבר שזקוק להמחשה: בלעדיה העמוד היה הופך לעמוד Letter

procedure Check(Condition: Boolean; const Msg: string);
begin
  if not Condition then
    raise Exception.Create(Msg);
end;

procedure CheckMovedPageIsIsolated;
var
  Lib: TPDFlib;
  FontID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Check(Lib.LoadFromString(BuildPdf([
      '<< /Type /Catalog /Pages 2 0 R >>',
      '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
      '<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
        '/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
      '<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
        '/MediaBox [0 0 612 792] >>',
      '<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
      '<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
      '<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
      StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
      '[0 0 400 300]']), '') = 1, 'load failed');

    Lib.SelectPage(1);
    Check(Lib.MovePage(3) = 1, 'MovePage failed');
    Lib.SelectPage(3);                       // העמוד שהעברנו זה עתה
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');

    Lib.SetPageBox(1, 0, 200, 200, 200);     // MediaBox 200 x 200
    Lib.SetPageBox(2, 0, 100, 100, 100);     // CropBox 100 x 100
    FontID := Lib.AddStandardFont(4);        // Helvetica
    Lib.SelectFont(FontID);
    Lib.SetTextSize(12);
    Lib.DrawText(20, 20, 'MOVED');

    // בדקו את ההורה הישן לפני בחירת עמוד אחר (ראו להלן)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // עמוד 2 לשעבר, עדיין תחת צומת 3
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
    Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
    Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
      'shared object 11 was rewritten');
  finally
    Lib.Free;
  end;
end;

GetPageBox(BoxType, Dimension) מקבל סוג תיבה 1 עבור MediaBox ו-2 עבור CropBox, וממד 2 עבור רוחב. עם ראשית ברירת המחדל בפינה השמאלית התחתונה, SetPageBox(1, 0, 200, 200, 200) פירושו שמאל 0, עליון 200, 200 רוחב ו-200 גובה. על builds בין v3.539.27 ל-v3.539.35, בדיקות האחים נכשלות: העריכה של ה-CropBox נוחתת במערך הישיר של צומת 3, והעריכה של ה-MediaBox שוכתבת את אובייקט 11 דרך ההפניה

האם CopyPageRanges משנה את מסמך המקור?

מאז v3.539.36, CopyPageRanges עדיין כותב על עמודי המקור, אבל כל ערך שהוא כותב הוא עותק נפרד, כך שעריכות מאוחרות על המקור נשארות מקומיות לעמוד שעורכים. הכתיבה עצמה מכוונת: עמוד המקור זקוק ל-MediaBox, CropBox, Rotate ו-Resources מפורשים לפני שהמילון שלו משובט ליעד, אחרת העותק היה מאבד את כל מה שירש. מספור מחדש והעתקת העמוד ליעד מכוסים ב-העתקה עמוקה של אובייקטים בין מסמכים ב-PDFlibPas; הבאג הזה ישב בצד המקור, שרוב האנשים מניחים שעותק רק קורא ממנו

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

procedure CheckSourceSurvivesCopy;
var
  Lib: TPDFlib;
  SourceID, TargetID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Check(Lib.LoadFromString(BuildPdf([
      '<< /Type /Catalog /Pages 2 0 R >>',
      '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
        '/MediaBox [0 0 400 300] /Resources << >> >>',
      '<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
      '<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
      StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
    SourceID := Lib.SelectedDocument;

    TargetID := Lib.NewDocument;             // הופך למסמך הנבחר
    Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // מצר רק את ה-CropBox
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
    Lib.SetPageBox(1, 0, 200, 200, 200);

    Lib.SelectPage(2);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');

    Lib.SelectDocument(TargetID);            // העותק שומר על גודלו המקורי
    Lib.SelectPage(Lib.PageCount);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
  finally
    Lib.Free;
  end;
end;

לפני v3.539.36 שני העמודים כאן ירשו את ה-MediaBox הישיר של צומת השורש, העותק חיבר את המופע הזה אל עמוד המקור 1, וחיבר אותו שוב בתור ה-CropBox של עמוד 1. צימצום ה-CropBox צימצם אפוא את ה-MediaBox, ושינוי גודל ה-MediaBox שינה את גודל עמוד 2 דרך צומת השורש. זרימות עבודה שמעתיקות עמודים החוצה ואז ממשיכות לערוך את המקור, כמו איסוף סריקות duplex ל-PDF אחד לפני חיתוך המקורות, הן המקום שבו זה התגלה

למה כינוי מופעים קשה כל כך לבדיקה?

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

מלכודת הסדר ב-PDFlibPas היא SelectPage. בחירת עמוד מיישמת מחדש את הגופן הנוכחי דרך SelectFont, שרושם את הגופן הזה במשאבי העמוד. עמוד ללא /Resources משלו מפוענח למילון של ההורה שלו, ולכן בחירה גרידא של עמוד כזה מוסיפה /Font לצומת ה-Pages בצדק. בבדיקת ה-MovePage שלמעלה, בחירת עמוד 2 לשעבר מוסיפה את רשומת ה-Helvetica לצומת 3, מה שהוא התנהגות נכונה ולא דליפה. מסיבה זו בדיקת ה-GetObjectToString(3) רצה לפני SelectPage(1); מחליפים ביניהן והבדיקה נכשלת על build מתוקן

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

רשימת בדיקה לקוד של מודל אובייקטים של PDF

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

  • בהמחשת תכונות בירושה לפי ISO 32000-1 §7.7.3.4, מעתיקים לעומק ערכים ישירים ומשאירים הפניות עקיפות בתור הפניות חדשות לאותו אובייקט
  • לעולם לא קוראים Add של מופע קיים אל קונטיינר שני אלא אם השיתוף מכוון ומתועד; בעלות של מאגר פירושה שהראנטיים לעולם לא יתלונן
  • עורכים במקום רק מה שהצומת הנוכחי מחזיק בתור אובייקט ישיר; מחליפים ערכים עקיפים או בירושה באובייקט ישיר טרי (copy-on-write)
  • ערכי ברירת מחדל שנגזרים מרשומה אחרת, כמו CropBox מתוך MediaBox, זקוקים למופע משלהם
  • בודקים כינוי עם רצפי שינוי ואז בחינה אצל המחזיק השני, ובודקים את סדר הקריאות שעשויות לכתוב בצדק בין לבין
  • השוואת פלט שמור לא מוכיחה דבר כאן: ערכים משותפים ומועתקים ממוספרים זהים עד העריכה הראשונה
  • ב-PDFlibPas, שדרגו ל-v3.539.36 ומעלה אם אתם קוראים ל-MovePage, CollateDocumentsEx, BalancePageTree או CopyPageRanges ואז עורכים תיבות עמוד או מציירים על עמודים

PDFlibPas חושפת עריכת עץ עמודים, העתקה בין מסמכים ושליטה בתיבות עמוד דרך מחלקת TPDFlib אחת עבור Delphi, C++Builder ו-Free Pascal. ראו את עמוד המוצר של PDFlibPas Delphi PDF library למהדורות, פלטפורמות והפניה מלאה ל-API