مقاله فنی

ویرایش Outline و بازنگاشت صفحه PDF در Delphi

هفت صفحه را از یک handbook دویست‌صفحه‌ای بیندازید و هر بوکمارک جای اشتباهی فرود می‌آید. درست‌کن بازسازی outline از یک لیست تخت عنوان‌ها نیست. PDFiumPas ‏TPdfOutlineEditor را در اختیارتان می‌گذارد، که درخت outline واقعی را لود می‌کند، اجازه می‌دهد آیتم‌ها را جابه‌جا و بازهدف‌گذاری کنید، و بعد ApplyPageMap را اجرا می‌کند تا هر مقصد صریح را از طریق طرح صفحه شما جابه‌جا کند

چرا حذف صفحات همه بوکمارک‌ها را می‌شکند؟

چون یک آیتم outline شماره صفحه ذخیره نمی‌کند. یک ارجاع به شیء صفحه ذخیره می‌کند، و وقتی اشیاء صفحه عوض می‌شوند آن ارجاع یا به صفحه‌ای اشاره می‌کند که جابه‌جا شده یا به هیچ چیز. ISO 32000-1 §12.3.2.2 یک مقصد صریح را آرایه‌ای تعریف می‌کند که عنصر اولش ارجاع غیرمستقیم به یک دیکشنری صفحه است و بعدش یک نام fit مثل /Fit یا /XYZ می‌آید. صفحه را حذف کنید و با یک ارجاع آویزان تنها می‌مانید؛ صفحات را جابه‌جا کنید و ارجاع هنوز معتبر است اما حالا فصلی متفاوت را توصیف می‌کند. PDFiumPas آن آرایه را هنگام لود به یک شماره صفحه حل می‌کند، پس TPdfOutlineItem.PageNumber یک ایندکس صفحه یک‌مبنا به شما می‌دهد که با API عمومی TPdf جور است نه با یک شماره شیء. کل نقطه این انتزاع همین است: منطق بازنگاشت شما در همان دستگاه مختصاتی کار می‌کند که طرح صفحه‌ای که هنگام split یا جابه‌جایی یا imposition سند ساختید. اگر دارید آن طرح را می‌سازید، همین قرارداد یک‌مبنا در تقسیم اسناد PDF به چند فایل و در imposition n-up و جابه‌جایی صفحات هم جریان دارد

outline یک درخت پیوند دوطرفه است، نه یک لیست

دلیل اینکه نمی‌توانید صرفاً یک آرایه تخت از عنوان‌ها را سریالایز کنید این است که ISO 32000-1 §12.3.3 هر آیتم outline را با پنج پیوند جدا به هم می‌بافد: /Parent و /Prev و /Next و /First و /Last. جابه‌جا کردن یک زیردرخت تنها پس بازنویسی والد قدیمی، والد جدید، هر دو هم‌سطح مجاور در هر دو طرف نقطه برش و نقطه درج، و اشاره‌گر والد خود گره جابه‌جاشده است. یکی از این‌ها را غلط بگیرید و خواننده‌های منطبق یک درخت بریده یا یک حلقه نشان می‌دهند. PDFiumPas وضعیت ویرایش را به‌شکل یک آرایه depth-first از رکوردهای TPdfOutlineItem با یک Id صحیح پایدار نگه می‌دارد، پس یک زیردرخت یک برش پیوسته است و زنجیره هم‌سطح مشتق می‌شود، هرگز دستی نگه‌داری نمی‌شود. TPdfOutlineEditor.Move آن برش را بلند می‌کند، زیر والد جدید در ایندکس هم‌سطح درخواستی دوباره درج می‌کند و فقط ریشه بلوک را دوباره تخصیص می‌دهد. همچنین از دو جابه‌جایی که گراف را خراب می‌کنند امتناع می‌کند: جابه‌جا کردن یک آیتم به درون زیردرخت خودش، و نام بردن از والدی که وجود ندارد

ویرایش outline در PDFiumPas در Delphi: جابه‌جا کردن فصل 3 از بخش I به زیر ریشه سند، اشاره‌گر /Parent گره جابه‌جاشده و پیوندهای /First و هم‌سطح /Prev و /Next دور نقطه برش و نقطه درج را بازنویسی می‌کند
یک فراخوانی Move اشاره‌گر والد زیردرخت بلندشده و پیوندهای هم‌سطح در دو طرف نقطه برش و نقطه درج را بازنویسی می‌کند

چرا /Count علامت‌دار است؟

چون علامت وضعیت باز بودن را حمل می‌کند، نه اندازه را. /Count مثبت یعنی آیتم باز است و عدد تعداد نوادگی‌هایی است که در حال حاضر مرئی‌اند؛ /Count منفی یعنی آیتم بسته است. PDFiumPas برای هر آیتمی که بچه دارد تعداد نوادگی را می‌نویسد و وقتی IsOpen برابر False است آن را منفی می‌کند، و هنگام لود وضعیت را به‌شکل IsOpen := HasCount and (CountValue > 0) برمی‌خواند. این رایج‌ترین باگ دست‌ساز در نویسنده‌های outline است: نوشتن یک count بی‌علامت و باز کردن بی‌سروصدای کل درخت

چگونگی انکود وضعیت باز بودن outline در Delphi توسط PDFiumPas: /Count مثبت یعنی آیتم باز است و نوادگی‌های مرئی را می‌شمارد، /Count منفی یعنی بسته، و 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 شروع شود، به همین دلیل پسوند خالی یا بدشکل بلافاصله raise می‌کند به‌جای اینکه آرایه مقصدی تولید کند که هیچ خواننده‌ای نمی‌تواند parse کند

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 آیتم و کل زیردرختش را حذف می‌کند، چون گره outline‌ای که هدفش غیبش زده معمولاً سری را می‌سازد که همراهش غیبش زده. با False، آیتم با عنوان و سلسله‌مراتب دست‌نخورده زنده می‌ماند اما /Dest و /A‌اش چیده شده، که همان چیزی است که می‌خواهید وقتی یک انسان قرار است در بازبینی بازهدف‌گذاری‌اش کند. ورودی واقعاً بدشکل هنوز بلند شکست می‌خورد به‌جای وصله شدن: یک مدخل منفی یا مقصدی که به بعد از انتهای map داده‌شده اشاره می‌کند False برمی‌گرداند با IssueKind ست‌شده به poviInvalidPageMap

چگونگی تغییر مسیر بوکمارک‌های PDF با ApplyPageMap در PDFiumPas در Delphi: یک page map ایندکس‌گذاری‌شده با صفحه قدیمی منهای یک مقصدهای بازمانده را به شماره صفحات جدیدشان می‌فرستد، در حالی که مدخل‌هایی که به صفر نگاشت می‌شوند یا با زیردرختشان حذف یا از هدفشان چیده می‌شوند
page map با صفحه قدیمی منهای یک ایندکس‌گذاری می‌شود، و یک مدخل صفر یا زیردرخت آویزان را حذف یا آیتم را با هدف چیده‌شده رها می‌کند

مدخل‌های مبهم و مصالحه صادقانه

هر آیتم outline‌ای شماره صفحه‌ای ندارد که PDFiumPas بتواند درباره‌اش استدلال کند. سه نوع دست‌نخورده حمل می‌شوند: مقصدهای نام‌دار، اکشن‌هایی که /S /GoTo نیستند، و کلیدهای دیکشنری ناشناخته‌ای که هر چیزی که فایل را تولید کرده اضافه کرده. این‌ها با PageNumber برابر صفر لود می‌شوند، بایت‌های اصلی‌شان را در آیتم نگه می‌دارند و عیناً برگردانده می‌شوند مگر اینکه صریحاً Retarget را رویشان صدا بزنید

  • یک مقصد نام‌دار یک کلید به درون درخت نام سند است، پس بازنگاشت درست آن یعنی حل کردن درخت و بازنویسی مدخل هدف، نه حدس زدن در سطح outline
  • یک اکشن /URI یا /Launch یا JavaScript اصلاً معناشناسی صفحه ندارد و نباید بی‌سروصدا به یک Go-To تبدیل شود
  • کلیدهای مخصوص فروشنده و مقصدهای ساختاری حفظ می‌شوند چون انداختن چیزی که نمی‌فهمید همان روشی است که رفت‌وبرگشت‌ها داده گم می‌کنند

هزینه واقعی است و ارزش گفتن صریح دارد: ApplyPageMap آن آیتم‌ها را کاملاً رد می‌کند، پس سندی که همه بوکمارک‌هایش از مقصد نام‌دار استفاده می‌کنند از یک حذف صفحه با outline‌ای ساختاراً معتبر و معناییاً منقضی بیرون می‌آید. آن انتخاب عمدی است — یک لینک منقضی که بازبینی‌کننده می‌تواند بگیرد از یک لینک مطمئنِ غلط که کسی نمی‌بیند بهتر است. اگر فایل‌های ورودی را قبل از ویرایش triage می‌کنید، یک گذر inventory در workbench بازبینی ورودی PDF به شما می‌گوید کدام اسناد در آن سطل می‌افتند

ذخیره: بازنگری افزایشی، بعد یک reload مستقل

TPdfOutlineEditor.SaveIncremental یک بازنگری افزایشی پراکنده را به فایل اضافه می‌کند به‌جای بازنویسی فایل. آیتم‌هایی که لود شده بودند ارجاع غیرمستقیم اصلی‌شان را شامل نسل دقیق نگه می‌دارند، پس ارجاع‌های متقابل موجود معتبر می‌مانند؛ فقط آیتم‌هایی که شما اضافه کردید یک شماره تازه می‌کشند، تخصیص‌یافته از یکی بعد از بیشینه شماره شیء بازنگری. کاتالوگ در همان بازنگری به‌روز می‌شود، و وقتی مبدأ اصلاً outline نداشت یک مدخل /Outlines غایب به آن اضافه می‌شود

آن‌چه بعد از نوشتن اتفاق می‌افتد بخشی است که ارزش کپی کردن دارد. PDFiumPas استریم مقصد را با یک ویرایشگر کاملاً مستقل دوباره باز می‌کند و درخت reload‌شده را با درخت درون حافظه مقایسه می‌کند — تعداد آیتم‌ها، عنوان‌ها، شماره صفحات، پسوندهای مقصد، شکل اکشن در مقابل مقصد مستقیم، استایل‌ها، وضعیت باز بودن و روابط والد. هر ناسازگاری، یا هر شکست لود، استریم مقصد را خالی می‌کند و poviVerificationFailure برمی‌گرداند به‌جای اینکه فایلی موجه‌نما به شما بدهد. مبدأهای رمزشده از همان اول با poviEncryptedInput رد می‌شوند، چون عنوان‌ها و مقصدهای جدید محتوای رشته‌ای می‌سازند که با کپی کردن /Encrypt trailer به جلو قابل تولید نیست

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;

با outline همان‌طور که هست رفتار کنید — یک گراف شیء پیوندی با ناورداهای خودش — و حذف صفحه از یک فاجعه بوکمارک به یک page map تبدیل می‌شود که به یک فراخوانی متد می‌دهید. TPdfOutlineEditor و ApplyPageMap و نویسنده افزایشی راستی‌آزمایی‌شده از v3.98.0 در PDFiumPas برای Delphi و C++Builder و Lazarus عرضه می‌شوند؛ می‌توانید API کامل را مرور و یک نسخه آزمایشی روی صفحه محصول PDFium Delphi Component دانلود کنید