مقاله فنی

کپی شیء PDF بین اسناد در Delphi: کرش‌های چرخه‌ای

دو PDF را با دست ادغام کنید و یک شیء صفحه را تنها به سند مقصد منتقل کنید، کپی مستقیم به یک access violation می‌خورد. PDFlibPas این را در CopyForeignObject حل کرده است: یک شیء غیرمستقیم به‌علاوه کل بستار ارجاع‌هایش را به‌صورت عمیق کپی می‌کند و ارجاع‌های برگشتی چرخه‌ای مثل /Parent را به‌جای بازگشت بیشتر، به null حل می‌کند

چرا کپی کردن فقط یک صفحه بین اسناد کرش می‌کند؟

زیرا درخت صفحه PDF فقط وقتی درخت است که آن را از بالا به پایین بخوانید. اگر مثل یک کپی‌کن بازگشتی روی آن حرکت کنید و هر مقدار داخل هر دیکشنری را دنبال کنید، دیکشنری صفحه /Parent را به شما می‌دهد که به همان گره /Pagesی اشاره می‌کند که از آن آمده‌اید، و آن گره /Kids را می‌دهد که به خود صفحه برمی‌گردد. ISO 32000-1 §7.7.3 وجود /Parent را روی هر گره درخت صفحه غیر از ریشه الزامی می‌کند، پس این فایل بدشکلی نیست که بشود ردش کرد — این شکل عادی هر سندی است که تا به حال تحویل گرفته‌اید

نیمه دوم مشکل شماره‌گذاری است. اشیای غیرمستقیم با شماره شیئی شناسایی می‌شوند که مختص یک فایل است (ISO 32000-1 §7.3.10)، پس شیئی که از سند A به سند B کشیده می‌شود باید شماره‌اش عوض شود و هر ارجاع به آن داخل بستار کپی‌شده هم به همان شکل دوباره شماره‌گذاری شود، وگرنه دو ارجاعی که قبلاً به یک فونت مشترک اشاره می‌کردند به دو چیز بی‌ربط اشاره خواهند کرد. این شماره‌گذاری مجدد همان کاری است که یک ادغام سریع در سطح بایت انجام می‌دهد و کنار هم گذاشتن این دو ارزش خواندن دارد: جابه‌جایی ارجاع در سطح بایت برای ادغام سریع PDF آن را با ترجمه کل فایل حل می‌کند، در حالی که کپی در سطح شیء باید آن را یک یال در هر بار حل کند

چرا کپی بین‌اسنادی PDF در Delphi دقت می‌خواهد: دیکشنری صفحه و گره /Pages آن از طریق /Parent و /Kids یک چرخه می‌سازند، بستار یک فونت رو به پایین حرکت می‌کند و پایان می‌یابد، و PDFlibPas هر شماره شیء مختص فایل را دوباره نقشه‌برداری می‌کند
درخت صفحه از مسیر /Parent و /Kids یک حلقه می‌بندد در حالی که بستارهای محتوا پایان می‌یابند، و هر شماره شیء کپی‌شده باید در همین مسیر دوباره نگاشت شود

CopyForeignObject در PDFlibPas واقعاً چه چیزی را کپی می‌کند

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) یک شیء غیرمستقیم و هر چیزی را که از آن قابل دستیابی است — دیکشنری‌های تو در تو، آرایه‌ها، رشته‌ها، نام‌ها، اعداد و استریم‌ها همراه با دیکشنری‌هایشان — در سند انتخاب‌شده فعلی کلون می‌کند و یک هندل غیرصفر به ارجاع غیرمستقیم جدید برمی‌گرداند. شماره‌های شیء مبدأ از طریق یک نگاشت زنده که به مدت فراخوان نگه داشته می‌شود دوباره نگاشت می‌شوند، پس شیئی که دو بار در بستار دیده شود یک بار کلون و دو بار به اشتراک گذاشته می‌شود. وقتی شناسه سند مبدأ ناشناخته باشد، وقتی مبدأ همان سند انتخاب‌شده باشد یا وقتی ObjectNumber کمتر از 1 باشد، بدون صدور استثنا صفر برمی‌گرداند

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFile در صورت موفقیت 1 برمی‌گرداند
    SourceDoc := Lib.SelectedDocument;   // عمل لود، همان چیزی که لود کرد را انتخاب می‌کند
    Lib.SelectDocument(TargetDoc);       // کپی در سند انتخاب‌شده نوشته می‌شود
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

دو جزئیات در اولین اجرا آدم‌ها را گاز می‌گیرد. LoadFromFile جوابش 1 یا 0 است نه یک شناسه سند، پس هندلی که لازم دارید بلافاصله بعد از لود از SelectedDocument می‌آید؛ و کپی همیشه در هر چیزی که SelectDocument آخرین بار فعلی کرده می‌نویسد، هرگز در سندی که از آن لود کرده‌اید نه. در داخل، بازگشت یک سقف عمق سخت 64 هم حمل می‌کند که پشتیبانِ تو در تو‌سازی مرضی است، نه سازوکار مدیریت چرخه — مدیریت چرخه جدا و آگاهانه است

چرا رزرو نگاشت Nil چرخه را نمی‌شکند؟

زیرا Nil در جدول نگاشت هم‌زمان دو معنای متفاوت دارد و کد نمی‌تواند آن‌ها را از هم تشخیص دهد. دفاع واضح در برابر چرخه این است که رکورد نگاشت را قبل از بازگشت به شیء اضافه کنید تا هر چیزی که به عقب برمی‌گردد رکورد را پیدا کند و متوقف شود. اما رکورد هنوز نمی‌تواند مقصد واقعی را نگه دارد — مقصد تا وقتی بستار پایین‌تر نوشته نشده وجود ندارد — پس Nil نگه می‌دارد، و جست‌وجویی که قرار بود یال برگشتی را بگیرد Nil می‌خواند و نتیجه می‌گیرد که شیء هرگز نگاشت نشده است

// خراب: مقصد رزروشده Nil از حالت «هنوز نگاشت نشده» قابل تفکیک نیست
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // رزرو شده، هنوز Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // فقط در مسیر بازگشت پر می‌شود
end;

این را در حلقه صفحه دنبال کنید. کلون صفحه به /Parent می‌رسد، به گره /Pages بازگشت می‌کند، که به /Kids می‌رسد، که به خود صفحه بازگشت می‌کند — رکورد رزروشده آن هنوز Nil است، پس بار دوم کلون می‌شود، بار سوم هم، و هر سطح یک فریم تازه و یک شیء نیمه‌ساخته تازه روی پشته می‌گذارد. آن‌چه مشاهده می‌کنید حتی سرریز پشته تمیزی هم نیست: فریم‌های بیرونی روی ارجاع‌هایی نشسته‌اند که مقصدشان هرگز مقدار نگرفته، پس اولین نوشتن از طریق یکی از آن اسلات‌ها یک access violation در جایی است که هیچ شباهتی به کپی صفحه‌ای که باعثش شد ندارد

چرا رزرو مقصد Nil در نگاشت، چرخه را در کپی بین‌اسنادی PDFlibPas متوقف نمی‌کند: جست‌وجو رکورد رزروشده را از نگاشت‌نشده تشخیص نمی‌دهد، پس کپی‌کن از فریم‌های نیمه‌ساخته هر بار عمیق‌تر پایین می‌رود تا یک نوشتن کرش کند
چون مقصد Nil هم‌زمان به دو پرسش متفاوت جواب می‌دهد، یال برگشتی هرگز شناسایی نمی‌شود و صفحه در هر گذر دوباره کلون می‌شود

راه‌حل: یک حالت صریح in-progress

تعمیر این است که بار معنایی اضافه را از دوش Nil بردارید و پرسش را مستقیم بپرسید. رکورد نگاشتی که مقصدش هنوز مقدار نگرفته یعنی این شیء در حال کلون شدن است، و گزاره InProgress دقیقاً همین را پیش از اجرای جست‌وجوی عادی می‌آزماید. وقتی true باشد، یال یک چرخه بازگشت به جد کپی فعلی است و PDFlibPas به جای دنبال کردنش یک شیء null برای آن صادر می‌کند

// رکورد نگاشت با مقصد Nil نشانه یک کلون در جریان است
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... داخل CloneObject، برای یک ارجاع غیرمستقیم:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // یال برگشتی چرخه‌ای، بازگشت نکن
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // ارجاع مبدأ بی‌ریشه
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // رزرو کن، بعد بازگشت بزن
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // پر کردن نهایی
end;
Exit(NewRef);

این تعمیم فقط به یک واقعیت ساختاری درباره PDF امن است: چرخه‌ها در گراف شیء روی پیوندهای برگشتی ظاهر می‌شوند، نه روی یال‌های محتوا. /Parent در درخت صفحه و /Prev در زنجیره فهرست مطالب به بالا یا عقب، به چیزی که از قبل دیده شده اشاره می‌کنند؛ بستار یک فونت، یک XObject تصویر یا یک XObject فرم رو به پایین می‌رود و پایان می‌یابد. پس کپی یک توصیف‌گر فونت، یک فضای رنگ یا یک دیکشنری سایه‌زنی از جایگزینی null بی‌تأثیر می‌ماند — هیچ چیز در این بستارها به InProgress نمی‌خورد. هزینه، که باید صریح گفت، این است که یال چرخه‌ای از کپی جان سالم نمی‌برد. دیکشنری صفحه‌ای که این‌طور کلون شود با /Parent به‌شکل شیء null می‌رسد، که ISO 32000-1 §7.3.9 آن را معادل نبودِ کلید می‌داند، پس صفحه کپی‌شده یک شیء معتبر است که به هیچ درخت صفحه‌ای تعلق ندارد تا آن را خودتان به گره /Pages مقصد پیوند بزنید و /Count را اصلاح کنید. یک آیتم فهرست کپی‌شده هم /Prev‌اش را به همین شکل از دست می‌دهد و زنجیره خواهری باید بازسازی شود. این همان معامله صادقانه است: CopyForeignObject یک بستار درست به شما می‌دهد و باز-والدی ساختاری را به فراخوان واگذار می‌کند، همان مرزی که جایگزینی صفحات با حفظ شماره اشیاء در آن کار می‌کند

راه‌حل در CopyForeignObject در PDFlibPas برای Delphi: آزمون صریح InProgress پیش از جست‌وجوی نگاشت اجرا می‌شود، یال برگشتی چرخه‌ای به شیء null تبدیل می‌شود و فراخوان بعداً صفحه کپی‌شده را به درخت صفحه مقصد دوباره پیوند می‌زند
یک حالت صریح در جریان جای Nil چندمعنایی را می‌گیرد، پس یال برگشتی به null حل می‌شود و فراخوان فقط یک تعمیر ساختاری بر عهده دارد

چرا رکورد نگاشت باید قبل از NewObjRef رزرو شود

یک جایگزین بدیهی کل این رقص in-progress را دور می‌زد: اول یک شیء پوسته خالی تخصیص بده، شماره واقعی‌اش را در نگاشت ثبت کن، بعد وقتی فرزندان کلون شدند پوسته را پر کن. این‌جا کار نمی‌کند، چون TPDFIndObj.Obj فقط‌خواندنی است و محتوایش بعد از ساخت قابل جایگزینی نیست — پوسته‌ای برای پر کردن وجود ندارد. شماره و محتوا با هم توسط NewObjRef تعیین می‌شوند، یعنی رکورد نگاشت باید قبل از فراخوان بازگشتی ساخته و بعد از آن کامل شود، و فاصله میان این دو لحظه دقیقاً همان چیزی است که InProgress باید پوشش دهد. یک پیامد که بهتر است قبل از diff گرفتن از خروجی بدانید: چون NewObjRef بعد از نوشته شدن بستار فرزند اجرا می‌شود، شماره‌گذاری در مقصد از پایین به بالا در می‌آید و شماره اشیاء ترتیب مبدأ را منعکس نخواهند کرد. فرمت فایل به هیچ کدام اهمیت نمی‌دهد، اما مقایسه بایتی با یک انتظار دست‌ساز اهمیت می‌دهد. اگر یک اجرا اشیایی به جا بگذارد که تصمیم گرفته‌اید به هیچ چیز پیوند نزنید، آن‌ها ارجاع‌نشده‌اند نه خراب، و جمع‌آوری mark-and-sweep اشیاء PDF دست‌نیافتنی ابزاری است که پیش از ذخیره آن‌ها را پاک می‌کند

رگرسیونی که این را پوشش می‌دهد به جزئیاتی نیاز دارد که نویسندگان تست‌های TPDFlib را غافلگیر می‌کند: سازنده از قبل یک سند پیش‌فرض نگه می‌دارد، پس DocumentCount از 1 شروع می‌شود و یک fixture دو‌سندیه باید >= 2 را اثبات کند نه = 2. در کنار کپی موفق، تست سه پس‌زدن را سنجاق می‌کند — شناسه مبدأ ناشناخته، سند انتخاب‌شده به‌عنوان مبدأ خودش، و شماره شیء صفر — که همه 0 برمی‌گردانند نه استثنا صادر می‌کنند، چون حلقه ادغام جای بدی است که بفهمید یک گارد استثنا می‌اندازد

جای این قابلیت در خط لوله ادغام کجاست

کپی در سطح شیء آن پیش‌فرضی است که وقتی ادغام کل‌فایل خیلی درشت‌دانه است به سراغش می‌روید: بیرون کشیدن یک برنامه فونت از یک قالب، آوردن یک XObject فرم تنها به یک سند مهر و موم، یا جابه‌جایی یک حاشیه‌نویسی همراه با استریم‌های ظاهرش بین فایل‌ها بدون کشیدن بقیه صفحه پشت سرش. PDFlibPas آن را به شکل یک فراخوان تنها روی اسناد لودشده در دسترس می‌گذارد و می‌توانید در مرجع PDFlibPas Delphi PDF Library ببینید در کنار بقیه API شیء سطح پایین چطور می‌نشیند