Technical Article

Revisão de Anotações PDF em Delphi com o PDFium Component

Uma anotação PDF é um dicionário anexado a uma página, não uma marca nela desenhada. A norma ISO 32000-1 §12.5 define cerca de duas dezenas de subtipos, e cada um transporta um /Subtype, um retângulo em coordenadas de página, um conjunto de sinalizadores (flags) e, normalmente, um fluxo (stream) de aparência que decide o que um visualizador realmente desenha. Os subtipos não significam todos a mesma coisa para uma pessoa que esteja a rever um documento. Um Realce (Highlight) e um traço de Tinta (Ink) são comentários; uma Ligação (Link) é navegação; um Balão (Popup) é a pequena janela que se abre quando clica numa nota autocolante, guardada como o seu próprio objeto e apontada por um elemento pai. As respostas são anotações de Texto completas que referenciam o comentário a que respondem através de uma entrada in-reply-to. Assim, o array de anotações ao nível da página não é a lista de comentários do revisor. É uma estrutura plana que contém comentários, as ligações que os conetam e vários elementos que nenhum revisor chamaria de comentário. Um painel que trate o array como a lista de comentários divergirá de qualquer outro visualizador que o cliente utilize.

Construir um fluxo de trabalho de revisão de anotações no PDFium Component, o componente VCL/LCL baseado em PDFium para Delphi, C++Builder e Lazarus, significa concentrar-se nos pontos onde essa diferença entre o array bruto e a visualização humana causa problemas: contagem, indexação, recolorização de marcas que o motor já congelou, eliminação sem deixar resíduos fantasmas e adição de marcas próprias.

Por que a sua contagem nunca coincide com o painel de comentários do Acrobat

Abra um contrato com marcações no seu visualizador e no Acrobat lado a lado e os totais raramente coincidirão. O Acrobat mostra uma visualização selecionada: marcações agrupadas em linhas de discussão (threads) de resposta, popups integrados nas notas a que pertencem, ligações e widgets de formulário deixados de fora. O array bruto contém tudo de forma indiferenciada, pelo que uma contagem ingénua resultará em números demasiado altos em alguns aspetos e demasiado baixos noutros ao mesmo tempo.

Os popups inflamam o total, porque cada nota autocolante é acompanhada por um objeto Popup separado e contar ambos duplica a nota. As respostas reduzem o total se filtrar por marcas visíveis, uma vez que uma resposta é uma anotação de Texto sem nada desenhado até que alguém expanda a discussão, e descartá-la perde a discussão. Os sinalizadores Hidden e NoView removem uma anotação do ecrã sem a retirar do array, pelo que uma contagem que ignore estes sinalizadores incluirá marcas que o utilizador não consegue ver. As anotações de Ligação (Link) situam-se no mesmo array que os comentários e não pertencem nem à contagem nem à lista. Defina a regra de contagem antes de escrever o ciclo e registe essa decisão, porque "porque é que o seu painel mostra um número diferente do Acrobat" é o primeiro pedido de suporte que uma funcionalidade de revisão recebe.

Indexe tudo uma vez e depois nunca reanalise uma página

Uma regra de design rege tudo o que se segue: a filtragem por autor, tipo ou página nunca deve reanalisar os objetos da página. Num documento de 300 páginas com muitas marcações, reanalisar a cada alteração de caixa de seleção transforma o painel em algo que bloqueia por vários segundos de cada vez. O componente expõe AnnotationCount e a propriedade indexada Annotation[], ambos no âmbito da página atualmente carregada, e o registo TPdfAnnotation que devolvem contém o que uma visualização em lista necessita: Subtype, Flags, Color, Rectangle, ContentsText, AuthorText. O procedimento correto é varrer cada página uma vez ao abrir o documento e manter o seu próprio índice plano:

procedure TReviewPanel.BuildIndex;
var
  PageNo, i: Integer;
  A: TPdfAnnotation;
begin
  FItems.Clear;
  for PageNo := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := PageNo;
    for i := 0 to Pdf.AnnotationCount - 1 do
    begin
      A := Pdf.Annotation[i];
      // Keep reviewer-relevant subtypes only; record the page and
      // index pair because all later edits are addressed by it
      if A.Subtype in [anText, anHighlight, anInk] then
        FItems.Add(TReviewItem.Create(PageNo, i,
          A.AuthorText, A.ContentsText, A.Rectangle, A.Color));
    end;
  end;
end;

O par que vale a pena sublinhar é (PageNo, i). Qualquer modificação posterior, seja uma alteração de cor ou uma eliminação, é endereçada pelo número da página mais o índice da anotação, e o índice é frágil: a remoção de uma anotação renumera tudo o que se lhe segue nessa página. Portanto, planeie reconstruir as entradas da página afetada após qualquer eliminação, em vez de corrigir os números de índice diretamente. A reconstrução demora um milissegundo. Um índice desatualizado, por outro lado, elimina o comentário do revisor errado, que é o tipo de erro que corrói a confiança em toda a funcionalidade.

O encadeamento de discussões (threading) merece um lugar no índice, mesmo que a sua primeira versão apenas conte as respostas em vez de as mostrar. Agrupe os itens pela sua referência pai enquanto tem a página aberta, para que o painel possa mais tarde contrair um tópico da mesma forma que o Acrobat faz. Reconstruir esse agrupamento de forma preguiçosa (lazy loading) durante o deslocamento de página contraria o propósito de indexar uma única vez, porque reabre páginas que já teve o custo de analisar. A geometria exige a mesma disciplina. O Rectangle em cada registo está no espaço da página, e a conversão para coordenadas de visualização pertence a uma função auxiliar partilhada, não espalhada pelo código. Os painéis desenvolvem erros de coordenadas quando a seleção, o teste de clique (hit-testing) e o desenho inventam, cada um, a sua própria matemática de zoom e rotação; encaminhe os três através de uma única conversão para que um realce, a sua linha na lista e o seu destino de clique permaneçam fixos no mesmo traço.

Recolorização de marcações e o veto do fluxo de aparência

Alterar um realce de amarelo para âmbar parece ser algo simples, e às vezes é. O problema reside na norma ISO 32000-1 §12.5.5. Quando uma anotação carrega um fluxo de aparência /AP, um visualizador em conformidade desenha esse fluxo pré-construído e trata a entrada de cor no dicionário como metadados inativos. O Acrobat escreve fluxos de aparência para praticamente tudo o que cria, pelo que a maioria das anotações provenientes de clientes já se encontra neste estado, e a cor que definiu com tanta confiança nunca chega ao ecrã. A alteração de cor é uma operação de leitura-modificação-escrita através da propriedade Annotation[], e o componente é claro sobre o conflito: quando o motor se recusa a permitir que uma cor de dicionário substitua uma aparência incorporada, a escrita lança uma exceção EPdfError.

A := Pdf.Annotation[Item.Index];
A.HasColor := True;
A.Color := $0000B0FF;       // amber
A.ColorAlpha := 160;
try
  Pdf.Annotation[Item.Index] := A;
except
  on EPdfError do
  begin
    // The annotation owns a pre-rendered /AP stream; the dictionary
    // color alone cannot change what viewers paint
    Item.AppearanceLocked := True;
    StatusBar.SimpleText := 'Color is fixed by the annotation appearance';
  end;
end;

Capture essa exceção sempre e trate-a como informação em vez de falha. Se ignorar esta proteção, o seu painel mostrará alegremente a cor âmbar na sua própria lista enquanto a página continua a ser desenhada a amarelo; o utilizador reportará isso semanas mais tarde como "o vosso visualizador ignora as minhas edições" e gastará uma tarde inteira a tentar reproduzir o erro num ficheiro que, por acaso, não tem fluxo de aparência. Assim que souber que a aparência está bloqueada, tem duas respostas honestas: recolorir a sua própria sobreposição de seleção em vez da anotação, para que o revisor pelo menos veja o realce que escolheu, ou marcar a linha como tendo a aparência bloqueada para que ninguém espere que a alteração seja aplicada.

Eliminar anotações sem deixar resíduos fantasmas

DeleteAnnotation remove o objeto da árvore de anotações da página atual, mas deixa intacto o raster da página em cache. Se desenhar imediatamente após a chamada, o realce eliminado continua no ecrã, residindo num mapa de bits que já não corresponde ao modelo de documento subjacente. A solução é tratar a nova renderização como parte do processo de eliminação, e não como um passo que o chamador possa esquecer:

Pdf.PageNumber := Item.PageNo;
Pdf.DeleteAnnotation(Item.Index);   // raises EPdfError on failure
Bmp := Pdf.RenderPage(0, 0, ViewWidth, ViewHeight, ro0, [reAnnotations]);
try
  PaintPageBitmap(Bmp);
finally
  Bmp.Free;  // RenderPage hands bitmap ownership to the caller
end;
RebuildPageEntries(Item.PageNo);  // indices after Item.Index shifted

Dois detalhes nesse bloco são fáceis de errar. A opção reAnnotations tem de estar presente, caso contrário, o novo raster remove todas as anotações restantes e a página parecerá ter sido limpa de todos os comentários em vez de apenas uma marca. E o Bmp.Free não é opcional: a sobrecarga da função RenderPage transfere a propriedade do mapa de bits para o chamador, pelo que a ausência desta libertação causará uma fuga de memória de um raster de página inteira a cada eliminação, o que um revisor que trabalhe num documento longo transformará em pressão real de memória em poucos minutos.

Adicionar marcas de revisor a partir da sua própria interface de utilizador

A criação de anotações é feita através de CreateAnnotation, que recebe um registo TPdfAnnotation preenchido (subtipo, retângulo, cor, conteúdo, autor) e o anexa à página atual. Uma nota autocolante, de subtipo anText, é o caso mais simples: defina a posição, o conteúdo e o autor e está terminado. As anotações de tinta (Ink) são onde as pessoas se deparam com dificuldades. O retângulo do registo apenas delimita o desenho; os traços em si são arrays de pontos que têm de ser anexados separadamente através da chamada de traço de tinta do motor, FPDFAnnot_AddInkStroke recebendo dados FS_POINTF data, capturados a partir da entrada do rato ou da caneta, um traço de cada vez. Construa uma anotação de tinta a partir de um retângulo e nada mais e obterá um rabisco vazio que é desenhado como um espaço em branco, o que parecerá um erro no motor, mas é na verdade uma anotação inacabada.

Defina a política de autoria no mesmo momento. Cada marca que a sua interface cria deve conter um AuthorText consistente, porque o filtro de revisor que construir no mês seguinte será tão bom quanto os nomes que gravar nos comentários hoje. Nomes de autor em branco ou inconsistentes não podem ser reparados retroativamente sem reabrir todos os ficheiros.

Extrair a revisão do visualizador

Os dados de revisão tornam-se realmente úteis quando podem sair do visualizador, seja como um resumo que o líder do projeto lê sem abrir o ficheiro ou como um ficheiro CSV que alimenta uma folha de controlo. Exporte a partir do índice que já construiu, nunca a partir de uma nova análise, e escolha uma forma estável de se referir a cada marca. Um número de página emparelhado com o retângulo da anotação sobrevive a operações de ida e volta que um índice de array não suportaria, porque a eliminação seguinte altera silenciosamente os índices e o seu CSV começará a apontar para os comentários errados.

Uma linha que valha a pena manter contém a página, o subtipo, o autor, a marca de tempo de criação quando o ficheiro a regista, o texto do conteúdo e uma coluna de estado que pertence à sua aplicação e não à que o PDF fornece. O mesmo passo de indexação é útil mais cedo, durante a receção, quando um documento chega de fora da equipa e se pretende saber o que contém antes de qualquer pessoa o rever. O artigo sobre o painel de receção de PDF aborda essa triagem, e a navegação em campos de formulário cobre o problema inverso: rever documentos construídos para recolher dados em vez de comentários.

Um caso que o array não lhe mostrará

Um modo de falha merece destaque porque parece um defeito no seu código e não é. Um cliente reporta realces visíveis por toda a página, mas o seu painel não lista nada e o AnnotationCount retorna zero. A explicação habitual é que as marcações foram achatadas (flattened) em algum momento anterior. O achatamento incorpora as aparências das anotações no conteúdo normal da página, pelo que os realces passam a fazer parte dos elementos gráficos da página e deixam completamente de existir como objetos de anotação. Não resta nada para uma API de anotações enumerar, recolorir ou eliminar. Quando vir marcações desenhadas com uma contagem zero, pare de procurar o erro no seu ciclo de enumeração e pergunte como o ficheiro foi produzido.

A superfície de anotação utilizada aqui, desde a enumeração e criação até à recolorização, eliminação e opções de renderização que mantêm a exibição correta, é fornecida com o PDFium Component para Delphi, C++Builder e Lazarus/FPC.