Artículo técnico

Resaltado de PDF no destructivo en Delphi: la capa de revisión de HotPDF

Un rectángulo dibujado alrededor de un párrafo durante una revisión no tiene por qué convertirse en una marca dentro del PDF. El THPDFViewerModel de HotPDF expone AddHighlightRegion, un método que mantiene cada resaltado como un registro en memoria en lugar de un cambio en el documento cargado, de modo que un revisor puede marcar decenas de páginas mientras el archivo en disco permanece byte a byte igual que estaba. Haced zoom hasta el 6400 %, rotad la página 90 grados, cambiad de Ajustar al ancho a Ajustar a la página, y el mismo rectángulo sigue cayendo sobre el mismo párrafo, porque la aritmética de coordenadas se ejecuta a través de la geometría de renderizado real en el momento en que se dibujó la marca

Las herramientas de revisión construidas alrededor de un visor de PDF se topan con este problema constantemente. Una pantalla de marcado de correcciones, una pasada de control de calidad sobre facturas generadas, un flujo interno de visto bueno: todas necesitan permitir que alguien llame la atención sobre una región de una página sin que cada marca provisional se convierta en un cambio permanente del archivo, y sin recurrir a todo un subsistema de anotaciones completo solo para mostrar un cuadro de color mientras alguien todavía está decidiendo si la marca es pertinente. HotPDF responde a esto con una capa de resaltado dedicada que reside enteramente en el lado del Modelo de la separación descrita en la construcción de un visor de PDF personalizado con arquitectura MVC en Delphi, que es también la razón por la que esa misma lista de resaltados puede gobernarse desde una prueba unitaria sin que haya a la vista ningún handle de ventana

¿Qué almacena realmente AddHighlightRegion de HotPDF?

AddHighlightRegion almacena exactamente tres cosas por marca: un índice de página basado en cero, un THPDFRectangle en coordenadas de espacio de usuario PDF y un TColor, todo empaquetado como un registro THPDFViewerHighlight dentro de THPDFViewerModel. Llamar a Viewer.HighlightRegion(PageIndex, PageRect, clYellow), o al equivalente Model.AddHighlightRegion, añade uno de estos registros a un array privado y devuelve su índice, y ese índice es el único identificador que recibe quien llama: no hay ningún objeto independiente, ninguna interfaz con recuento de referencias, nada que liberar. Todas las demás capacidades de este artículo, dibujar la marca, reproyectarla tras un cambio de zoom, eliminarla, se construyen sobre ese único registro pequeño

Todo rectángulo se normaliza y se recorta antes de aceptarse. AddHighlightRegion intercambia los bordes izquierdo y derecho si un revisor arrastra de derecha a izquierda, intercambia arriba y abajo para un arrastre hacia arriba, y luego recorta el resultado contra el MediaBox de la página obtenido mediante GetLoadedPageBox. Un rectángulo que acaba con anchura cero, altura cero o completamente fuera de la página se rechaza sin más: el método devuelve -1 y no se añade nada a la lista. Ese valor de retorno no es decorativo: un lote de resaltados reconstruido a partir de un archivo de revisión externo, o a partir de coordenadas obsoletas tras sustituir una página, puede perder entradas silenciosamente si quien llama no lo comprueba

¿Cómo se mantiene un resaltado alineado tras un zoom o una rotación?

Un resaltado se mantiene alineado porque HotPDF lo almacena en el espacio de página del PDF y lo reproyecta al espacio de pantalla en cada repintado, en lugar de almacenar un rectángulo de pantalla que quedaría obsoleto en cuanto cambiara el nivel de zoom. THPDFViewerModel.PagePointToView y su inversa, ViewPointToPage, realizan esa proyección en dos etapas: primero la propia entrada /Rotate de la página, después la ViewRotation independiente del Visor, que nunca se reescribe en el PDF y solo afecta a lo que muestra el Visor. Deshacer la transformación al soltar el ratón ejecuta las mismas dos etapas en orden inverso, que es lo que permite que un resaltado dibujado con un zoom elevado sobre una página rotada 270 grados aterrice exactamente en el sitio correcto después de que el revisor devuelva la vista a Ajustar a la página

El DPI usado para esa proyección importa tanto como la rotación. El Visor de HotPDF captura el DPI exacto del mapa de bits que hay en ese momento en pantalla en FRenderedDPI justo después de cada renderizado, e ImageMouseUp pasa ese mismo valor a ViewPointToPage, de modo que una coordenada de ratón siempre se convierte usando la resolución con la que realmente se dibujó, no una resolución recalculada a partir de la propiedad de zoom actual. CreatePageSnapshot y sus parientes limitan el DPI a un rango de 12 a 2400, pero la vía de renderizado interactivo no lleva ese tope: la escala de zoom estándar llega hasta el 6400 %, lo que se traduce en bastante más de 2400 DPI sobre la base predeterminada de 96 DPI, así que reutilizar un límite de estilo instantánea para el mapeo de coordenadas desplazaría cada resaltado varios píxeles en el extremo superior del rango de zoom. Dos valores predeterminados menores completan la interacción: un arrastre más corto de dos píxeles en cualquiera de los dos ejes se trata como un clic y no produce resaltado, y el resaltado no puede comenzar hasta que al menos una página se haya renderizado realmente, ya que FRenderedDPI empieza en cero

Conectar el resaltado interactivo a una pantalla de revisión

Activar el resaltado interactivo es cuestión de tres propiedades en el propio control THPDFViewer: poned InteractionMode a vimHighlight en lugar del vimBrowse predeterminado, elegid un HighlightColor, que por defecto es clYellow, y gestionad OnMarqueeSelect para saber qué acaba de dibujar el revisor. Todo lo demás, capturar el ratón, dibujar el rectángulo de selección punteado mientras el revisor arrastra, convertir el punto de suelta de vuelta a espacio de página, llamar a AddHighlightRegion, ocurre dentro del control antes de que se dispare ese evento

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 solo se dispara para un arrastre que realmente haya producido un resaltado: un clic demasiado pequeño para contar como arrastre borra de inmediato la superposición de selección, y un arrastre que caiga íntegramente fuera de la página llega hasta AddHighlightRegion pero se rechaza allí del mismo modo que lo haría una llamada programática, así que el evento permanece en silencio en ambos casos. Un detalle de implementación que merece la pena conocer si el resaltado alguna vez parece dejar de responder en los bordes del control: la captura del ratón pertenece al propio THPDFViewer, un descendiente de TScrollBox, no al TImage interno que muestra el mapa de bits de la página, que es lo que permite que un revisor arrastre más allá del borde de la página renderizada y aun así obtenga una suelta limpia

Añadir, eliminar y releer resaltados desde código

Los resaltados no tienen por qué proceder en absoluto de un arrastre de ratón. Viewer.HighlightRegion(PageIndex, PageRect, Color), que se canaliza hacia el mismo Model.AddHighlightRegion que llama internamente el arrastre interactivo, es público precisamente para que una pantalla de revisión pueda reconstruir resaltados a partir de datos que ya posee: comentarios cargados desde una base de datos, resultados de una búsqueda de texto o marcas restauradas de una sesión anterior. Como las coordenadas son simples números en espacio de usuario PDF, nada en esta vía depende de que una página se haya renderizado antes, a diferencia del arrastre interactivo, que necesita que FRenderedDPI contenga ya un 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;

Eliminar un único resaltado es donde se nota el almacenamiento respaldado por un array. RemoveHighlightRegion borra un registro y desplaza una posición hacia abajo cada registro posterior para cerrar el hueco, lo que significa que cualquier índice capturado con anterioridad, desde un evento OnMarqueeSelect o desde una enumeración previa, deja de ser fiable en cuanto se elimina algo que estuviera antes que él en la lista. OnHighlightChange se dispara en cada adición, eliminación y llamada a ClearHighlightRegions, pero no lleva información sobre qué ha cambiado, así que el patrón seguro es tratarlo como una señal para reconstruir cualquier lista que un panel de revisión esté mostrando a partir de HighlightCount y TryGetHighlightRegion, en lugar de parchear en el sitio un índice guardado en caché

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;

¿Cuándo debería una marca convertirse en una anotación Highlight real?

Una región de resaltado debería convertirse en una anotación real en el momento en que necesite sobrevivir fuera de esa única instancia de THPDFViewer. HotPDF también expone AddHighlightAnnotation para una página nueva y AddLoadedHighlightAnnotation para un documento ya cargado, y a pesar del nombre casi idéntico, se trata de un mecanismo completamente distinto: ambos escriben una auténtica anotación de marcado de texto según ISO 32000-1 §12.5.6.10, PDF /Subtype /Highlight, dentro del array /Annots de la página, con /QuadPoints marcando la tira exacta de glifos, y cualquier visor de PDF conforme la renderiza una vez guardado el archivo, no solo el propio HotPDF. Ese mismo límite de mecanismo decide si una marca sobrevive a un ida y vuelta por XFDF: una anotación creada con AddLoadedHighlightAnnotation es un objeto PDF normal que ExportLoadedAnnotationsToXFDF recoge y entrega a Acrobat o a otra herramienta de revisión como marcado ISO 19444-1, cubierto en la importación y exportación de anotaciones de PDF como XFDF en Delphi, mientras que una región añadida mediante AddHighlightRegion es invisible para esa exportación porque nunca se escribió en el grafo de objetos: solo existe mientras exista el THPDFViewerModel que la creó. La familia completa de tipos de anotación de marcado y geométricos disponibles en una página, y cómo un rectángulo coloca cada uno, se cubre en el artículo sobre anotaciones de PDF en Delphi con HotPDF, y la regla práctica es sencilla: mantened una marca desechable mientras un documento todavía esté en discusión, y consolidadla como anotación en cuanto una decisión sea definitiva

Dónde se detiene la capa de resaltado

La capa de resaltado, por su parte, no intenta en ningún momento parecer un rotulador fluorescente translúcido: RefreshDocument dibuja cada región como un rectángulo de contorno de dos píxeles en su propio color sobre el mapa de bits de página en caché, del mismo modo que dibuja los resultados de búsqueda, en lugar de mezclar un relleno de color sobre el texto subyacente, así que un aspecto clásico de lavado amarillo hay que pintarlo en el código de la aplicación o dejarlo para el propio flujo de apariencia de una anotación ya promovida. Una capacidad que merece la pena reutilizar en cuanto existe una región es CreateCurrentPageRegionSnapshot, que toma el mismo THPDFRectangle que ya lleva un resaltado y renderiza solo esa área a un mapa de bits, útil para adjuntar una pequeña imagen de vista previa a un comentario de revisión sin exportar la página completa. Una aplicación de revisión no tiene por qué elegir entre los dos mecanismos de antemano: haced que cada marca nueva sea por defecto una región desechable THPDFViewerHighlight mientras un hilo de comentarios siga abierto, y llamad a AddLoadedHighlightAnnotation solo cuando un revisor lo resuelva, lo que mantiene intacto el PDF cargado durante el ir y venir que produce la mayor parte de los cambios. El control de visor descrito aquí forma parte del componente HotPDF estándar para Delphi y C++Builder, junto con el resto de las API de anotaciones y formularios referenciadas más arriba