Техническа статия

Потребителски PDF преглед в Delphi: MVC архитектурата на HotPDF

HotPDF разделя своя Delphi PDF преглед на две части: THPDFViewerModel, обикновен клас, който притежава състоянието за мащаб, завъртане, търсене, осветяване и навигация без зависимост от дескриптор на прозорец, и THPDFViewer, контрола на основата на TScrollBox, която превръща това състояние в пиксели. Именно това разделяне позволява логиката на прегледа да се изпълнява и тества, без изобщо да се създава форма

Повечето потребителски контроли за преглед не изглеждат така. Нивото на мащабиране стои в частно поле на контролата, навигацията между страниците ограничава границите си в обработчик OnClick на бутон, а единственият начин да разберете дали Ctrl+превъртане спазва горната граница на мащаба е да стартирате приложението, да щракнете и да погледнете. Контрола, изградена по този начин, работи добре, докато не се появи нужда от регресионен набор от тестове или втори хост — диалогов прозорец за визуализация преди печат, лента с миниатюри или пакетен преглед без видим прозорец — и се окаже, че необходимото състояние е заварено към TWinControl, който настоява за реален дескриптор, преди да направи каквото и да е

Защо PDF контролата за преглед изобщо има нужда от MVC разделяне

PDF прегледът има нужда от такова разделяне, защото състоянието му и представянето му се променят по различни причини и с различна честота. Индексът на страницата, мащабът, завъртането на изгледа, резултатите от търсенето и областите за осветяване са логика на приложението: те могат да се изчисляват, валидират и сериализират без нито един пиксел на екрана. Рисуването на растерно изображение, прихващането на мишката и очертаването на правоъгълник за избор имат смисъл едва след създаването на контрола. 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 изпраща същия текущ изглед — текущата страница, текущото DPI, изведено от мащаба, и текущото завъртане — към 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, пак разбира за промяната; свива се само собственото прерисуване на контролата до едно извикване вместо четири

Как маркирането с рамка преобразува плъзгането на мишката обратно към PDF координати

Маркирането с рамка преобразува плъзгането на мишката обратно към PDF координати чрез два метода на модела, създадени точно за това двупосочно преобразуване: PagePointToView и ViewPointToPage. И двата приемат индекс на страница, DPI и точка и разрешават трансформацията на два етапа — първо собствения запис /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;

Какво печелите от разделянето извън зеления набор от тестове

Ползата не се изчерпва с преминаването на тестове в CI задача без работна сесия на настолен компютър. Тъй като THPDFViewer препраща към THPDFViewerModel, вместо да дублира логиката му, HotPDF успя да добави трети потребител — THPDFViewerAction и конкретни наследници като THPDFZoomInAction и THPDFFindNextAction — които включват навигацията, мащабирането, търсенето и завъртането в стандартен Delphi TActionList, така че бутон от лента с инструменти или елемент от меню да управлява прегледа декларативно и да се активира автоматично според това дали прегледът е разрешен като цел на действието. Този слой не трябва да знае нищо за растерни изображения или GDI; той извиква Viewer.NextPage или Viewer.Model.FindNext, а съществуващата верига от събития се грижи за прерисуването. И понеже нищо в THPDFViewerModel не препраща към TScrollBox, TImage или дескриптор на прозорец, машината на състоянията отдолу също не е заварена към тази конкретна контрола — същият модел може да стои зад друга повърхност за рендиране, без да се променя нито един ред от логиката за навигация, мащабиране или търсене

Къде кешът на рендирането помага и къде не помага

Кешът на рендирането на THPDFViewerModel помага в рамките на зареден документ, но не променя цената на първоначалното зареждане на този документ. CreatePageSnapshot, CreateCurrentPageSnapshot и методите за предварително зареждане PrefetchPageSnapshots/PrefetchCurrentPageSnapshots използват един и същ кеширан рендерер, индексиран по страница и DPI, така че връщането към страница, която вече сте гледали със същото ниво на мащаб, е попадение в кеша, а не повторно рендиране, и предварителното зареждане на малък радиус съседни страници изглажда обичайния случай, при който читателят преминава напред по една страница. Нищо от това обаче не засяга цената на първоначалното извикване на LoadFromFile, а преглед, създаден да отваря всичко, което потребителят пусне върху него, в крайна сметка ще срещне файл, достатъчно голям, за да превърне това извикване в реалното тесно място. За йерархичната алтернатива на основата на дескриптори към пълното зареждане — полезно е да я знаете предварително — вижте съпътстващата статия за Direct File API за големи PDF файлове

Описаните тук класове Model и View са още две части от същата повърхност за зареден документ, използвана навсякъде в HotPDF Component за Delphi и C++Builder, създадена да се управлява от форма, от TActionList или изобщо без нито едно от тях