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

Неразрушающее выделение PDF в Delphi: слой рецензирования HotPDF

Прямоугольник, обведённый вокруг абзаца во время рецензирования, не обязан становиться пометкой внутри самого 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: AddHighlightRegion нормализует перетаскиваемый прямоугольник, отсекает его по MediaBox страницы, затем либо сохраняет запись THPDFViewerHighlight, либо отклоняет её с возвратом -1
Нормализация и отсечение по MediaBox происходят обе внутри AddHighlightRegion, а путь отбоя с -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 — это THotPDF, уже загруженный в другом месте формы
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;    // ваша собственная запись: 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, а практическое правило простое: держите пометку одноразовой, пока документ ещё обсуждается, и фиксируйте её как аннотацию, как только решение окончательно принято

HotPDF фиксирует точное разрешение каждой отрисовки в FRenderedDPI, и ImageMouseUp переводит координаты мыши с его помощью, оставаясь честным, хотя лестница интерактивного масштаба достигает 6400 процентов, а вспомогательные снимки зажимают отрисовку на 2400 DPI
Настоящее разрешение битмапа путешествует от вызова отрисовки до преобразования координат, поэтому переиспользование DPI-потолка в стиле снимков увело бы подсветки на несколько пикселей в верхней части диапазона зума

Где заканчивается слой выделения

Слой выделения, со своей стороны, даже не пытается выглядеть как полупрозрачный маркер: RefreshDocument рисует каждую область как двухпиксельный прямоугольник-контур своего цвета поверх кэшированного растрового изображения страницы, точно так же, как рисует совпадения поиска, а не смешивает цветную заливку с текстом под ней, так что классический жёлтый «размыв» нужно рисовать в коде приложения или отложить до собственного потока внешнего вида аннотации, до которой пометка будет повышена. Одна возможность, которую стоит переиспользовать, как только область уже существует, — CreateCurrentPageRegionSnapshot, который принимает тот же THPDFRectangle, что уже несёт выделение, и отрисовывает именно эту область в растровое изображение — удобно для прикрепления небольшого изображения-превью к комментарию рецензирования без экспорта всей страницы. Сборке для рецензирования не нужно заранее выбирать между двумя механизмами: по умолчанию делайте каждую новую пометку одноразовой областью THPDFViewerHighlight, пока ветка обсуждения остаётся открытой, и вызывайте AddLoadedHighlightAnnotation только тогда, когда рецензент её разрешит, — это сохраняет загруженный PDF нетронутым во время переписки туда-обратно, которая порождает больше всего изменений. Описанный здесь элемент управления просмотрщиком — часть стандартного компонента HotPDF для Delphi и C++Builder, наряду с остальными API аннотаций и форм, упомянутыми выше

Граница механизмов в рецензировании HotPDF: AddHighlightRegion держит одноразовые пометки в памяти, где экспорт XFDF их не видит, а AddLoadedHighlightAnnotation пишет настоящую аннотацию Highlight с QuadPoints в /Annots, которую рисует любой соответствующий стандарту просмотрщик
Отметка остаётся в одноразовом слое, пока ветка комментариев открыта, и фиксируется как аннотация ISO 32000-1 после разрешения, и та же граница решает, сможет ли экспорт XFDF донести её до других инструментов рецензирования