عارض PDF لا يرسم شيئًا عادة لا يحمل أي علّة في كود الرسم نفسه. في مكوّن HotPDF لـDelphi وC++Builder، جعلت أربع عيوب منفصلة الصفحات تُعرَض فارغة بينما بقي كل سطر سجلّ نظيفًا: مُعاملات اسم تحمل شرطة مائلة solidus بادئة، ودمج cm معكوس، وفهرس رمز token يقرأ صفرًا. لا شيء منها رفع استثناءً. ولا شيء منها سجّل خطأ. تُرمّز tokenize تدفق المحتوى بشكل صحيح، ويتعرّف مُوزِّع المُشغّلات operator dispatcher على كل مُشغِّل، ويُفَك ترميز كائن الصورة XObject إلى صورة نقطية صحيحة، ثم تخرج الصفحة فارغة. هذا التوليف — خط أنابيب يُبلغ عن نجاح في كل مرحلة وينتج لا شيء مرئي — بصمة بحث lookup أو فهرس يخطئ بصمت لا يفشل. هذا تشريح ما بعد الوفاة post-mortem لعائلة كهذه، ولنظام الاختبار الذي سمح لها بالنجاة عبر 38 إصدارًا
لماذا لا يرسم عارض PDF شيئًا على الإطلاق؟
لأن بحث مورد فاشل في عارض PDF لا يمكن تمييزه عن صفحة فارغة. مُعاملات الاسم في تدفق المحتوى ومفاتيح قاموس الموارد فضاءا سلاسل نصية مختلفان، وكانت HotPDF تقارن بينهما دون تسوية normalise. يقرأ المُرمِّز /Im0 ويبقي الشرطة المائلة solidus، لأن ذلك هو الرمز token فعلًا؛ ويخزّن قاموس /Resources /XObject المُحمَّل المفتاح كـIm0، لأن المُحلِّل يحذف الفاصل حين يبني مفاتيح القاموس. كل FindValue مقابل اسم معامل كانت تعيد إذن -1. نطاق الانفجار كان أوسع من الصور. يغطي ISO 32000-1 §8.9 Do، ويغطي §8.4 gs وبحثه في /ExtGState، ويغطي §8.6 cs وCS، ويغطي §8.7.4.3 sh. كل المُشغّلات الخمسة رتّبت قاموسها الفرعي للموارد بالمعامل الخام، فأخطأتها كلها. مساحات الألوان المسمّاة تراجعت إلى DeviceGray، وهذا يحوّل 1 scn إلى حبر أبيض على صفحة بيضاء. كائنات الصور XObject لم تُرسَم قط على الإطلاق — مسار الصورة النقطية لم يعمل عمليًا منذ يوم إطلاقه. الإصلاح مساعد على مستوى الوحدة يُطبَّق عند كل بحث مرتَّب بمعامل، وهذه هي الطريقة الوحيدة لمنع الانحراف عن الاتفاقية مجددًا
// Page content stream, the ordinary image-placement idiom:
// q
// /GS0 gs
// 200 0 0 120 60 400 cm
// /Im0 Do
// Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.
function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
Result := N;
if (Result <> '') and (Result[1] = '/') then
Delete(Result, 1, 1);
end;
// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
Exit;
هفوة ثانية ذات صلة كانت طبقة أدنى. كان للعارض مُحلِّلات resolvers مُنمَّطة للتدفقات والقواميس فقط، فمرجع غير مباشر يشير إلى كائن مصفوفة على المستوى الأعلى — نمط /CS0 5 0 R الشائع الذي يقود إلى [/Separation ...] على الطرف الآخر — كان يُحلّ إلى nil عبر كليهما ويتراجع إلى الوصلة غير المحلولة. إضافة مُحلِّل كائن عام أصلح مساحات الألوان المسمّاة ومصفوفات الدوال في خطوة واحدة. إن كنت توصّل قواميس التظليل، فإن نظام الحل نفسه ينطبق على مسار التظليل المحوري والشعاعي، حيث يكون مدخل /Function غير مباشر غالبًا جدًا
مُشغِّل cm ودمج مكتوب بالمقلوب
العلّة الثانية وضعت الصور بعيدة عن الصفحة بنحو مئة ألف بكسل، وهذا يبدو تمامًا كعدم رسمها. يعرّف ISO 32000-1 §8.3.4 تحويلات PDF بمتجهات صفوف، ويدمج مُشغِّل cm مصفوفة معامله M على مصفوفة التحويل الحالية كـM × CTM — يسري M أولًا، ثم CTM الموجود بعده. تركّب HotPDF المصفوفات عبر HPDFMatMul(A, B)، الذي يطبّق B قبل A. الاستدعاء الصحيح إذن يمرّر CTM القديم كـA. الكود المشحون مرّر مصفوفة المعامل كـA، منتجًا CTM × M
الترتيب المعكوس غير ضار لـcm واحد وكارثي للنمط القياسي من خطوتين. ضع صورة بـ1 0 0 1 x y cm يتبعها w 0 0 h 0 0 cm والتتالي الصحيح يقيس المربع الواحد بـ(w, h) ثم يزيحه بـ(x, y). تحت التتالي المعكوس تدخل الإزاحة أولًا ويضربها القياس، فصورة اسميًا عند (60, 400) مقيسة إلى 200 في 120 تهبط عند (12000, 48000). فحص القص clip test في أعلى عملية الرسم blit يرفضها، وتُتخطى عملية الرسم، ولا شيء في أي مكان يبلّغ عن مشكلة
// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.
// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
NumAt(3), NumAt(2), NumAt(1)), GS.CTM);
// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
NumAt(3), NumAt(2), NumAt(1)));
ما يجعل هذه مفيدة تعليميًا هو أن ملف المصدر نفسه كان يحمل بالفعل الترتيب الصحيح. مدخل /Matrix الخاص بكائن نموذج Form XObject كان له التركيب المعكوس نفسه، لكن مسار حرف Type 3 ومسار مخطط الحرف المضمَّن كلاهما أصابا منذ البداية، لأن وضع الحرف ينهار بشكل مرئي إلى نقطة الأصل حين تعكسه وكان أحدهم قد اضطُر بالفعل لإصلاحه. تعايش اتفاقان في وحدة واحدة لثلاث دزينات من الإصدارات، كل منهما صحيح في دالته الخاصة، ولم يلاحظ أي مراجع لأن ولا موضع استدعاء بدا خاطئًا بمعزل عن الآخر
ماذا يحدث حين يخطئ فهرس رمز بواحد؟
تحصل على اثني عشر مُشغِّلًا معالَجة نحويًا وميتة دلاليًا. مُوصِّل المعامل في العارض هو NumAt(Back)، الذي يقرأ Tokens[OpIndex - Back]، وOpIndex هو فهرس رمز المُشغِّل نفسه. فمُشغِّل بمعامل واحد يجد رقمه إذن عند back 1. اثنا عشر منها كُتبت كـNumAt(0)، التي تقرأ رمز المُشغِّل، وتفشل فحص نوع ctOperandNumber، وتُعيد القيمة الافتراضية صفر. القائمة هي Tc وTw وTz وTL وTs وTr من مُشغّلات حالة النص في ISO 32000-1 §9.3، إضافة إلى w وJ وj وM وri وi من مُشغّلات حالة الرسوميات في §8.4.3. أصبح تباعد المحارف والكلمات بلا أثر، ولم يُطبَّق القياس الأفقي قط، وبقي التصدير leading عند صفر فلم يتقدم T* سطرًا قط، ولم يفعل ارتفاع النص text rise شيئًا، وكان وضع العرض دومًا تعبئة fill، وخرج كل رسم خط في كل مستند كخط شعرة hairline بعرض بكسل واحد بصرف النظر عن عرض الخط المُعلَن. مُشغِّلات متعددة المعاملات مثل m وrg وTm استخدمت NumAt(1..6) وكانت كلها صحيحة، فرأى مراجع يفحص الدالة جدارًا من حساب فهرسة معقول تُضمَّن فيه اثنا عشر مدخلًا خاطئًا
function NumAt(Back: Integer): Double;
begin
Result := 0;
if (OpIndex - Back >= 0)
and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
Result := Tokens[OpIndex - Back].NumValue;
end;
// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1) // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading := NumAt(1) // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w' then GS.LineWidth := NumAt(1) // previously NumAt(0)
لماذا بقيت مجموعة الاختبارات خضراء لمدة 38 إصدارًا؟
لأن التأكيدات assertions كانت أضعف من أن تميّز صفحة مرسومة عن صفحة مرسومة جزئيًا. أكّدت اختبارات الرسم الآلي smoke أشياء مثل أن الصورة النقطية للمُخرَج ليست سوداء بالكامل، أو أن الصفحة ليست فارغة، أو أن ملخّص الصورة digest غير صفري. كل واحد من هذه يصمد حين يُرسَم النص ولا تُرسَم الصور. رُسم النص بشكل جيد، فلم تكن ذاكرة الإطار frame buffer موحّدة قط، ولم يكن الملخّص صفرًا قط، وأبلغت المجموعة عن نجاح بينما كان خط أنابيب الصور بأكمله كودًا ميتًا dead code عمليًا. التأكيدات الضعيفة مُغرية للرسوميات بالتحديد لأن القوية منها تبدو هشة. لا أحد يريد اختبارًا ينكسر حين تنزاح حافة تنعيم anti-aliasing ببكسل واحد، فالتراجع الطبيعي هو تأكيد شيء لا يمكن لأي تغيير معقول أن ينتهكه — وذلك التراجع يقودك إلى محمولات predicates لا يمكن لأي تغيير غير معقول أن ينتهكها أيضًا. اختبار مساحة ألوان انفصال separation أكّد أن المُخرَج قابل للتمييز عن الأسود؛ اجتاز الرمادي على الأبيض ذلك الاختبار، وكذلك الأبيض على الأبيض. الاختبار لم يكن يقيس ما إذا كان اللون الصحيح قد رُسم. كان يقيس ما إذا كان أي شيء على الإطلاق قد حدث على القماشة canvas
كيف تكتب تأكيد رسم يفشل فعلًا؟
عُدّ بكسلات اللون المتوقَّع، بالكمية المتوقَّعة، ودع الموضع والحجم ينتجان عن العدّ. النظام البديل ملف PDF أدنى مبني يدويًا، حقيقة بصرية واحدة لكل ملف، وتأكيد على عدد البكسلات التي تقع ضمن سماحية tolerance لثلاثية RGB محددة. صورة حمراء صرفة بحجم 200 في 120 موضوعة عند إزاحة معروفة يجب أن تنتج نحو 24000 بكسل أحمر. إن أخطأ بحث المورد، فالعدد 0. إن انعكس تتالي cm، فالعدد 0. إن رُسمت الصورة بمساحة ألوان خاطئة، فالعدد 0. رقم واحد يمسك بالثلاثة كلها، ونطاق السماحية يمتص ضجيج تنعيم anti-aliasing الذي جعل الناس يتراجعون عن المقارنة الدقيقة أصلًا
function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
X, Y: Integer;
C: TColor;
begin
Result := 0;
for Y := 0 to Bmp.Height - 1 do
for X := 0 to Bmp.Width - 1 do
begin
C := Bmp.Canvas.Pixels[X, Y];
if (Abs(GetRValue(C) - R) <= Tol)
and (Abs(GetGValue(C) - G) <= Tol)
and (Abs(GetBValue(C) - B) <= Tol) then
Inc(Result);
end;
end;
// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
'image XObject was never drawn');
أُعيدت كتابة أربعة اختبارات آلية بهذه الطريقة — تحويل صبغة tint transform من النوع 4، ووضع صورة عبر Do، وحالة رؤية محتوى اختياري، ووضع رسم خط Tr — وكشفت مجتمعة العائلة بأكملها. هذا هو الدرس الحقيقي، وهو يعمَّم إلى ما وراء قاعدة الكود هذه: في خط أنابيب رسم، يجب أن يسمّي التأكيد اللون. أي شيء أطرى منه فحص أن العارض عمل، لا فحص أنه رسم. إن كنت تبني إطار اختبار harness خاصًا بك من صفحة إلى صورة نقطية، فإن شرح التنقيط rasterisation للصفحة هو المكان الطبيعي لتثبيت مساعد عدّ بكسلات على أول اختبار انحدار لديك
حدود صادقة
حدّان يستحقان الذكر الصريح. أوضاع رسم قص النص clipping من 4 إلى 7 تُرسَم بوضع التعبئة أو الرسم الأساسي الخاص بها، لأن العارض لا يُنمذج مسارات قص متراكمة من مخططات الحروف؛ فالمستندات المعتمدة على قص مُشكَّل بالنص ستُرسِم النص بدل العمل الفني المقصوص تحته. ونظام عدّ البكسلات الموصوف هنا تقنية اختبار آلي smoke، لا مجموعة توافق conformance — فهو يثبت أن حقيقة بصرية محددة وصلت إلى ذاكرة الإطار، وهو معيار أدنى بكثير من إثبات أن المُخرَج يطابق مُنقِّط rasteriser مرجعيًا. وهو مع ذلك بالضبط المعيار الذي فشلت هذه العلل الأربع في اجتيازه لثلاث سنوات من الإصدارات
العارض المناقَش هنا يُشحن ضمن HotPDF Component القياسي لـ Delphi وC++Builder؛ وتحمل صفحة المنتج المرجع الكامل لواجهة برمجة عرض الصفحات، بما في ذلك نقاط دخول ذاكرة الصور النقطية المؤقتة والجلب المسبق في الخلفية