רכיב 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 עשוי לעשות אותו דבר
מה מנקה 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-" כמו שאר פעולות העמודים על מסמך טעון, ועוברת מהאינדקס הגבוה ביותר שנבחר כלפי מטה כך שהאינדקסים שכתבתם נשארים תקפים בזמן העבודה
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 כדי שהמכולה תיכתב מחדש, ולהשאיר את רשימת הפנויים בשקט
// עדכון מצטבר: רק המכולות שנגעו בהן
// ואובייקט העמוד ששוחרר נכנסים למקטע המצורף.
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 של מציג חיצוני או בתלות כלשהי