مقاله فنی

نمایشگر سفارشی PDF در Delphi با HotPDF: معماری MVC

HotPDF نمایشگر PDF خود در Delphi را به دو بخش تقسیم می‌کند: THPDFViewerModel، یک کلاس ساده که وضعیت زوم، چرخش، جستجو، هایلایت، و ناوبری را بدون هیچ وابستگی به handle پنجره در اختیار دارد، و THPDFViewer، یک کنترل مبتنی بر TScrollBox که آن وضعیت را به پیکسل تبدیل می‌کند. این تفکیک همان چیزی است که اجازه می‌دهد منطق نمایشگر بدون ساختن حتی یک فرم اجرا و تست شود

اغلب کنترل‌های نمایشگر سفارشی این‌طور نیستند. سطح زوم در یک فیلد خصوصی روی کنترل زندگی می‌کند، ناوبری صفحه محدوده‌ی خود را درون handler رویداد OnClick یک دکمه گیره می‌زند، و تنها راه فهمیدن اینکه آیا Ctrl+scroll یک سقف زوم را رعایت می‌کند یا نه، اجرای برنامه، کلیک کردن، و نگاه کردن است. کنترلی که این‌طور ساخته شده تا زمانی که به یک مجموعه تست رگرسیون یا یک میزبان دوم نیاز پیدا نکند خوب کار می‌کند — یک دیالوگ پیش‌نمایش چاپ، یک ردیف بندانگشتی، یک بازبین دسته‌ای بدون هیچ پنجره‌ی قابل‌مشاهده‌ای — و آنگاه وضعیتی که نیاز دارید معلوم می‌شود به یک TWinControl جوش‌خورده که پیش از انجام هر کاری اصرار دارد یک handle واقعی داشته باشد

چرا اصلاً یک کنترل نمایشگر 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، که یک جدول ثابت از نوزده سطح از پیش تعیین‌شده از ۵٪ تا ۶۴۰۰٪ را می‌پیمایند — نیز اینجا زندگی می‌کنند، در کنار FindAll/FindNext/FindPrevious برای جستجوی متن و AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions برای حاشیه‌نویسی‌های ماندگار صفحه که یک فراخواننده می‌خواهد بین رندرها نگه دارد. مدل خروجی را نیز به‌اندازه‌ی ورودی در اختیار دارد: CreateCurrentPageSnapshot و CreateCurrentPageMetafile دقیقاً همان صفحه‌ای را که الان روی صفحه است export می‌کنند، و PrintCurrentView همان نمای فعلی — صفحه‌ی فعلی، DPI مشتق‌شده از زوم فعلی، چرخش فعلی — را به یک TPrinter می‌فرستد، یک کار محدودتر و مقیدشده به نما نسبت به خط لوله‌ی چاپ در سطح سند که در راهنمای چاپ TPrinter در HotPDF پوشش داده شده. هر جهش مهمی هم رویداد متناظرش را raise می‌کند — OnPageChange، OnZoomChange، OnSearchChange، OnHighlightChange، OnViewRotationChange — پس یک مشترک بدون نظرسنجی می‌فهمد چه چیزی تغییر کرده

THPDFViewer چطور می‌فهمد چه زمانی باید دوباره رسم کند؟

THPDFViewer می‌فهمد چه زمانی باید دوباره رسم کند چون به مدل مشترک می‌شود به‌جای اینکه حدس بزند. سازنده‌ی THPDFViewer یک THPDFViewerModel خصوصی می‌سازد، سپس هر یک از رویدادهای اعلانش را — OnBeginUpdate، OnEndUpdate، OnHighlightChange، OnPageChange، OnSearchChange، OnViewRotationChange، OnZoomChange — به یک handler خصوصی متناظر سیم‌کشی می‌کند. کار هر handler کوچک است: RefreshDocument را صدا بزن، متدی که واقعاً صفحه‌ی فعلی را از طریق همان رندرر صفحه‌ی کش‌شده که در جزئیات داخلی رندر صفحه به بیت‌مپ در HotPDF توصیف شده رستری می‌کند، سپس جعبه‌های هایلایت و نتایج جستجو را روی آن ترکیب می‌کند و چرخش نمای فعلی را اعمال می‌کند. ویژگی‌های published مثل PageIndex، Zoom، ZoomMode، و ViewRotation فقط forwarderهای نازک هستند — getter مقدار FModel.PageIndex را می‌خواند، setter مقدار FModel.PageIndex را می‌نویسد — پس از دید Object Inspector یا از دید کد، به‌نظر می‌رسد کنترل خودش وضعیت را مستقیماً نگه می‌دارد، درحالی‌که THPDFViewerModel تنها جایی است که آن وضعیت واقعاً در آن زندگی می‌کند. فراخوانندگان به زیرمجموعه‌ی forward‌شده هم محدود نیستند: THPDFViewer خودِ مدل را از طریق یک ویژگی فقط‌خواندنی به نام Model: THPDFViewerModel در معرض دید می‌گذارد، پس کدی که FindFormFieldAt یا PrefetchCurrentPageSnapshots را می‌خواهد — که هیچ‌کدام دوباره توسط کنترل در معرض دید گذاشته نشده‌اند — می‌تواند از wrapper عبور کند و مستقیماً مدل را صدا بزند

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 برمی‌گرداند؟

هایلایت marquee یک کشیدن ماوس را از طریق یک جفت متد مدل که دقیقاً برای همین رفت‌وبرگشت ساخته شده‌اند به مختصات PDF برمی‌گرداند: PagePointToView و ViewPointToPage. هر دو یک شماره‌ی صفحه، یک DPI، و یک نقطه می‌گیرند، و هر دو تبدیل را در دو مرحله حل می‌کنند — ابتدا ورودی /Rotate خودِ صفحه و مبدأ پایین-چپ PDF آن، سپس ViewRotation جداگانه و غیرمخرب نما و مبدأ بالا-چپ دستگاهی نمایشگر — دقیقاً برای اینکه جهت معکوس بتواند این دو مرحله را به ترتیب معکوس دقیق لغو کند و به‌درستی در تمام شانزده ترکیب از چرخش صفحه و چرخش نما رفت‌وبرگشت کند. THPDFViewer وقتی کاربر پس از کشیدن یک مستطیل در حالت تعامل vimHighlight ماوس را رها می‌کند، ViewPointToPage را صدا می‌زند، دو نقطه‌ی دستگاهی را به یک THPDFRectangle در فضای صفحه تبدیل می‌کند، و آن را به Model.AddHighlightRegion می‌سپارد. یک جزئیات که ارزش دانستن دارد اگر چیزی مشابه بسازید: مالکیت capture ماوس متعلق به نمایشگر مشتق‌شده از TScrollBox است، نه به TImage فرزندی که بیت‌مپ در آن رسم می‌شود، چون TControl.MouseCapture protected است و فقط کنترل والد می‌تواند آن را طلب کند — پس کشیدنی که پیش از بالاآمدن دکمه از محدوده‌ی تصویر خارج شود، همچنان از طریق 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;

این تفکیک فراتر از یک مجموعه تست سبز چه چیزی می‌دهد

سود این کار محدود به قبول‌شدن تست‌ها در یک job از CI بدون نشست دسکتاپ نیست. چون THPDFViewer به‌جای تکرار منطق THPDFViewerModel آن را forward می‌کند، HotPDF توانست یک مصرف‌کننده‌ی سوم اضافه کند — THPDFViewerAction و زیرکلاس‌های عینی مثل THPDFZoomInAction و THPDFFindNextAction — که ناوبری، زوم، جستجو، و چرخش را به یک TActionList استاندارد Delphi وصل می‌کنند، پس یک دکمه‌ی نوار ابزار یا یک آیتم منو می‌تواند نمایشگر را به‌شکل اعلانی هدایت کند، و بر اساس اینکه یک نمایشگر در حال حاضر به‌عنوان هدف عمل حل شده یا نه، خودش را به‌طور خودکار فعال کند. هیچ‌کدام از آن لایه لازم نبود چیزی درباره‌ی بیت‌مپ یا GDI بداند؛ این Viewer.NextPage یا Viewer.Model.FindNext را صدا می‌زند، و زنجیره‌ی رویداد موجود مسئولیت رسم مجدد را برعهده می‌گیرد. و چون هیچ چیزی در THPDFViewerModel به TScrollBox، TImage، یا یک handle پنجره ارجاع نمی‌دهد، ماشین وضعیت زیرین هم به آن یک کنترل جوش نخورده — همان مدل می‌تواند پشت یک سطح رندر متفاوت بنشیند بدون اینکه حتی یک خط از منطق ناوبری، زوم، یا جستجو را لمس کند

کش رندر کجا کمک می‌کند و کجا نه

کش رندر THPDFViewerModel در درون یک سند بارشده کمک می‌کند، اما هزینه‌ی بارکردن آن سند در ابتدا را تغییر نمی‌دهد. CreatePageSnapshot، CreateCurrentPageSnapshot، و متدهای پیش‌واکشی PrefetchPageSnapshots/PrefetchCurrentPageSnapshots همگی از طریق همان رندرر کش‌شده که با صفحه و DPI کلیددهی شده مسیر می‌یابند، پس صفحه‌گردانی به عقب به سمت صفحه‌ای که از پیش با همان سطح زوم دیده‌اید، یک اصابت کش است نه یک رندر مجدد، و پیش‌واکشی یک شعاع کوچک از صفحات همسایه، حالت رایج یک خواننده که یک صفحه در یک زمان به جلو صفحه‌گردانی می‌کند را روان می‌کند. هیچ‌کدام از این‌ها هزینه‌ی فراخوانی اولیه‌ی LoadFromFile را لمس نمی‌کند، هرچند، و نمایشگری که برای بازکردن هر چیزی که کاربر روی آن می‌کشد ساخته شده، سرانجام با فایلی به‌اندازه‌ی کافی بزرگ روبه‌رو می‌شود که آن فراخوانی را به گلوگاه واقعی تبدیل کند. برای جایگزین لایه‌بندی‌شده و مبتنی بر handle به‌جای یک بارگذاری کامل — که ارزش دانستن دارد پیش از رسیدن آن روز — به مقاله‌ی همراه درباره‌ی Direct File API برای PDFهای بزرگ مراجعه کنید

کلاس‌های Model و View که در اینجا توصیف شدند، دو تکه‌ی دیگر از همان سطح سند بارشده هستند که در سراسر کامپوننت HotPDF برای Delphi و C++Builder استفاده می‌شوند، ساخته‌شده برای اینکه از یک فرم، از یک TActionList، یا اصلاً از هیچ‌کدام هدایت شوند