מאמר טכני

תוויות עמודים ב-PDF בדלפי: תיקון עצי מספור /Kids

PDF Library for Delphi כותבת טווחי תוויות עמודים עם AddPageLabels, ומאז v3.539.10 הקריאה הזאת עובדת גם על קבצים טעונים שעץ המספור של /PageLabels שלהם מפוצל לצמתי /Kids: השורש מושטח לעלה /Nums יחיד לפני שהטווח החדש נכנס, כך שהתווית באמת מופיעה במציג במקום להתעלם בשקט. הקורבן הטיפוסי הוא PDF בסגנון ספר מתוך כלי עימוד, עם ספרות רומיות בפתח הדבר, מספור ערבי בגוף ונספח עם תוויות A-1, A-2, שבו רציתם רק לתייג מחדש את הנספח — ושום דבר לא השתנה

מהן תוויות עמודים ב-PDF ואיך הן מאוחסנות?

תוויות עמודים הן המחרוזות שמציג מציג בתיבת העמוד שלו במקום אינדקס העמוד הפיזי, ו-ISO 32000-1 §12.4.2 מאחסן אותן כעץ מספור תחת מפתח הקטלוג /PageLabels. כל מפתח הוא אינדקס עמוד מבוסס-0 שפותח טווח תיוג, וכל ערך הוא מילון תווית עמוד עם עד שלוש רשומות: /S עבור סגנון המספור (D, R, r, A או a), /P עבור מחרוזת קידומת, ו-/St עבור הערך המספרי של העמוד הראשון בטווח, שברירת המחדל שלו היא 1. טווח רץ עד המפתח הבא, והמפרט דורש שהעץ יכיל ערך עבור אינדקס עמוד 0, כך שכל עמוד מכוסה על ידי איזשהו טווח

אחסון תוויות עמודים במונחי PDFlibPas: עץ המספור /PageLabels ממפתח כל טווח לפי עמוד ההתחלה מבוסס-האפס שלו, כל ערך הוא מילון תווית עם סגנון /S, קידומת /P ומספר ראשון /St, והדוגמה של הספר ממפה פתח-דבר רומי, עמודי גוף ערביים ונספח A- אל שלושה טווחים
טווח רץ עד המפתח הבא, המפרט דורש ערך עבור אינדקס עמוד 0, ו-GetPageLabel מיישם את הטווח האחרון שהמפתח שלו בגובה העמוד או מתחתיו, כך שכל עמוד מפוענח למשהו
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
      Exit;
    // עמודים 1-4: i, ii, iii, iv (רומיות קטנות)
    Lib.AddPageLabels(1, 3, 1, '');
    // עמודים 5-120: 1, 2, 3 ... (עשרוני)
    Lib.AddPageLabels(5, 1, 1, '');
    // עמודים 121 והלאה: A-1, A-2 ... (עשרוני עם קידומת)
    Lib.AddPageLabels(121, 1, 1, 'A-');
    WriteLn(Lib.GetPageLabel(5));    // 1
    WriteLn(Lib.GetPageLabel(122));  // A-2
    Lib.SaveToFile('handbook-labeled.pdf');
  finally
    Lib.Free;
  end;
end;

TPDFlib.AddPageLabels(Start, Style, Offset, Prefix) ממפה את הארגומנטים שלו אל המילון הזה בלי הפתעות ברגע שמכירים שלושה כללים. Start הוא מבוסס-1 כמו כל ארגומנט עמוד אחר בספרייה ונכתב לעץ כ-Start - 1. Style רץ מ-0 עד 5, כאשר 0 פירושו קידומת בלבד ו-1 עד 5 הופכים לערכי /S של D, R, r, A ו-a; כל דבר מחוץ לטווח הזה מחזיר 0 ולא נוגע בכלום. Offset הופך ל-/St רק כשהוא גדול מאפס, ולכן העברת 0 פשוט משמיטה את המפתח והמציג חוזר לברירת המחדל 1. כיוון שתוויות עמודים הגיעו ב-PDF 1.3, הקריאה גם מריצה EnsureMinVersion('1.3', '/PageLabels'), שמעלה את גרסת הפלט של קובץ ישן יותר אלא אם נעלתם במפורש את גרסת השמירה

למה תוויות עמודים חדשות נעלמות כשהעץ מכיל /Kids?

תוויות חדשות נעלמות כי ISO 32000-1 §7.9.7 (טבלה 37) מכתיב שהשורש של עץ מספור נושא או /Kids או /Nums, לעולם לא את שניהם, וה-helper NumTreeSet הקודם ידע רק לחפש /Nums. מפיקים שמשגרים מסמכים ארוכים מפצלים לעיתים קרובות את העץ לצמתי ביניים, כל אחד עם זוג /Limits, ותולים אותם על שורש שיש לו רק /Kids. הקוד הישן לא מצא /Nums על השורש ההוא, יצר אחד טרי לצד ה-/Kids הקיים, והכניס את הטווח החדש לשם. התוצאה הייתה שורש עם שתי נקודות כניסה הדדית בלעדיות. מציגים יורדים דרך /Kids ולעולם לא מביטים במערך התועה, ה-EnumNumTree של הספרייה עצמה גם בודק /Kids קודם, ו-NumTreeLookup מסרב לצומת שבו HasKids xor HasNums אינו אמת. AddPageLabels עדיין החזיר 1 והקובץ השמור עדיין נפתח נקי, וזה הסוג הגרוע ביותר של כשל: אף אחד לא מתלונן, התוויות פשוט נשארות כמו שהיו

התיקון ב-NumTreeSet הופך את השורש לעלה לפני שמכניסים משהו. כשהשורש נושא /Kids, ה-EnumNumTree עובר על כל עלה בסדר ואוסף כל זוג מפתח וערך, מערך /Nums שטוח חדש נבנה מהרשימה הזאת, וה-/Kids, ה-/Limits וכל /Nums מיושן מטוהרים מהשורש לפני שהמערך השטוח מחובר. השמטת /Limits אינה קוסמטית, שכן טבלה 37 מתירה את הרשומה הזאת רק על צמתי ביניים ועלים, לעולם לא על השורש. מאותו רגע ההכנסה היא הכנסה ממוינת רגילה למערך אחד, וטווחים קיימים שורדים עם מילוני התוויות המקוריים שלהם. הפשרה מכוונת: העץ לא נבנה מחדש לצמתי /Kids מאוזנים אחר כך. עבור תוויות עמודים זה לא עולה כלום, כי גם ספר עיון גדול בקושי חורג מכמה תריסרי טווחים, ועלה יחיד הוא בכלל מה שרוב המפיקים כותבים

תיקון עץ מספור ב-PDFlibPas: שורש שנושא /Kids ומערך /Nums תועה בלתי נראה למציגים כי ISO 32000-1 מתיר רק אחד מהשניים, ולכן NumTreeSet משטח כל עלה למערך /Nums יחיד ומטהר את /Kids ואת /Limits, שטבלה 37 לעולם לא מתירה על שורש
אף אחד לא התלונן כי כל בדיקה עברה: AddPageLabels החזיר 1, הקובץ השמור נפתח נקי, ורק קורא שיורד קודם דרך /Kids — כפי שמציגים והספרייה עצמה עושים כל אחד בתורו — לעולם לא מוצא את הטווח החדש
// תיוג מחדש של הנספח בקובץ שהשורש של /PageLabels שלו משתמש ב-/Kids
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
  WriteLn('Before: ', Lib.GetPageLabel(121));  // למשל A-1
  // החלפת הטווח שמתחיל בעמוד 121: App-a, App-b ...
  if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
    Lib.SaveToFile('vendor-manual-relabeled.pdf');
  // הטווחים הרומיים והעשרוניים הקיימים עדיין בעלה המושטח
  WriteLn('After: ', Lib.GetPageLabel(121));   // App-a
  WriteLn('Front: ', Lib.GetPageLabel(2));     // ii, ללא שינוי
end;

איך מערך /Nums יכול להיקרא שלא כראוי כמפתחות?

מערך /Nums נקרא שלא כראוי כשהקוד עובר עליו אלמנט אחד בכל פעם, כי המערך הוא רצף שטוח של זוגות מתחלפים, [key0 value0 key1 value1 ...], ורק המקומות הזוגיים הם מפתחות. הלולאה הישנה של NumTreeSet בדקה כל אלמנט לסוג מספרי, ולכן ערך שקרה להיות מספר הושווה כאילו היה מפתח; פגיעת less-than יכולה הייתה לקבוע את נקודת ההכנסה לאינדקס אי-זוגי ולהפיל את הזוג החדש לתוך זוג קיים, ולהסיט כל זוג מאוחר יותר ממקומו. גם ה-EnumNumTree היה עם אותה הליכה חד-צעדית. שניהם כעת מאיטרטים זוגות בצעד של שניים, קוראים את המפתח ב-X * 2 ואת הערך ב-X * 2 + 1, והתאמת מפתח מדויקת מחליפה את הערך ויוצאת עם Break. בהגינות, ערכי תוויות עמודים הם מילונים, ולכן הבאג השני הזה כמעט ולא הופעל על /PageLabels עצמו, אבל helper של עץ מספור שקורא בצעד שגוי מתקלקל ברגע שכל ערך הוא מספרי, והוא תוקן באותו מעבר

תיקון צעד הזוגות בעצי מספור של PDFlibPas: מערך /Nums הוא רצף שטוח של רשומות מפתח וערך מתחלפות, כך שהליכה שבודקת כל אלמנט עלולה להכניס זוג חדש באינדקס אי-זוגי ולהסיט זוגות מאוחרים ממקומם, בזמן שההליכה המתוקנת קוראת את המפתח ב-X*2 ואת הערך ב-X*2+1
הבאג כמעט ולא הופעל על /PageLabels כי ערכי התוויות הם מילונים, אבל helper של עץ מספור שקורא בצעד שגוי מתקלקל ברגע שכל ערך הוא מספרי, ולכן שתי ההליכות צועדות כעת בזוגות

קריאת תוויות בחזרה וביצוע להן round trip

TPDFlib.GetPageLabel(Page) מחזיר את התווית עבור עמוד מבוסס-1 ויש לו שני fallbacks ששווה להכיר. בלי רשומת /PageLabels בכלל הוא מחזיר את מספר העמוד העשרוני, כך שקורא ל-API יכול להשתמש בו בלי תנאי. עם עץ קיים אבל בלי טווח שמכסה את העמוד הוא מחזיר מחרוזת ריקה, וזה בדיוק מה שקורה כשקובץ מדלג על רשומת אינדקס 0 המחייבת; תיעוד ההתייחסות אומר שטווח שמתחיל בעמוד 1 חייב להתקיים כדי שהתוויות יוצגו נכון, והקוד עושה את הדרישה הזאת נראית לעין. סגנונות האותיות הולכים לפי המפרט ולא לפי עמודות גיליון אלקטרוני: אחרי Z בא AA, ואז BB, חזרה על האות במקום נשיאה

var
  P: Integer;
  Data: WideString;
begin
  // ביקורת מהירה של מה שהמציג יראה בתיבת העמוד שלו
  for P := 1 to Lib.PageCount do
    WriteLn(P, ' -> ', Lib.GetPageLabel(P));

  // ערך אפשרות 4 מייצא רק טווחי תוויות כרשומות PageLabelBegin
  Data := Lib.ExportDocumentData(4);
  // הייבוא משחזר אותם דרך ClearPageLabels + AddPageLabels
  Lib.ImportDocumentData(Data, 0);
end;

לעריכות בכמות, ExportDocumentData עם ערך אפשרות 4 כותב כל טווח כבלוק PageLabelBegin עם שורות PageLabelNewIndex, PageLabelStart, PageLabelPrefix ו-PageLabelNumStyle, ו-ImportDocumentData מתייחס לרשומת התווית הראשונה שהוא רואה כהחלפה מלאה: הוא קורא ל-ClearPageLabels פעם אחת ואז מזרים כל רשומה אל AddPageLabels. זה הופך round trip טקסטואלי לדטרמיניסטי גם כשהקובץ המקורי השתמש בעץ /Kids, כי הניקוי מסיר את כל רשומת הקטלוג והעץ הנבנה מחדש הוא עלה יחיד כבר מההתחלה

מה התיקון עדיין לא מבטיח?

ההשטחה היא חד-כיוונית וסומכת על הסדר שהיא מוצאת. ה-EnumNumTree אוסף זוגות בסדר הקובץ, וה-GetPageLabel מיישם את הטווח האחרון שהמפתח שלו קטן או שווה לאינדקס העמוד, ולכן קובץ זר שעליו מחוץ לסדר — מה ש-§7.9.7 אוסר אבל בהחלט מסתובב בשוק — עדיין יכול להניב תוויות שגויות עד שתבנו מחדש את הטווחים עם ClearPageLabels וקריאות AddPageLabels טריות. התוויות גם קשורות לאינדקסי עמודים ולא לאובייקטי עמודים, ולכן כל פעולה שמשנה את מספר העמודים או הסדר משאירה את הטווחים במקומם. החלפה במקום כמו החלפת עמודים תוך שימור מספרי אובייקטים שומרת על המספר ולכן על יישור התוויות, בזמן שמיזוג כמו מיון משולב של סריקות duplex משובצות מייצר רצף עמודים חדש שראוי לו סט טווחים שנכתב טריים

קריאות התוויות לעמודים, הטיפול בעץ המספור וייצוא וייבוא נתוני המסמך שמתוארים כאן מגיעים כולם בPDF Library for Delphi עבור Delphi, C++Builder ו-Lazarus, עם רשומת ההתייחסות של AddPageLabels שמתעדת את ערכי הסגנון ואת קודי ההחזרה