מאמר טכני

עריכת מפת סימניות ומיפוי עמודים מחדש ב־Delphi

משליכים שבעה עמודים מתוך מדריך של 200 עמודים וכל סימנייה נוחתת במקום לא נכון. הפתרון אינו בנייה מחדש של מפת הסימניות מרשימת כותרות שטוחה. PDFiumPas חושף את TPdfOutlineEditor, שטוען את עץ מפת הסימניות האמיתי, מאפשר להזיז ולכוון מחדש פריטים, ואז מריץ את ApplyPageMap כדי להזיז כל יעד מפורש דרך תוכנית העמודים שלכם

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

כי פריט מפת סימניות לא מאחסן מספר עמוד. הוא מאחסן הפניה לאובייקט עמוד, וכשאובייקטי העמודים משתנים ההפניה או מצביעה על עמוד שזז או על כלום. ISO 32000-1 §12.3.2.2 מגדיר יעד מפורש כמערך שהאיבר הראשון שלו הוא הפניה עקיפה למילון עמוד, ואחריו שם התאמה כמו /Fit או /XYZ. מוחקים את העמוד ונשארת לכם הפניה תלויה באוויר; מסדרים את העמודים מחדש וההפניה עדיין תקפה אבל מתארת עכשיו פרק אחר. PDFiumPas פותר את המערך הזה בחזרה למספר עמוד בטעינה, כך ש־TPdfOutlineItem.PageNumber נותן לכם אינדקס עמוד מבוסס־אחד שתואם את ה־API הציבורי של TPdf ולא מספר אובייקט. זו כל המטרה של ההפשטה: הלוגיקה שלכם למיפוי מחדש עובדת באותה מערכת קואורדינטות כמו תוכנית העמודים שכבר בניתם כשפיצלתם, סידרתם מחדש או סידרתם את המסמך לפריסה. אם אתם בונים את התוכנית הזאת, אותו קונבנציה מבוססת־אחד רצה בפיצול מסמכי PDF לכמה קבצים ובפריסת n-up וסידור עמודים מחדש

מפת הסימניות היא עץ קשור דו־כיווני, לא רשימה

הסיבה שאי אפשר פשוט לסדר בטור מערך שטוח של כותרות היא ש־ISO 32000-1 §12.3.3 מחבר כל פריט במפת הסימניות אל חמישה קישורים נפרדים: /Parent, /Prev, /Next, /First ו־/Last. הזזת תת־עץ בודד לכן כותבת מחדש את ההורה הישן, את ההורה החדש, את שני האחים השכנים משני צידי החיתוך ונקודת ההכנסה, ואת מצביע ההורה של הצומת שהוזז עצמו. טועים באחד מהם וקוראים תואמי תקן מציגים עץ קטוע, או נתקעים בלולאה. PDFiumPas שומר את מצב העריכה כמערך depth-first של רשומות TPdfOutlineItem עם Id שלם יציב, כך שתת־עץ הוא שבר רצוף ושרשרת האחים נגזרת, לעולם לא מתוחזקת ידנית. TPdfOutlineEditor.Move מרים את השבר הזה, מכניס אותו מחדש תחת ההורה החדש באינדקס האח המבוקש, ומקצה מחדש רק את השורש של הבלוק. הוא גם מסרב לשתי ההזזות שהיו משחיתות את הגרף: הזזת פריט אל תת־העץ של עצמו, וציון הורה שאינו קיים

עריכת מפת סימניות ב־PDFiumPas ב־Delphi: הזזת פרק 3 החוצה מחלק א׳ ותחת שורש המסמך כותבת מחדש את מצביע ה־/Parent של הצומת שהוזז ואת הקישורים /First והאחים /Prev ו־/Next סביב החיתוך ונקודת ההכנסה
קריאת Move אחת כותבת מחדש את מצביע ההורה של תת־העץ שהורם ואת קישורי האחים משני צידי החיתוך ונקודת ההכנסה

למה ה־/Count חתום?

כי הסימן נושא את מצב הפתיחה, לא את הגודל. /Count חיובי פירושו שהפריט פתוח והמספר הוא כמה צאצאים גלויים כרגע; /Count שלילי פירושו שהפריט מקופל. PDFiumPas כותב את מספר הצאצאים לכל פריט שיש לו ילדים ושולל אותו כש־IsOpen הוא False, ובטעינה קורא את המצב בחזרה כ־IsOpen := HasCount and (CountValue > 0). זהו הבאג הנפוץ ביותר שנכתב ידנית בכותבי מפות סימניות: פליטת מספר לא חתום ופתיחת העץ כולו בשקט

איך PDFiumPas מקודד את מצב הפתיחה של מפת סימניות ב־Delphi: /Count חיובי פירושו שהפריט פתוח וסופר צאצאים גלויים, /Count שלילי פירושו מקופל, ומספר לא חתום מכריח כל קורא לפתוח את העץ כולו
הסימן של ה־/Count הוא מצב הפתיחה והגודל הוא מספר הצאצאים הגלויים, ולכן מספר לא חתום פותח בשקט את העץ כולו
var
  Source, Dest: TMemoryStream;
  Editor: TPdfOutlineEditor;
  Options: TPdfOutlineEditOptions;
  Report: TPdfOutlineValidationReport;
  RootId, ChapterId: Integer;
begin
  Source := TMemoryStream.Create;
  Dest := TMemoryStream.Create;
  Editor := nil;
  try
    Source.LoadFromFile('handbook.pdf');
    Options := TPdfOutlineEditOptions.Default;   // MaxItems 100000, MaxDepth 64
    if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
      raise Exception.Create(Report.ErrorMessage);

    RootId := Editor[0].Id;
    ChapterId := Editor[2].Id;

    Editor.Move(ChapterId, RootId, 1);           // הופך לילד השני של השורש
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // כותב /Count שלילי
    Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');

    if not Editor.SaveIncremental(Source, Dest, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Dest.SaveToFile('handbook-edited.pdf');
  finally
    Editor.Free;
    Dest.Free;
    Source.Free;
  end;
end;

Retarget מטפל בשתי הצורות שהמפרט מאפשר. מעבירים DestinationInAction כ־False ו־PDFiumPas כותב מערך /Dest ישיר; מעבירים True והוא כותב פעולת Go-To, /A << /S /GoTo /D [ page ref suffix ] >>, לפי ISO 32000-1 §12.6.4.2. בשתי הדרכים הוא קודם מפשיט כל /Dest ו־/A קיימים מהפריט כדי שהשניים לא יוכלו להתקיים יחד ולסתור זה את זה. הסיומת ברירת המחדל שלה /Fit וחייבת להתחיל בשם PDF, ולכן סיומת ריקה או פגומה מרימה חריגה מיד במקום לייצר מערך יעד שאף קורא לא מסוגל לנתח

איך ApplyPageMap צורך תוכנית עמודים?

ApplyPageMap מקבל בדיוק את המערך שתוכנית העמודים שלכם כבר אימתה: NewPageNumbers, ממופה לפי עמוד ישן פחות אחד, שמחזיק את מספר העמוד החדש המבוסס־אחד או אפס כאשר העמוד ההוא לא שרד. הוא הולך על מערך הפריטים אחורה כדי שמחיקת תת־עץ לעולם לא תפסול אינדקס שהוא עדיין לא ביקר בו, והוא מדווח על מה שעשה דרך RemappedDestinationCount ו־RemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // רשומה אחת לכל עמוד של המסמך המקורי
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == העמוד הזה הושלך

  NewPageNumbers[0] := 1;                // עמוד ישן 1 -> עמוד חדש 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // עמוד ישן 10 -> עמוד חדש 3

  // True: מחיקת תת-העץ התלוי כולו. False: שמירת הפריט, הסרת היעד שלו
  if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
    raise Exception.Create(Report.ErrorMessage);

  WriteLn(Format('%d remapped, %d dangling items removed',
    [Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;

דגל ה־DeleteDangling מכריע את המדיניות ליעד שמופה לאפס, ושני הענפים מכוונים. עם True, PDFiumPas מוחק את הפריט ואת תת־העץ כולו, כי צומת שהיעד שלו נעלם בדרך כלל עומד בראש פרק שנעלם איתו. עם False, הפריט שורד עם הכותרת וההיררכיה שלמים אבל עם /Dest ו־/A מוסרים, וזה מה שרוצים כשאדם עומד לכוון אותו מחדש בסקירה. קלט פגום באמת עדיין נכשל בקול במקום להתוקן: רשומה שלילית או יעד שמצביע מעבר לסוף המפה שסופקה מחזירים False עם IssueKind שהוגדר ל־poviInvalidPageMap

איך ApplyPageMap של PDFiumPas מכוון סימניות PDF מחדש ב־Delphi: מפת עמודים ממופה לפי עמוד ישן פחות אחד שולחת יעדים ששרדו אל מספרי העמודים החדשים שלהם, ורשומות שמופות לאפס נמחקות עם תת־העץ שלהן או מאבדות את היעד שלהן
מפת העמודים ממופה לפי עמוד ישן פחות אחד, ורשומת אפס מוחקת את תת־העץ התלוי או משאירה את הפריט ללא היעד שלו

רשומות אטומות, והפשרה הישרה

לא לכל פריט במפת הסימניות יש מספר עמוד ש־PDFiumPas יכול להתמצא בו. שלושה סוגים עוברים ללא פגע: יעדים בשם, פעולות שאינן /S /GoTo, ומפתחות מילון לא מוכרים שהוסיף מי שיצר את הקובץ. אלה נטענים עם PageNumber שווה אפס, שומרים את בתיהם המקוריים בפריט, ונכתבים בחזרה כמות שהם אלא אם קראתם במפורש ל־Retarget עליהם

  • יעד בשם הוא מפתח אל עץ השמות של המסמך, ולכן מיפוי נכון שלו פירושו פתרון העץ וכתיבה מחדש של רשומת היעד, לא ניחוש ברמת מפת הסימניות
  • פעולת /URI, /Launch או JavaScript אין לה שום סמנטיקה של עמוד ואסור להמיר אותה בשקט ל־Go-To
  • מפתחות ספציפיים לספק ויעדי מבנה נשמרים, כי השלכת מה שלא מבינים היא הדרך שבה סבבי כתיבה־קריאה מאבדים נתונים

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

שמירה: תיקון מצטבר, ואז טעינה מחדש בלתי תלויה

TPdfOutlineEditor.SaveIncremental מצרף תיקון מצטבר דליל במקום לכתוב את הקובץ מחדש. פריטים שנטענו שומרים על הפניית האובייקט העקיפה המקורית שלהם כולל הדור המדויק, כך שהפניות צולבות קיימות נשארות תקפות; רק פריטים שהוספתם שולפים מספר טרי, שמוקצה מאחד מעבר למספר האובייקט המקסימלי של התיקון. הקטלוג מתעדכן באותו תיקון, ורשומת /Outlines חסרה מתווספת אליו כשלמקור לא הייתה מפת סימניות כלל

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

if not Editor.SaveIncremental(Source, Dest, Report) then
  case Report.IssueKind of
    poviEncryptedInput:
      Log('Source is encrypted; outline editing needs an unprotected copy');
    poviInvalidDestination:
      Log(Format('Item %d %d targets a missing page',
        [Report.ObjectNumber, Report.Generation]));
    poviVerificationFailure:
      Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
  else
    Log(Report.ErrorMessage);
  end;

מתייחסים אל מפת הסימניות כאל מה שהיא — גרף אובייקטים קשור עם אינווריאנטות משלה — ומחיקת עמודים מפסיקה להיות אסון סימניות והופכת למפת עמודים שאתם מוסרים לקריאת שיטה אחת. TPdfOutlineEditor, ApplyPageMap וכותב התיקונים המצטברים המאומת מגיעים ב־PDFiumPas החל מ־v3.98.0 עבור Delphi, C++Builder ו־Lazarus; אפשר לסקור את ה־API המלא ולהוריד גרסת ניסיון בעמוד המוצר PDFium Delphi Component