احذف سبع صفحات من دليل من 200 صفحة وستهبط كل إشارة مرجعية في موضع خطأ. الحل ليس إعادة بناء المخطط من قائمة عناوين مسطحة. يكشف PDFiumPas عن TPdfOutlineEditor، الذي يحمّل شجرة المخطط الحقيقية، ويتيح لك نقل العناصر وإعادة توجيهها، ثم يشغّل ApplyPageMap لتحويل كل وجهة صريحة عبر خطة صفحاتك
لماذا يكسر حذف الصفحات كل إشارة مرجعية؟
لأن عنصر المخطط لا يخزّن رقم صفحة. إنه يخزّن مرجعًا إلى كائن صفحة، وعندما تتغير كائنات الصفحات يشير المرجع إما إلى صفحة انتقلت أو إلى لا شيء إطلاقًا. تُعرّف ISO 32000-1 §12.3.2.2 الوجهة الصريحة بوصفها مصفوفة أول عناصرها مرجع غير مباشر إلى قاموس صفحة، يتبعه اسم ملاءمة مثل /Fit أو /XYZ. احذف الصفحة فيبقى لديك مرجع معلّق بلا هدف؛ أعد ترتيب الصفحات فيظل المرجع صالحًا لكنه يصف الآن فصلًا مختلفًا. يحلّ PDFiumPas تلك المصفوفة عائدًا إلى رقم صفحة عند التحميل، لذا يمنحك TPdfOutlineItem.PageNumber فهرس صفحة يبدأ من الواحد يطابق واجهة API العمومية لـ TPdf بدلًا من رقم كائن. هذه هي فكرة التجريد كاملة: منطق إعادة التعيين لديك يعمل في نظام الإحداثيات نفسه لخطة الصفحات التي بنيتها بالفعل عند تقسيم المستند أو إعادة ترتيبه أو فرضه. إذا كنت تبني تلك الخطة، فإن الاتفاقية نفسها ذات الأساس الواحد تسري عبر تقسيم مستندات PDF إلى ملفات متعددة وعبر فرض n-up وإعادة ترتيب الصفحات
المخطط شجرة مزدوجة الربط، لا قائمة
السبب في أنك لا تستطيع تسلسل مصفوفة مسطحة من العناوين ببساطة هو أن ISO 32000-1 §12.3.3 تربط كل عنصر مخطط بخمسة روابط منفصلة: /Parent و/Prev و/Next و/First و/Last. لذا فإن نقل شجرة فرعية واحدة يعيد كتابة الأصل القديم والأصل الجديد، والأخوين المجاورين على كل جانب من موضع القطع ونقطة الإدراج، ومؤشر الأصل للعقدة المنقولة نفسها. أخطئ في واحد منها فتُظهر العارضات المطابقة شجرة مقتطعة، أو تدخل حلقة. يحتفظ PDFiumPas بحالة التحرير بوصفها مصفوفة بحث عمق أولاً من سجلات TPdfOutlineItem ذات Id صحيح مستقر، بحيث تكون الشجرة الفرعية شريحة متصلة وسلسلة الأشقاء مشتقّة، لا تُصان يدويًا أبدًا. ترفع TPdfOutlineEditor.Move تلك الشريحة، وتعيد إدراجها تحت الأصل الجديد عند فهرس الأشقاء المطلوب، وتعيد إسناد جذر الكتلة فقط. وترفض أيضًا النقلتين اللتين من شأنهما إفساد المخطط: نقل عنصر إلى شجرته الفرعية نفسه، وتسمية أصل غير موجود
لماذا /Count موسوم؟
لأن الإشارة تحمل حالة التمدد، لا الحجم. /Count الموجب يعني أن العنصر مفتوح وأن الرقم هو عدد المنحدرين المرئيين حاليًا؛ و/Count السالب يعني أن العنصر مطوي. يكتب PDFiumPas عدد المنحدرين لكل عنصر له أبناء وينفيه عندما يكون IsOpen هو False، وعند التحميل يقرأ الحالة عائدًا بوصفها IsOpen := HasCount and (CountValue > 0). هذا هو أكثر الأخطاء اليدوية شيوعًا في كاتبي المخططات: إصدار عدد بلا إشارة وإجبار الشجرة كلها على الفتح بصمت
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، ولهذا تطلق اللاحقة الفارغة أو المشوّهة استثناء فورًا بدلًا من إنتاج مصفوفة وجهة لا يستطيع أي قارئ تحليلها
كيف تستهلك 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 العنصر وشجرته الفرعية كلها، لأن عقدة المخطط التي اختفت وجهتها تترأس عادة فصلًا اختفى معها. مع False، ينجو العنصر بعنوانه وتدرجه سليمين لكن مع إزالة /Dest و/A، وهو ما تريده عندما سيعيد إنسان توجيهه أثناء المراجعة. الإدخال المشوّه فعلًا لا يزال يفشل بصوت عالٍ بدلًا من ترقيعه: المدخل السالب أو الوجهة التي تشير إلى ما وراء نهاية الخريطة الممرّرة تُرجع False مع ضبط IssueKind على poviInvalidPageMap
المداخل المعتمة، والمقايضة الصريحة
ليس كل عنصر مخطط يملك رقم صفحة يستطيع PDFiumPas الاستدلال عليه. ثلاثة أنواع تُمرَّر دون مساس: الوجهات المسماة، والإجراءات التي ليست /S /GoTo، ومفاتيح القواميس غير المعروفة التي أضافها أيًا كان من أنتج الملف. تُحمَّل هذه وPageNumber فيها يساوي صفرًا، وتحتفظ ببايتاتها الأصلية في العنصر، وتُكتب عائدًا حرفيًا ما لم تستدعِ Retarget عليها صراحة
- الوجهة المسماة هي مفتاح في شجرة أسماء المستند، لذا فإن إعادة تعيينها بشكل صحيح تعني حلّ الشجرة وإعادة كتابة المدخل الهدف، لا التخمين على مستوى المخطط
- إجراء
/URIأو/Launchأو JavaScript لا يملك أي دلالات صفحات إطلاقًا ويجب ألا يُحوَّل بصمت إلى Go-To - تُحفَظ المفاتيح الخاصة بالمورّدين والوجهات البنيوية لأن إسقاط ما لا تفهمه هو الطريقة التي تفقد بها الرحلات ذهابًا وإيابًا البيانات
الثمن حقيقي ويستحق التصريح به بوضوح: ApplyPageMap يتخطى تلك العناصر بالكامل، لذا فإن المستند الذي تستخدم إشاراته المرجعية كلها وجهات مسماة سيمرّ عبر حذف الصفحات ومخططه صالح بنيويًا لكنه قديم دلاليًا. هذا هو الاختيار المقصود — رابط قديم يستطيع مراجع اكتشافه خيرٌ من رابط واثق الخطأ لا يلاحظه أحد. إذا كنت تفرز الملفات الواردة قبل تحريرها، فإن تمريرة جرد في منضدة مراجعة استلام PDF ستخبرك أي المستندات تقع في تلك السلة
الحفظ: مراجعة تزايدية، ثم إعادة تحميل مستقلة
تلحق TPdfOutlineEditor.SaveIncremental مراجعة تزايدية متفرقة بدلًا من إعادة كتابة الملف. العناصر التي حُملت تحتفظ بمرجع كائنها غير المباشر الأصلي بما في ذلك رقم الجيل الدقيق، لذا تظل المراجع التبادلية القائمة صالحة؛ العناصر التي أضفتها وحدها هي من تسحب رقمًا جديدًا، يُخصَّص من واحد بعد أقصى رقم كائن في المراجعة. يُحدَّث الكتالوج في المراجعة نفسها، ويُضاف إليه مدخل /Outlines مفقود عندما لا يحمل المصدر مخططًا إطلاقًا
ما يحدث بعد الكتابة هو الجزء الجدير بالنسخ. يعيد PDFiumPas فتح تيار الوجهة بمحرّر مستقل تمامًا ويقارن الشجرة المعاد تحميلها بتلك الموجودة في الذاكرة — عدد العناصر، والعناوين، وأرقام الصفحات، ولواحق الوجهات، وشكل الوجهة كإجراء مقابل المباشرة، والأنماط، وحالة التمدد، وعلاقات الأصول. أي عدم تطابق، أو أي فشل تحميل، يمسح تيار الوجهة ويُرجع poviVerificationFailure بدلًا من تسليمك ملفًا يبدو معقولًا. المصادر المشفّرة تُرفض من البداية بـ poviEncryptedInput، لأن العناوين والوجهات الجديدة تنشئ محتوى سلاسل لا يمكن إنتاجه بنسخ مقطورة /Encrypt إلى الأمام
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;
عامِل المخطط على حقيقته — مخطط كائنات مترابط بثوابته الخاصة — فيتوقف حذف الصفحات عن كونه كارثة إشارات مرجعية ويصبح خريطة صفحات تسلمها لاستدعاء دالة واحد. TPdfOutlineEditor وApplyPageMap والكاتب التزايدي المُتحقَّق منه تأتي في PDFiumPas منذ الإصدار v3.98.0 لـ Delphi وC++Builder وLazarus؛ يمكنك مراجعة API الكاملة وتنزيل نسخة تجريبية على صفحة منتج مكوّن PDFium Delphi