מאמר טכני

מחיקת עמודים ב-PDF בדלפי בלי הפניות תלויות

רכיב HotPDF ל-Delphi מוחק עמוד מ-PDF טעון דרך THotPDF.DeletePage, ומאז גרסה 2.751.0 הקריאה הזו גם מנקה כל הפניה ברמת המסמך שעדיין מצביעה על העמוד: יעדים בעלי שם בעץ /Names /Dests, מילון ה-/Dests הישן ב-catalog, פעולות /GoTo של סימניות, איברי מבנה תחת /StructTreeRoot, ה-ParentTree, רשומות OBJR של הערות, והערות קישור בעמודים ששורדים. עץ העמודים נבנה מחדש אחרון, אחרי ששום דבר אחר לא יכול להגיע לאובייקט שנמחק

הכשל שזה מונע קל לשחזר וקשה לאבחן. מחקו את עמוד השער של דוח מתויג, שמרו, ופתחו את התוצאה: Acrobat מציג את מספר העמודים הנכון, אבל הסימנייה "Contents" נוחתת כעת בשום מקום, בודק הנגישות מדווח על איבר מבנה בלי עמוד, ומאמת קפדני מפרט הפניה לאובייקט פנוי. שום דבר בעץ העמודים אינו שגוי. הבעיה היא שעמוד PDF אינו רק עלה של /Pages; הוא יעד שחצי מה-catalog מצביע עליו, והסרת העלה משאירה כל אחד מהמצביעים האלה תלוי

למה הסרת עמוד מ-/Kids לא מספיקה?

כי ISO 32000-1 מתיר ללפחות שבעה מבנים בלתי תלויים להחזיק הפניה לאובייקט עמוד, ורק אחד מהם הוא עץ העמודים. הוצאת העמוד מ-/Kids והקטנת /Count מספקת את §7.7.3, וכל הפניה אחרת הופכת למצביע לאובייקט שמשוחרר ב-xref או שפשוט חסר בקובץ הנכתב מחדש. מציג שהולך אחרי אחד מהמצביעים האלה מקבל null, ומה שהוא עושה עם ה-null הזה תלוי במציג

  • עץ השמות תחת /Names /Dests (§7.7.4, §12.3.2.3) ממפה שמות למערכי יעד שהאיבר הראשון שלהם הוא העמוד
  • מילון ה-/Dests מלפני 1.2, ישירות ב-catalog, מחזיק מערכים מאותו סוג במפתח שם
  • איברי outline (§12.3.3) מגיעים לעמוד או דרך /Dest מוטבע או דרך פעולת /A עם /S /GoTo ומערך /D
  • איברי מבנה (§14.7.2) נושאים מפתח /Pg שמציין את העמוד שהתוכן המסומן שלהם יושב עליו, והילדים ב-/K שלהם יכולים להיות הפניות לתוכן מסומן והפניות לאובייקטים (§14.7.4.3) הקשורים לאותו עמוד
  • ה-ParentTree (§14.7.4.4) ממפה מספרי /StructParents של עמודים והערות חזרה לאיברי מבנה, ואיבר יכול לחיות שם בלי להופיע בכלל על שרשרת ה-/K מהשורש
  • הערות קישור בעמודים אחרים (§12.5.6.5) נושאות /Dest או פעולת /GoTo שמכוונות לעמוד, וה-/OpenAction של ה-catalog עשוי לעשות אותו דבר
למה הסרת עמוד ב-HotPDF מ-/Kids לא מספיקה: ISO 32000-1 מתיר לעץ השמות /Names /Dests, למילון /Dests הישן ב-catalog, לאיברי outline, לאיברי מבנה עם /Pg, ל-ParentTree, להערות קישור ול-/OpenAction להחזיק כולם הפניה לאותו אובייקט עמוד, ורק עץ העמודים נבנה מחדש
עמוד PDF הוא יעד שחצי מה-catalog מצביע עליו: הוצאת העלה מספקת את עץ העמודים בעוד כל מצביע אחר נפתר ל-null, כך שדוח מקוצר מאבד את סימניית Contents שלו ונכשל בבדיקת הנגישות

מה מנקה THotPDF.DeletePage לפני שהיא נוגעת בעץ העמודים?

THotPDF.DeletePage(PageIndex) על מסמך טעון מריצה קודם את כל סחיפת ההפניות, ואז מסמנת את אובייקט העמוד כמחוק עם DeleteObj, מנתקת הערות widget מעץ שדות ה-AcroForm, מזיזה את מערך העמודים הפנימי, ולבסוף קוראת ל-RebuildLoadedPageTree כדי לכתוב מחדש את /Kids, /Count ואת ה-/Parent של כל עמוד ששרד. הסחיפה עוברת על ה-catalog בסדר קבוע: עץ השמות /Names /Dests, מילון ה-/Dests בסגנון הישן, /OpenAction, עץ ה-outline, /StructTreeRoot עם ה-ParentTree שלו, ולבסוף מערכי ה-/Annots של כל עמוד שנשאר. כל שלב מחליט אם הפניה מוסרת, מנותבת מחדש או נשארת בשקט לפי מה שהמפרט מתיר לאותו מבנה לעשות בלי העמוד. שני שומרים חלים לפני שכל זה רץ: DeletePage מעלה Invalid page number עבור אינדקס מחוץ לטווח ומסרבת להסיר את העמוד האחרון, כי צומת /Pages בלי ילדים אינו PDF תקין, בעוד DeletePages מקבלת את אותו סימון מבוסס-1 של "1,3-5,7-" כמו שאר פעולות העמודים על מסמך טעון, ועוברת מהאינדקס הגבוה ביותר שנבחר כלפי מטה כך שהאינדקסים שכתבתם נשארים תקפים בזמן העבודה

סחיפת ההפניות בסדר קבוע ש-THotPDF.DeletePage מריצה לפני שנוגעים בעץ העמודים: השומרים דוחים אינדקס מחוץ לטווח או את העמוד האחרון, ואז /Names /Dests וה-/Dests הישן מנוקים, /OpenAction נזרק, outlines מנותבים מחדש ל-NearestRetainedPage, StructTreeRoot ו-ParentTree מנוקים, קישורים בעמודים שנשארו מוסרים, ו-RebuildLoadedPageTree רץ אחרון
כל מבנה מקבל את הטיפול שהמפרט מתיר: שמות נעלמים, סימניות נוחתות על העמוד השמור הקרוב, איברי מבנה מאבדים /Pg או נעלמים, וכתיבת ה-/Kids מחדש קורית רק אחרי ששום דבר אחר לא יכול להגיע לאובייקט שנמחק
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // מבוסס-0: להפיל את עמוד השער. יעדים בעלי שם,
      // סימניות, עץ המבנה, ParentTree והערות
      // קישור שהצביעו עליו מנוקים לפני ש-
      // עץ ה-/Pages נבנה מחדש.
      Pdf.DeletePage(0);
      // סימון טווח מבוסס-1 לאצוות, האינדקס הגבוה ביותר ראשון
      // מבפנים כדי שאינדקסים קודמים יישארו תקפים.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

איך יעדים בעלי שם וסימניות מטופלים אחרת?

יעדים בעלי שם מוסרים וסימניות מנותבות מחדש, כי שם שכבר לא קיים הוא תוצאה מקובלת בעוד סימנייה בלי יעד היא פגם גלוי. בעץ /Names /Dests HotPDF עוברת על כל צומת, בודקת כל יעד, גם בצורת המערך החשוף וגם בצורת המילון עם מפתח /D, מול העמוד שנמחק, ומסירה את זוג השם/ערך כשהאיבר הראשון במערך הוא אותו עמוד. צומת שה-/Names וה-/Kids שלו נגמרים ריקים מסומן כמחוק ומנותק מההורה שלו, כך שהעץ אף פעם לא מחזיק עלים חלולים. אותה בדיקה רצה על מילון ה-/Dests בסגנון הישן ב-catalog, וה-/OpenAction של ה-catalog נזרק פשוט אם הוא נפתח על העמוד שנמחק. גבול אחד כאן: כשצומת בעץ השמות מאבד רשומות, HotPDF מוחקת את זוג ה-/Limits של אותו צומת במקום לחשב מחדש את המפתח הנמוך והגבוה החדשים, ולמרות שמציגים פותרים שמות היטב בלעדיו, בודק תאימות קפדני שקורא את ISO 32000-1 §7.9.6 עשוי לסמן צומת שאינו שורש ובלי /Limits

איברי outline הולכים לכיוון ההפוך. RetargetOutlineDestinations עוברת על /First ו-/Next משורש ה-outline, עם רשימת ביקורים ומגבלת עומק של 128 כדי שעץ מעגלי פגום לא יתלה את הקריאה, ולכל מערך /Dest או מערך /D של פעולת /GoTo שמכוון לעמוד היא מחליפה את האיבר הראשון ב-NearestRetainedPage: העמוד שבא אחרי זה שנמחק, או העמוד שלפניו כשהעמוד שנמחק היה האחרון. פרמטרי התצוגה שאחרי הפניית העמוד נשארים כפי שהיו. סימנייה שהצביעה על פותח פרק שנמחק נוחתת לכן על העמוד הראשון של מה שנשאר במקום להיעלם מסרגל הצד, וזו ההתנהגות שסוקרים מצפים לה ממסמך מקוצר. בדיקת היעד מתאימה מערכים מפורשים בלבד, עם זאת: איבר outline שה-/Dest שלו הוא מחרוזת שם שפעם נפתרה לעמוד שנמחק לא מנותב מחדש, כי רשומת עץ השמות נעלמה וההפניה נפתרת כעת לכלום ולא לאובייקט משוחרר, כך שהמציג מתייחס אליה כאל סימנייה מתה. המכניקה של עץ ה-outline עצמו, /First, /Next, וסמנטיקת ה-/Count הלא מובנת מאליה, מכוסה במדריך להוספת סימניות ויעדים בעלי שם ל-PDF טעון

// לאמת את הסחיפה במקום לסמוך עליה.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// סימנייה שכיוונה לשער נפתרת כעת ל
// העמוד שאחריו (אינדקס מבוסס-0 שהוא 0 אחרי המחיקה).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

מה קורה לעץ המבנה ול-ParentTree?

איברי מבנה שקיימים רק בגלל העמוד שנמחק מוסרים, ואיברים שמשתרעים על כמה עמודים מאבדים את מפתח ה-/Pg שלהם אבל שומרים על הילדים. PruneStructureElement יורדת בשרשרת ה-/K מ-/StructTreeRoot לעומק של 128, ומטפלת גם בצורת המערך וגם בצורת המילון הבודד של /K ש-§14.7.2 מתיר. לכל איבר היא קודם מנקה את הילדים, ואז מעריכה את האיבר עצמו: אם הניקוי רוקן את ה-/K שלו, האיבר מסומן כמחוק וההורה שלו מפיל אותו. אם ה-/Pg של האיבר עצמו מציין את העמוד שנמחק ולאיבר עדיין יש ילדים ועוד הורה /P, רק ה-/Pg מוסר, כי /Pg על איבר הוא עמוד ברירת המחדל לילדי התוכן המסומן שלו ואותם ילדים עשויים להפנות במפורש לעמודים אחרים. רק איבר שה-/Pg שלו הוא העמוד שנמחק ולא נשאר מתחתיו כלום מוסר כליל

ה-ParentTree מקבל את אותו טיפול, והסיבה היא זו שנגסה בזמן הפיתוח: איבר מבנה יכול להיות נגיש מה-ParentTree ומשום מקום אחר. עץ המספרים ממפה מספרים שלמים של /StructParents לאיבר בודד או למערך של איברים, ו-PruneParentTreeNode מריצה את PruneStructureElement על כל ערך שהיא מוצאת, מסירה ערכים שנוקו, מוחקת זוג /Nums כשמערך הערכים שלו ריק, ומנתקת צומת שה-/Nums וה-/Kids שלו נעלמו שניהם. ניקוי רק של הצאצאים של /K היה משאיר את האיברים היתומים האלה מצביעים על עמוד משוחרר דרך /Pg ועל הפניות תוכן מסומן משוחררות דרך ילדי ה-/MCR שלהם. אם אתם מחלצים טקסט לפי סדר המבנה, זה חשוב ישירות: חילוץ טקסט לפי סדר המבנה עובר בדיוק על העצים האלה, ואיבר עם /Pg שהוא null הוא פסקה שנופלת בשקט מסדר הקריאה

אילו הערות קישור בעמודים ששורדים מוסרות?

כל הערת קישור בעמוד שנשמר שמערך ה-/Dest שלה או פעולת ה-/GoTo שלה מצביעים על העמוד שנמחק מוסרת יחד עם הבעלות שלה בעץ המבנה. RemoveRetainedPageDestinationAnnotations עוברת על מערך ה-/Annots של כל עמוד שאינו היעד, מחילה את אותה בדיקת יעד ששימשה ל-outlines, מסמנת הערה תואמת כמחוקה, מפילה אותה מהמערך, ואז קוראת ל-PruneAnnotationReferencesInStructureTree כך שמילון ה-OBJR שה-/Obj שלו ציין את ההערה ההיא מוסר מאיבר המבנה שלו, כשהאיבר עצמו מוסר אם ה-OBJR היה הילד היחיד שלו. השארת ה-OBJR במקום הייתה מפרה את §14.7.4.3, שדורש ש-/Obj יפנה לאובייקט קיים, והייתה מופיעה בבדיקת PDF/UA כקישור מתויג בלי הערה מאחוריו. שימו לב לא-סימטריה עם סימניות: קישורים מוסרים, לא מנותבים מחדש. הפניה צולבת בגוף הטקסט שאמרה "see page 3" שגויה ברגע שעמוד 3 נעלם, והפנייתה לעמוד 4 תהיה שקר בדרך שסימנייה שנוחתת על הפרק הקרוב אינה, כך שאם זרימת העבודה שלכם צריכה לשמר את הקישורים האלה, נתבו אותם בעצמכם מחדש לפני הקריאה ל-DeletePage

למה אסור לרשום MCR או OBJR שהוסרו כרשומה פנויה?

כי הפניות לתוכן מסומן והפניות לאובייקטים הן בדרך כלל מילונים ישירים בתוך מערך ה-/K של איבר האב, ורישום השינויים המצטבר פותר אובייקט ישיר לאובייקט העקיף הקרוב שמכיל אותו. כשהפונקציה RemoveArrayItem מפילה ילד ממערך /K היא משחררת את האובייקט בזיכרון רק אם הוא היה THPDFLink או ערך שאינו עקיף, ו-MarkRemovedObject רושמת אובייקט לרשימת הפנויים רק כשמספר האובייקט שלה גדול מאפס. הגרסה הראשונה של הסחיפה הזו לא עשתה את ההבחנה הזו, וההשפעה בשמירה מצטברת הייתה בדיוק מה שהרישום נועד לעשות: RegisterIncrementalChange הלכה מהמילון הישיר /MCR כלפי מעלה לשורש הטרנזקציה בגרף שלו, שהיה איבר המבנה השמור שהיה הבעלים שלו, וכתבה את האיבר הזה כ-null. מסמך שאיבד עמוד אחד חזר עם תוכן מתויג בעמודים האחרים שהפך בשקט ללא מתויג. המהלך הנכון היחיד עבור ילד ישיר הוא לסמן את המכולה שלו כמזוהמת דרך TouchContainer כדי שהמכולה תיכתב מחדש, ולהשאיר את רשימת הפנויים בשקט

למה ילד /MCR או OBJR שהוסר אסור להירשם כרשומה פנויה ב-HotPDF: רישום השינויים המצטבר פותר מילון ישיר למכולה העקיפה הקרובה, כך שהגרסה הראשונה כתבה את איבר המבנה השמור כ-null והפכה בשקט עמודים ששרדו ללא מתויגים, בעוד TouchContainer כותבת כעת את המכולה מחדש ומשאירה את רשימת הפנויים בשקט
שחרור הילד בזיכרון שמור ל-THPDFLink או לערכים שאינם עקיפים ולמספרי אובייקט הגדולים מאפס, כך ששמירה מצטברת מוסיפה רק את המכולות שנגעו בהן ואת אובייקט העמוד ששוחרר
// עדכון מצטבר: רק המכולות שנגעו בהן
// ואובייקט העמוד ששוחרר נכנסים למקטע המצורף.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // איברי מבנה שנשמרו וה-/K שלהם איבד /MCR ישיר
  // נכתבים מחדש במקום, ואף פעם לא נכתבים כ-null.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

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

DeletePage מול DeleteLoadedPage: למי לקרוא?

קראו ל-DeletePage לכל הסרת עמוד שפונה למשתמש, ושמרו את DeleteLoadedPage למקרה שבו כל המסמך מסודר מחדש ואף הפניה ברמת המסמך אינה שווה שימור. THotPDF.DeleteLoadedPage(PageIndex), שנוספה בגרסה 2.508.0, היא הווריאנט הקליל: היא מזיזה את מערך העמודים הפנימי, קוראת ל-RebuildLoadedKidsArray כדי לכתוב מחדש את /Kids ואת /Count, מבטלת את ה-cache של העמודים המרונדרים, ומפעילה את OnLoadedDocumentModified. היא לא עוברת על עץ השמות, על ה-outlines, על עץ המבנה או על ההערות של עמודים אחרים, והיא לא מסמנת את אובייקט העמוד כמחוק. זה הכלי הנכון בתוך imposition של N-up, שבו HotPDF מצרפת גיליונות שהורכבו זה עתה ואז מפילה כל עמוד מקורי עם DeleteLoadedPage(0): עמודי המקור מוחלפים באופן גורף, ותוכן הגיליון מפנה למשאבים שלהם ולא לאובייקטי העמודים. למשימה הרגילה של "להסיר את עמוד 7 מהחוזה הזה", DeletePage היא הקריאה היחידה שמשאירה מסמך מתויג, עם סימניות וקישורים צולבים עקבי מספיק כדי לעבור מאמת, גם בכתיבה מלאה מחדש דרך SaveLoadedDocument וגם בעדכון מצטבר דרך SaveIncrementalUpdate. שתי השיטות נשלחות ברכיב HotPDF ל-Delphi עבור Delphi ו-C++Builder, בלי צורך ב-runtime של מציג חיצוני או בתלות כלשהי