Articolo tecnico

Evidenziazione PDF Non Distruttiva in Delphi: il Layer di Revisione di HotPDF

Un rettangolo disegnato attorno a un paragrafo durante una revisione non deve necessariamente diventare un segno all'interno del PDF. Il THPDFViewerModel di HotPDF espone AddHighlightRegion, un metodo che mantiene ogni evidenziazione come un record in memoria anziché come una modifica al documento caricato, cosicché un revisore possa annotare decine di pagine mentre il file su disco resta identico byte per byte a quello originale. Fai zoom al 6400%, ruota la pagina di 90 gradi, passa da Adatta alla Larghezza ad Adatta alla Pagina, e lo stesso rettangolo continua a cadere sullo stesso paragrafo, perché il calcolo delle coordinate passa attraverso la geometria di rendering effettiva nel momento in cui il segno è stato disegnato

Gli strumenti di revisione costruiti attorno a un visualizzatore PDF incontrano continuamente questo problema. Una schermata di redlining, un passaggio di QA su fatture generate automaticamente, un flusso di approvazione interno: tutti hanno bisogno di permettere a qualcuno di richiamare l'attenzione su una regione di una pagina senza che ogni segno di bozza diventi una modifica permanente al file, e senza dover ricorrere a un intero sottosistema di annotazioni solo per mostrare un riquadro colorato mentre qualcuno sta ancora decidendo se quel segno debba restare. HotPDF risponde a questa esigenza con un layer di evidenziazione dedicato che risiede interamente sul lato Model della suddivisione descritta in costruire un visualizzatore PDF personalizzato con architettura MVC in Delphi, motivo per cui lo stesso elenco di evidenziazioni può essere pilotato da un unit test senza alcun handle di finestra in vista

Cosa memorizza realmente AddHighlightRegion di HotPDF?

AddHighlightRegion memorizza esattamente tre cose per ogni segno: un indice di pagina a base zero, un THPDFRectangle in coordinate dello spazio utente PDF, e un TColor, tutti raggruppati come un record THPDFViewerHighlight all'interno di THPDFViewerModel. Chiamare Viewer.HighlightRegion(PageIndex, PageRect, clYellow), o l'equivalente Model.AddHighlightRegion, aggiunge uno di questi record a un array privato e ne restituisce l'indice, e quell'indice è l'unico riferimento che un chiamante riceve indietro: non esiste un oggetto separato, nessuna interfaccia con conteggio di riferimenti, nulla da liberare. Ogni altra funzionalità descritta in questo articolo, disegnare il segno, ricalcolarne la posizione dopo un cambio di zoom, eliminarlo, è costruita sopra quel singolo piccolo record

Ogni rettangolo viene normalizzato e ritagliato prima di essere accettato. AddHighlightRegion scambia i bordi sinistro e destro se un revisore trascina da destra verso sinistra, scambia superiore e inferiore per un trascinamento verso l'alto, poi ritaglia il risultato rispetto alla MediaBox della pagina recuperata tramite GetLoadedPageBox. Un rettangolo che finisce con larghezza zero, altezza zero, o interamente fuori dalla pagina viene respinto direttamente: il metodo restituisce -1 e nulla viene aggiunto all'elenco. Quel valore di ritorno non è decorativo: un lotto di evidenziazioni ricostruito da un file di revisione esterno, o da coordinate obsolete dopo che una pagina è stata sostituita, può perdere silenziosamente delle voci se il chiamante non lo verifica

Come resta allineata un'evidenziazione dopo zoom o rotazione?

Un'evidenziazione resta allineata perché HotPDF la memorizza nello spazio pagina PDF e la riproietta nello spazio schermo a ogni ridisegno, invece di memorizzare un rettangolo schermo che diventerebbe obsoleto nel momento in cui cambia il livello di zoom. THPDFViewerModel.PagePointToView e la sua inversa, ViewPointToPage, eseguono quella proiezione in due fasi: prima la voce /Rotate propria della pagina, poi la ViewRotation indipendente del Viewer, che non viene mai riscritta nel PDF e influenza solo ciò che il Viewer visualizza. Annullare la trasformazione al rilascio del mouse esegue le stesse due fasi in ordine inverso, ed è ciò che permette a un'evidenziazione disegnata ad alto zoom su una pagina ruotata di 270 gradi di finire esattamente nel punto giusto dopo che il revisore riporta la vista ad Adatta alla Pagina

Il DPI usato per quella proiezione conta quanto la rotazione. Il Viewer di HotPDF cattura il DPI esatto del bitmap attualmente a schermo in FRenderedDPI subito dopo ogni rendering, e ImageMouseUp passa quello stesso valore a ViewPointToPage cosicché una coordinata del mouse venga sempre convertita usando la risoluzione a cui è stata effettivamente disegnata, non una risoluzione ricalcolata dalla proprietà di zoom corrente. CreatePageSnapshot e i metodi correlati limitano il DPI a un intervallo da 12 a 2400, ma il percorso di rendering interattivo non porta alcun tetto simile: la scala di zoom standard arriva fino al 6400%, che equivale a ben oltre 2400 DPI partendo dalla base predefinita di 96 DPI, quindi riutilizzare un limite in stile snapshot per la mappatura delle coordinate sposterebbe ogni evidenziazione di diversi pixel al vertice dell'intervallo di zoom. Due impostazioni predefinite più piccole completano l'interazione: un trascinamento più corto di due pixel su entrambi gli assi viene trattato come un clic e non produce alcuna evidenziazione, e l'evidenziazione non può iniziare finché almeno una pagina non è stata effettivamente renderizzata, poiché FRenderedDPI parte da zero

Collegare l'evidenziazione interattiva a una schermata di revisione

Attivare l'evidenziazione interattiva è un lavoro di tre proprietà sul controllo THPDFViewer stesso: imposta InteractionMode su vimHighlight invece del predefinito vimBrowse, scegli un HighlightColor, che ha come predefinito clYellow, e gestisci OnMarqueeSelect per scoprire cosa ha appena disegnato il revisore. Tutto il resto, catturare il mouse, disegnare il rettangolo di selezione tratteggiato mentre il revisore trascina, convertire il punto di rilascio nello spazio pagina, chiamare AddHighlightRegion, avviene all'interno del controllo prima che quell'evento venga generato

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 si genera solo per un trascinamento che ha effettivamente prodotto un'evidenziazione: un clic troppo piccolo per contare come trascinamento cancella immediatamente l'overlay di selezione, e un trascinamento che finisce interamente fuori dalla pagina raggiunge AddHighlightRegion ma viene lì respinto nello stesso modo in cui lo sarebbe una chiamata programmatica, quindi l'evento resta silenzioso in entrambi i casi. Un dettaglio implementativo utile da conoscere se l'evidenziazione sembra smettere di rispondere ai bordi del controllo: la cattura del mouse appartiene al THPDFViewer stesso, un discendente di TScrollBox, non al TImage interno che mostra il bitmap della pagina, ed è ciò che permette a un revisore di trascinare oltre il bordo della pagina renderizzata e ottenere comunque un rilascio pulito

Aggiungere, rimuovere e rileggere le evidenziazioni da codice

Le evidenziazioni non devono per forza provenire da un trascinamento del mouse. Viewer.HighlightRegion(PageIndex, PageRect, Color), che confluisce nello stesso Model.AddHighlightRegion chiamato internamente dal trascinamento interattivo, è pubblico proprio perché una schermata di revisione possa ricostruire le evidenziazioni da dati già disponibili: commenti caricati da un database, risultati di una ricerca testuale, oppure segni ripristinati da una sessione precedente. Poiché le coordinate sono semplici numeri dello spazio utente PDF, nulla in questo percorso dipende dal fatto che una pagina sia già stata renderizzata, a differenza del trascinamento interattivo, che richiede che FRenderedDPI contenga già un valore reale

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;

La rimozione di una singola evidenziazione è il punto in cui emerge la natura basata su array della memorizzazione. RemoveHighlightRegion elimina un record e sposta ogni record successivo di una posizione indietro per chiudere il vuoto, il che significa che qualsiasi indice catturato in precedenza, da un evento OnMarqueeSelect o da un'enumerazione precedente, non è più affidabile una volta che qualcosa che lo precede nell'elenco viene rimosso. OnHighlightChange si genera a ogni aggiunta, rimozione e chiamata a ClearHighlightRegions, ma non porta alcuna informazione su cosa sia cambiato, quindi lo schema sicuro è trattarlo come un segnale per ricostruire qualsiasi elenco stia mostrando un pannello di revisione a partire da HighlightCount e TryGetHighlightRegion, piuttosto che correggere sul posto un indice memorizzato in cache

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 un segno dovrebbe invece diventare una vera annotazione Highlight?

Una regione evidenziata dovrebbe diventare una vera annotazione nel momento in cui deve sopravvivere al di fuori di quella singola istanza di THPDFViewer. HotPDF espone anche AddHighlightAnnotation per una pagina nuova e AddLoadedHighlightAnnotation per un documento già caricato, e nonostante il nome quasi identico, si tratta di un meccanismo completamente diverso: entrambi scrivono una vera annotazione di markup testuale secondo ISO 32000-1 §12.5.6.10, PDF /Subtype /Highlight, nell'array /Annots della pagina, con /QuadPoints a marcare esattamente la sequenza di glifi, e qualsiasi visualizzatore PDF conforme la renderizza una volta salvato il file, non solo HotPDF stesso. Lo stesso confine tra i due meccanismi decide se un segno viene mantenuto attraverso XFDF: un'annotazione creata con AddLoadedHighlightAnnotation è un normale oggetto PDF che ExportLoadedAnnotationsToXFDF raccoglie e passa ad Acrobat o a un altro strumento di revisione come markup ISO 19444-1, trattato in importare ed esportare annotazioni PDF come XFDF in Delphi, mentre una regione aggiunta tramite AddHighlightRegion è invisibile a quell'esportazione perché non è mai stata scritta nel grafo degli oggetti: esiste solo finché esiste il THPDFViewerModel che l'ha creata. L'intera famiglia di tipi di annotazione di markup e geometrici disponibili su una pagina, e come un rettangolo posizioni ciascuno di essi, è trattata nell'articolo sulle annotazioni PDF in Delphi con HotPDF, e la regola pratica è semplice: mantieni un segno usa e getta finché un documento è ancora in discussione, e trasformalo in annotazione una volta che una decisione è definitiva

Dove si ferma il layer di evidenziazione

Il layer di evidenziazione, da parte sua, non tenta affatto di somigliare a un evidenziatore traslucido: RefreshDocument disegna ogni regione come un rettangolo di contorno spesso due pixel nel proprio colore sopra il bitmap di pagina con cache, allo stesso modo in cui disegna i risultati di ricerca, invece di fondere un riempimento colorato sopra il testo sottostante, quindi il classico effetto "lavata" gialla dell'evidenziatore va dipinto nel codice applicativo o rimandato all'appearance stream propria di un'annotazione promossa. Una funzionalità utile da riutilizzare una volta che una regione esiste è CreateCurrentPageRegionSnapshot, che prende lo stesso THPDFRectangle già portato da un'evidenziazione e renderizza solo quell'area in un bitmap, utile per allegare una piccola immagine di anteprima a un commento di revisione senza esportare l'intera pagina. Una build di revisione non deve scegliere in anticipo tra i due meccanismi: imposta come predefinito ogni nuovo segno su una regione THPDFViewerHighlight usa e getta finché un thread di commenti resta aperto, e chiama AddLoadedHighlightAnnotation solo quando un revisore lo risolve, il che mantiene il PDF caricato intatto durante gli scambi che generano il maggior movimento. Il controllo viewer descritto qui fa parte del componente HotPDF standard per Delphi e C++Builder, insieme al resto delle API di annotazioni e moduli citate sopra