Teknisk artikel

PDF-annoteringsgranskning i Delphi med PDFium Component

En PDF-annotering är en ordbok fäst vid en sida, inte ett märke ritat på den. ISO 32000-1 §12.5 definierar ungefär två dussin subtyper, och var och en bär en /Subtype, en rektangel i sidkoordinater, en uppsättning flaggor, och oftast en utseendeström som avgör vad en visare faktiskt målar. Subtyperna betyder inte alla samma sak för en person som granskar ett dokument. En Highlight och ett Ink-drag är kommentarer; en Link är navigering; en Popup är det lilla fönster som öppnas när du klickar på en klisterlapp, lagrat som ett eget objekt och utpekat av en förälder. Svar är fullvärdiga Text-annoteringar som refererar till kommentaren de besvarar genom en in-reply-to-post. Så sidans annoteringsarray är inte granskarens lista av kommentarer. Den är en platt påse som innehåller kommentarer, rörmokeriet som kopplar ihop dem, och flera saker ingen granskare skulle kalla en kommentar över huvud taget. En panel som behandlar arrayen som kommentarslistan kommer att vara oense med varje annan visare kunden kör

Att bygga ett arbetsflöde för annoteringsgranskning på PDFium Component, den PDFium-baserade VCL/LCL-komponenten för Delphi, C++Builder och Lazarus, betyder att koncentrera sig på de punkter där glappet mellan den råa arrayen och den mänskliga vyn ställer till problem: att räkna, att indexera, att färga om märken motorn redan fryst, att ta bort utan att lämna spöken, och att lägga till egna märken

Diagram som visar hur en Delphi-PDFium-granskningspanel filtrerar den råa sidannoteringsarrayen av kommentarer, popup-fönster, svar och länkar till den kuraterade kommentarslista granskaren ser
Sidans annoteringsarray blandar kommentarer med popup-fönster, svar, länkar och dolda märken, så en granskningspanel behöver en räkneregel innan den visar en summa

Varför ditt antal aldrig stämmer med Acrobats kommentarsruta

Öppna ett kommenterat avtal i din visare och i Acrobat sida vid sida så stämmer summorna sällan. Acrobat visar en kuraterad vy: markup grupperad i svarstrådar, popup-fönster invikta i de anteckningar de hör till, länkar och formulärwidgetar utelämnade. Den råa arrayen håller alltihop odifferentierat, så en naiv räkning ligger högt på vissa sätt och lågt på andra samtidigt

Popup-fönster blåser upp summan, eftersom varje klisterlapp levereras med ett separat Popup-objekt och att räkna båda dubblerar anteckningen. Svar tömmer den om du filtrerar på synliga märken, eftersom ett svar är en Text-annotering utan något målat tills någon fäller ut tråden, och att släppa det förlorar diskussionen. Flaggorna Hidden och NoView tar bort en annotering från skärmen utan att ta bort den ur arrayen, så en flaggblind räkning inkluderar märken användaren inte kan se. Link-annoteringar sitter i samma array som kommentarerna och hör hemma varken i antalet eller i listan. Bestäm räknereglen innan du skriver loopen, och skriv ned beslutet, eftersom ”varför visar din panel ett annat tal än Acrobat” är det första ärendet en granskningsfunktion drar på sig

Indexera allt en gång, tolka sedan aldrig om en sida

En designregel driver allt som följer: filtrering på författare, typ eller sida får aldrig tolka om sidobjekt. På ett dokument på 300 sidor med tung markup förvandlar omtolkning vid varje ändring i en rullgardinslista panelen till något som hackar i sekunder åt gången. Komponenten exponerar AnnotationCount och den indexerade egenskapen Annotation[], båda avgränsade till den för tillfället inlästa sidan, och posten TPdfAnnotation de lämnar tillbaka bär vad en listvy behöver: Subtype, Flags, Color, Rectangle, ContentsText, AuthorText. Rätt drag är att svepa igenom varje sida en gång vid öppning och hålla ett eget platt index:

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];
      // Behåll bara subtyper som är relevanta för granskaren; registrera paret
      // av sida och index eftersom alla senare redigeringar adresseras med det
      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;

Paret värt att stryka under är (PageNo, i). Varje senare mutation, vare sig en omfärgning eller en borttagning, adresseras med sidnummer plus annoteringsindex, och indexet är skört: att ta bort en annotering numrerar om allt efter den på den sidan. Så planera för att bygga om den berörda sidans poster efter varje borttagning istället för att lappa indexnummer på plats. Ombyggnaden kostar en millisekund. Ett inaktuellt index tar däremot bort fel granskares kommentar, vilket är den sortens bugg som urholkar förtroendet för hela funktionen

Trådning förtjänar en plats i indexet även om din första utgåva bara räknar svar snarare än visar dem. Gruppera poster efter sin föräldrareferens medan du har sidan öppen, så att panelen senare kan fälla ihop en tråd så som Acrobat gör. Att rekonstruera den grupperingen lat under rullning omintetgör hela poängen med att indexera en gång, eftersom det öppnar om sidor du redan betalat för att tolka. Geometrin vill ha samma disciplin. Rectangle i varje post är i sidrymd, och att konvertera den till vykoordinater hör hemma i en enda delad hjälpfunktion, inte utspridd genom koden. Paneler odlar koordinatbuggar när markering, träffkontroll och målning var för sig uppfinner sin egen zoom- och rotationsmatematik; låt alla tre gå genom en enda konvertering så förblir en överstrykning, dess rad i listan och dess klickmål fastnitade vid samma bläck

Att färga om markup och vetot från utseendeströmmen

Att ändra en överstrykning från gul till bärnstensgul låter som en enrading, och ibland är det det. Haken är ISO 32000-1 §12.5.5. När en annotering bär en /AP-utseendeström målar en regelrätt visare den färdigbyggda strömmen och behandlar färgposten i ordboken som död metadata. Acrobat skriver utseendeströmmar för i stort sett allt den skapar, så de flesta annoteringar som anländer från kunder är redan i det tillståndet, och färgen du så självsäkert satte når aldrig skärmen. Omfärgning är en läs-ändra-skriv genom egenskapen Annotation[], och komponenten är ärlig om konflikten: när motorn vägrar låta en ordboksfärg åsidosätta ett inbakat utseende utlöser skrivningen EPdfError

Diagram över läs-ändra-skriv-vägen för omfärgning i en Delphi-PDFium-komponent där en inbakad utseendeström vetar ordboksfärgen och utlöser EPdfError
När en annotering bär en förbyggd /AP-ström vägrar motorn ordboksfärgen och utlöser EPdfError, så panelen färgar om sitt eget överlägg eller märker raden som utseendelåst
A := Pdf.Annotation[Item.Index];
A.HasColor := True;
A.Color := $0000B0FF;       // bärnstensgul
A.ColorAlpha := 160;
try
  Pdf.Annotation[Item.Index] := A;
except
  on EPdfError do
  begin
    // Annoteringen äger en förrenderad /AP-ström; ordboksfärgen
    // ensam kan inte ändra vad visare målar
    Item.AppearanceLocked := True;
    StatusBar.SimpleText := 'Color is fixed by the annotation appearance';
  end;
end;

Fånga det undantaget varje gång, och behandla det som information snarare än som ett misslyckande. Hoppa över skyddsvakten så visar din panel glatt bärnstensgult i sin egen lista medan sidan fortsätter måla gult; användaren rapporterar det veckor senare som ”din visare ignorerar mina redigeringar”, och du lägger en eftermiddag på att misslyckas med att återskapa det på en fil som råkar sakna utseendeström. När du väl vet att utseendet är låst har du två ärliga svar: färga om ditt eget markeringsöverlägg istället för annoteringen, så att granskaren åtminstone ser den överstrykning de valde, eller märk raden som utseendelåst så att ingen väntar sig att ändringen ska fastna

Att ta bort annoteringar utan att lämna spöken

DeleteAnnotation tar bort objektet ur den aktuella sidans annoteringsträd, men den lämnar den cachelagrade sidrastern i fred. Måla direkt efter anropet och den borttagna överstrykningen är fortfarande på skärmen, sittande i en bitmapp som inte längre matchar dokumentmodellen bakom den. Botemedlet är att behandla omrenderingen som en del av borttagningen, inte som ett steg anroparen kan glömma:

Diagram över den trestegs borttagningscykel i Delphi-PDFium som tar bort annoteringen, renderar om sidan med reAnnotations och bygger om sidindexet
Borttagning rör bara annoteringsträdet, så panelen måste rendera om med reAnnotations och bygga om sidans poster innan visningen och indexet är ärliga igen
Pdf.PageNumber := Item.PageNo;
Pdf.DeleteAnnotation(Item.Index);   // utlöser EPdfError vid misslyckande
Bmp := Pdf.RenderPage(0, 0, ViewWidth, ViewHeight, ro0, [reAnnotations]);
try
  PaintPageBitmap(Bmp);
finally
  Bmp.Free;  // RenderPage lämnar ägarskapet av bitmappen till anroparen
end;
RebuildPageEntries(Item.PageNo);  // indexen efter Item.Index förskjöts

Två detaljer i det blocket är lätta att få fel. Alternativet reAnnotations måste vara med, annars släpper den nya rastern varje kvarvarande annotering och sidan ser ut som om du raderat hela kommentarsuppsättningen istället för ett märke. Och Bmp.Free är inte valfri: den funktionsformade överlagringen av RenderPage lämnar ägarskapet av bitmappen till anroparen, så en utebliven frigöring läcker en helsidesraster vid varje enskild borttagning, vilket en granskare som arbetar sig genom ett långt dokument förvandlar till verkligt minnestryck inom minuter

Att lägga till granskarmärken från ditt eget gränssnitt

Att skapa annoteringar går genom CreateAnnotation, som tar en ifylld TPdfAnnotation-post (subtyp, rektangel, färg, innehåll, författare) och fäster den vid den aktuella sidan. En klisterlapp, subtyp anText, är det lätta fallet: sätt positionen, innehållet och författaren så är du klar. Ink-annoteringar är där folk fastnar. Postens rektangel avgränsar bara ritningen; själva dragen är arrayer av punkter som måste fästas separat genom motorns anrop för bläckdrag, FPDFAnnot_AddInkStroke matad med FS_POINTF-data, fångad från mus- eller penninmatning ett drag i taget. Bygg en ink-annotering av en rektangel och inget annat så får du ett tomt klotter som renderas som blank yta, vilket ser ut som en bugg i motorn men egentligen är en halvfärdig annotering

Avgör policyn för författarskap i samma andetag. Varje märke ditt gränssnitt skapar bör bära en konsekvent AuthorText, eftersom det granskarfilter du bygger nästa månad bara blir så bra som de namn du stämplar på kommentarer i dag. Tomma eller inkonsekventa författarsträngar går inte att laga i efterhand utan att varje fil öppnas igen

Att få ut granskningen ur visaren

Granskningsdata gör nytta när den kan lämna visaren, som en sammanfattning projektledaren läser utan att öppna filen eller en CSV som matar ett uppföljningsblad. Exportera från indexet du redan byggt, aldrig från en ny tolkning, och välj ett stabilt sätt att hänvisa tillbaka till varje märke. Ett sidnummer i par med annoteringens rektangel överlever rundturer som ett arrayindex inte gör, eftersom nästa borttagning tyst numrerar om indexen och din CSV börjar peka på fel kommentarer

En rad värd att behålla bär sidan, subtypen, författaren, tidsstämpeln för skapandet när filen registrerar en, innehållstexten, och en statuskolumn du äger snarare än en PDF:en levererar. Samma indexeringspass är användbart tidigare, vid mottagningen, när ett dokument anländer utifrån teamet och du vill veta vad som finns i det innan någon granskar det. Artikeln om arbetsbänken för PDF-mottagning går igenom den sorteringen, och navigering i formulärfält täcker spegelbildsproblemet: att granska dokument byggda för att samla in data snarare än kommentarer

Ett fall arrayen inte visar dig

Ett felbeteende förtjänar en flagga eftersom det ser ut som en defekt i din kod och inte är det. En kund rapporterar synliga överstrykningar över hela en sida, men din panel listar ingenting, och AnnotationCount kommer tillbaka som noll. Den vanliga förklaringen är att märkena plattats ut någonstans uppströms. Utplattning bakar in annoteringsutseenden i vanligt sidinnehåll, så att överstrykningarna blir en del av sidgrafiken och slutar existera som annoteringsobjekt över huvud taget. Det finns inget kvar för ett annoterings-API att räkna upp, färga om eller ta bort. När du ser målad markup med ett nollantal, sluta leta efter buggen i din uppräkningsloop och fråga hur filen framställdes

Den annoteringsyta som används här, från uppräkning och skapande genom omfärgning, borttagning och de renderingsalternativ som håller visningen ärlig, levereras med PDFium Component för Delphi, C++Builder och Lazarus/FPC