مقاله فنی

حذف صفحه از PDF در Delphi بدون ارجاع‌های آویزان

HotPDF Delphi Component یک صفحه را از یک PDF بارگذاری‌شده از طریق THotPDF.DeletePage حذف می‌کند و از نسخهٔ 2.751.0 به بعد این فراخوانی هر ارجاع سطح-سند را هم که هنوز به آن صفحه اشاره می‌کند پاک‌سازی می‌کند: مقصدهای نام‌دار در درخت /Names /Dests، dictionary قدیمی /Dests در catalog، اکشن‌های /GoTo در bookmarkها، عناصر ساختار زیر /StructTreeRoot، ParentTree، درایه‌های OBJR مربوط به annotationها، و link annotationهای روی صفحه‌های جان‌سالماده. درخت صفحه آخر از همه بازسازی می‌شود، وقتی هیچ چیز دیگری نمی‌تواند به شیء حذف‌شده برسد

شکستی که این جلویش را می‌گیرد هم بازتولیدش آسان است و هم تشخیصش سخت. صفحهٔ جلد یک گزارش تگ‌دار را حذف کن، ذخیره کن و نتیجه را باز کن: Acrobat تعداد صفحهٔ درست را نشان می‌دهد، اما bookmark مربوط به “Contents” حالا هیچ‌جا فرود نمی‌آید، accessibility checker یک عنصر ساختار بدون صفحه گزارش می‌کند و یک validator سخت‌گیر ارجاعی به یک شیء آزاد فهرست می‌کند. هیچ‌چیز در درخت صفحه غلط نیست. مشکل این است که یک صفحهٔ PDF فقط برگ /Pages نیست؛ هدفی است که نیمی از catalog به آن اشاره می‌کند و برداشتن آن برگ، همهٔ آن اشاره‌گرها را آویزان رها می‌کند

چرا برداشتن یک صفحه از /Kids کافی نیست؟

چون ISO 32000-1 اجازه می‌دهد دست‌کم هفت ساختار مستقل ارجاعی به شیء یک صفحه داشته باشند و تنها یکی از آن‌ها درخت صفحه است. برداشتن صفحه از /Kids و کم کردن /Count شرط §7.7.3 را برآورده می‌کند و هر ارجاع دیگر تبدیل به اشاره‌گری به شیئی می‌شود که یا در xref آزاد شده یا به‌سادگی در فایل بازنویسی‌شده غایب است. viewerی که یکی از آن اشاره‌گرها را دنبال کند null می‌گیرد و اینکه با آن null چه می‌کند به خود viewer مربوط است

  • درخت نام زیر /Names /Dests (§7.7.4، §12.3.2.3) نام‌ها را به آرایه‌های مقصد نگاشت می‌کند که اولین عنصرشان همان صفحه است
  • dictionary پیش از نسخهٔ 1.2 یعنی /Dests که مستقیم در 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 صفحه و annotation را به عناصر ساختار نگاشت می‌کند و یک عنصر می‌تواند همان‌جا زندگی کند بی‌آنکه اصلاً روی زنجیرهٔ /K از ریشه ظاهر شود
  • link annotationهای روی صفحه‌های دیگر (§12.5.6.5) یک /Dest یا اکشن /GoTo دارند که همان صفحه را هدف می‌گیرد و /OpenAction در catalog هم می‌تواند همین کار را بکند
چرا برداشتن یک صفحهٔ HotPDF از /Kids کافی نیست: ISO 32000-1 اجازه می‌دهد درخت نام /Names /Dests و dictionary قدیمی /Dests در catalog و آیتم‌های outline و عناصر ساختار با /Pg و ParentTree و link annotationها و /OpenAction همه ارجاعی به همان شیء صفحه داشته باشند و فقط درخت صفحه بازسازی می‌شود
یک صفحهٔ PDF هدفی است که نیمی از catalog به آن اشاره می‌کند: برداشتن آن برگ درخت صفحه را راضی می‌کند در حالی که هر اشاره‌گر دیگری به null resolve می‌شود، پس یک گزارش کوتاه‌شده bookmark مربوط به Contents را از دست می‌دهد و در بررسی دسترس‌پذیری رد می‌شود

THotPDF.DeletePage پیش از دست زدن به درخت صفحه چه چیزی را پاک‌سازی می‌کند؟

THotPDF.DeletePage(PageIndex) روی یک سند بارگذاری‌شده اول کل جاروب ارجاع‌ها را اجرا می‌کند، بعد شیء صفحه را با DeleteObj حذف‌شده علامت می‌زند، widget annotationها را از درخت فیلد AcroForm جدا می‌کند، آرایهٔ داخلی صفحه‌ها را جابه‌جا می‌کند و در آخر RebuildLoadedPageTree را صدا می‌زند تا /Kids و /Count و /Parent هر صفحهٔ جان‌سالماده را بازنویسی کند. این جاروب catalog را با یک ترتیب ثابت می‌پیماید: درخت نام /Names /Dests، dictionary سبک-قدیمی /Dests، /OpenAction، درخت outline، /StructTreeRoot با ParentTree اش، و آخر آرایه‌های /Annots هر صفحه‌ای که می‌ماند. هر مرحله تصمیم می‌گیرد که یک ارجاع حذف شود، دوباره هدف‌گیری شود، یا رها بماند، بر مبنای آنچه spec به آن ساختار اجازه می‌دهد بدون آن صفحه انجام بدهد. پیش از اجرای هر یک از این‌ها دو نگهبان اعمال می‌شود: DeletePage برای شاخص خارج از بازه Invalid page number را raise می‌کند و از حذف آخرین صفحه سرباز می‌زند، چون یک گرهٔ /Pages با صفر فرزند PDF معتبری نیست؛ در حالی که DeletePages همان نماد یک-پایهٔ "1,3-5,7-" را می‌گیرد که بقیهٔ عملیات صفحه روی سند بارگذاری‌شده دارند و از بالاترین شاخص انتخاب‌شده رو به پایین تکرار می‌کند تا شاخص‌هایی که نوشتی حین کار معتبر بمانند

جاروب ثابت ارجاع‌ها که THotPDF.DeletePage پیش از دست زدن به درخت صفحه اجرا می‌کند: نگهبان‌ها شاخص خارج از بازه یا آخرین صفحه را رد می‌کنند، بعد /Names /Dests و /Dests قدیمی پاک‌سازی می‌شوند، /OpenAction دور انداخته می‌شود، outlineها به NearestRetainedPage دوباره هدف‌گیری می‌شوند، StructTreeRoot و ParentTree پاک‌سازی می‌شوند، linkهای صفحه‌های مانده حذف می‌شوند و RebuildLoadedPageTree آخر از همه اجرا می‌شود
هر ساختار همان برخوردی را می‌گیرد که spec اجازه می‌دهد: نام‌ها ناپدید می‌شوند، bookmarkها روی نزدیک‌ترین صفحهٔ مانده فرود می‌آیند، عناصر ساختار /Pg را از دست می‌دهند یا محو می‌شوند، و بازنویسی /Kids فقط وقتی رخ می‌دهد که هیچ چیز دیگری نتواند به شیء حذف‌شده برسد
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // صفرمبنا: صفحهٔ جلد را بینداز. مقصدهای نام‌دار،
      // bookmarkها، درخت ساختار، ParentTree و link
      // annotationهایی که به آن اشاره می‌کردند پیش از
      // بازسازی درخت /Pages پاک‌سازی می‌شوند.
      Pdf.DeletePage(0);
      // نماد بازهٔ یک-پایه برای دسته‌ای، از بالاترین شاخص
      // رو به پایین تا شاخص‌های قبلی معتبر بمانند.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

مقصدهای نام‌دار و bookmarkها چگونه متفاوت برخورد می‌شوند؟

مقصدهای نام‌دار حذف می‌شوند و bookmarkها دوباره هدف‌گیری می‌شوند، چون نامی که دیگر وجود ندارد یک نتیجهٔ قابل‌قبول است، در حالی که یک bookmark بدون مقصد یک نقص دیدنی است. در درخت /Names /Dests، HotPDF هر گره را می‌پیماید، هر مقصد را هم در شکل آرایهٔ ساده و هم در شکل dictionary با کلید /D در برابر صفحهٔ حذف‌شده می‌آزماید و وقتی اولین عنصر آرایه همان صفحه باشد جفت نام/مقدار را حذف می‌کند. گرهی که هم /Names و هم /Kids اش در نهایت خالی شوند حذف‌شده علامت می‌خورد و از والدش جدا می‌شود، پس درخت هیچ‌وقت برگ‌های توخالی نگه نمی‌دارد. همین آزمون روی dictionary سبک-قدیمی /Dests در catalog هم اجرا می‌شود و /OpenAction در catalog اگر روی صفحهٔ حذف‌شده باز می‌شد به‌سادگی دور انداخته می‌شود. یک مرز اینجا: وقتی یک گرهٔ درخت نام درایه‌هایی را از دست می‌دهد، HotPDF جفت /Limits آن گره را پاک می‌کند، به‌جای اینکه کمترین و بیشترین کلیدهای تازه را از نو حساب کند؛ و هرچند viewerها بدون آن هم نام‌ها را درست resolve می‌کنند، یک conformance checker سخت‌گیر که ISO 32000-1 §7.9.6 را بخواند ممکن است گره‌ای غیر-ریشه که /Limits ندارد را علامت بزند

آیتم‌های outline راه دیگری می‌روند. RetargetOutlineDestinations از ریشهٔ outline روی /First و /Next پیمایش می‌کند، با یک لیست بازدیدشده و یک سقف عمق 128 تا یک درخت چرخه‌دار خراب نتواند فراخوانی را معلق کند، و برای هر آرایهٔ /Dest یا آرایهٔ /D در اکشن /GoTo که به آن صفحه هدف گرفته باشد، اولین عنصر را با NearestRetainedPage جایگزین می‌کند: همان صفحه‌ای که بعد از صفحهٔ حذف‌شده می‌آمد، یا صفحهٔ قبلش وقتی صفحهٔ حذف‌شده آخرین صفحه بود. پارامترهای نمای بعد از ارجاع صفحه همان‌طور که بودند رها می‌شوند. پس bookmarkی که به یک سرآغاز فصل حذف‌شده اشاره می‌کرد، روی اولین صفحه از آنچه باقی مانده فرود می‌آید نه اینکه از نوار کناری ناپدید شود، و این همان رفتاری است که بازبین‌ها از یک سند کوتاه‌شده انتظار دارند. اما آزمون مقصد فقط با آرایه‌های صریح منطبق می‌شود: آیتم outlineی که /Dest اش یک رشتهٔ نام است و قبلاً به صفحهٔ حذف‌شده resolve می‌شد دوباره هدف‌گیری نمی‌شود، چون درایهٔ درخت نام رفته و ارجاع حالا به هیچ چیز resolve می‌شود نه به یک شیء آزاد، پس viewer آن را یک bookmark مرده می‌بیند. مکانیک خود درخت outline، یعنی /First و /Next و معناشناسی غیرشهودی /Count، در راهنمای افزودن bookmark و مقصد نام‌دار به یک PDF بارگذاری‌شده پوشش داده شده است

// جاروب را بررسی کن، نه اینکه به آن اعتماد کنی.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// bookmarkی که جلد را هدف گرفته بود حالا به صفحه‌ای
// که بعد از آن می‌آمد resolve می‌شود (شاخص صفرمبنای 0 بعد از حذف).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

بر سر درخت ساختار و ParentTree چه می‌آید؟

عناصر ساختاری که فقط به خاطر صفحهٔ حذف‌شده وجود دارند حذف می‌شوند و عناصری که چند صفحه را در بر می‌گیرند کلید /Pg را از دست می‌دهند اما فرزندانشان را نگه می‌دارند. PruneStructureElement از /StructTreeRoot روی زنجیرهٔ /K تا عمق 128 پایین می‌رود و هم شکل آرایه‌ای و هم شکل dictionary تکی /K را که §14.7.2 اجازه می‌دهد مدیریت می‌کند. برای هر عنصر اول فرزندانش را پاک‌سازی می‌کند، بعد خود عنصر را ارزیابی می‌کند: اگر پاک‌سازی /K اش را خالی کرد، عنصر حذف‌شده علامت می‌خورد و والدش آن را می‌اندازد. اگر /Pg خود عنصر همان صفحهٔ حذف‌شده را نام ببرد و عنصر هنوز فرزند و یک والد /P داشته باشد، فقط /Pg حذف می‌شود، چون /Pg روی یک عنصر صفحهٔ پیش‌فرض فرزندان محتوای علامت‌خورده‌اش است و آن فرزندان ممکن است صراحتاً به صفحه‌های دیگری ارجاع بدهند. فقط عنصری که /Pg اش همان صفحهٔ حذف‌شده است و زیر خودش هیچ چیزی نمانده، یکسره حذف می‌شود

ParentTree همان برخورد را می‌گیرد و دلیلش همان چیزی است که در جریان توسعه نیش زد: یک عنصر ساختار می‌تواند از ParentTree قابل‌دسترس باشد و از هیچ‌جای دیگر. این number tree اعداد /StructParents را یا به یک عنصر واحد یا به آرایه‌ای از عناصر نگاشت می‌کند و PruneParentTreeNode روی هر مقداری که پیدا می‌کند PruneStructureElement را اجرا می‌کند، مقدارهایی را که پاک‌سازی شده‌اند حذف می‌کند، یک جفت /Nums را وقتی آرایهٔ مقدارش خالی شد پاک می‌کند و گرهی را که هم /Nums و هم /Kids اش رفته از والدش جدا می‌کند. پاک‌سازی فقط فرزندان /K آن عناصر یتیم را با /Pg به یک صفحهٔ آزاد و با فرزندان /MCR شان به ارجاع‌های محتوای علامت‌خوردهٔ آزادشده اشاره‌گر می‌کرد. اگر متن را به ترتیب ساختار استخراج می‌کنی این مستقیم مهم می‌شود: استخراج متن به ترتیب ساختار دقیقاً همین درخت‌ها را می‌پیماید و عنصری با /Pg تهی یک پاراگراف است که بی‌صدا از ترتیب خواندن بیرون می‌افتد

کدام link annotationهای روی صفحه‌های جان‌سالماده حذف می‌شوند؟

هر link annotation روی یک صفحهٔ مانده که آرایهٔ /Dest یا اکشن /GoTo اش به صفحهٔ حذف‌شده اشاره کند، همراه با مالکیتش در درخت ساختار حذف می‌شود. RemoveRetainedPageDestinationAnnotations آرایهٔ /Annots هر صفحه‌ای جز هدف را می‌پیماید، همان آزمون مقصدی را اعمال می‌کند که برای outlineها به کار رفت، annotation منطبق را حذف‌شده علامت می‌زند، از آرایه می‌اندازدش و بعد PruneAnnotationReferencesInStructureTree را صدا می‌زند تا dictionary مربوط به OBJR که /Obj اش همان annotation را نام می‌برد از عنصر ساختارش حذف شود، و اگر OBJR تنها فرزندش بود خود عنصر هم حذف شود. رها گذاشتن OBJR در جای خودش §14.7.4.3 را نقض می‌کند که لازم می‌دارد /Obj به یک شیء موجود ارجاع بدهد، و در یک بررسی PDF/UA به‌صورت یک لینک تگ‌دار بدون هیچ annotation پشتش ظاهر می‌شود. به این عدم‌تقارن با bookmarkها دقت کن: لینک‌ها حذف می‌شوند، دوباره هدف‌گیری نمی‌شوند. یک ارجاع متقابل در متن که می‌گفت «صفحهٔ 3 را ببین» به‌محض رفتن صفحهٔ 3 غلط است و اشاره دادنش به صفحهٔ 4 به شکلی دروغ می‌شود که فرود آمدن یک bookmark روی نزدیک‌ترین فصل دروغ نیست؛ پس اگر گردش‌کارت نگه داشتن آن لینک‌ها را لازم دارد، خودت پیش از صدا زدن DeletePage دوباره هدف‌گیری‌شان کن

چرا یک /MCR یا /OBJR حذف‌شده هرگز نباید به‌عنوان آزاد ثبت شود؟

چون ارجاع‌های محتوای علامت‌خورده و ارجاع‌های شیء معمولاً dictionaryهای مستقیم داخل آرایهٔ /K والدشان‌اند و رجیستری تغییرهای افزایشی یک شیء مستقیم را به نزدیک‌ترین شیء غیرمستقیمی که در برش دارد resolve می‌کند. وقتی RemoveArrayItem فرزندی را از یک آرایهٔ /K می‌اندازد، شیء درون-حافظه‌ای را فقط وقتی آزاد می‌کند که یک THPDFLink یا یک مقدار غیرمستقیم‌نشده باشد، و MarkRemovedObject یک شیء را فقط وقتی در لیست آزاد ثبت می‌کند که شمارهٔ شیءش بزرگ‌تر از صفر باشد. اولین نسخهٔ این جاروب آن تفکیک را نمی‌کرد و اثرش در یک ذخیرهٔ افزایشی دقیقاً همان چیزی بود که رجیستری برای انجامش طراحی شده: RegisterIncrementalChange از /MCR مستقیم تا ریشهٔ تراکنش گرافش بالا می‌رفت، که همان عنصر ساختار مانده‌ای بود که مالکش بود، و آن عنصر را به‌صورت null بیرون می‌نوشت. سندی که یک صفحه از دست داده بود، با محتوای تگ‌دار روی صفحه‌های دیگر که بی‌صدا از تگ خارج شده بودند برمی‌گشت. تنها حرکت درست برای یک فرزند مستقیم این است که با TouchContainer ظرفش را کثیف علامت بزنی تا ظرف بازنویسی شود، و لیست آزاد را رها کنی

چرا یک فرزند /MCR یا OBJR حذف‌شده هرگز نباید در HotPDF به‌عنوان آزاد ثبت شود: رجیستری تغییرهای افزایشی یک dictionary مستقیم را به نزدیک‌ترین ظرف غیرمستقیم resolve می‌کند، پس نسخهٔ اول عنصر ساختار مانده را به‌صورت 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 عمداً روی یک سند بارگذاری‌شده آزاد نمی‌کند. streamهای محتوا و XObjectها و annotationهای غیر-widget صفحهٔ حذف‌شده به‌صورت object رها می‌شوند، چون یک فایل بارگذاری‌شده ممکن است هرکدام را با صفحه‌ای که می‌ماند شریک باشد و راه ارزانی برای اثبات خلافش در لحظهٔ حذف وجود ندارد. برداشتن ارجاع درخت صفحه برای درستی کافی است؛ byteهایی که آن objectها هنوز اشغال می‌کنند پرسش جدایی است و گراف وابستگی objectها و تحلیل byteهای نگه‌داشته‌شده ابزار اندازه‌گیری آن است که یک سند کوتاه‌شده هنوز چه چیزی حمل می‌کند

DeletePage در برابر DeleteLoadedPage: کدام را باید صدا زد؟

برای هر حذف صفحهٔ رو-به-کاربر DeletePage را صدا بزن و DeleteLoadedPage را برای حالتی نگه دار که کل سند دارد دوباره چیده می‌شود و هیچ ارجاع سطح-سندی ارزش نگه داشتن ندارد. THotPDF.DeleteLoadedPage(PageIndex) که در نسخهٔ 2.508.0 اضافه شد همان گونهٔ سبک است: آرایهٔ داخلی صفحه‌ها را جابه‌جا می‌کند، RebuildLoadedKidsArray را برای بازنویسی /Kids و /Count صدا می‌زند، cache صفحات رندرشده را بی‌اعتبار می‌کند و OnLoadedDocumentModified را شلیک می‌کند. درخت نام و outlineها و درخت ساختار و annotationهای صفحه‌های دیگر را نمی‌پیماید و شیء صفحه را حذف‌شده علامت نمی‌زند. این ابزار درست داخل چیدمان N-up است، جایی که HotPDF برگ‌های تازه‌چیده‌شده را ضمیمه می‌کند و بعد هر صفحهٔ اصلی را با DeleteLoadedPage(0) می‌اندازد: صفحه‌های منبع یکسره جایگزین می‌شوند و محتوای برگ به منابع آن‌ها ارجاع می‌دهد نه به خود شیءهای صفحه. برای کار معمولی «صفحهٔ 7 را از این قرارداد بردار»، DeletePage تنها فراخوانی‌ای است که یک سند تگ‌دار و bookmark‌دار و ارجاع‌متقابل‌دار را به‌قدر کافی سازگار رها می‌کند تا از یک validator قبول بیرون بیاید، هم در بازنویسی کامل از طریق SaveLoadedDocument و هم در به‌روزرسانی افزایشی از طریق SaveIncrementalUpdate. هر دو متد در HotPDF Delphi Component برای Delphi و C++Builder عرضه می‌شوند، بی‌نیاز به هیچ runtime یا وابستگی viewer بیرونی