يفصل HotPDF عارض PDF الخاص به في Delphi إلى جزأين: THPDFViewerModel، وهو فئة بسيطة تمتلك حالة التكبير والدوران والبحث والتظليل والتنقل دون أي اعتماد على مقبض نافذة، وTHPDFViewer، وهو عنصر تحكم قائم على TScrollBox يحوّل تلك الحالة إلى بكسلات. هذا الفصل هو ما يسمح لمنطق العارض بالعمل، والخضوع للاختبار، دون إنشاء نموذج (form) على الإطلاق
معظم عناصر تحكم العارض المخصصة لا تبدو هكذا. مستوى التكبير يعيش في حقل خاص داخل عنصر التحكم، وتقييد التنقل بين الصفحات يحدث داخل معالج OnClick لزر ما، والطريقة الوحيدة لمعرفة ما إذا كان التمرير بمفتاح Ctrl يحترم سقف التكبير هي تشغيل التطبيق والنقر والمراقبة. عنصر تحكم مبني بهذه الطريقة يعمل جيدًا حتى يحتاج إلى مجموعة اختبارات انحدارية، أو مضيف ثانٍ — نافذة معاينة طباعة، شريط صور مصغّرة، مراجع دفعي بلا أي نافذة مرئية على الإطلاق — عندها تتضح أن الحالة المطلوبة ملحومة بـTWinControl يصرّ على مقبض حقيقي قبل أن يقوم بأي شيء
لماذا يحتاج عنصر تحكم عارض PDF إلى فصل MVC أصلًا؟
يحتاج عارض PDF إلى هذا النوع من الفصل لأن حالته وعرضه يتغيران لأسباب مختلفة وبمعدلات مختلفة. رقم الصفحة، والتكبير، ودوران العرض، ونتائج البحث، ومناطق التظليل هي حالة عمل: يمكن حسابها والتحقق منها وتسلسلها دون بكسل واحد على الشاشة. رسم صورة نقطية، والتقاط الماوس، ورسم مستطيل تحديد (marquee) هي اهتمامات عرض لا معنى لها إلا بوجود عنصر تحكم فعلي. يحتفظ HotPDF بالمجموعة الأولى في THPDFViewerModel، وهي فئة بلا أي أصل نافذة VCL على الإطلاق، وبالمجموعة الثانية في THPDFViewer، الذي يمتلك مثيلًا من النموذج ويتفاعل معه — وهو أقرب إلى زوج نموذج-عرض (Model-View) من نمط MVC الثلاثي الطبقات الكلاسيكي، إذ لا توجد فئة تحكم (Controller) منفصلة وTHPDFViewer نفسه يحوّل أحداث لوحة المفاتيح والماوس الخام إلى استدعاءات نموذج. الأهم من التسمية هو اتجاه الاعتماد: لا شيء في THPDFViewerModel يتطلب Handle أو حلقة رسائل أو سطح مكتب مرئي، وهذا بالضبط ما يسمح لمجموعة اختبارات HotPDF نفسها بتشغيل التنقل بين الصفحات، وتقييد التكبير، وأوامر لوحة المفاتيح، ورحلات الإحداثيات ذهابًا وإيابًا عبر DUnitX دون فتح أي نافذة
uses
DUnitX.TestFramework,
HPDFDoc, HPDFViewerModel;
type
[TestFixture]
TViewerModelTests = class
public
[Test]
procedure ZoomInStopsAtTheTopPresetLevel;
end;
procedure TViewerModelTests.ZoomInStopsAtTheTopPresetLevel;
var
Doc: THotPDF;
Model: THPDFViewerModel;
begin
Doc := THotPDF.Create(nil);
Model := THPDFViewerModel.Create;
try
Doc.LoadFromFile('sample.pdf');
Model.Document := Doc;
Model.Zoom := 64.0; // top of the preset table (6400%)
Model.ZoomIn; // already at the ceiling
Assert.AreEqual(64.0, Model.Zoom, 0.0001);
finally
Model.Free;
Doc.Free;
end;
end;
ما الذي تمتلكه THPDFViewerModel فعليًا
تمتلك THPDFViewerModel كل ما يحتاجه عارض للإجابة عن السؤال "ماذا يجب أن يكون على الشاشة الآن" دون أن تمتلك كيفية رسمه. PageIndex، وPageNumber، وPageCount تتابع الموضع؛ وZoom وZoomMode (vzmActualSize، وvzmFitPage، وvzmFitWidth، وvzmCustom) تتابع المقياس؛ وViewRotation يتابع دورانًا غير إتلافي على الشاشة لا يمسّ إطلاقًا إدخال /Rotate الخاص بالصفحة نفسها. طرائق التنقل — FirstPage، وPriorPage، وNextPage، وLastPage — وطرائق التكبير — ZoomIn، وZoomOut، التي تتنقل عبر جدول ثابت من تسعة عشر مستوى جاهزًا من 5% إلى 6400% — تعيش هنا أيضًا، إلى جانب FindAll/FindNext/FindPrevious للبحث النصي وAddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions لتعليقات توضيحية دائمة على الصفحة يريد المستدعي الاحتفاظ بها بين عمليات الرسم. يمتلك النموذج المخرجات كما يمتلك المدخلات: تُصدّر CreateCurrentPageSnapshot وCreateCurrentPageMetafile الصفحة الحالية المعروضة على الشاشة تمامًا، وترسل PrintCurrentView هذا العرض الحالي نفسه — الصفحة الحالية، ودقة النقاط في البوصة المشتقة من التكبير الحالي، والدوران الحالي — إلى TPrinter، وهي مهمة أضيق ومحصورة بالعرض مقارنة بخط أنابيب الطباعة على مستوى المستند الموصوف في شرح HotPDF للطباعة عبر TPrinter. كل تغيير مهم يُطلق حدثًا مطابقًا أيضًا — OnPageChange، وOnZoomChange، وOnSearchChange، وOnHighlightChange، وOnViewRotationChange — بحيث يعرف المشترك ما تغيّر دون استقصاء متكرر
كيف تعرف THPDFViewer متى تعيد الرسم؟
تعرف THPDFViewer متى تعيد الرسم لأنها تشترك في النموذج بدلًا من التخمين. يُنشئ منشئ THPDFViewer مثيلًا خاصًا من THPDFViewerModel، ثم يربط كل حدث من أحداث الإشعار الخاصة به — OnBeginUpdate، وOnEndUpdate، وOnHighlightChange، وOnPageChange، وOnSearchChange، وOnViewRotationChange، وOnZoomChange — بمعالج خاص مطابق. مهمة كل معالج صغيرة: استدعاء RefreshDocument، وهي الطريقة التي ترقّم فعليًا الصفحة الحالية عبر عارض الصفحة المخزَّن مؤقتًا نفسه الموصوف في التفاصيل الداخلية لرسم الصفحة إلى صورة نقطية في HotPDF، ثم تُركّب مربعات التظليل ونتائج البحث فوقها وتطبّق دوران العرض الحالي. الخصائص المنشورة مثل PageIndex، وZoom، وZoomMode، وViewRotation هي مُحوّلات بسيطة — الجالب (getter) يقرأ FModel.PageIndex، والضابط (setter) يكتب FModel.PageIndex — بحيث يبدو عنصر التحكم من مفتش الكائنات (Object Inspector) أو من الشيفرة كأنه يحمل الحالة مباشرة، رغم أن THPDFViewerModel هو المكان الوحيد الذي تعيش فيه تلك الحالة فعليًا. المستدعون ليسوا محصورين بالمجموعة المُحوَّلة أيضًا: يعرض THPDFViewer النموذج نفسه عبر خاصية للقراءة فقط Model: THPDFViewerModel، بحيث يمكن للشيفرة التي تريد FindFormFieldAt أو PrefetchCurrentPageSnapshots — وكلاهما لا يعيد عنصر التحكم عرضه — أن تتجاوز الغلاف وتستدعي النموذج مباشرة
procedure THPDFViewer.RefreshDocument;
var
Bitmap: TBitmap;
DPI: Integer;
begin
// simplified: the real method also resolves fit-mode DPI
// and composites highlight and search-hit rectangles first
if (FModel.Document = nil) or (FModel.PageIndex < 0) then Exit;
DPI := Round(96 * FModel.Zoom);
Bitmap := FModel.Document.RenderLoadedPageToBitmapCached(FModel.PageIndex, DPI);
try
FModel.ApplyViewRotation(Bitmap);
FImage.Picture.Bitmap.Assign(Bitmap);
finally
Bitmap.Free;
end;
end;
BeginUpdate وEndUpdate: إيقاف عواصف إعادة الرسم
توجد BeginUpdate وEndUpdate لأن تغييرًا منطقيًا واحدًا كثيرًا ما يمسّ عدة أجزاء من الحالة دفعة واحدة، وإعادة الرسم بعد كل جزء ستكون مضيعة للموارد ومزعجة بصريًا. استبدال المستند المحمَّل هو المثال الأوضح: تعيين THPDFViewerModel.Document يعيد ضبط دوران العرض، ويمسح نتائج البحث، ويمسح مناطق التظليل، ويقفز إلى الصفحة الأولى، وكل خطوة من هذه الخطوات تُطلق عادة حدث تغيير خاصًا بها. تغلّف THPDFViewerModel هذا التسلسل داخل زوج BeginUpdate/EndUpdate، وهو زوج معدود مرجعيًا حيث لا تُطلق الاستدعاءات المتداخلة OnBeginUpdate إلا عند الانتقال إلى الاستدعاء الأعمق (الخارجي) وOnEndUpdate عند الانتقال للخروج منه. تتابع THPDFViewer العمق نفسه من جانبها وتتخطى RefreshDocument لكل حدث دقيق طالما كان العداد أعلى من صفر، ثم تعيد الرسم مرة واحدة بالضبط عند إغلاق الدفعة. الأحداث الدقيقة لا تزال تُطلق أثناء الدفعة، لذا فإن مشتركًا يهتم فقط بـOnSearchChange لا يزال يسمع عنه؛ فقط إعادة رسم عنصر التحكم نفسه هي التي تُختصر إلى استدعاء واحد بدلًا من أربعة
كيف يعيد التظليل بالتحديد المستطيلي (marquee) ربط سحبة الماوس بإحداثيات PDF؟
يعيد التظليل بالتحديد المستطيلي ربط سحبة الماوس بإحداثيات PDF عبر زوج من طرائق النموذج مبنيتين تحديدًا لهذه الرحلة ذهابًا وإيابًا: PagePointToView وViewPointToPage. كلتاهما تأخذان رقم صفحة، ودقة نقاط في البوصة، ونقطة، وكلتاهما تحلّان التحويل على مرحلتين — أولًا إدخال /Rotate الخاص بالصفحة نفسها وأصل PDF السفلي الأيسر، ثم دوران العرض المنفصل غير الإتلافي ViewRotation وأصل العارض العلوي الأيسر الخاص بالجهاز — تحديدًا كي يمكن للاتجاه العكسي التراجع عن المرحلتين بترتيب عكسي صارم والعودة بشكل صحيح عبر كل التوليفات الست عشرة من دوران الصفحة ودوران العرض. تستدعي THPDFViewer الدالة ViewPointToPage عندما يترك المستخدم زر الماوس بعد سحب مستطيل في نمط التفاعل vimHighlight، وتحوّل نقطتي الجهاز إلى THPDFRectangle في فضاء الصفحة، وتسلّمها إلى Model.AddHighlightRegion. تفصيل يستحق المعرفة إذا كنت تبني شيئًا مماثلًا: التقاط الماوس ينتمي إلى العارض المنحدر من TScrollBox، لا إلى TImage الفرعي الذي تُرسم فيه الصورة النقطية، لأن TControl.MouseCapture محمية ولا يمكن إلا لعنصر التحكم الأب المطالبة بها — لذا فإن سحبة تغادر حدود الصورة قبل رفع الزر لا تزال تُحل عبر MouseMove/MouseUp المُعاد تعريفهما الخاصين بالعارض بدلًا من أن يُسقطها عنصر التحكم الفرعي بصمت
var
ViewPt, PagePt: THPDFViewerPoint;
Rect: THPDFRectangle;
begin
ViewPt.X := 240; // device pixels inside the rendered image
ViewPt.Y := 96;
if Model.ViewPointToPage(Model.PageIndex, ViewPt, PagePt,
RenderedDPI) then // DPI you last rendered at
begin
Rect.Left := PagePt.X - 40; Rect.Bottom := PagePt.Y - 10;
Rect.Right := PagePt.X + 40; Rect.Top := PagePt.Y + 10;
Model.AddHighlightRegion(Model.PageIndex, Rect);
end;
end;
ما الذي يمنحك هذا الفصل بخلاف مجموعة اختبارات ناجحة
الفائدة لا تقتصر على نجاح الاختبارات في مهمة تكامل مستمر بلا جلسة سطح مكتب. ولأن THPDFViewer تُحوّل الاستدعاءات إلى THPDFViewerModel بدلًا من تكرار منطقها، تمكّن HotPDF من إضافة مستهلك ثالث — THPDFViewerAction وفئات فرعية ملموسة مثل THPDFZoomInAction وTHPDFFindNextAction — تربط التنقل والتكبير والبحث والدوران بقائمة إجراءات Delphi القياسية TActionList، بحيث يمكن لزر شريط أدوات أو عنصر قائمة أن يقود العارض بشكل تصريحي، مفعّلًا نفسه تلقائيًا بحسب ما إذا كان أحد العارضين محلولًا حاليًا كهدف للإجراء. لم تكن تلك الطبقة بحاجة لمعرفة أي شيء عن الصور النقطية أو GDI؛ فهي تستدعي Viewer.NextPage أو Viewer.Model.FindNext، وتتولى سلسلة الأحداث الموجودة أمر إعادة الرسم. ولأن لا شيء في THPDFViewerModel يشير إلى TScrollBox أو TImage أو مقبض نافذة، فإن آلة الحالة الكامنة تحتها ليست ملحومة بعنصر التحكم ذلك أيضًا — يمكن للنموذج نفسه أن يقف خلف سطح رسم مختلف دون المساس بسطر واحد من منطق التنقل أو التكبير أو البحث
أين تساعد ذاكرة التخزين المؤقت للرسم، وأين لا تساعد
تساعد ذاكرة التخزين المؤقت للرسم في THPDFViewerModel داخل مستند محمَّل، لكنها لا تغيّر تكلفة تحميل ذلك المستند أصلًا. تمر CreatePageSnapshot، وCreateCurrentPageSnapshot، وطرائق التحميل المسبق PrefetchPageSnapshots/PrefetchCurrentPageSnapshots جميعها عبر العارض المخزَّن مؤقتًا نفسه المفهرس بالصفحة ودقة النقاط في البوصة، بحيث تكون العودة إلى صفحة شاهدتها بالفعل عند مستوى التكبير نفسه إصابة ذاكرة تخزين مؤقت بدلًا من إعادة رسم، والتحميل المسبق لنطاق صغير من الصفحات المجاورة يُنعّم الحالة الشائعة لقارئ يتنقل صفحة واحدة تلو الأخرى. لكن لا شيء من هذا يمسّ تكلفة استدعاء LoadFromFile الأولي، وعارض مبني لفتح أي شيء يسحبه المستخدم عليه يصل في النهاية إلى ملف كبير بما يكفي لجعل ذلك الاستدعاء عنق الزجاجة الفعلي. للبديل المتدرج القائم على المقابض بدلًا من التحميل الكامل — وهو أمر يستحق المعرفة قبل حلول ذلك اليوم — راجع المقالة المرافقة حول واجهة الملف المباشر (Direct File API) لملفات PDF الكبيرة
فئتا النموذج والعرض الموصوفتان هنا هما قطعتان إضافيتان من سطح المستند المحمَّل نفسه المستخدم في جميع أرجاء مكوّن HotPDF لـDelphi وC++Builder، وقد بُنيتا لتُقادا من نموذج (form)، أو من TActionList، أو من لا شيء على الإطلاق