Прямоугольник, обведённый вокруг абзаца во время рецензирования, не обязан становиться пометкой внутри самого PDF. THPDFViewerModel в HotPDF предоставляет метод AddHighlightRegion, который хранит каждое выделение как запись в памяти, а не как изменение загруженного документа, так что рецензент может разметить десятки страниц, пока файл на диске остаётся побайтово таким же, каким был. Увеличьте масштаб до 6400%, поверните страницу на 90 градусов, переключитесь с «По ширине» на «По странице» — и тот же прямоугольник по-прежнему попадает на тот же абзац, потому что расчёт координат проходит через реальную геометрию рендеринга в момент, когда пометка была нарисована
Инструментарий рецензирования, построенный вокруг просмотрщика PDF, постоянно сталкивается с этой проблемой. Экран разметки правок, проверка качества сгенерированных счетов, внутренний процесс согласования — всем им нужно позволить кому-то привлечь внимание к области страницы без того, чтобы каждая черновая пометка превращалась в постоянное изменение файла, и без обращения к полноценной подсистеме аннотаций только ради того, чтобы показать цветной прямоугольник, пока кто-то ещё решает, уместна ли эта пометка. HotPDF отвечает на это выделенным слоем выделения, который целиком располагается на стороне модели в разделении, описанном в статье о построении собственного просмотрщика PDF с архитектурой MVC в Delphi, и именно поэтому тем же списком выделений можно управлять из модульного теста, не имея в поле зрения ни одного дескриптора окна
Что на самом деле хранит AddHighlightRegion в HotPDF?
AddHighlightRegion хранит ровно три вещи на каждую пометку: индекс страницы с отсчётом от нуля, THPDFRectangle в координатах пространства пользователя PDF и TColor, всё упаковано в запись THPDFViewerHighlight внутри THPDFViewerModel. Вызов Viewer.HighlightRegion(PageIndex, PageRect, clYellow) или эквивалентный Model.AddHighlightRegion добавляет одну такую запись в приватный массив и возвращает её индекс, и этот индекс — единственный дескриптор, который получает вызывающий код: отдельного объекта, интерфейса со счётчиком ссылок, чего-либо для освобождения памяти не существует. Все остальные возможности, описанные в этой статье, — отрисовка пометки, её переотображение после смены масштаба, удаление — построены поверх этой одной небольшой записи
Каждый прямоугольник нормализуется и обрезается прежде, чем будет принят. AddHighlightRegion меняет местами левый и правый края, если рецензент тянет справа налево, меняет местами верхний и нижний при перетаскивании снизу вверх, а затем обрезает результат по MediaBox страницы, полученному через GetLoadedPageBox. Прямоугольник, у которого в итоге нулевая ширина, нулевая высота или который целиком оказался за пределами страницы, отклоняется без исключений: метод возвращает -1, и ничего не добавляется в список. Это возвращаемое значение — не декоративная деталь: пакет выделений, восстановленный из внешнего файла рецензирования или из устаревших координат после замены страницы, может незаметно потерять записи, если вызывающий код это значение не проверяет
Как выделение остаётся выровненным после масштабирования или поворота?
Выделение остаётся выровненным потому, что HotPDF хранит его в пространстве страницы PDF и заново проецирует в экранное пространство при каждой перерисовке, а не хранит экранный прямоугольник, который устареет в момент изменения уровня масштаба. THPDFViewerModel.PagePointToView и обратный ему ViewPointToPage выполняют эту проекцию в два этапа: сначала собственная запись /Rotate страницы, затем независимый ViewRotation просмотрщика, который никогда не записывается обратно в PDF и влияет только на то, что отображает просмотрщик. Отмена преобразования при отпускании кнопки мыши проходит те же два этапа в обратном порядке, и именно это позволяет выделению, нарисованному при высоком масштабе на странице, повёрнутой на 270 градусов, оказаться точно в нужном месте после того, как рецензент вернёт вид обратно к «По странице»
DPI, используемое для этой проекции, важно не меньше поворота. Просмотрщик HotPDF фиксирует точное DPI растрового изображения, находящегося сейчас на экране, в FRenderedDPI сразу после каждого рендеринга, и ImageMouseUp передаёт то же самое значение в ViewPointToPage, так что координата мыши всегда преобразуется с использованием разрешения, при котором изображение было фактически отрисовано, а не разрешения, пересчитанного из текущего свойства масштаба. CreatePageSnapshot и родственные ему методы ограничивают DPI диапазоном от 12 до 2400, но интерактивный путь рендеринга не несёт такого потолка: стандартная лестница масштабов заканчивается на 6400%, что при базовых 96 DPI по умолчанию соответствует значительно более чем 2400 DPI, так что повторное использование ограничения в стиле снимков для сопоставления координат сдвинуло бы каждое выделение на несколько пикселей в верхней части диапазона масштаба. Две менее значительные настройки по умолчанию дополняют взаимодействие: перетаскивание короче двух пикселей по любой оси считается кликом и не создаёт выделения, а выделение не может начаться, пока не отрисуется хотя бы одна страница, поскольку FRenderedDPI изначально равен нулю
Подключение интерактивного выделения к экрану рецензирования
Включение интерактивного выделения — это настройка трёх свойств самого элемента управления THPDFViewer: установить InteractionMode в vimHighlight вместо режима по умолчанию vimBrowse, выбрать HighlightColor, который по умолчанию равен clYellow, и обработать OnMarqueeSelect, чтобы узнать, что рецензент только что нарисовал. Всё остальное — захват мыши, рисование пунктирного прямоугольника выделения во время перетаскивания рецензентом, преобразование точки отпускания обратно в пространство страницы, вызов AddHighlightRegion — происходит внутри элемента управления до того, как это событие сработает
type
TReviewForm = class(TForm)
Viewer: THPDFViewer;
ReviewLog: TMemo;
procedure FormCreate(Sender: TObject);
private
procedure ViewerMarqueeSelect(Sender: TObject; Shift: TShiftState;
PageIndex: Integer; const PageRect: THPDFRectangle;
HighlightIndex: Integer);
end;
// PdfDoc is a THotPDF already loaded elsewhere on the form
procedure TReviewForm.FormCreate(Sender: TObject);
begin
Viewer.PDFDocument := PdfDoc;
Viewer.InteractionMode := vimHighlight;
Viewer.HighlightColor := clLime;
Viewer.OnMarqueeSelect := ViewerMarqueeSelect;
end;
procedure TReviewForm.ViewerMarqueeSelect(Sender: TObject; Shift: TShiftState;
PageIndex: Integer; const PageRect: THPDFRectangle; HighlightIndex: Integer);
begin
ReviewLog.Lines.Add(Format('page %d, mark #%d at (%.1f, %.1f)-(%.1f, %.1f)',
[PageIndex + 1, HighlightIndex, PageRect.Left, PageRect.Bottom,
PageRect.Right, PageRect.Top]));
end;
OnMarqueeSelect срабатывает только для перетаскивания, которое действительно создало выделение: клик, слишком малый, чтобы считаться перетаскиванием, немедленно очищает наложение выделения, а перетаскивание, целиком оказавшееся за пределами страницы, доходит до AddHighlightRegion, но отклоняется там точно так же, как отклонялся бы программный вызов, так что событие в обоих случаях молчит. Одна деталь реализации, которую стоит знать, если выделение вдруг перестаёт реагировать у краёв элемента управления: захват мыши принадлежит самому THPDFViewer, потомку TScrollBox, а не внутреннему TImage, показывающему растровое изображение страницы, — именно это позволяет рецензенту тащить курсор за границу отрисованной страницы и всё равно получить корректное отпускание кнопки
Добавление, удаление и повторное чтение выделений из кода
Выделения вовсе не обязаны появляться только из перетаскивания мышью. Viewer.HighlightRegion(PageIndex, PageRect, Color), который направляет вызов в тот же Model.AddHighlightRegion, что и интерактивное перетаскивание внутри себя, публичен именно для того, чтобы экран рецензирования мог восстанавливать выделения из уже имеющихся данных: комментариев, загруженных из базы данных, результатов текстового поиска или пометок, восстановленных из предыдущей сессии. Поскольку координаты — это простые числа в пространстве пользователя PDF, ничто в этом пути не зависит от того, была ли страница уже отрисована, в отличие от интерактивного перетаскивания, которому нужно, чтобы FRenderedDPI уже содержал реальное значение
var
I: Integer;
Item: TPriorComment; // your own record: PageIndex + PageRect
NewIndex: Integer;
begin
for I := 0 to PriorComments.Count - 1 do
begin
Item := TPriorComment(PriorComments[I]);
NewIndex := Viewer.HighlightRegion(Item.PageIndex, Item.PageRect, clAqua);
if NewIndex < 0 then
LogWarning('comment %d fell outside the page and was dropped', [I]);
end;
end;
Именно при удалении одного выделения проявляется хранение на основе массива. RemoveHighlightRegion удаляет одну запись и сдвигает каждую последующую запись на одну позицию вниз, чтобы закрыть образовавшийся пробел, а это значит, что любой индекс, полученный ранее — из события OnMarqueeSelect или из предыдущего перечисления, — перестаёт быть надёжным, как только что-то, стоявшее в списке раньше него, будет удалено. OnHighlightChange срабатывает при каждом добавлении, удалении и вызове ClearHighlightRegions, но не несёт никакой информации о том, что именно изменилось, поэтому безопасный подход — воспринимать это событие как сигнал перестроить весь список, который показывает панель рецензирования, из HighlightCount и TryGetHighlightRegion, а не исправлять на месте закэшированный индекс
procedure TReviewForm.ViewerHighlightChange(Sender: TObject);
var
I: Integer;
Mark: THPDFViewerHighlight;
begin
MarkList.Items.Clear;
for I := 0 to Viewer.Model.HighlightCount - 1 do
if Viewer.Model.TryGetHighlightRegion(I, Mark) then
MarkList.Items.AddObject(Format('page %d', [Mark.PageIndex + 1]),
TObject(I));
end;
Когда пометке стоит вместо этого стать настоящей аннотацией Highlight?
Область выделения должна стать настоящей аннотацией в тот момент, когда ей нужно пережить время существования одного-единственного экземпляра THPDFViewer. HotPDF также предоставляет AddHighlightAnnotation для новой страницы и AddLoadedHighlightAnnotation для уже загруженного документа, и, несмотря на почти идентичное название, это совершенно другой механизм: оба метода записывают настоящую аннотацию текстовой разметки по ISO 32000-1 §12.5.6.10, PDF /Subtype /Highlight, в массив /Annots страницы, с /QuadPoints, отмечающими точный ряд глифов, и любой соответствующий спецификации просмотрщик PDF отрисует её после сохранения файла — не только собственный просмотрщик HotPDF. Эта же граница механизмов определяет, пройдёт ли пометка полный цикл через XFDF: аннотация, созданная через AddLoadedHighlightAnnotation, — обычный объект PDF, который подхватывает ExportLoadedAnnotationsToXFDF и передаёт Acrobat или другому инструменту рецензирования как разметку ISO 19444-1, описанную в статье об импорте и экспорте аннотаций PDF в формате XFDF в Delphi, тогда как область, добавленная через AddHighlightRegion, невидима для этого экспорта, потому что вообще никогда не записывалась в граф объектов: она существует ровно столько, сколько существует создавший её THPDFViewerModel. Полное семейство типов разметки и геометрических аннотаций, доступных на странице, и то, как прямоугольник размещает каждую из них, описано в статье об аннотациях PDF в Delphi с HotPDF, а практическое правило простое: держите пометку одноразовой, пока документ ещё обсуждается, и фиксируйте её как аннотацию, как только решение окончательно принято
Где заканчивается слой выделения
Слой выделения, со своей стороны, даже не пытается выглядеть как полупрозрачный маркер: RefreshDocument рисует каждую область как двухпиксельный прямоугольник-контур своего цвета поверх кэшированного растрового изображения страницы, точно так же, как рисует совпадения поиска, а не смешивает цветную заливку с текстом под ней, так что классический жёлтый «размыв» нужно рисовать в коде приложения или отложить до собственного потока внешнего вида аннотации, до которой пометка будет повышена. Одна возможность, которую стоит переиспользовать, как только область уже существует, — CreateCurrentPageRegionSnapshot, который принимает тот же THPDFRectangle, что уже несёт выделение, и отрисовывает именно эту область в растровое изображение — удобно для прикрепления небольшого изображения-превью к комментарию рецензирования без экспорта всей страницы. Сборке для рецензирования не нужно заранее выбирать между двумя механизмами: по умолчанию делайте каждую новую пометку одноразовой областью THPDFViewerHighlight, пока ветка обсуждения остаётся открытой, и вызывайте AddLoadedHighlightAnnotation только тогда, когда рецензент её разрешит, — это сохраняет загруженный PDF нетронутым во время переписки туда-обратно, которая порождает больше всего изменений. Описанный здесь элемент управления просмотрщиком — часть стандартного компонента HotPDF для Delphi и C++Builder, наряду с остальными API аннотаций и форм, упомянутыми выше