Technisch artikel

Niet-destructieve PDF-markering in Delphi: de HotPDF-reviewlaag

Een rechthoek die tijdens review om een alinea wordt getekend, hoeft geen markering in de PDF zelf te worden. HotPDF's THPDFViewerModel ontsluit AddHighlightRegion, een methode die elke markering als een record in het geheugen bewaart in plaats van als een wijziging aan het geladen document, zodat een reviewer tientallen pagina's kan annoteren terwijl het bestand op schijf byte-voor-byte blijft wat het was. Zoom in naar 6400%, roteer de pagina 90 graden, schakel van Passend in breedte naar Passend op pagina, en dezelfde rechthoek komt nog steeds op dezelfde alinea terecht, omdat de coördinaatberekening loopt via de daadwerkelijke rendergeometrie op het moment dat de markering werd getekend

Reviewtooling gebouwd rond een PDF-viewer loopt constant tegen dit probleem aan. Een correctiescherm, een QA-controle over gegenereerde facturen, een intern goedkeuringsproces: ze moeten allemaal iemand in staat stellen de aandacht op een deel van een pagina te vestigen zonder dat elke conceptmarkering een permanente wijziging aan het bestand wordt, en zonder terug te grijpen naar een volledig annotatiesubsysteem alleen om een gekleurd vak te tonen terwijl iemand nog aan het beslissen is of de markering wel terecht is. HotPDF beantwoordt dit met een toegewijde markeringslaag die zich volledig aan de Model-kant bevindt van de splitsing die wordt beschreven in het bouwen van een aangepaste PDF-viewer met een MVC-architectuur in Delphi, wat ook verklaart waarom dezelfde markeringslijst kan worden aangestuurd vanuit een unit-test zonder ook maar een vensterhandle in zicht

Wat slaat HotPDF's AddHighlightRegion daadwerkelijk op?

AddHighlightRegion slaat precies drie dingen per markering op: een op nul gebaseerde pagina-index, een THPDFRectangle in PDF-gebruikersruimtecoördinaten, en een TColor, allemaal verpakt als een THPDFViewerHighlight-record binnen THPDFViewerModel. Het aanroepen van Viewer.HighlightRegion(PageIndex, PageRect, clYellow), of het equivalente Model.AddHighlightRegion, voegt een van deze records toe aan een private array en geeft de index ervan terug, en die index is het enige handvat dat een aanroeper terugkrijgt: er is geen apart object, geen interface met referentietelling, niets om vrij te geven. Elke andere mogelijkheid in dit artikel, de markering tekenen, deze opnieuw mappen na een zoomwijziging, verwijderen, is gebouwd bovenop dat ene kleine record

Elke rechthoek wordt genormaliseerd en geclipt voordat deze wordt geaccepteerd. AddHighlightRegion verwisselt de linker- en rechterrand als een reviewer van rechts naar links sleept, verwisselt boven en onder bij een opwaartse sleepbeweging, en clipt het resultaat vervolgens tegen de MediaBox van de pagina, opgehaald via GetLoadedPageBox. Een rechthoek die uiteindelijk een breedte van nul, hoogte van nul, of volledig buiten de pagina heeft, wordt zonder meer afgewezen: de methode geeft -1 terug en er wordt niets aan de lijst toegevoegd. Die retourwaarde is niet decoratief: een reeks markeringen die is heropgebouwd uit een extern reviewbestand, of uit verouderde coördinaten nadat een pagina is vervangen, kan stilletjes vermeldingen verliezen als de aanroeper er niet op controleert

Hoe blijft een markering uitgelijnd na zoom of rotatie?

Een markering blijft uitgelijnd omdat HotPDF deze opslaat in PDF-paginaruimte en op elke hertekening opnieuw projecteert naar schermruimte, in plaats van een schermrechthoek op te slaan die verouderd zou raken zodra het zoomniveau verandert. THPDFViewerModel.PagePointToView en zijn inverse, ViewPointToPage, voeren die projectie uit in twee fasen: eerst de eigen /Rotate-vermelding van de pagina, dan de onafhankelijke ViewRotation van de Viewer, die nooit teruggeschreven wordt naar de PDF en alleen beïnvloedt wat de Viewer weergeeft. Het ongedaan maken van de transformatie bij het loslaten van de muis doorloopt dezelfde twee fasen in omgekeerde volgorde, en dat is wat een markering die bij hoge zoom werd getekend op een pagina die 270 graden is geroteerd, precies op de juiste plek laat terechtkomen nadat de reviewer de weergave terugzet naar Passend op pagina

De DPI die voor die projectie wordt gebruikt, is net zo belangrijk als de rotatie. HotPDF's Viewer legt de exacte DPI van de bitmap die momenteel op het scherm staat vast in FRenderedDPI direct na elke render, en ImageMouseUp geeft diezelfde waarde door aan ViewPointToPage, zodat een muiscoördinaat altijd wordt omgezet met de resolutie waarmee het daadwerkelijk is getekend, niet een resolutie die opnieuw is berekend op basis van de huidige zoom-eigenschap. CreatePageSnapshot en verwanten begrenzen de DPI tot een bereik van 12 tot 2400, maar het interactieve renderpad kent geen dergelijk plafond: de standaard zoomladder loopt op tot 6400%, wat bij de standaard 96 DPI-basislijn ver boven 2400 DPI uitkomt, dus het hergebruiken van een snapshot-achtige limiet voor coördinaatmapping zou elke markering met meerdere pixels verschuiven aan de bovenkant van het zoombereik. Twee kleinere standaardinstellingen maken de interactie compleet: een sleepbeweging korter dan twee pixels op beide assen wordt behandeld als een klik en levert geen markering op, en markeren kan pas beginnen zodra ten minste één pagina daadwerkelijk is gerenderd, aangezien FRenderedDPI begint op nul

Interactief markeren aansluiten op een reviewscherm

Interactief markeren inschakelen is een taak van drie eigenschappen op het THPDFViewer-besturingselement zelf: zet InteractionMode op vimHighlight in plaats van de standaard vimBrowse, kies een HighlightColor, die standaard clYellow is, en behandel OnMarqueeSelect om te achterhalen wat de reviewer zojuist heeft getekend. Al het andere, de muis vastleggen, de gestippelde selectierechthoek tekenen terwijl de reviewer sleept, het loslaatpunt terugzetten naar paginaruimte, AddHighlightRegion aanroepen, gebeurt binnen het besturingselement voordat die gebeurtenis wordt geactiveerd

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 wordt alleen geactiveerd voor een sleepbeweging die daadwerkelijk een markering heeft opgeleverd: een klik die te klein is om als sleepbeweging te tellen, wist de selectie-overlay onmiddellijk, en een sleepbeweging die volledig buiten de pagina terechtkomt, bereikt AddHighlightRegion maar wordt daar op dezelfde manier afgewezen als een programmatische aanroep zou worden, dus blijft de gebeurtenis in beide gevallen stil. Eén implementatiedetail dat de moeite waard is om te kennen als markeren ooit lijkt te stoppen met reageren aan de randen van het besturingselement: muisvastlegging behoort toe aan de THPDFViewer zelf, een afstammeling van TScrollBox, niet aan de interne TImage die de paginabitmap toont, en dat is wat een reviewer in staat stelt voorbij de rand van de gerenderde pagina te slepen en toch een schone loslating te krijgen

Markeringen toevoegen, verwijderen en opnieuw uitlezen vanuit code

Markeringen hoeven helemaal niet uit een muissleepbeweging te komen. Viewer.HighlightRegion(PageIndex, PageRect, Color), die uitkomt bij hetzelfde Model.AddHighlightRegion dat de interactieve sleepbeweging intern aanroept, is publiek gemaakt juist zodat een reviewscherm markeringen kan heropbouwen uit gegevens die het al heeft: opmerkingen geladen uit een database, resultaten van een tekstzoekopdracht, of markeringen hersteld uit een vorige sessie. Omdat de coördinaten gewone PDF-gebruikersruimtegetallen zijn, hangt niets aan dit pad ervan af dat een pagina eerst is gerenderd, in tegenstelling tot de interactieve sleepbeweging, die vereist dat FRenderedDPI al een echte waarde bevat

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;

Het verwijderen van een enkele markering is waar de op arrays gebaseerde opslag doorschemert. RemoveHighlightRegion verwijdert één record en schuift elk later record één positie omlaag om de opening te sluiten, wat betekent dat elke index die eerder is vastgelegd, van een OnMarqueeSelect-gebeurtenis of van een eerdere opsomming, niet langer betrouwbaar is zodra iets ervoor in de lijst wordt verwijderd. OnHighlightChange wordt geactiveerd bij elke toevoeging, verwijdering, en ClearHighlightRegions-aanroep, maar bevat geen informatie over wat er is gewijzigd, dus is het veilige patroon om het te behandelen als een signaal om elke lijst die een reviewpaneel toont, opnieuw op te bouwen vanuit HighlightCount en TryGetHighlightRegion, in plaats van een gecachte index ter plekke te patchen

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;

Wanneer zou een markering een echte Highlight-annotatie moeten worden?

Een markeringsregio zou een echte annotatie moeten worden zodra deze buiten die ene THPDFViewer-instantie moet blijven bestaan. HotPDF ontsluit ook AddHighlightAnnotation voor een nieuwe pagina en AddLoadedHighlightAnnotation voor een reeds geladen document, en ondanks de bijna identieke naam is dit een volledig ander mechanisme: beide schrijven een echte ISO 32000-1 §12.5.6.10-tekstmarkeringsannotatie, PDF /Subtype /Highlight, in de /Annots-array van de pagina, met /QuadPoints die de exacte glyphreeks markeren, en elke conforme PDF-viewer rendert deze zodra het bestand is opgeslagen, niet alleen die van HotPDF zelf. Datzelfde mechanismegrens bepaalt of een markering een heen-en-terugreis via XFDF maakt: een annotatie gemaakt met AddLoadedHighlightAnnotation is een normaal PDF-object dat ExportLoadedAnnotationsToXFDF oppikt en doorgeeft aan Acrobat of een ander reviewtool als ISO 19444-1-markering, behandeld in het importeren en exporteren van PDF-annotaties als XFDF in Delphi, terwijl een regio die is toegevoegd via AddHighlightRegion onzichtbaar is voor die export omdat deze nooit naar de objectgraaf is geschreven: het bestaat alleen zolang de THPDFViewerModel die het heeft aangemaakt, bestaat. De volledige familie van markerings- en geometrische annotatietypen die beschikbaar zijn op een pagina, en hoe een rechthoek elk daarvan plaatst, wordt behandeld in het artikel over PDF-annotaties in Delphi met HotPDF, en de praktische regel is eenvoudig: houd een markering wegwerpbaar zolang een document nog wordt besproken, en leg deze pas vast als annotatie zodra een beslissing definitief is

Waar de markeringslaag ophoudt

De markeringslaag doet op zijn beurt geen enkele poging om op een doorschijnende markeerstift te lijken: RefreshDocument tekent elke regio als een rechthoek met een omtrek van twee pixels in zijn eigen kleur bovenop de gecachte paginabitmap, op dezelfde manier als het zoektreffers tekent, in plaats van een gekleurde vulling over de onderliggende tekst te mengen, dus een klassieke gele-waslook moet in applicatiecode worden geschilderd of worden overgelaten aan de eigen weergavestroom van een gepromoveerde annotatie. Eén mogelijkheid die het waard is om te hergebruiken zodra een regio bestaat, is CreateCurrentPageRegionSnapshot, dat dezelfde THPDFRectangle neemt die een markering al draagt en alleen dat gebied naar een bitmap rendert, nuttig om een kleine voorbeeldafbeelding aan een reviewopmerking te koppelen zonder de volledige pagina te exporteren. Een reviewbuild hoeft niet vooraf te kiezen tussen de twee mechanismen: laat elke nieuwe markering standaard een wegwerpbare THPDFViewerHighlight-regio zijn zolang een opmerkingenthread openstaat, en roep AddLoadedHighlightAnnotation pas aan zodra een reviewer deze afhandelt, wat de geladen PDF onaangeraakt houdt tijdens het heen-en-weer dat de meeste wijzigingen oplevert. Het hier beschreven viewer-besturingselement maakt deel uit van de standaard HotPDF-component voor Delphi en C++Builder, naast de rest van de hierboven genoemde annotatie- en formulier-API's