HotPDF розділяє свій переглядач PDF для Delphi на дві частини: 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, це вужче завдання, прив'язане до перегляду, ніж конвеєр друку всього документа, описаний у огляді друку через TPrinter в HotPDF. Кожна важлива зміна також піднімає відповідну подію — OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange, — тож підписник дізнається, що змінилося, без опитування
Звідки THPDFViewer знає, коли перемалювати?
THPDFViewer знає, коли перемалювати, бо він підписується на модель замість того, щоб вгадувати. Конструктор THPDFViewer створює приватний THPDFViewerModel, а потім під'єднує кожну з його подій сповіщення — OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange — до відповідного приватного обробника. Завдання кожного обробника невелике: викликати RefreshDocument — метод, що фактично растеризує поточну сторінку через той самий кешований рендерер сторінок, описаний у внутрішній будові рендерингу сторінки в растрове зображення в HotPDF, — а потім накладає поверх рамки підсвічування й збіги пошуку та застосовує поточне обертання перегляду. Опубліковані властивості на кшталт PageIndex, Zoom, ZoomMode та ViewRotation — це тонкі переспрямовувачі: гетер читає FModel.PageIndex, сетер пише 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, — що під'єднують навігацію, масштаб, пошук та обертання до стандартного TActionList Delphi, тож кнопка панелі інструментів чи пункт меню може керувати переглядачем декларативно, вмикаючи себе автоматично залежно від того, чи розв'язується наразі переглядач як ціль дії. Жодному з цього шару не довелося знати щось про растрові зображення чи GDI; він викликає Viewer.NextPage чи Viewer.Model.FindNext, а наявний ланцюжок подій сам подбає про перемальовування. І оскільки ніщо в THPDFViewerModel не посилається на TScrollBox, TImage чи дескриптор вікна, машина станів під ними теж не приварена до цього одного елемента керування — та сама модель могла б стояти за іншою поверхнею рендерингу без жодної зміни в логіці навігації, масштабу чи пошуку
Де кеш рендерингу допомагає, а де ні
Кеш рендерингу THPDFViewerModel допомагає в межах завантаженого документа, але не змінює того, у що обходиться саме завантаження цього документа. CreatePageSnapshot, CreateCurrentPageSnapshot та методи попереднього завантаження PrefetchPageSnapshots/PrefetchCurrentPageSnapshots — усі проходять через той самий кешований рендерер, ключем якого є сторінка та DPI, тож повернення на сторінку, яку вже переглядали з тим самим масштабом, — це влучання в кеш, а не повторний рендер, а попереднє завантаження невеликого радіуса сусідніх сторінок згладжує звичайний випадок, коли читач гортає сторінки вперед по одній. Однак ніщо з цього не торкається вартості початкового виклику LoadFromFile, а переглядач, побудований для відкриття будь-чого, що користувач перетягне на нього, рано чи пізно натрапить на файл, достатньо великий, щоб саме цей виклик став справжнім вузьким місцем. Про багаторівневу альтернативу на основі дескрипторів замість повного завантаження — варто знати про неї заздалегідь — дивіться супутню статтю про Direct File API для великих PDF
Описані тут класи Model і View — ще дві частини тієї самої поверхні завантаженого документа, що використовується по всьому компоненту HotPDF для Delphi та C++Builder, побудованому так, щоб ним можна було керувати з форми, з TActionList або взагалі без жодного з них