يصغّر مكوّن PDFium لـ Delphi (PDFiumPas) ملفات PDF بثلاث واجهات إعادة كتابة على TPdf: SaveAsReachable يُسقط الكائنات التي لا شيء يشير إليها، و SaveAsDeduplicated يدمج الخطوط والصور و Form XObjects المتطابقة بايتياً، و SaveAsFlateRecompressed يعيد ترميز تدفقات Flate الموجودة بأقصى ضغط. كل منها يعيد سجل تقرير حتى ترى ما أُزيل وكم بايت وفّر
المثير المعتاد تذكرة دعم تقول "حذفنا عشرين صفحة وكبر الملف". أحدهم فتح عقداً، وأزال ملحقاً، ودمج صفحة غلاف، وحفظ. وكانت كل خطوة تحديثاً تزايدياً، فالكائنات القديمة ما تزال في الملف، وحين يُحمَّل ذلك المستند لاحقاً ويُكتب عبر FPDF_SaveAsCopy تخرج من جديد كما كانت. يُسلسل PDFium كل ما يجلس في جدول كائناته في الذاكرة؛ وليس لديه استدعاء عام لسرد الكائنات غير المباشرة أو حذف واحد منها أو إعادة كتابة المراجع. لذا كل شيء في هذه المقالة يعمل في طبقة غلاف PDFium فوق البايتات التي ينتجها PDFium
لماذا يُبقي FPDF_SaveAsCopy الصفحات المحذوفة في الملف؟
يُبقي FPDF_SaveAsCopy الكائنات اليتيمة لأن PDFium يحمل كل مدخل حي في جدول المراجع المتقاطعة إلى مستنده، سواء كان ما يزال شيء ما يشير إليه أم لا. يصف ISO 32000-1 §7.5.6 التحديثات التزايدية بوصفها إلحاق نسخ كائنات جديدة وقسم xref جديد بآخر الملف. إزالة صفحة تحرر شجرة الصفحات فقط؛ ويظل قاموس الصفحة وتدفق محتواه وأي خطوط أو صور استخدمتها تلك الصفحة وحدها مسرودَين بوصفهم كائنات قيد الاستخدام. ولا يغيّر الحفظ الكامل غير التزايدي ذلك، لأنه من وجهة نظر جدول الكائنات تلك الكائنات صالحة تماماً
وتظهر البقايا نفسها بعد دمج الصفحات، وتسطيح النماذج الذي تجريه أدوات أخرى، وحفظ إصلاح الملفات التالفة. وهي غير مرئية في كل عارض ولا تضيف شيئاً سوى البايتات. وعلى المستندات الممسوحة ضوئياً يمكن لصورة صفحة يتيمة واحدة أن تكون بضعة ميغابايتات
الوسم-والمسح عبر SaveAsReachable
يزيل TPdf.SaveAsReachable اليتيمة بمرور وسم-و-مسح كلاسيكي: يتيح لـ PDFium كتابة نسخة غير تزايدية من المستند الحالي، ويحلل تلك النسخة إلى مخطط كائنات، ويعلّم كل ما يمكن الوصول إليه من الجذور، ثم يكتب مراجعة كلاسيكية واحدة تحوي الكائنات المعلَّمة فقط. تُجسَّد الكائنات داخل تدفقات الكائنات بوصفها كائنات غير مباشرة عادية على طول الطريق، فيستخدم الخرج دائماً جدول xref عادياً
اختيار الجذور هو موضع خطأ التنفيذ الساذج. البذرة البديهية هي Catalog المسماى من المقطع الختامي /Root. ومن هناك يصل السير إلى شجرة الصفحات وموارد الصفحات والخطوط والصور و Form XObjects و AcroForm وشجرة البنية والمخططات التفصيلية وأشجار الأسماء والمحتوى الاختياري والبيانات الوصفية XMP ونوايا الإخراج والملفات المضمّنة. لكن قاموس معلومات المستند يشير إليه مدخل المقطع الختامي /Info (ISO 32000-1 §7.5.5) لا الـ Catalog. ازرع من /Root فقط وستحذف بصمت عنوان كل ملف تعالجه ومؤلفه ومنتجه. يزرع PDFiumPas السير بكلا المقطعَين /Root و /Info، ولا يحمل المقطع الختامي المُعاد بناؤه /Info إلى الأمام إلا عندما ينجو ذلك الكائن فعلاً
الماشي اجتياز بعمق-أولاً بمكدس صريح ومجموعة زيارة، فدورات المراجع (روابط الآباء في شجرة الصفحات، ومؤشرات /P الراجعة للتعليقات التوضيحية) لا تستطيع تعليقه. والمرجع إلى رقم كائن حر أصلاً لا يُتبع ببساطة بدلاً من معالجته خطأً، وهو ما يبقي المرور متسامحاً مع إدخال متضرر قليلاً
أرقام الكائنات تُحفظ ولا تُعَدّ ترقيمها
يُبقي TPdf.SaveAsReachable كل كائن ناجٍ عند رقم كائنه وجيله الأصليَين. وتُكتب الكائنات الممسوحة مدخلات حرة في جدول xref (ISO 32000-1 §7.5.4)، وتُملأ الفجوات بين الأرقام الحية مدخلات حرة، ويبقى الكائن 0 رأس القائمة الحرة. ولأنه لا يُعاد ترقيم شيء، تبقى كل مرجع ناجٍ صحيحاً دون بايتة واحدة من إعادة الكتابة، ولا توجد خريطة قديم-إلى-جديد ليُخطأ فيها. التكلفة بضعة أسطر xref غير مستخدمة، 20 بايتاً لكل منها، وهي صفقة تستحق إبرامها
uses
PDFium, FPdfCompress;
procedure PruneOrphans(const SourcePdf, TargetPdf: string);
var
Pdf: TPdf;
Report: TPdfPruneReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourcePdf;
Pdf.Active := True;
if not Pdf.SaveAsReachable(TargetPdf, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('%d bytes -> %d bytes, kept %d object(s), pruned %d',
[Report.SourceByteCount, Report.OutputByteCount,
Report.KeptObjectCount, Report.PrunedObjectCount]));
finally
Pdf.Free;
end;
end;
يحمل TPdfPruneReport الحقول SourceByteCount (حجم النسخة غير التزايدية التي أنتجها PDFium) و OutputByteCount و KeptObjectCount و PrunedObjectCount و Succeeded و ErrorMessage. ويكتب تحميل اسم الملف إلى ملف مؤقت بجوار الهدف ولا يستبدل الهدف إلا عند نجاح إعادة الكتابة، فلا يترك تشغيل فاشل أبداً ملف PDF مقطوعاً خلفه
أي ملفات سترفضها هذه الواجهات؟
ترفض الواجهات الثلاث لإعادة الكتابة المستندات المشفرة، وبشكل افتراضي الموقَّعة أيضاً. يفشل الإدخال المشفر بـ Succeeded = False ورسالة خطأ بدلاً من استثناء: إعادة كتابة بمراجعة واحدة لا تستطيع أن تحفظ أرقام الكائنات ونطاقات البايت والمفاتيح لكل كائن التي يعتمد عليها قاموس التشفير، فكان الإخراج سيكون غير قابل للقراءة. والمستند ذو التوقيعات الرقمية يرفع EPdfError ما لم تمرر AllowSignedDocument = True، لأن إعادة الكتابة الكاملة تبطل دائماً نطاق البايت الموقَّع. ضبط ذلك العلم يعني أنك تقبل ملفاً ستُبلِّغ تواقيعه عن كونها معطوبة؛ وهو القرار الصائب لنسخة أرشيف، ولا يكون صائباً أبداً لعقد ستعيده قريباً
كيف يقرر SaveAsDeduplicated أن صورتين متماثلتان؟
يعامل TPdf.SaveAsDeduplicated موردين بوصفهما متطابقين فقط عندما تتطابق قاموساتهما ورسومهما الفرعية الكاملة القابلة للوصول وبايتات تدفقهما الخام كلها. يجري السير نفسه للوصولية أولاً، ثم يأخذ بصمة كل خط وصورة XObject و Form XObject يمكن الوصول إليه، ويدمج كل مجموعة تطابقات على العضو صاحب أدنى رقم كائن، ويعيد كتابة كل مرجح ليشير إلى ذلك الناجي. وتصبح المكررات المدموجة مدخلات xref حرة كاليتيمة تماماً. هذا هو إصلاح تقرير "شعار مضمّن 400 مرة" الكلاسيكي الذي تنتجه أدوات دمج البريد تعيد تضمين الموارد نفسها في كل صفحة
البصمة هاش FNV-1a من 64 بتة محسوب استدعائياً. لكل مرشَّح يأخذ الماشي جسم الكائن، ويستبدل كل مرجع N G R ببصمة الكائن الذي يشير إليه، ويهاش النتيجة. ذلك يجعل الهاش مستقلاً عن ترقيم الكائنات: نسختا الخط نفسه تصادف أن واصفيهما كائنان 12 و87 ما تزالان تنتجان القيمة نفسها. وتُحفظ النتائج ذاكرةً بحيث تُهاش الكائنات الفرعية المشتركة مرة واحدة، وتعيد مجموعة الزارة حارساً ثابتاً للأحجام الراجعة فتستقر المخططات الدائرية
والخلل الدقيق يجلس في حمولة التدفق. لإيجاد المراجع بأمان، يمسح PDFiumPas نسخة من كل كائن بُهِمت فيها بيانات التدفق والسلاسل والتعليقات بمسافات؛ وإلا فإن جرية من بيانات بكسل ثنائية تصادف أن تبدو مثل 12 0 R كانت ستعالَج مرجعاً. هاش تلك النسخة المطهرة فقط وصارت صورتان بقاموسين متطابقين (نفس /Width و /Height و /BitsPerComponent و /Filter و /Length) لكن ببكسلات مختلفة تنهار إلى بصمة واحدة، وتُستبدل الصورة الثانية على الصفحة بصمت بالأولى. يلحق SaveAsDeduplicated البايتات الخام بين stream و endstream إلى البيانات المهاشَة، فتصير البكسلات جزءاً من الهوية. كما تُجمَّع المرشَّحات بالفئة قبل المقارنة، فتصادم هاش نادر بين خط وصورة لا يمكنه أبداً أن يدمجهما
إعادة كتابة المراجع تعيد استخدام حيلة المسح المطهَّر نفسها بالمقلوب: تُحدَّد المراجع في النسخة المهبَمة لكن البايتات تُنسخ من الجسم الأصلي، فلا يُعدَّل أبداً سوى بنية القواميس والمصفوفات وتمر الحمولات الثنائية دون مساس
procedure MergeDuplicateResources(Pdf: TPdf; const TargetPdf: string);
var
Report: TPdfDedupReport;
begin
if not Pdf.SaveAsDeduplicated(TargetPdf, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('fonts %d, images %d, forms %d merged; %d -> %d bytes',
[Report.MergedFontCount, Report.MergedImageCount,
Report.MergedFormXObjectCount,
Report.SourceByteCount, Report.OutputByteCount]));
end;
اقرأ TPdfDedupReport بعناية. إلى جانب حقول Merged*Count الثلاثة لديه KeptObjectCount و PrunedObjectCount، وفي هذا التقرير يحصي PrunedObjectCount المكررات المدموجة فقط. لا تزال الكائنات التي لا يمكن الوصول إليها تُسقط، لكنها غير مشمولة في ذلك الرقم. وإذا احتجت عدد اليتيمة للتسجيل، فخذه من تشغيل SaveAsReachable منفصل. أما الحد الآخر فهو أن المطابقة بتّية-دقيقة على التدفق المرمَّز: الشعار نفسه مضغوطاً بواسطة مولّدين مختلفين عند مستويَي Flate مختلفَين لن يُدمج
إعادة ضغط تدفقات Flate بأقصى مستوى
ينفخ TPdf.SaveAsFlateRecompressed كل تدفق FlateDecode يمكن الوصول إليه ويهويه مجدداً بأعلى مستوى ضغط، محتفظاً بالنتيجة فقط عندما تكون أصغر وتجتاز عتباتك. كثير من المولّدات تكتب Flate بمستوى افتراضي سريع، فتتقلص تدفقات المحتوى وبرامج الخطوط المضمّنة غالباً أكثر. تبقى أرقام الكائنات والأجيال في مكانها؛ يتغير الحمولة و /Length المباشر الخاص بها فقط
لدى TPdfFlateRecompressOptions حقلان ودار بناء افتراضي غائب، فاضبط كليهما. MinSavingBytes هو الحد الأدنى المطلق للتوفير لكل تدفق، و MinSavingRatio كسر من الطول الأصلي (0.05 تعني 5%)؛ ونسبة 0 تعطّل اختبار النسبة. يُكتب التدفق المُعاد بناؤه بـ stream ثم CRLF ثم الحمولة الجديدة ثم CRLF ثم endstream، فلا تعتمد النتيجة على نهايات أسطر الملف الأصلي
procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
Options: TPdfFlateRecompressOptions;
Report: TPdfFlateRecompressReport;
begin
Options.MinSavingBytes := 256; // تجاهل المكاسب الضئيلة
Options.MinSavingRatio := 0.05; // وأي شيء دون 5%
if not Pdf.SaveAsFlateRecompressed(TargetPdf, Options, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('recompressed %d, skipped %d, failed %d; streams %d -> %d bytes',
[Report.RecompressedCount, Report.SkippedCount, Report.FailedCount,
Report.OriginalStreamBytes, Report.RecompressedStreamBytes]));
end;
الحالات الطرفية تحفظية بالتصميم. التدفق الذي تكون /Length فيه مرجعاً غير مباشر يُتخطى، لأن إعادة الكتابة لا بد أن تخزن عدداً صحيحاً مباشراً. الحمولة التي تفشل في النفخ تُحسب ضمن FailedCount وتُنسخ دون تغيير. وتدفق Flate لا يبلغ العتبات يُحسب ضمن SkippedCount. وصور DCT و JPX و JBIG2 و CCITT لا تدخل قائمة المرشَّحين أبداً، فهذه المرحلة لا تلمس جودة JPEG قط. ولتوفير الصور الضار استخدم إعادة المعاينة التكيفية للصور في PDFiumPas قبل الحفظ النهائي
فخ قابلية نقل من كتابة اختبار العتبة ذاك: قارنت المسودة الأولى Single(Saving)/Single(OrigLen) مع Saving معلَناً بوصفه Int64. يقبلها Delphi، لكن FPC 3.2.2 يتوقف عند Illegal type conversion: "Int64" to "Single"، لأن تلك الصياغة في FPC صبّ نوع لا تحويل رقمي. أما Saving / OrigLen العادي فقسمة حقيقية في المجمّعَين ويقارن مباشرةً مع Single، فكانت الصبّات زائدة ببساطة
بأي ترتيب تعمل الواجهات الثلاث؟
شغّل SaveAsDeduplicated أولاً، وحمّل الإخراج من جديد، ثم شغّل SaveAsFlateRecompressed. إزالة التكرار تشمل مسبقاً مسح اللاوصولية، فمرور SaveAsReachable منفصلاً أمامها حشو زائد؛ واحتفظ بـ SaveAsReachable للمهام التي تريد فيها تنظيف اليتيمة فقط وعدد تقليم دقيقاً. إعادة الضغط ثانيةً تعني أن عمل النفخ-وال-الهواء المكلف يعمل مرة واحدة لكل تدفق ناجٍ بدلاً من مرة لكل مكرر. كما يمشي تمريرة إعادة الضغط في المخطط من /Root و /Info مجدداً، وهو ما ينظف أي واصفات خطوط أو ملفات خطوط لم يكن يشير إليها إلا خط مكرر مدموج
procedure ShrinkPdf(const SourcePdf, TargetPdf: string);
var
Pdf: TPdf;
TempPdf: string;
begin
TempPdf := ChangeFileExt(TargetPdf, '.dedup.pdf');
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourcePdf;
Pdf.Active := True;
MergeDuplicateResources(Pdf, TempPdf); // تقليم + دمج
Pdf.Active := False;
Pdf.FileName := TempPdf;
Pdf.Active := True;
RecompressFlate(Pdf, TargetPdf); // إعادة ضغط الناجين
finally
Pdf.Free;
DeleteFile(TempPdf);
end;
end;
وإذا كنت تطبّق أيضاً حجب المحتوى (redaction)، فافعل ذلك قبل كل هذا، لأن الإزالة الفيزيائية للمحتوى هي ما يخلق اليتيمة الجديدة في المقام الأول؛ وتتناول مقالة الحجب على مستوى المعاملات وتقليم الموارد جانب الحجب. وحمّل دائماً الملف النهائي من جديد بوصفه فحص سلامة في خط أنابيبك: عدد صفحات يطابق المصدر تأمين رخيص قبل أن تستبدل أي شيء يهتم به عميل
الأسئلة الشائعة
هل يعيد SaveAsReachable ترقيم الكائنات أو تغيير المراجع؟
لا. يُبقي TPdf.SaveAsReachable كل كائن ناجٍ عند رقمه وجيله الأصليَين ويعلّم الكائنات المزالة مدخلات xref حرة، فلا يلزم إعادة كتابة أي مرجع
هل يمكن أن يدمج SaveAsDeduplicated صورتين مختلفتين بالخطأ؟
ليس عبر مطابقة القواميس وحدها. تشمل البصمة بايتات التدفق الخام والرسم الفرعي المشار إليه كاملاً، وتُقارن المرشَّحات داخل الفئة نفسها فقط (خط، صورة، Form XObject)
هل تُبقي هذه الواجهات تواقيعي الرقمية صالحة؟
لا. تجري كل واجهة إعادة كتابة كاملة، وهذا يكسر نطاق البايت الموقَّع، فتُرفض الملفات الموقَّعة ما لم تمرر AllowSignedDocument = True وتقبل تواقيع غير صالحة في الإخراج
تأتي الواجهات الثلاث ضمن مكوّن PDFium لـ Delphi و C++Builder، إلى جانب ميزات الحجب وإعادة معاينة الصور المشمولة في مواضع أخرى من هذه المدونة