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

Собственный просмотрщик PDF на Delphi в HotPDF: архитектура MVC

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 или вообще без них