مقال تقني

مقارنة ملفَي PDF في Delphi: البنية والبكسلات

يقارن HotPDF بين مستندَي PDF من Delphi عبر THPDFDocComparison، الذي يجتاز الرسم البياني للكائنات في كلا الملفين ابتداءً من الفهرس وانتهاءً بالخارج، ويقوم أيضًا، عند الطلب، برسم كل زوج صفحات وقياس البكسلات المختلفة فيه. والنتيجة تقرير بصيغة JSON يسمّي كل فرق عثر عليه، والميزانية التي استهلكها، وما إذا كانت المقارنة قد اكتملت. ويهم كلا المسارين، لأن فرق البنية وفرق المظهر البصري يجيبان عن سؤالين مختلفين

والسؤال الكامن خلف هذه الميزة عادة ما يكون سؤال إصدار. فمحرك تقارير يتلقى تغييرًا، وتُعاد توليد المخرجات، ويتعيّن على شخص ما أن يقرر ما إذا كان أي شيء قد تغيّر. وفتح الملفين جنبًا إلى جنب يصلح لنحو ثلاث صفحات قبل أن ينهار التركيز. أما مقارنة البايتات الخام فتفشل فورًا، إذ إن تشغيلَين للمولّد نفسه ينتجان بايتات مختلفة لأسباب لا علاقة لها بما يراه القارئ

لماذا يمكن أن تختلف ملفات PDF في البايتات وتتطابق بصريًا؟

ملفا PDF مُولَّدان بشكل مستقل ويطبعان بشكل متطابق يختلفان بشكل روتيني في بايتاتهما، والأسباب بنيوية لا تجميلية. فأرقام الكائنات تُخصَّص وفق الترتيب الذي كُتبت فيه الكائنات مصادفة. ومجموعات الخط الفرعية تُخصِّص معرّفات CID وفق ترتيب أول ظهور للأحرف الرسومية، لذا فإن مجموعة فرعية بُنيت أثناء اجتياز مختلف قليلًا تنتج بايتات تدفق محتوى مختلفة للنص المرئي نفسه. وإزاحات جدول المرجع المتقاطع تتحرك متى ما تغيّر طول أي شيء في المنبع

وهذا هو السبب في تعذّر استخدام أرقام الكائنات كهوية عابرة للمستندات. وبدلًا من ذلك، يبني HotPDF كل لقطة عبر الاجتياز من الفهرس، وتوسيع القواميس وفق ترتيب بايتات مفاتيحها، والمصفوفات وفق الفهرس، بحيث يُسمّى كل كائن بالمسار الذي يصل إليه. أما الكائنات التي يعجز الاجتياز عن بلوغها من الجذر فتعود إلى مسار اصطناعي على هيئة $Unreachable[...] يحمل رقم الكائن والجيل، ما يُبقي المحتوى اليتيم مرئيًا في التقرير بدلًا من غيابه بصمت

ولا تُقارَن التدفقات بالنسخ. فكل تدفق يُسهم بتوقيع SHA-256 تراكمي، يُحسَب مع استعادة موضع التدفق الأصلي بعد ذلك، بحيث لا تعني مقارنة ملفين حجم كل منهما مئتا ميغابايت تجسيد أربعمئة ميغابايت في الذاكرة

محاذاة الصفحات حين يحتوي أحد المستندين على إدراج

مقارنة الصفحة 1 بالصفحة 1، والصفحة 2 بالصفحة 2 وهكذا، صحيحة فقط حين لا يُدرَج أي شيء. وإدراج صفحة غلاف يجعل المقارنة الساذجة تُبلغ عن تغيّر كل صفحة، وهو صحيح من الناحية التقنية لكنه عديم الفائدة عمليًا

يُحاذي HotPDF الصفحات قبل مقارنتها. فهو يبني توقيعًا لكل صفحة من النص القابل للاستخراج، ويعود إلى توقيع بنيوي للصفحات الخالية من النص، ثم يحسب أطول متتالية متزايدة عبر فهارس الهدف المُطابَقة. والصفحات داخل تلك المتتالية هي تلك التي انزاحت فحسب؛ أما الصفحات خارجها فهي انتقالات حقيقية. وهذا التمييز هو ما يجعل فرق دليل من 400 صفحة قابلًا للقراءة، لأن التقرير يقول إن صفحة واحدة أُدرِجت بدلًا من أن يقول إن أربعمئة صفحة تغيّرت

تشغيل مقارنة بنيوية

أبسط استدعاء يأخذ مستندين محمَّلين ووضعًا. يُنفّذ cmStructural اجتياز الرسم البياني للكائنات، ويُنفّذ cmRenderedImage مقارنة البكسلات، ويقوم cmFull بكليهما، بينما توجد الأوضاع الأخف cmPageCount وcmPageText وcmObjectCount لفحوصات دخانية زهيدة التكلفة:

uses
  HPDFDoc, HPDFDocCompare;

var
  DocA, DocB: THotPDF;
  Report: AnsiString;
begin
  DocA := THotPDF.Create(nil);
  DocB := THotPDF.Create(nil);
  try
    if (DocA.LoadFromFile('baseline.pdf') <= 0) or
       (DocB.LoadFromFile('candidate.pdf') <= 0) then
      Exit;
    Report := THPDFDocComparison.Compare(DocA, DocB, cmStructural);
    with TFileStream.Create('diff.json', fmCreate) do
    try
      WriteBuffer(Report[1], Length(Report));
    finally
      Free;
    end;
  finally
    DocB.Free;
    DocA.Free;
  end;
end;

ويُميّز التقرير بين ثلاث حالات لا تستطيع قيمة منطقية بسيطة تمييزها. فـ identical يقول ما إذا كان أي شيء قد اختلف، وcomparisonComplete يقول ما إذا كان الاجتياز قد اكتمل، وcomparisonBudget يسمّي الحد الذي أوقفه إن حدث ذلك. والمقارنة التي تستنفد ميزانيتها تُبلغ عن comparisonComplete=false وidentical=false معًا، لأن اجتيازًا مبتورًا ليس له أساس لادّعاء التطابق. وأي أتمتة تقرأ identical فقط ستُعامل في نهاية المطاف توقّف الميزانية كفرق حقيقي، لذا اقرأ الحقول الثلاثة جميعًا

ما الحدود التي تُبقي الاجتياز محدودًا؟

القيم الافتراضية في THPDFStructuralCompareLimits.Default مُقاسة لمستندات حقيقية لا لمستندات عدائية، ولكل ميزانية ذات دلالة سقفها الخاص: 250,000 كائن، و2,000,000 حافة، وعمق 128، و10,000 فرق مُبلَّغ عنه، و64 ميغابايت لكل تدفق و512 ميغابايت من بايتات التدفقات إجمالًا، وميغابايت واحد لكل قيمة، و4,096 بايت لكل مسار. ارفعها عمدًا حين تعرف مجموعة ملفاتك، وخفّضها عند مقارنة ملفات وصلت من مصدر خارجي:

var
  Limits: THPDFStructuralCompareLimits;
  Options: THPDFRenderedCompareOptions;
begin
  Limits := THPDFStructuralCompareLimits.Default;
  Limits.MaxDifferences := 200;        // فشل سريع في CI
  Limits.MaxTotalStreamBytes := 128 * 1024 * 1024;

  Options := THPDFRenderedCompareOptions.Default;
  Options.DPI := 150;                  // الافتراضي هو 72
  Options.ColorTolerance := 2;         // تجاهل ضوضاء التقريب بمستوى 1-2
  Options.MinimumSimilarity := 0.9995;
  Options.MaxChangedPixelRatio := 0.0005;
  Options.GenerateHeatmaps := True;    // كتابة صور تراكب للمراجعة

  Report := THPDFDocComparison.CompareWithOptions(DocA, DocB, cmFull,
    Limits, Options);
end;

يقدّر مسار الترسيم عدد البكسلات من أبعاد الصفحة ودقة DPI المطلوبة قبل تخصيص أي صورة نقطية، ويعيد التحقق من الصورة النقطية الفعلية بعد ذلك، بحيث لا تستطيع هندسة صفحة مشوَّهة الإفلات من الميزانية بالكذب بشأن حجمها. ورفع DPI يرفع الدقة والتكلفة تربيعيًا: فـ 150 DPI هو أربعة أضعاف بكسلات 72، وسقوف البكسلات لكل صفحة وإجمالًا موجودة تحديدًا لأن مهمة دفعية بدقة 300 DPI ستخصّص خلاف ذلك ذاكرتها نحو المتاعب

ما مدى التشابه الكافي؟

تُحسَب صفحتان متشابهتين فقط حين يتحقق الشرطان معًا: نسبة البكسلات المتغيّرة عند أو دون MaxChangedPixelRatio، والتشابه عند أو فوق MinimumSimilarity. وعتبتان بدلًا من واحدة، لأن حفنة من البكسلات الخاطئة كارثيًا وموجة واسعة من تحوّلات لونية طفيفة إخفاقان مختلفان، وكل منهما بمفرده قد يكون مقبولًا في سير عمل ومُقصيًا في آخر. وتستخدم اختبارات العتبة قيمًا غير مُقرَّبة؛ والمنازل العشرية الست في JSON موجودة لإبقاء التقارير مستقرة وقابلة لمقارنة الفروق، لا لتعريف المقارنة

وتُجمَّع البكسلات المتغيّرة في مناطق باستخدام بلاطات ثابتة الحجم كعُقد بتجاور رباعي الاتجاهات، بدلًا من تعبئة الفيضان لكل بكسل. وهذا يُبقي الذاكرة محدودة وقائمة المناطق مستقرة عبر عمليات التشغيل. واقتطاع تفاصيل المناطق المحتفَظ بها يؤثر فقط في القائمة، لا في عدد المناطق المُبلَّغ عنه، لذا فإن صفحة تحتوي على مناطق متغيّرة أكثر من MaxChangedRegions لا تزال تُبلغ عن عددها الفعلي

وثمة سلوك يستحق ذكره بوضوح لأنه يعكس الحدس المعتاد. فإخفاقات الترسيم، وإخفاقات التخصيص، وإخفاقات التراكب لا تُبتلَع أبدًا. فأي شيء من هذا النوع يُسجَّل كـ renderError أو renderBudget ويفرض renderComparisonComplete=false، لأن صفحة فشل ترسيمها هي صفحة لم يقارنها أحد، والإبلاغ عنها بأنها متطابقة أسوأ من عدم الإبلاغ عن شيء

أين يقع كل وضع في خط الأنابيب

تُجيب المقارنة البنيوية عن سؤال ما الذي تغيّر، وهي الخيار الافتراضي الصحيح لمجموعات اختبار الانحدار: فهي تسمّي المسار، وفهرس الصفحة، وأرقام الكائنات المعنية، بحيث يشير الفشل إلى الكود الذي أنتجه. أما المقارنة المُرسَّمة فتُجيب عن سؤال ما إذا كان أحد سيلاحظ، وهو السؤال المطروح في الموافقات وفي التحقق من أن مسار تحسين ما كان بلا فقدان بالفعل

وهما يتكاملان جيدًا. شغّل cmStructural على كل بناء ودعه يفشل بوضوح عند تغيّرات غير متوقعة على مستوى الكائنات؛ وشغّل cmFull مع خرائط الحرارة قبل الإصدار، حين يتوفر شخص للنظر في التراكبات. أما بالنسبة إلى خطوط الأنابيب التي تُصدر بالفعل ترميز صفحة لأسباب أخرى، فإن المخرجات النصية الموصوفة في تصدير صفحات PDF إلى SVG تمنح عرضًا ثالثًا قابلًا لمقارنة الفروق بشريًا، بينما تُغطي الفحوصات الآلية في أتمتة تقارير الفحص المسبق أسئلة المطابقة التي لا يُقصد بأي من وضعَي المقارنة الإجابة عنها

وتشترك المقارنة والفحص المسبق والترسيم في نموذج الكائن نفسه للمستند المحمَّل، بحيث يمكن لمرور واحد على ملف أن يغذّي الثلاثة جميعًا. وتوجد قائمة الميزات الكاملة لـ Delphi وC++Builder على صفحة مكوّن HotPDF لـ PDF في Delphi