Articolo tecnico

Eliminare pagine PDF in Delphi senza riferimenti penzolanti

Un report taggato a cui è stata tagliata via la copertina può aprirsi con il numero di pagine giusto ed essere comunque rotto in tre punti: il bookmark "Contenuto" non fa nulla quando lo clicchi, il checker di accessibilità segnala un elemento di struttura che non appartiene a nessuna pagina e un validatore severo segnala un riferimento a un oggetto libero. L'albero delle pagine è a posto. ISO 32000-1 §7.7.3 chiede solo che la pagina esca da /Kids e che /Count scali di uno, e un file può rispettare quella clausola con metà del catalogo che punta ancora alla pagina mancante

Sette strutture possono tenere un riferimento a una pagina

Una sola è l'albero delle pagine. Le altre sono sparse tra catalogo, outline, struttura logica e annotazioni di altre pagine, e ognuna di loro risolve a null una volta che l'oggetto pagina è sparito dal file. Cosa fa un viewer con quel null è una sua scelta, quindi lo stesso file alleggerito può comportarsi male in modo diverso in due reader

  • 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 catalogo contiene lo stesso tipo di array, con i nomi come chiavi
  • Gli outline item (§12.3.3) raggiungono una pagina 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) riporta i numeri /StructParents di pagine e annotazioni agli elementi di struttura, e un elemento può starci senza comparire da nessuna parte nella catena di /K dalla radice
  • Le annotazioni link su altre pagine (§12.5.6.5) portano un /Dest o un'azione /GoTo puntata sulla pagina, e il /OpenAction del catalogo può fare lo stesso
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 fa THotPDF.DeletePage prima di toccare /Kids

THotPDF.DeletePage(PageIndex) su un documento caricato passa prima in rassegna tutte quelle strutture e riscrive l'albero delle pagine per ultimo. L'indice è zero-based. Due guardie girano prima che qualcosa cambi: un indice fuori intervallo solleva Invalid page number, e una richiesta di rimuovere l'unica pagina rimasta solleva a sua volta un'eccezione, perché un nodo /Pages con zero figli non descrive un documento valido

L'ordine della scansione è fisso: l'albero /Names /Dests, il dizionario /Dests legacy del catalogo, /OpenAction, l'outline, /StructTreeRoot insieme al suo ParentTree, e infine gli array /Annots di ogni pagina che resta. Solo dopo l'oggetto pagina viene marcato come eliminato, i suoi widget annotation vengono staccati dall'albero dei campi AcroForm, e /Kids, /Count e il /Parent di ogni pagina superstite vengono riscritti. Sul fronte dei form, un campo padre rimasto senza figli dopo che il suo widget è andato viene rimosso a sua volta, fino a risalire a /Fields, così un form alleggerito non conserva alcuna voce di campo senza nulla sullo schermo alle sue spalle

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 abbandonato, le destinazioni dell'outline spostate sulla pagina conservata più vicina, StructTreeRoot e ParentTree potati, i link delle pagine superstiti rimossi, e /Kids, /Count e /Parent riscritti per ultimi
Ogni struttura riceve il trattamento che la specifica consente: i nomi spariscono, i bookmark atterrano sulla pagina conservata più vicina, gli elementi di struttura perdono /Pg o scompaiono, e la riscrittura di /Kids avviene solo dopo che nient'altro può raggiungere l'oggetto eliminato

DeletePages prende una stringa di intervalli one-based come '1,3-5,7-', la notazione che usano le altre operazioni su pagine del documento caricato, ed elimina dalla pagina selezionata più alta verso il basso. Andare dall'alto in basso mantiene validi i numeri che hai scritto mentre le pagine spariscono sotto di essi. Un intervallo che copre tutte le pagine solleva un'eccezione per la stessa ragione per cui DeletePage rifiuta l'ultima, e la funzione restituisce quante pagine ha rimosso

var
  Pdf: THotPDF;
  Removed: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
        // LoadFromFile restituisce il numero di pagine caricate
    if Pdf.LoadFromFile('tagged-report.pdf') > 0 then
    begin
            // Zero-based: la pagina 0 è la copertina
      Pdf.DeletePage(0);
            // Intervallo one-based, numerato sul documento com'è adesso
      Removed := Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

I nomi spariscono, i bookmark si spostano

Le named destination puntate sulla pagina eliminata vengono rimosse. I bookmark puntati su di essa vengono riportati altrove. Nell'albero /Names /Dests ogni nodo viene visitato e ogni destinazione viene provata nelle due forme che la specifica consente, l'array nudo e il dizionario con chiave /D; la coppia nome/valore va via quando il primo elemento dell'array è la pagina eliminata. Un nodo intermedio rimasto senza né /Names né /Kids viene scollegato dal padre, così l'albero non conserva foglie vuote. Il dizionario /Dests legacy del catalogo riceve la stessa prova, chiave per chiave, e un /OpenAction del catalogo che apriva sulla pagina eliminata viene abbandonato, il che lascia la vista iniziale al default del viewer

Un nodo che perde voci perde anche il suo array /Limits. Le nuove chiavi minima e massima non vengono ricalcolate, e i viewer risolvono i nomi senza di esse. Il §7.9.6 richiede /Limits sui nodi intermedi e sulle foglie, quindi se un passaggio di preflight della tua pipeline fa rispettare quella regola in modo severo, fai girare il file alleggerito anche lì come parte dello stesso lavoro

L'outline viene percorso tramite /First e /Next con una lista dei visitati e un limite di profondità di 128, così una catena /Next corrotta che rimanda a se stessa non può bloccare la chiamata. Per ogni array /Dest o array /D di un'azione /GoTo puntato sulla pagina eliminata, il primo elemento viene sostituito con la pagina che la seguiva, o con quella che la precedeva quando la pagina eliminata era l'ultima. I parametri di vista dopo il riferimento alla pagina restano com'erano, così un salto /XYZ conserva i suoi valori left, top e zoom e li applica alla nuova pagina

L'asimmetria è voluta. Un nome che non risolve a nulla è legale e innocuo; un GoTo che nomina una destinazione mancante semplicemente non fa nulla. Un bookmark con destinazione null è peggio: resta nella sidebar, sembra cliccabile e fallisce. Atterrare sulla pagina superstite più vicina è il migliore di due esiti imperfetti. Il retargeting copre solo gli array di destinazione espliciti. Un outline item il cui /Dest è una stringa di nome risolve attraverso il name tree, e una volta che la scansione ha rimosso quel nome il bookmark non va da nessuna parte, quindi i documenti che usano pesantemente i bookmark nominati meritano un controllo dopo l'eliminazione. Il controllo qui sotto legge i nomi con ResolveLoadedNamedDestination, che guarda solo l'array /Names del nodo radice; dà una risposta significativa per un name tree piatto, mentre un albero diviso in /Kids restituisce -1 per ogni nome. La struttura dell'outline in sé, inclusa la semantica non ovvia di /Count, è trattata nella guida all'aggiunta di bookmark e named destination su un PDF caricato

// Controlla il risultato invece di darlo per scontato.
// Memo1 è un TMemo sulla tua form.
Pdf.DeletePage(0);
// ResolveLoadedNamedDestination legge solo l'array /Names radice,
// quindi questo controllo ha senso per un name tree piatto; su un
// albero con /Kids restituisce -1 per ogni nome, eliminato o no
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  Memo1.Lines.Add('"cover" is not resolvable from the root /Names array');
// Un bookmark che puntava alla copertina ora risolve alla pagina
// che la seguiva, cioè l'indice zero-based 0 dopo l'eliminazione
Memo1.Lines.Add(Format('Bookmark "Contents" -> page index %d',
  [Pdf.GetLoadedBookmarkPageIndex('Contents')]));

Potare lo structure tree e il ParentTree

Gli elementi di struttura che esistevano solo per la pagina eliminata vengono rimossi, e gli elementi che coprono più pagine perdono la chiave /Pg ma conservano i figli. La scansione parte da /StructTreeRoot, scende lungo /K fino a una profondità di 128 e accetta sia la forma array sia la forma dizionario singolo di /K che il §14.7.2 consente. I figli di ogni elemento vengono potati prima di giudicare l'elemento stesso. Se la potatura ha svuotato il suo /K, l'elemento viene rimosso e il padre lo lascia cadere. Se il suo /Pg nomina la pagina eliminata ma ha ancora figli e un padre /P, va via solo /Pg, perché /Pg su un elemento è solo la pagina di default per i figli marked-content che non nominano la propria, e quei figli possono benissimo vivere su altre pagine. Un elemento il cui /Pg è la pagina eliminata e che non ha nulla sotto viene rimosso senza indugi; i dizionari marked-content reference il cui /Pg nomina la pagina rientrano in quest'ultima regola

Il ParentTree riceve lo stesso trattamento, per una ragione facile da perdere di vista: un elemento può essere raggiungibile dal ParentTree e da nessun altro posto. Quel number tree mappa gli interi /StructParents a un singolo elemento o a un array di elementi. Ogni valore viene potato con le regole di sopra, i valori rimossi cadono dai loro array, una coppia /Nums il cui array finisce vuoto viene eliminata, e un nodo senza né /Nums né /Kids viene scollegato. Salta questo passaggio e gli orfani continuano a puntare a una pagina liberata tramite /Pg. Per chi estrae testo in ordine di tag la cosa è diretta: la estrazione testo in ordine di struttura percorre esattamente questi alberi, e un elemento con /Pg null è un paragrafo che cade fuori dall'ordine di lettura

Perché i link sulle pagine superstiti vengono rimossi e non riportati altrove?

Un'annotazione link su una pagina conservata il cui array /Dest o la cui azione /GoTo punta alla pagina eliminata viene rimossa dagli /Annots di quella pagina, e il suo tag va con lei. Il dizionario OBJR il cui /Obj nominava l'annotazione viene tolto dal suo elemento di struttura; se quell'OBJR era l'unico figlio dell'elemento, anche l'elemento viene rimosso, e il ParentTree viene potato di nuovo. Lasciare l'OBJR al suo posto violerebbe il §14.7.4.3, che si aspetta che /Obj referenzi un oggetto reale, e un checker PDF/UA vedrebbe un elemento Link il cui object reference non porta da nessuna parte

I bookmark si spostano; i link no. Un riferimento incrociato nel testo che dice "vedi pagina 3" è sbagliato una volta che la pagina 3 è sparita, e puntarlo in silenzio sulla pagina 4 trasforma un link rotto in un link fuorviante. Un bookmark che atterra sul capitolo successivo è navigazione; un link nel corpo del testo è un'affermazione sul contenuto. La rimozione è il default più sicuro. Se il tuo flusso di lavoro ha bisogno che quei link sopravvivano, riscrivi le loro destinazioni prima di chiamare DeletePage, perché dopo la chiamata sono già fuori dagli array /Annots

Salvataggi incrementali e la trappola del figlio diretto

DeletePage si comporta allo stesso modo tra BeginIncrementalUpdate e SaveIncrementalUpdate. La sezione aggiunta contiene gli oggetti modificati dalla scansione più voci libere per gli oggetti rimossi che avevano un numero d'oggetto proprio, come la pagina e le annotazioni link eliminate. Tutto ciò che sta sopra il %%EOF originale resta byte per byte, come richiede il §7.5.6

Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
    // Gli elementi di struttura che hanno perso un figlio /MCR diretto
    // vengono aggiunti in forma riscritta con un array /K più corto
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

È qui che un potatore scritto a mano sbaglia. Le marked-content reference (/Type /MCR) e le object reference (/Type /OBJR) sono in genere dizionari diretti che stanno dentro l'array /K del loro elemento padre. Un oggetto diretto non ha numero d'oggetto né voce xref, quindi non può essere liberato da solo. La mossa ovvia è "liberare" comunque ogni figlio rimosso, e qualsiasi writer che mappa un oggetto diretto all'oggetto indiretto che lo contiene finirà per liberare il contenitore, che è l'elemento di struttura conservato. Quell'elemento esce come null, e il contenuto taggato di pagine mai toccate perde i suoi tag. HotPDF manda un oggetto rimosso nella free list solo quando ha un numero d'oggetto; per un figlio diretto marca l'elemento contenitore come modificato, così l'elemento viene riscritto con il figlio in meno e la free list resta pulita

Perché un figlio /MCR o OBJR rimosso non va mai registrato come libero in un aggiornamento incrementale: è un dizionario diretto dentro l'array /K del suo elemento, quindi liberarlo significherebbe liberare l'elemento di struttura contenitore, che verrebbe scritto come null e de-taggerebbe le pagine superstiti, mentre la mossa corretta riscrive il contenitore e lascia stare la free list
Solo gli oggetti con un numero d'oggetto proprio vanno nella free list, quindi un salvataggio incrementale aggiunge i contenitori riscritti più voci libere per la pagina e gli altri oggetti indiretti rimossi

La stessa cautela decide cosa DeletePage lascia in pace 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ò condividere ognuno di loro con una pagina che resta e non c'è modo economico di provare il contrario al momento dell'eliminazione. Togliere il riferimento dall'albero delle pagine basta per un file corretto. Quanti byte costano ancora quei relitti è una misura a parte, e il grafo delle dipendenze tra oggetti e l'analisi dei byte trattenuti è lo strumento per farla

DeletePage o DeleteLoadedPage?

Usa DeletePage per qualsiasi rimozione di pagina richiesta da un utente. THotPDF.DeleteLoadedPage(PageIndex) è la variante leggera: applica le stesse due guardie, scala la lista delle pagine caricate, riscrive /Kids e /Count, invalida la cache delle pagine renderizzate e scatta OnLoadedDocumentModified. Non tocca il name tree, l'outline, lo structure tree né le annotazioni delle altre pagine, e non marca l'oggetto pagina come eliminato. ComposeLoadedPagesNUp è il lavoro che le si addice: aggiunge fogli appena composti che disegnano le pagine sorgente come form XObject, poi elimina ogni originale con DeleteLoadedPage(0), dato che i fogli referenziano il contenuto delle pagine e non gli oggetti pagina. Per un "togli la pagina 7 da questo contratto" su un file taggato, con bookmark e riferimenti incrociati, DeletePage è la chiamata che lascia ogni riferimento coerente, che tu salvi con SaveLoadedDocument o aggiunga sezioni con SaveIncrementalUpdate

Entrambi i metodi, il DeletePages a intervalli e il percorso di aggiornamento incrementale usato sopra fanno parte dell'HotPDF Delphi Component, che modifica PDF caricati da codice Delphi e C++Builder senza runtime di viewer sulla macchina