مقاله فنی

برچسب‌های صفحهٔ PDF در Delphi: تعمیر درخت شمارهٔ /Kids

PDF Library for Delphi بازه‌های برچسب صفحه را با AddPageLabels می‌نویسد و از v3.539.10 به بعد آن فراخوانی روی فایل‌های بارگذاری‌شده‌ای هم کار می‌کند که درخت شمارهٔ /PageLabels شان به گره‌های /Kids شکسته شده: ریشه پیش از آنکه بازهٔ تازه برود، به یک برگ /Nums تکی تخت می‌شود، پس برچسب واقعاً در viewer خودش را نشان می‌دهد به‌جای آنکه بی‌سروصدا نادیده گرفته شود. قربانی معمول یک PDF کتابی از یک ابزار page layout است، با اعداد رومی در صفحات آغازین، شماره‌گذاری عربی در متن و یک ضمیمه با برچسب A-1 و A-2، جایی که تو فقط می‌خواستی ضمیمه را دوباره برچسب بزنی و هیچ چیز عوض نشد

برچسب‌های صفحهٔ PDF چیستند و چطور ذخیره می‌شوند؟

برچسب‌های صفحه رشته‌هایی هستند که viewer در جعبهٔ صفحه‌اش به‌جای ایندکس فیزیکی صفحه نشان می‌دهد، و ISO 32000-1 §12.4.2 آن‌ها را به‌صورت یک number tree زیر کلید /PageLabels در catalog ذخیره می‌کند. هر کلید یک ایندکس صفحهٔ صفرمبناست که شروع یک بازهٔ برچسب‌گذاری را اعلام می‌کند، و هر مقدار یک dictionary برچسب صفحه با حداکثر سه درایه است: /S برای سبک شماره‌گذاری (D و R و r و A یا a)، /P برای یک رشتهٔ پیشوند، و /St برای مقدار عددی اولین صفحهٔ بازه که پیش‌فرضش 1 است. یک بازه تا کلید بعدی ادامه دارد و spec می‌خواهد درخت برای ایندکس صفحهٔ 0 مقداری داشته باشد، پس هر صفحه زیر پوشش یک بازه است

ذخیرهٔ برچسب صفحه به اصطلاحات PDFlibPas: درخت شمارهٔ /PageLabels هر بازه را با صفحهٔ شروع صفرمبنای آن کلید می‌کند، هر مقدار یک dictionary برچسب با سبک /S و پیشوند /P و شمارهٔ اول /St است، و مثال کتاب، صفحات آغازین رومی و صفحات متن عربی و ضمیمهٔ A- را روی سه بازه می‌نگارد
یک بازه تا کلید بعدی ادامه دارد، spec برای ایندکس صفحهٔ 0 مقدار می‌خواهد، و GetPageLabel آخرین بازه‌ای را اعمال می‌کند که کلیدش روی صفحه یا زیر آن است، پس هر صفحه به چیزی resolve می‌شود
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) آرگومان‌هایش را بدون غافلگیری روی همان dictionary می‌نگارد، البته وقتی سه قاعده را بدانی. Start مثل بقیهٔ آرگومان‌های صفحه در کتابخانه یک‌مبناست و به‌شکل Start - 1 در درخت نوشته می‌شود. Style از 0 تا 5 می‌رود؛ 0 یعنی فقط پیشوند و 1 تا 5 می‌شوند مقادیر /S به‌شکل D و R و r و A و a؛ هر چیزی بیرون این بازه 0 برمی‌گرداند و به هیچ چیز دست نمی‌زند. Offset فقط وقتی از صفر بزرگ‌تر باشد /St می‌شود، پس پاس دادن 0 به‌سادگی کلید را حذف می‌کند و viewer به پیش‌فرض 1 برمی‌گردد. چون برچسب‌های صفحه از PDF 1.3 آمدند، فراخوانی EnsureMinVersion('1.3', '/PageLabels') را هم اجرا می‌کند که نسخهٔ خروجی یک فایل قدیمی‌تر را بالا می‌برد مگر اینکه صریحاً نسخهٔ ذخیره را قفل کرده باشی

چرا برچسب‌های صفحهٔ تازه وقتی درخت /Kids دارد محو می‌شوند؟

برچسب‌های تازه محو می‌شوند چون ISO 32000-1 §7.9.7 (جدول 37) می‌خواهد ریشهٔ number tree یا /Kids داشته باشد یا /Nums، هرگز هر دو را، و هلپر قدیمی NumTreeSet فقط بلد بود دنبال /Nums بگردد. تولیدکننده‌هایی که اسناد طولانی می‌سازند اغلب درخت را به گره‌های میانی می‌شکنند، هر کدام با یک جفت /Limits، و از یک ریشه که فقط /Kids دارد آویزانشان می‌کنند. کد قدیمی روی آن ریشه /Nums پیدا نمی‌کرد، یکی تازه کنار /Kids موجود می‌ساخت و بازهٔ جدید را آنجا می‌گذاشت. نتیجه می‌شد ریشه‌ای با دو نقطهٔ ورود متقابلاً انحصاری. viewerها از /Kids پایین می‌روند و هرگز به آن آرایهٔ سرگردان نگاه نمی‌کنند، EnumNumTree خود کتابخانه هم اول /Kids را بررسی می‌کند، و NumTreeLookup گره‌ای را که در آن HasKids xor HasNums برابر false است رد می‌کند. AddPageLabels همچنان 1 برمی‌گرداند و فایل ذخیره‌شده همچنان تمیز باز می‌شد، که بدترین نوع شکست است: هیچ چیز گله نمی‌کند، برچسب‌ها فقط همان‌قدیمیه می‌مانند

fix در NumTreeSet ریشه را پیش از درج هر چیزی به یک برگ تبدیل می‌کند. وقتی ریشه /Kids دارد، EnumNumTree هر برگ را به ترتیب می‌پیماید و هر جفت کلید و مقدار را جمع می‌کند، یک آرایهٔ تخت /Nums از آن فهرست ساخته می‌شود، و /Kids و /Limits و هر /Nums کهنه پیش از اتصال آرایهٔ تخت از ریشه پاک می‌شوند. انداختن /Limits صرفاً ظاهرسازی نیست، چون جدول 37 آن درایه را فقط روی گره‌های میانی و برگ مجاز می‌داند، هرگز روی ریشه. از آن نقطه به بعد درج، یک درج مرتب‌شدهٔ معمولی در یک آرایه است و بازه‌های موجود با dictionaryهای برچسب اصلی‌شان زنده می‌مانند. این معامله عمدی است: درخت بعدش به گره‌های متوازن /Kids بازسازی نمی‌شود. برای برچسب صفحه این هیچ هزینه‌ای ندارد، چون حتی یک مرجع دستی بزرگ به‌ندرت بیش از چند دوجین بازه دارد و یک برگ تکی همان چیزی است که اکثر تولیدکننده‌ها می‌نویسند

تعمیر number tree در PDFlibPas: ریشه‌ای که /Kids و یک آرایهٔ سرگردان /Nums دارد برای viewerها نامرئی است چون ISO 32000-1 فقط یکی از آن دو را مجاز می‌داند، پس NumTreeSet هر برگ را به یک آرایهٔ تکی /Nums تخت می‌کند و /Kids و /Limits را پاک می‌کند، که جدول 37 هرگز روی ریشه مجازشان نمی‌داند
هیچ چیز گله نکرد چون همهٔ بررسی‌ها قبول شدند: AddPageLabels مقدار 1 برگرداند، فایل ذخیره‌شده تمیز باز شد، و فقط خواننده‌ای که اول از /Kids پایین می‌رود — همان‌طور که viewerها و خود کتابخانه هر دو رفتار می‌کنند — هرگز بازهٔ تازه را پیدا نمی‌کند
// برچسب‌گذاری دوبارهٔ ضمیمه در فایلی که ریشهٔ /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 قدیمی هر عنصر را برای نوع عددی آزمایش می‌کرد، پس مقداری که اتفاقاً عدد بود طوری مقایسه می‌شد انگار کلید است؛ یک برخورد کوچک‌تر-از می‌توانست نقطهٔ درج را روی ایندکسی فرد بگذارد و جفت تازه را وسط یک جفت موجود بیندازد و همهٔ جفت‌های بعدی را از فاز بیندازد. EnumNumTree هم همان پیمایش تک‌قدمی را داشت. هر دو حالا جفت‌ها را با گام دو می‌پیمایند، کلید را در X * 2 و مقدار را در X * 2 + 1 می‌خوانند، و تطابق دقیق کلید، مقدار را جایگزین و با Break خارج می‌شود. انصافاً مقادیر برچسب صفحه dictionary هستند، پس این باگ دوم روی خود /PageLabels به‌ندرت فعال می‌شد، اما هلپری که گام غلط می‌خواند لحظه‌ای که هر مقداری عددی باشد خراب است، و در همان نوبت fix شد

fix گام جفتی در number treeهای PDFlibPas: آرایهٔ /Nums یک دنبالهٔ تخت از درایه‌های متناوب کلید و مقدار است، پس پیمایشی که هر عنصر را آزمایش کند می‌تواند یک جفت تازه در ایندکسی فرد درج کند و جفت‌های بعدی را از فاز بیندازد، در حالی که پیمایش اصلاح‌شده کلید را در X*2 و مقدار را در X*2+1 می‌خواند
باگ روی /PageLabels به‌ندرت فعال می‌شد چون مقادیر برچسب dictionary هستند، اما هلپری که گام غلط بخواند لحظه‌ای که هر مقداری عددی باشد خراب است، پس هر دو پیمایش حالا جفتی قدم برمی‌دارند

خواندن برچسب‌ها و رفت‌وبرگشتشان

TPDFlib.GetPageLabel(Page) برچسب یک صفحهٔ یک‌مبنا را برمی‌گرداند و دو fallback دارد که بدانشان بد نیست. بدون هیچ درایهٔ /PageLabels شمارهٔ اعشاری صفحه را برمی‌گرداند، پس فراخوانی‌کننده می‌تواند بی‌قید و شرط از آن استفاده کند. با وجود درخت اما بدون بازه‌ای که صفحه را پوشش دهد، رشتهٔ خالی برمی‌گرداند، که دقیقاً همان چیزی است وقتی فایلی درایهٔ اجباری ایندکس 0 را جا انداخته؛ مستندات مرجع می‌گوید برای نمایش درست برچسب‌ها باید بازه‌ای که از صفحهٔ 1 شروع می‌شود وجود داشته باشد و کد این الزام را دیدنی می‌کند. سبک‌های حرفی از spec پیروی می‌کنند نه از ستون‌های spreadsheet: بعد از Z می‌شود AA، بعد BB، حرف تکرار می‌شود نه carry

var
  P: Integer;
  Data: WideString;
begin
  // بازرسی سریع اینکه viewer در جعبهٔ صفحه‌اش چه نشان خواهد داد
  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 می‌دهد. این رفت‌وبرگشت متنی را حتی وقتی فایل اصلی از درخت /Kids استفاده کرده قطعی می‌کند، چون پاک کردن کل درایهٔ catalog را برمی‌دارد و درخت بازسازی‌شده از اول یک برگ تکی است

fix همچنان چه چیزی را تضمین نمی‌کند؟

تخت کردن یک‌طرفه است و به ترتیبی که پیدا می‌کند اعتماد می‌کند. EnumNumTree جفت‌ها را به ترتیب فایل جمع می‌کند و GetPageLabel آخرین بازه‌ای را اعمال می‌کند که کلیدش کوچک‌تر یا مساوی ایندکس صفحه است، پس یک فایل بیگانه که برگ‌هایش بی‌ترتیب‌اند — که §7.9.7 منعش کرده اما در گردش است — همچنان می‌تواند برچسب‌های غلط بدهد تا وقتی بازه‌ها را با ClearPageLabels و فراخوانی‌های تازهٔ AddPageLabels بازسازی کنی. برچسب‌ها هم به ایندکس صفحه‌ها بسته‌اند نه به objectهای صفحه، پس هر عملیاتی که تعداد یا ترتیب صفحه‌ها را عوض کند بازه‌ها را همان‌جا که بودند رها می‌کند. یک جابه‌جایی درجا مثل جایگزینی صفحات با حفظ شماره‌های object تعداد را نگه می‌دارد و در نتیجه برچسب‌ها هم‌تراز می‌مانند، در حالی که یک merge مثل مرتب‌سازی متناوب اسکن‌های دوسویه یک توالی صفحهٔ تازه تولید می‌کند که لایق یک مجموعهٔ بازهٔ تازه‌نویسی‌شده است

فراخوانی‌های برچسب صفحه، مدیریت number tree و خروجی و ورود دادهٔ سند که اینجا توصیف شد همه در PDF Library for Delphi برای Delphi و C++Builder و Lazarus عرضه می‌شوند، با درایهٔ مرجع AddPageLabels که مقادیر سبک و کدهای بازگشت را مستند می‌کند