Artigo Técnico

Destaque Não Destrutivo em PDF no Delphi: a Camada de Revisão do HotPDF

Um retângulo desenhado ao redor de um parágrafo durante a revisão não precisa virar uma marca dentro do PDF. O THPDFViewerModel do HotPDF expõe AddHighlightRegion, um método que mantém cada destaque como um registro em memória, e não como uma alteração no documento carregado, de modo que um revisor pode marcar dezenas de páginas enquanto o arquivo em disco permanece byte a byte o mesmo que era. Dê zoom até 6400%, gire a página 90 graus, alterne de Ajustar à Largura para Ajustar à Página, e o mesmo retângulo continua caindo sobre o mesmo parágrafo, porque a matemática de coordenadas roda pela geometria real de renderização no momento em que a marca foi desenhada

Ferramentas de revisão construídas em torno de um visualizador de PDF esbarram nesse problema o tempo todo. Uma tela de marcação de correções, uma passada de QA sobre faturas geradas, um fluxo interno de aprovação: todos precisam permitir que alguém chame a atenção para uma região de uma página sem que cada marca de rascunho vire uma mudança permanente no arquivo, e sem recorrer a um subsistema completo de anotações só para mostrar uma caixa colorida enquanto alguém ainda está decidindo se a marca deve permanecer. O HotPDF responde a isso com uma camada de destaque dedicada que fica inteiramente do lado Model da divisão descrita em construindo um visualizador de PDF personalizado com arquitetura MVC em Delphi, o que também é o motivo de a mesma lista de destaques poder ser conduzida a partir de um teste unitário sem nenhum handle de janela à vista

O que o AddHighlightRegion do HotPDF realmente armazena?

AddHighlightRegion armazena exatamente três coisas por marca: um índice de página baseado em zero, um THPDFRectangle em coordenadas de espaço de usuário do PDF e uma TColor, tudo empacotado como um registro THPDFViewerHighlight dentro de THPDFViewerModel. Chamar Viewer.HighlightRegion(PageIndex, PageRect, clYellow), ou o equivalente Model.AddHighlightRegion, anexa um desses registros a um array privado e devolve seu índice, e esse índice é o único handle que quem chama recebe de volta: não há objeto separado, nenhuma interface com contagem de referências, nada para liberar. Toda outra capacidade descrita neste artigo — desenhar a marca, remapeá-la após uma mudança de zoom, excluí-la — é construída em cima desse único e pequeno registro

Todo retângulo é normalizado e recortado antes de ser aceito. AddHighlightRegion troca as bordas esquerda e direita se um revisor arrasta da direita para a esquerda, troca topo e base para um arraste de baixo para cima, depois recorta o resultado contra o MediaBox da página obtido por GetLoadedPageBox. Um retângulo que acaba com largura zero, altura zero, ou totalmente fora da página é rejeitado de imediato: o método retorna -1 e nada é adicionado à lista. Esse valor de retorno não é decorativo: um lote de destaques reconstruído a partir de um arquivo externo de revisão, ou a partir de coordenadas obsoletas depois que uma página foi substituída, pode silenciosamente perder entradas se quem chama não verificar isso

Como um destaque permanece alinhado após zoom ou rotação?

Um destaque permanece alinhado porque o HotPDF o armazena no espaço de página do PDF e o reprojeta no espaço de tela a cada repintura, em vez de armazenar um retângulo de tela que ficaria obsoleto no instante em que o nível de zoom mudasse. THPDFViewerModel.PagePointToView e sua inversa, ViewPointToPage, fazem essa projeção em dois estágios: primeiro a própria entrada /Rotate da página, depois a ViewRotation independente do Viewer, que nunca é gravada de volta no PDF e só afeta o que o Viewer exibe. Desfazer a transformação ao soltar o mouse executa os mesmos dois estágios em ordem reversa, o que é o que permite que um destaque desenhado em zoom alto em uma página girada 270 graus caia exatamente no lugar certo depois que o revisor redefine a visualização de volta para Ajustar à Página

O DPI usado nessa projeção importa tanto quanto a rotação. O Viewer do HotPDF captura o DPI exato do bitmap atualmente na tela em FRenderedDPI logo após cada renderização, e ImageMouseUp passa esse mesmo valor para ViewPointToPage de modo que uma coordenada de mouse é sempre convertida usando a resolução na qual foi de fato desenhada, não uma resolução recalculada a partir da propriedade de zoom atual. CreatePageSnapshot e seus parentes limitam o DPI a uma faixa de 12 a 2400, mas o caminho de renderização interativo não carrega esse teto: a escala padrão de zoom vai até 6400%, o que calcula bem mais de 2400 DPI na linha de base padrão de 96 DPI, de modo que reutilizar um limite no estilo do snapshot para o mapeamento de coordenadas deslocaria cada destaque em vários pixels no topo da faixa de zoom. Dois padrões menores completam a interação: um arraste menor que dois pixels em qualquer eixo é tratado como um clique e não produz destaque nenhum, e o destaque não pode começar até que ao menos uma página tenha de fato sido renderizada, já que FRenderedDPI começa em zero

Conectando o destaque interativo a uma tela de revisão

Ativar o destaque interativo é uma tarefa de três propriedades no próprio controle THPDFViewer: definir InteractionMode como vimHighlight em vez do padrão vimBrowse, escolher uma HighlightColor, que assume clYellow por padrão, e tratar OnMarqueeSelect para descobrir o que o revisor acabou de desenhar. Todo o resto — capturar o mouse, desenhar o retângulo de seleção pontilhado enquanto o revisor arrasta, converter o ponto de soltura de volta para o espaço de página, chamar AddHighlightRegion — acontece dentro do controle antes de esse evento disparar

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 só dispara para um arraste que de fato produziu um destaque: um clique pequeno demais para contar como arraste limpa a sobreposição de seleção imediatamente, e um arraste que cai inteiramente fora da página chega a AddHighlightRegion mas é rejeitado ali da mesma forma que uma chamada programática seria, de modo que o evento permanece silencioso de qualquer maneira. Um detalhe de implementação que vale conhecer se o destaque parecer parar de responder nas bordas do controle: a captura do mouse pertence ao próprio THPDFViewer, um descendente de TScrollBox, não ao TImage interno que exibe o bitmap da página, o que é o que permite que um revisor arraste para além da borda da página renderizada e ainda assim obtenha uma soltura limpa

Adicionando, removendo e relendo destaques a partir do código

Destaques não precisam vir necessariamente de um arraste do mouse. Viewer.HighlightRegion(PageIndex, PageRect, Color), que canaliza para o mesmo Model.AddHighlightRegion que o arraste interativo chama internamente, é público especificamente para que uma tela de revisão possa reconstruir destaques a partir de dados que já possui: comentários carregados de um banco de dados, resultados de uma busca de texto, ou marcas restauradas de uma sessão anterior. Como as coordenadas são simples números de espaço de usuário do PDF, nada nesse caminho depende de uma página já ter sido renderizada antes, ao contrário do arraste interativo, que precisa que FRenderedDPI já contenha um valor real

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;

Remover um único destaque é onde o armazenamento baseado em array se mostra. RemoveHighlightRegion exclui um registro e desloca cada registro posterior uma posição para baixo para fechar a lacuna, o que significa que qualquer índice capturado anteriormente, de um evento OnMarqueeSelect ou de uma enumeração prévia, deixa de ser confiável assim que algo antes dele na lista é removido. OnHighlightChange dispara a cada adição, remoção e chamada de ClearHighlightRegions, mas não carrega nenhuma informação sobre o que mudou, de modo que o padrão seguro é tratá-lo como um sinal para reconstruir toda a lista que um painel de revisão está exibindo a partir de HighlightCount e TryGetHighlightRegion, em vez de corrigir um índice em cache no lugar

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;

Quando uma marca deveria virar uma anotação Highlight de verdade?

Uma região de destaque deveria virar uma anotação de verdade no momento em que precisar sobreviver fora daquela única instância de THPDFViewer. O HotPDF também expõe AddHighlightAnnotation para uma página nova e AddLoadedHighlightAnnotation para um documento já carregado, e apesar do nome quase idêntico, esse é um mecanismo completamente diferente: ambos gravam uma anotação real de marcação de texto conforme a ISO 32000-1 §12.5.6.10, PDF /Subtype /Highlight, no array /Annots da página, com /QuadPoints marcando a sequência exata de glifos, e qualquer visualizador de PDF em conformidade a renderiza assim que o arquivo é salvo, não apenas o próprio HotPDF. Essa mesma fronteira de mecanismo decide se uma marca faz o percurso de ida e volta pelo XFDF: uma anotação criada com AddLoadedHighlightAnnotation é um objeto PDF normal que ExportLoadedAnnotationsToXFDF captura e entrega ao Acrobat ou a outra ferramenta de revisão como marcação ISO 19444-1, coberto em importando e exportando anotações de PDF como XFDF em Delphi, enquanto uma região adicionada por meio de AddHighlightRegion é invisível para essa exportação porque nunca foi gravada no grafo de objetos: ela existe apenas enquanto o THPDFViewerModel que a criou existir. A família completa de tipos de anotação de marcação e geométrica disponíveis em uma página, e como um retângulo posiciona cada uma, é coberta em o artigo sobre anotações de PDF em Delphi com HotPDF, e a regra prática é simples: mantenha uma marca descartável enquanto um documento ainda está sendo discutido, e a promova a uma anotação assim que uma decisão for final

Onde a camada de destaque para

A camada de destaque, por sua vez, não tenta parecer uma caneta marca-texto translúcida: RefreshDocument desenha cada região como um retângulo de contorno de dois pixels em sua própria cor, sobre o bitmap de página em cache, da mesma forma que desenha resultados de busca, em vez de misturar um preenchimento colorido sobre o texto abaixo, de modo que o clássico visual de "lavagem" amarela precisa ser pintado no código da aplicação ou deixado para o próprio stream de aparência de uma anotação promovida. Uma capacidade que vale reutilizar assim que uma região existe é CreateCurrentPageRegionSnapshot, que recebe o mesmo THPDFRectangle que um destaque já carrega e renderiza apenas essa área para um bitmap, útil para anexar uma pequena imagem de pré-visualização a um comentário de revisão sem exportar a página inteira. Um projeto de revisão não precisa escolher entre os dois mecanismos de antemão: defina por padrão cada nova marca como uma região THPDFViewerHighlight descartável enquanto uma thread de comentários permanecer aberta, e só chame AddLoadedHighlightAnnotation quando um revisor a resolver, o que mantém o PDF carregado intocado durante o vai e vem que produz a maior parte da rotatividade. O controle de visualizador descrito aqui faz parte do componente HotPDF padrão para Delphi e C++Builder, ao lado do restante das APIs de anotação e formulário referenciadas acima