HotPDF разделяет свой просмотрщик PDF на Delphi на две части: THPDFViewerModel — простой класс, владеющий состоянием масштаба, поворота, поиска, выделения и навигации без какой-либо зависимости от дескриптора окна, и THPDFViewer — элемент управления на основе TScrollBox, превращающий это состояние в пиксели. Именно это разделение позволяет логике просмотрщика выполняться и тестироваться без создания формы
Большинство пользовательских элементов управления просмотром выглядят иначе. Уровень масштаба хранится в приватном поле элемента управления, навигация по страницам ограничивается прямо внутри обработчика OnClick кнопки, а единственный способ узнать, соблюдает ли Ctrl+прокрутка предел масштаба, — запустить приложение, кликнуть и посмотреть. Такой элемент управления работает нормально до тех пор, пока не понадобится набор регрессионных тестов или второй хост — диалог предпросмотра печати, панель миниатюр, пакетный ревьюер вообще без видимого окна, — и тогда выясняется, что нужное состояние намертво приварено к TWinControl, который настаивает на настоящем дескрипторе окна прежде, чем что-либо делать
Зачем элементу управления просмотром PDF вообще нужно разделение MVC?
Просмотрщику PDF нужно такое разделение, потому что его состояние и его представление меняются по разным причинам и с разной частотой. Индекс страницы, масштаб, поворот вида, найденные совпадения поиска и области выделения — это бизнес-состояние: его можно вычислять, проверять и сериализовать без единого пикселя на экране. Отрисовка растрового изображения, захват мыши и рисование прямоугольника выделения-«лассо» — это задачи представления, которые имеют смысл только когда элемент управления уже существует. HotPDF хранит первую группу в THPDFViewerModel — классе, вообще не имеющем предка, связанного с оконной системой VCL, — а вторую группу в THPDFViewer, который владеет экземпляром модели и реагирует на него — это ближе к паре «модель — представление», чем к учебной трёхуровневой MVC, поскольку отдельного класса-контроллера нет, а сам 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, — тонкие переадресаторы: геттер читает FModel.PageIndex, сеттер пишет FModel.PageIndex, — так что из инспектора объектов или из кода элемент управления выглядит так, будто хранит состояние напрямую, хотя единственное место, где это состояние действительно живёт, — 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 защищённое (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;
Что даёт это разделение, помимо зелёного набора тестов
Выгода не ограничивается прохождением тестов в задании 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
Описанные здесь классы модели и представления — ещё две части той же поверхности работы с загруженным документом, используемой во всём компоненте HotPDF для Delphi и C++Builder, созданном для управления из формы, из TActionList или вообще без них