Articolo tecnico

Eliminare pagine PDF in Delphi senza riferimenti penzolanti

HotPDF Delphi Component elimina una pagina da un PDF caricato tramite THotPDF.DeletePage, e dalla versione 2.751.0 quella chiamata pota anche ogni riferimento a livello di documento che punta ancora alla pagina: le named destination nell'albero /Names /Dests, il dizionario /Dests legacy del catalog, le azioni /GoTo dei bookmark, gli elementi di struttura sotto /StructTreeRoot, il ParentTree, le entry OBJR delle annotazioni, e le annotazioni link sulle pagine che restano. L'albero delle pagine viene ricostruito per ultimo, dopo che nient'altro può più raggiungere l'oggetto eliminato

Il guasto che questo previene è facile da riprodurre e difficile da diagnosticare. Elimina la copertina di un report taggato, salva e apri il risultato: Acrobat mostra il numero di pagine giusto, ma il bookmark "Contents" ora non atterra da nessuna parte, il controllo di accessibilità riporta un elemento di struttura senza pagina, e un validatore severo elenca un riferimento a un oggetto libero. Nell'albero delle pagine non c'è niente di sbagliato. Il problema è che una pagina PDF non è solo una foglia di /Pages; è un target verso cui punta metà del catalog, e rimuovere la foglia lascia ognuno di quei puntatori penzolante

Perché rimuovere una pagina da /Kids non basta?

Perché ISO 32000-1 lascia che almeno sette strutture indipendenti tengano un riferimento a un oggetto pagina, e solo una di esse è l'albero delle pagine. Togliere la pagina da /Kids e decrementare /Count soddisfa la §7.7.3, e ogni altro riferimento diventa un puntatore a un oggetto che nella xref è libero o che nel file riscritto semplicemente non c'è. Un viewer che segue uno di quei puntatori ottiene null, e cosa ne faccia di quel null dipende dal viewer

  • Il name tree sotto /Names /Dests (§7.7.4, §12.3.2.3) mappa nomi su array di destinazione il cui primo elemento è la pagina
  • Il dizionario /Dests pre-1.2 direttamente nel catalog contiene lo stesso tipo di array indicizzati per nome
  • Gli outline item (§12.3.3) raggiungono una pagina o tramite un /Dest inline o tramite un'azione /A con /S /GoTo e un array /D
  • Gli elementi di struttura (§14.7.2) portano una chiave /Pg che nomina la pagina su cui vive il loro marked content, e i loro figli /K possono essere marked-content reference e object reference (§14.7.4.3) legati a quella pagina
  • Il ParentTree (§14.7.4.4) mappa i numeri /StructParents di pagine e annotazioni di nuovo agli elementi di struttura, e un elemento può vivere lì senza comparire affatto nella catena /K dalla root
  • Le annotazioni link su altre pagine (§12.5.6.5) portano un /Dest o un'azione /GoTo che punta alla pagina, e lo stesso può fare /OpenAction nel catalog
Perché rimuovere una pagina HotPDF da /Kids non basta: ISO 32000-1 lascia che il name tree /Names /Dests, il dizionario /Dests legacy del catalog, gli outline item, gli elementi di struttura con /Pg, il ParentTree, le annotazioni link e /OpenAction tengano tutti un riferimento allo stesso oggetto pagina, e solo l'albero delle pagine viene ricostruito
Una pagina PDF è un target verso cui punta metà del catalog: togliere la foglia soddisfa l'albero delle pagine mentre ogni altro puntatore risolve a null, quindi un report potato perde il bookmark Contents e fallisce il controllo di accessibilità

Cosa ripulisce THotPDF.DeletePage prima di toccare l'albero delle pagine?

THotPDF.DeletePage(PageIndex) su un documento caricato esegue prima tutta la scansione dei riferimenti, poi marca l'oggetto pagina come eliminato con DeleteObj, stacca eventuali widget annotation dall'albero dei campi AcroForm, sposta l'array interno delle pagine, e infine chiama RebuildLoadedPageTree per riscrivere /Kids, /Count e il /Parent di ogni pagina superstite. La scansione visita il catalog in un ordine fisso: il name tree /Names /Dests, il dizionario /Dests vecchio stile, /OpenAction, l'albero degli outline, /StructTreeRoot con il suo ParentTree, e per ultimi gli array /Annots di ogni pagina che resta. Ogni passo decide se un riferimento va rimosso, riportato altrove o lasciato stare secondo ciò che la specifica permette a quella struttura di fare senza la pagina. Prima che parta tutto questo valgono due guardie: DeletePage solleva Invalid page number per un indice fuori intervallo e rifiuta di rimuovere l'ultima pagina, perché un nodo /Pages con zero figli non è un PDF valido, mentre DeletePages accetta la stessa notazione one-based "1,3-5,7-" delle altre operazioni sulle pagine di un documento caricato e itera dall'indice selezionato più alto verso il basso, così gli indici che hai scritto restano validi mentre lavora

La scansione fissa dei riferimenti che THotPDF.DeletePage esegue prima di toccare l'albero delle pagine: le guardie rifiutano un indice fuori intervallo o l'ultima pagina, poi /Names /Dests e il /Dests legacy vengono potati, /OpenAction eliminato, gli outline riportati a NearestRetainedPage, StructTreeRoot e ParentTree potati, i link delle pagine conservate rimossi, e RebuildLoadedPageTree gira per ultimo
Ogni struttura riceve il trattamento che la specifica permette: i nomi spariscono, i bookmark atterrano sulla pagina conservata più vicina, gli elementi di struttura perdono /Pg o spariscono, e la riscrittura di /Kids avviene solo dopo che nient'altro può raggiungere l'oggetto eliminato
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // Zero-based: togli la copertina. Named destination,
      // bookmark, structure tree, ParentTree e annotazioni
      // link che puntavano lì vengono potati prima che
      // l'albero /Pages sia ricostruito.
      Pdf.DeletePage(0);
      // Sintassi a intervalli one-based per i lotti, internamente
      // dall'indice più alto così gli indici precedenti restano validi.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Come vengono trattati diversamente named destination e bookmark?

Le named destination vengono rimosse e i bookmark vengono riportati altrove, perché un nome che non esiste più è un esito accettabile mentre un bookmark senza destinazione è un difetto visibile. Nell'albero /Names /Dests HotPDF percorre ogni nodo, verifica ogni destinazione, sia nella forma di array nudo sia in quella di dizionario con chiave /D, contro la pagina eliminata, e rimuove la coppia nome/valore quando il primo elemento dell'array è quella pagina. Un nodo le cui /Names e /Kids finiscono entrambe vuote viene marcato come eliminato e scollegato dal suo parent, così l'albero non tiene mai foglie vuote. Lo stesso controllo gira sul dizionario /Dests vecchio stile del catalog, e il /OpenAction del catalog viene semplicemente eliminato se apriva sulla pagina cancellata. Un confine, qui: quando un nodo del name tree perde delle entry, HotPDF cancella la coppia /Limits di quel nodo invece di ricalcolare le nuove chiavi minima e massima, e anche se i viewer risolvono i nomi benissimo senza, un conformance checker severo che legge ISO 32000-1 §7.9.6 può segnalare un nodo non-root privo di /Limits

Gli outline item vanno nella direzione opposta. RetargetOutlineDestinations attraversa /First e /Next dalla root dell'outline, con una lista dei visitati e un limite di profondità di 128 perché un albero ciclico corrotto non possa bloccare la chiamata, e per ogni array /Dest o array /D di un'azione /GoTo mirato alla pagina sostituisce il primo elemento con NearestRetainedPage: la pagina che seguiva quella eliminata, o quella precedente quando la pagina eliminata era l'ultima. I parametri di vista dopo il riferimento alla pagina vengono lasciati come stavano. Un bookmark che puntava a un'apertura di capitolo eliminata atterra quindi sulla prima pagina di quello che resta invece di sparire dalla barra laterale, che è il comportamento che i revisori si aspettano da un documento potato. Il controllo sulle destinazioni però intercetta solo gli array espliciti: un outline item il cui /Dest è una stringa di nome che risolveva alla pagina eliminata non viene riportato altrove, perché l'entry del name tree è sparita e il riferimento ora non risolve a nulla invece che a un oggetto liberato, quindi il viewer lo tratta come bookmark morto. La meccanica dell'albero degli outline in sé, /First, /Next e la non ovvia semantica di /Count, è coperta in la guida per aggiungere bookmark e named destination a un PDF caricato

// Verifica la scansione invece di fidarti.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// Un bookmark che puntava alla copertina ora risolve alla
// pagina che la seguiva (indice zero-based 0 dopo la delete).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

Cosa succede allo structure tree e al ParentTree?

Gli elementi di struttura che esistono solo per la pagina eliminata vengono rimossi, e gli elementi che coprono più pagine perdono la loro chiave /Pg ma mantengono i figli. PruneStructureElement scende la catena /K da /StructTreeRoot fino a una profondità di 128, gestendo sia la forma ad array sia quella a dizionario singolo di /K che la §14.7.2 permette. Per ogni elemento prima pota i figli, poi valuta l'elemento stesso: se la potatura ha svuotato il suo /K, l'elemento viene marcato come eliminato e il suo parent lo scarta. Se il /Pg dell'elemento nomina la pagina eliminata e l'elemento ha ancora figli più un parent /P, viene rimosso solo il /Pg, perché un /Pg su un elemento è la pagina di default per i suoi figli marked-content e quei figli possono riferire altre pagine esplicitamente. Viene rimosso del tutto solo un elemento il cui /Pg è la pagina eliminata e sotto cui non è rimasto niente

Il ParentTree riceve lo stesso trattamento, e la ragione è quella che ha morso durante lo sviluppo: un elemento di struttura può essere raggiungibile dal ParentTree e da nessun'altra parte. Il number tree mappa interi /StructParents su un singolo elemento o su un array di elementi, e PruneParentTreeNode esegue PruneStructureElement su ogni valore che trova, rimuove i valori che sono stati potati via, cancella una coppia /Nums quando il suo array di valori è vuoto, e scollega un nodo le cui /Nums e /Kids sono entrambe sparite. Potare solo i discendenti di /K avrebbe lasciato quegli elementi orfani puntare a una pagina liberata tramite /Pg e a marked-content reference liberate tramite i loro figli /MCR. Se estrai testo in ordine di struttura, la cosa conta direttamente: l'estrazione del testo in ordine di struttura percorre esattamente questi alberi, e un elemento con /Pg nullo è un paragrafo che esce in silenzio dall'ordine di lettura

Quali annotazioni link sulle pagine superstiti vengono rimosse?

Qualunque annotazione link su una pagina conservata il cui array /Dest o la cui azione /GoTo punta alla pagina eliminata viene rimossa insieme alla sua proprietà nello structure tree. RemoveRetainedPageDestinationAnnotations percorre l'array /Annots di ogni pagina diversa dal target, applica lo stesso controllo sulle destinazioni usato per gli outline, marca come eliminata un'annotazione corrispondente, la scarta dall'array, e poi chiama PruneAnnotationReferencesInStructureTree così il dizionario OBJR il cui /Obj nominava quell'annotazione viene rimosso dal suo elemento di struttura, con l'elemento stesso rimosso se l'OBJR era il suo unico figlio. Lasciare l'OBJR al suo posto violerebbe la §14.7.4.3, che richiede che /Obj riferisca un oggetto esistente, e comparirebbe in un controllo PDF/UA come un link taggato senza annotazione dietro. Nota l'asimmetria con i bookmark: i link vengono rimossi, non riportati altrove. Un rimando nel corpo del testo che diceva "vedi pagina 3" è sbagliato una volta che la pagina 3 è sparita, e puntarlo alla pagina 4 sarebbe una bugia in un modo in cui un bookmark che atterra sul capitolo più vicino non lo è, quindi se il tuo flusso di lavoro ha bisogno di conservare quei link, riportali tu altrove prima di chiamare DeletePage

Perché un /MCR o un /OBJR rimosso non va mai registrato come libero?

Perché le marked-content reference e le object reference sono di solito dizionari diretti dentro l'array /K del loro elemento padre, e il registry dei cambi incrementali risolve un oggetto diretto all'oggetto indiretto più vicino che lo contiene. Quando RemoveArrayItem scarta un figlio da un array /K, libera l'oggetto in memoria solo se era un THPDFLink o un valore non indiretto, e MarkRemovedObject registra un oggetto per la free list solo quando il suo numero di oggetto è maggiore di zero. La prima versione di questa scansione non faceva quella distinzione, e l'effetto in un salvataggio incrementale è stato esattamente ciò per cui il registry è progettato: RegisterIncrementalChange risaliva dal /MCR diretto fino alla sua graph transaction root, che era l'elemento di struttura conservato che lo possedeva, e scriveva quell'elemento come null. Un documento che perdeva una pagina tornava indietro con il contenuto taggato delle altre pagine silenziosamente non taggato. L'unica mossa corretta per un figlio diretto è marcare il suo contenitore come dirty tramite TouchContainer così il contenitore viene riscritto, e lasciare stare la free list

Perché un figlio /MCR o OBJR rimosso non va mai registrato come libero in HotPDF: il registry dei cambi incrementali risolve un dizionario diretto al contenitore indiretto più vicino, quindi la prima versione scriveva l'elemento di struttura conservato come null e destaggava in silenzio le pagine superstiti, mentre ora TouchContainer riscrive il contenitore e lascia stare la free list
Liberare il figlio in memoria è riservato a THPDFLink o ai valori non indiretti e ai numeri di oggetto maggiori di zero, così un salvataggio incrementale aggiunge solo i contenitori toccati e l'oggetto pagina liberato
// Aggiornamento incrementale: nella sezione aggiunta finiscono
// solo i contenitori toccati e l'oggetto pagina liberato.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Gli elementi di struttura conservati il cui /K ha perso
  // un /MCR diretto vengono riscritti sul posto, mai come null.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

La stessa cautela dà forma a ciò che DeletePage di proposito non libera su un documento caricato. I content stream, gli XObject e le annotazioni non-widget della pagina eliminata restano come oggetti, perché un file caricato può condividerne qualcuno con una pagina che resta e non c'è un modo economico di dimostrare il contrario al momento dell'eliminazione. Rimuovere il riferimento nell'albero delle pagine basta per la correttezza; i byte che quegli oggetti occupano ancora sono una domanda separata, e l'object dependency graph e l'analisi dei byte trattenuti è lo strumento per misurare cosa porta ancora con sé un documento potato

DeletePage contro DeleteLoadedPage: quale devi chiamare?

Chiama DeletePage per qualunque rimozione di pagina rivolta all'utente, e riserva DeleteLoadedPage al caso in cui l'intero documento viene riflusso e nessun riferimento a livello di documento vale la pena di conservare. THotPDF.DeleteLoadedPage(PageIndex), aggiunto nella versione 2.508.0, è la variante leggera: sposta l'array interno delle pagine, chiama RebuildLoadedKidsArray per riscrivere /Kids e /Count, invalida la cache delle pagine renderizzate e solleva OnLoadedDocumentModified. Non percorre il name tree, gli outline, lo structure tree o le annotazioni delle altre pagine, e non marca l'oggetto pagina come eliminato. È lo strumento giusto dentro l'imposizione N-up, dove HotPDF aggiunge fogli appena composti e poi scarta ogni pagina originale con DeleteLoadedPage(0): le pagine di origine vengono sostituite in blocco, e il contenuto del foglio riferisce le loro risorse invece che gli oggetti pagina. Per il normale lavoro "togli la pagina 7 da questo contratto", DeletePage è l'unica chiamata che lascia un documento taggato, con bookmark e cross-link abbastanza coerente da passare un validatore, sia in una riscrittura completa tramite SaveLoadedDocument sia in un aggiornamento incrementale tramite SaveIncrementalUpdate. Entrambi i metodi sono distribuiti in HotPDF Delphi Component per Delphi e C++Builder, senza richiedere alcun runtime di viewer esterno o dipendenza