Articolo tecnico

PDFium Component: OBJR e struttura dopo la cancellazione

Eliminate un'annotazione Link da un PDF con tag con un editor che opera solo sulla pagina, e la pagina viene renderizzata bene mentre l'albero della struttura conserva ancora un OBJR, una voce ParentTree e un elemento Link per un'annotazione sparita. RemovePdfPageAnnotations di PDFium Component ritira l'annotazione dalla pagina e, su richiesta, rimuove con lei anche quei pezzi di struttura

Dove un'annotazione con tag aggancia l'albero della struttura

Un'annotazione con tag siede all'incrocio di quattro oggetti, e una cancellazione pulita deve toccarli tutti. ISO 32000-1 §14.7.4.3 dice che quando il contenuto di un elemento di struttura è un intero oggetto PDF, come un'annotazione, l'array /K dell'elemento lo nomina con un dizionario di riferimento a oggetto (Tabella 325): /Type /OBJR, un /Obj indiretto obbligatorio che punta all'annotazione, e una /Pg facoltativa che sostituisce la pagina dell'elemento stesso. Questo copre la direzione in avanti, dall'albero verso la pagina

Come un'annotazione Link con tag si collega all'array Annots della pagina PDF, al riferimento a oggetto OBJR, alla chiave StructParent e alla voce ParentTree in PDFium Component per Delphi
Un'annotazione con tag è cablata su quattro oggetti, e una cancellazione pulita visita il riferimento alla pagina, l'OBJR, la chiave StructParent e la coppia ParentTree

Il percorso inverso è il §14.7.4.4. Il parent tree è un number tree (§7.9.7) con una voce per ogni oggetto che è un content item, indicizzata dall'intero che quell'oggetto porta. Un'annotazione porta /StructParent, e il suo valore nel parent tree è un unico riferimento indiretto all'elemento proprietario; una pagina il cui content stream contiene marked content porta invece /StructParents, e il suo valore è un array indicizzato per MCID. La Tabella 326 ammette al massimo una delle due chiavi su un oggetto, perché un oggetto è o un content item nella sua interezza o un contenitore di marked content. Per gli hyperlink, il §14.8.4.4.2 consente a un elemento Link di tenere sia le parole visibili, come riferimenti a marked content (MCR), sia un OBJR verso l'annotazione del link. Eliminate l'annotazione e questo è l'inventario:

PosizioneCosa contieneCosa fa una rimozione pulita
Pagina /AnnotsRiferimento indiretto all'annotazioneToglie il riferimento; le superstiti conservano il loro ordine
Elemento Link /KOBJR con /Obj e /PgRimuove il content item OBJR
Dizionario dell'annotazioneChiave intera /StructParentRimuove la chiave, visto che l'oggetto non è più un content item
Foglia /Nums del ParentTreeChiave abbinata a un riferimento all'elemento LinkRimuove la coppia chiave e valore
Radice dell'albero di struttura/ParentTreeNextKeyLa lascia stare

Come fa un link fantasma a sopravvivere alla sua annotazione?

Un editor a livello di pagina cancella un'annotazione togliendola da /Annots, e da lì la storia segue una di due strade. Se lo strumento libera anche l'oggetto annotazione, il /Obj dell'OBJR ora punta a un oggetto non definito, che il §7.3.10 dice al lettore di trattare come null: l'elemento Link possiede un content item che non risolve a nulla. Se lo strumento lascia l'oggetto nel file, l'OBJR risolve ancora, ma la sua /Pg nomina una pagina il cui /Annots non lo elenca più. In entrambi i casi l'elemento Link conserva i suoi figli marked content, così le parole "Download the datasheet" restano taggate come link, e uno screen reader che percorre l'albero in ordine di lettura può annunciare un link che nulla sulla pagina attiverà mai. I verificatori PDF/UA come PAC e veraPDF, che validano contro ISO 14289-1, confrontano ciò che la pagina elenca con ciò che l'albero dichiara, così un disaccordo tra i due può saltare fuori lì anche quando la pagina renderizzata sembra perfetta

Le due strade che lasciano un link fantasma in un PDF con tag dopo una cancellazione che tocca solo la pagina, e come le pulisce PDFium Component in Delphi
Che l'oggetto annotazione venga liberato o lasciato sul file, l'elemento link conserva le sue parole e gli screen reader possono annunciare un link che non porta a nulla

Anche la cancellazione di contenuto ha la sua versione del problema. Togliete gli oggetti di testo dentro /P <</MCID 4>> BDC … EMC e lasciate gli operatori, e la pagina resta con una sequenza di marked content vuota il cui MCR siede ancora in un elemento <P>; il lettore riceve un paragrafo senza parole. Togliete il BDC ma non il suo EMC e il content stream si rompe sul posto. Il §14.6 esige che ogni sequenza di marked content sia racchiusa da un BDC o un BMC e da un EMC corrispondente dentro un unico content stream, e un EMC orfano fa abbinare a ogni sequenza successiva della pagina l'apertura sbagliata

Rimuovere annotazioni con RemovePdfPageAnnotations

RemovePdfPageAnnotations in FPdfDocumentStructure prende uno stream sorgente, un indice di pagina a base zero in ordine di page tree, i numeri di oggetto delle annotazioni da rimuovere, un record TPdfAnnotationRemovalOptions e due parametri out per il nuovo file e un TPdfAnnotationRemovalReport. Con TPdfAnnotationRemovalOptions.Default tocca solo la pagina: una revisione incrementale porta il dizionario di pagina con l'array /Annots accorciato (o nessuna chiave /Annots una volta andata via l'ultima annotazione) più il catalogo invariato, così i numeri di oggetto, il document ID e ogni altra chiave di pagina sopravvivono. L'oggetto annotazione conserva il suo corpo, e chi chiama e conserva il suo numero può ancora leggerlo. L'albero della struttura resta com'era, il che tiene intatto il tagging per il caso in cui state per puntare quell'elemento Link verso un'annotazione di sostituzione. L'input viene controllato prima che qualcosa venga scritto:

  • Ogni numero deve comparire negli /Annots della pagina selezionata e nominare un oggetto /Type /Annot; un numero elencato due volte nella stessa chiamata viene rifiutato
  • Le annotazioni widget vengono rifiutate, perché un widget appartiene al suo form field e staccarlo dalla pagina lascerebbe un campo il cui widget nessuna pagina mostra
  • Una sorgente cifrata viene respinta, visto che la modifica alla struttura serve byte decifrati; decifrate il file in anticipo
  • MaxOutputBytes, MaxWork e CancellationToken delimitano il lavoro, e ogni fallimento restituisce False senza output e con un ErrorMessage che nomina la fase dove si è fermato

Per un documento con tag l'albero della struttura vi dice già quali numeri di annotazione contano. Ogni item OBJR in uno snapshot di ReadPdfDocumentStructure porta il numero di oggetto referenziato, e il suo numero di pagina arriva dalla /Pg dell'OBJR stesso o, in mancanza, da quella dell'elemento. La routine sotto raccoglie ogni annotazione a cui un elemento Link di una pagina si riferisce e le rimuove con entrambe le opzioni di pulizia accese. Tenete il filtro di pagina: un numero preso da un'altra pagina fa rifiutare l'intero batch

uses
  System.SysUtils, System.Classes, System.IOUtils,
  FPdfCompress, FPdfDocumentStructure;

function RemoveTaggedLinks(const InName, OutName: string;
  PageIndex: Integer): TPdfAnnotationRemovalReport;
var
  Source: TMemoryStream;
  Pages: TPdfSparsePageDictionaries;
  ReadOptions: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  ReadReport: TPdfStructureEditReport;
  Item: TPdfDocumentStructureContentItem;
  Targets: array of Integer;
  Options: TPdfAnnotationRemovalOptions;
  Report: TPdfAnnotationRemovalReport;
  Output: TBytes;
  N, I, J: Integer;
  Known: Boolean;
begin
  Report := Default(TPdfAnnotationRemovalReport);
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(InName);
    if not ReadPdfSparsePageDictionaries(Source, Pages) or
       (PageIndex < 0) or (PageIndex > High(Pages)) then
      raise Exception.Create('Page index is outside the page tree');
    ReadOptions := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, ReadOptions, Snapshot, ReadReport) then
      raise Exception.Create(ReadReport.ErrorMessage);

    // Ogni annotazione a cui punta un elemento Link di questa pagina
    for N := 0 to High(Snapshot.Nodes) do
      if Snapshot.Nodes[N].RoleName = 'Link' then
        for I := 0 to High(Snapshot.Nodes[N].ContentItems) do
        begin
          Item := Snapshot.Nodes[N].ContentItems[I];
          if (Item.Kind <> psckObjectReference) or
             (Item.PageObjectNumber <> Pages[PageIndex].ObjectNumber) then
            Continue;
          Known := False;
          for J := 0 to High(Targets) do
            if Targets[J] = Item.ReferencedObjectNumber then Known := True;
          if not Known then
          begin
            SetLength(Targets, Length(Targets) + 1);
            Targets[High(Targets)] := Item.ReferencedObjectNumber;
          end;
        end;
    if Length(Targets) = 0 then
      Exit(Report);

    Options := TPdfAnnotationRemovalOptions.Default;
    Options.RemoveOwnedStructurePeers := True;    // item OBJR e voci ParentTree
    Options.RemoveEmptiedStructureNodes := True;  // elementi rimasti senza nulla dentro
    if not RemovePdfPageAnnotations(Source, PageIndex, Targets, Options,
      Output, Report) then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
    Result := Report;
  finally
    Source.Free;
  end;
end;

Cosa succede alla chiave ParentTree

Con RemoveOwnedStructurePeers impostata, ogni OBJR il cui /Obj è un'annotazione rimossa su quella pagina lascia il suo elemento attraverso l'editor di struttura, e così l'indice inverso resta allineato. La coppia chiave e valore conservata sotto il /StructParent dell'annotazione esce dalla sua foglia /Nums, e la chiave /StructParent stessa viene tolta dal dizionario dell'annotazione, visto che la Tabella 326 la chiede solo sugli oggetti che sono content item strutturali. /ParentTreeNextKey non viene abbassato. Il §14.7.4.4 richiede solo che sia maggiore di ogni chiave in uso, cosa che una rimozione non può rompere, e le chiavi di un number tree non devono essere contigue, così un writer che segue /ParentTreeNextKey non ridistribuisce mai la chiave liberata. Ogni annotazione superstite conserva il suo numero di oggetto, la sua posizione negli /Annots e la sua chiave ParentTree; la funzione rilegge la pagina che ha scritto e rifiuta il risultato se una superstite è sparita o ha cambiato posto. Atteso: su una pagina con un solo link con tag, Report.RemovedAnnotations e Report.RemovedObjectReferences valgono entrambi 1, e Report.SourceSha256 e Report.OutputSha256 registrano quali byte sono entrati e quali sono usciti

Quando va buttato via anche l'elemento svuotato?

RemoveEmptiedStructureNodes rimuove gli elementi che la rimozione degli OBJR ha lasciato senza content item né elementi figli, dal più profondo in giù. Lavora a tornate: ogni tornata rilegge la struttura, rimuove gli elementi ormai vuoti e aggiunge i loro padri ai candidati della tornata successiva, così una catena di wrapper collassa dal basso verso l'alto mentre la radice dell'albero di struttura resta sempre. Un elemento Annot che tagga una sticky note di solito non tiene niente oltre l'OBJR (il §14.8.4.4.3 lo consente), quindi si svuota e va via. Un elemento Link è diverso. Possiede ancora i riferimenti a marked content delle sue parole visibili, quindi non è vuoto e sopravvive, come Link senza annotazione. RemovePdfPageAnnotations non lo rinomina, perché se quelle parole debbano restare un link è una domanda editoriale più che strutturale. Per una cancellazione netta, accendete entrambe le opzioni e poi declassate a Span ogni elemento Link rimasto senza OBJR: la tecnologia assistiva legge quelle parole come testo semplice invece di offrire un link che non porta a nulla

Flusso decisionale per gli elementi di struttura svuotati: un elemento Annot collassa tornata dopo tornata, un elemento Link sopravvive, e PDFium Component può declassarlo a Span
I wrapper svuotati collassano dal più profondo in giù mentre la radice resta, e un Link rimasto senza il suo OBJR può essere declassato a Span o conservato per un'annotazione di sostituzione
function DemoteUnlinkedLinks(const Data: TBytes; out Fixed: TBytes): Integer;
var
  Source: TBytesStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  N: Integer;
begin
  Result := 0;
  Fixed := Data;
  Source := TBytesStream.Create(Data);
  try
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);
    for N := 0 to High(Snapshot.Nodes) do
      if (Snapshot.Nodes[N].RoleName = 'Link') and
         (Snapshot.Nodes[N].ObjectReferenceCount = 0) and
         (Length(Snapshot.Nodes[N].ContentItems) > 0) then
      begin
        SetLength(Ops, Length(Ops) + 1);
        Ops[High(Ops)] := Default(TPdfStructureEditOperation);
        Ops[High(Ops)].Kind := psekSetRole;
        Ops[High(Ops)].NodeID := Snapshot.Nodes[N].NodeID;
        Ops[High(Ops)].Value := 'Span';
      end;
    if Length(Ops) = 0 then Exit;
    // Il digest dello snapshot presidia i byte cambiati nel frattempo
    if not EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops,
      Options, Fixed, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Result := Length(Ops);
  finally
    Source.Free;
  end;
end;

Tenete i valori predefiniti quando la cancellazione è metà di una sostituzione. Un elemento svuotato lasciato al suo posto conserva ancora ruolo, titolo, testo alternativo e element ID, e se un passo successivo gli aggancia una nuova annotazione con un'operazione psekAddObjectReference, che dà all'annotazione una chiave /StructParent fresca e avanza /ParentTreeNextKey secondo le stesse regole che registrano nuovi item MCR e OBJR nel parent tree, l'elemento conserva il suo posto nell'ordine di lettura. Togliere e ricreare quell'elemento butterebbe via quei valori e gli darebbe un nuovo numero di oggetto

Cancellare contenuto con tag: la proprietà va dal BDC al suo EMC

Le annotazioni non sono oggetti di pagina, quindi testo, percorsi e immagini passano da un'altra API: l'editor batch di oggetti, PlanStyledPdfObjectEdits e ExecuteStyledPdfObjectEdits in FPdfStyledEdit, il cui TPdfObjectEditOptions porta RemoveOwnedStructurePeers e RemoveEmptiedStructureNodes con lo stesso significato. Una sequenza di marked content con tag va via insieme alla sua voce di struttura solo quando ogni oggetto tra il suo BDC e l'EMC corrispondente sta per essere cancellato. In quel caso entrambi gli operatori lasciano il content stream, l'MCR lascia il suo elemento, e lo slot di quell'MCID nell'array ParentTree della pagina viene messo a null invece che chiuso, perché ogni altro MCID della pagina indicizza nello stesso array. Una sequenza che avvolge anche contenuto superstite resta, insieme al suo MCR. Una pagina che riusa l'MCID rimosso per contenuto superstite viene rifiutata in blocco, visto che gli MCID hanno come ambito il content stream della pagina e buttare via la voce di struttura lascerebbe orfano il superstite. Con l'opzione lasciata False, il batch conserva invece le coppie BDC e EMC al confine della rimozione, così l'albero di struttura continua a risolvere e nulla dentro di lui cambia

La proprietà va misurata dal BDC fino alla fine del suo EMC corrispondente. La nostra prima versione la misurava sul solo intervallo di byte dell'operatore BDC, che è dove un mark analizzato registra la sua posizione, e non trovava alcun oggetto racchiuso. Un errore affine è marcare il BDC per la rimozione e perdere di vista il suo compagno, che scrive esattamente lo stream sbilanciato descritto sopra, così l'editor rimuove sempre insieme entrambe le metà di una coppia. La routine sotto cancella ogni oggetto di primo livello che porta un MCID. ExecuteStyledPdfObjectEdits ricarica Pdf con il risultato a meno che EditOptions.PreviewOnly sia impostata, e Report.RemovedStructurePeers e Report.RemovedStructureNodes contano cosa ha lasciato l'albero; la funzione restituisce quel report

uses
  System.SysUtils, PDFium, FPdfStyledEdit;

function DeleteTaggedRun(Pdf: TPdf; PageIndex, Mcid: Integer): TPdfObjectEditReport;
var
  Ops: TPdfObjectEditOperations;
  NoSemanticEdits: TPdfObjectSemanticEdits;
  Options: TPdfObjectEditOptions;
  Plan: TPdfObjectEditPlan;
  Preview: TBytes;
  Report: TPdfObjectEditReport;
  I: Integer;
begin
  Pdf.PageNumber := PageIndex + 1;          // PageNumber è a base uno
  for I := 0 to Pdf.ObjectCount - 1 do
    if Pdf.PageObjectMarkedContentID(I) = Mcid then
    begin
      SetLength(Ops, Length(Ops) + 1);
      Ops[High(Ops)] := Default(TPdfObjectEditOperation);
      Ops[High(Ops)].Kind := poekRemove;
      Ops[High(Ops)].ObjectIndex := I;
    end;
  if Length(Ops) = 0 then
    raise Exception.CreateFmt('No object on page %d carries MCID %d',
      [PageIndex + 1, Mcid]);
  NoSemanticEdits := nil;
  Options := TPdfObjectEditOptions.Default;
  Options.RemoveOwnedStructurePeers := True;
  Options.RemoveEmptiedStructureNodes := True;
  Plan := PlanStyledPdfObjectEdits(Pdf, PageIndex, Ops, NoSemanticEdits, Options);
  if not ExecuteStyledPdfObjectEdits(Pdf, Plan, Options, Preview, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Result := Report;
end;

Un'annotazione rimossa occupa ancora il suo oggetto nel file dopo una qualsiasi delle due chiamate, perché viene ritirato solo il riferimento dalla pagina. Una volta che l'OBJR è andato, nulla nel grafo del catalogo lo raggiunge, così TPdf.SaveAsReachable può spazzare via quell'oggetto orfano da una copia riscritta; con le opzioni predefinite l'OBJR tiene l'annotazione raggiungibile e la pulizia la mantiene anche lei. Eseguite la rimozione con entrambe le opzioni di struttura accese ogni volta che la cancellazione è definitiva, e la pagina, l'albero di struttura e il parent tree nel file che PDFium Component per Delphi riscrive descriveranno tutti lo stesso insieme di annotazioni