Togli sette pagine da un manuale di 200 pagine e ogni segnalibro finisce da qualche parte sbagliata. La soluzione non è ricostruire lo outline da un elenco piatto di titoli. PDFiumPas espone TPdfOutlineEditor, che carica il vero albero outline, ti lascia spostare e reindirizzare le voci, poi esegue ApplyPageMap per spostare ogni destinazione esplicita attraverso il tuo piano di pagine
Perché eliminare pagine rompe ogni segnalibro?
Perché una voce outline non memorizza un numero di pagina. Memorizza un riferimento a un page object, e quando i page object cambiano il riferimento punta o a una pagina spostata o al nulla. ISO 32000-1 §12.3.2.2 definisce una destinazione esplicita come un array il cui primo elemento è un riferimento indiretto a un dizionario di pagina, seguito da un nome di fit come /Fit o /XYZ. Elimini la pagina e ti resta un riferimento pendente; riordini le pagine e il riferimento è ancora valido ma ora descrive un capitolo diverso. PDFiumPas risolve quellarray in un numero di pagina al caricamento, quindi TPdfOutlineItem.PageNumber ti dà un indice di pagina a base uno che corrisponde alla API pubblica di TPdf invece che a un numero di oggetto. Questo è il senso dell'astrazione: la tua logica di rimappatura lavora nello stesso sistema di coordinate del piano di pagine che hai già costruito quando hai diviso, riordinato o imposto il documento. Se stai costruendo quel piano, la stessa convenzione a base uno attraversa la pagina su dividere documenti PDF in più file e quella su imposizione n-up e riordino delle pagine
Lo outline è un albero a doppi collegamenti, non una lista
Il motivo per cui non puoi semplicemente serializzare un array piatto di titoli è che ISO 32000-1 §12.3.3 collega ogni voce outline a cinque collegamenti distinti: /Parent, /Prev, /Next, /First e /Last. Spostare un solo subtree quindi riscrive il vecchio padre, il nuovo padre, i fratelli vicini su entrambi i lati del taglio e del punto di inserimento, e il puntatore al padre del nodo spostato stesso. Sbagliane uno e i reader conformi mostrano un albero troncato, o un ciclo. PDFiumPas tiene lo stato di modifica come un array depth-first di record TPdfOutlineItem con un Id intero stabile, così un subtree è una fetta contigua e la catena dei fratelli è derivata, mai mantenuta a mano. TPdfOutlineEditor.Move solleva quella fetta, la reinserisce sotto il nuovo padre allindice di fratello richiesto, e riassegna solo la radice del blocco. Rifiuta anche i due spostamenti che corromperebbero il grafo: spostare una voce nel suo stesso subtree, e nominare un padre che non esiste
Perché /Count è con segno?
Perché il segno trasporta lo stato di espansione, non la dimensione. Un /Count positivo significa che la voce è aperta e il numero è quanti discendenti sono attualmente visibili; un /Count negativo significa che la voce è chiusa. PDFiumPas scrive il conteggio dei discendenti per ogni voce che ha figli e lo nega quando IsOpen è False, e al caricamento rilegge lo stato come IsOpen := HasCount and (CountValue > 0). Questo è il bug fatto in casa più comune tra chi scrive outline: emettere un conteggio senza segno e forzare in silenzio aperto lintero albero
var
Source, Dest: TMemoryStream;
Editor: TPdfOutlineEditor;
Options: TPdfOutlineEditOptions;
Report: TPdfOutlineValidationReport;
RootId, ChapterId: Integer;
begin
Source := TMemoryStream.Create;
Dest := TMemoryStream.Create;
Editor := nil;
try
Source.LoadFromFile('handbook.pdf');
Options := TPdfOutlineEditOptions.Default; // MaxItems 100000, MaxDepth 64
if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
raise Exception.Create(Report.ErrorMessage);
RootId := Editor[0].Id;
ChapterId := Editor[2].Id;
Editor.Move(ChapterId, RootId, 1); // diventa il secondo figlio della radice
Editor.SetTitle(ChapterId, 'Appendix B');
Editor.SetStyle(ChapterId, [posBold, posItalic]);
Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
Editor.SetExpanded(RootId, False); // scrive un /Count negativo
Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');
if not Editor.SaveIncremental(Source, Dest, Report) then
raise Exception.Create(Report.ErrorMessage);
Dest.SaveToFile('handbook-edited.pdf');
finally
Editor.Free;
Dest.Free;
Source.Free;
end;
end;
Retarget gestisce entrambe le forme che la specifica permette. Passi DestinationInAction come False e PDFiumPas scrive un array /Dest diretto; passi True e scrive una azione Go-To, /A << /S /GoTo /D [ page ref suffix ] >>, secondo ISO 32000-1 §12.6.4.2. In ogni caso prima rimuove ogni /Dest e /A esistenti dalla voce così i due non possono coesistere e discordare. Il suffisso è /Fit per impostazione predefinita e deve iniziare con un nome PDF, ecco perché un suffisso vuoto o malformato solleva subito invece di produrre un array di destinazione che nessun reader sa analizzare
Come consuma ApplyPageMap un piano di pagine?
ApplyPageMap prende esattamente larray che il tuo piano di pagine ha già validato: NewPageNumbers, indicizzato per pagina vecchia meno uno, che contiene il nuovo numero di pagina a base uno oppure zero quando quella pagina non è sopravvissuta. Percorre larray delle voci allindietro così che eliminare un subtree non invalidi mai un indice che deve ancora visitare, e riporta cosa ha fatto attraverso RemappedDestinationCount e RemovedDanglingItemCount
var
NewPageNumbers: array of Integer;
Report: TPdfOutlineValidationReport;
I: Integer;
begin
// Una voce per pagina del documento ORIGINALE
SetLength(NewPageNumbers, OriginalPageCount);
for I := 0 to OriginalPageCount - 1 do
NewPageNumbers[I] := 0; // 0 == questa pagina è stata scartata
NewPageNumbers[0] := 1; // pagina vecchia 1 -> pagina nuova 1
NewPageNumbers[1] := 2;
NewPageNumbers[9] := 3; // pagina vecchia 10 -> pagina nuova 3
// True: elimina lintero subtree pendente. False: tieni la voce, togli il suo target
if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
raise Exception.Create(Report.ErrorMessage);
WriteLn(Format('%d remapped, %d dangling items removed',
[Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;
Il flag DeleteDangling decide la politica per una destinazione mappata a zero, ed entrambi i rami sono deliberati. Con True, PDFiumPas elimina la voce e il suo intero subtree, perché un nodo outline il cui target è sparito di solito guida un capitolo sparito con lui. Con False, la voce sopravvive con titolo e gerarchia intatti ma con /Dest e /A rimossi, che è ciò che vuoi quando una persona la reindirizzerà in revisione. Un input davvero malformato fallisce comunque rumorosamente invece di essere rattoppato: una voce negativa o una destinazione che punta oltre la fine della mappa fornita restituisce False con IssueKind impostato a poviInvalidPageMap
Voci opache, e il compromesso onesto
Non ogni voce outline ha un numero di pagina su cui PDFiumPas può ragionare. Tre tipi vengono portati avanti intatti: le destinazioni con nome, le azioni che non sono /S /GoTo, e le chiavi di dizionario sconosciute aggiunte da chi ha prodotto il file. Queste si caricano con PageNumber uguale a zero, tengono i loro byte originali nella voce, e vengono riscritte alla lettera a meno che tu non chiami esplicitamente Retarget su loro
- Una destinazione con nome è una chiave nel name tree del documento, quindi rimapparla correttamente significa risolvere lalbero e riscrivere la voce target, non indovinare a livello outline
- Una azione
/URI,/Launcho JavaScript non ha nessuna semantica di pagina e non deve essere convertita in silenzio in una Go-To - Le chiavi specifiche del produttore e le destinazioni di struttura sono preservate perché buttare ciò che non capisci è così che i round-trip perdono dati
Il costo è reale e vale la pena dirlo chiaro: ApplyPageMap salta quelle voci del tutto, così un documento i cui segnalibri usano tutti destinazioni con nome esce da una eliminazione di pagine con lo outline strutturalmente valido e semanticamente stantio. È la scelta deliberata — un collegamento stantio che un revisore può beccare batte uno sicuramente sbagliato che nessuno nota. Se stai facendo il triage dei file in arrivo prima di modificarli, un passaggio di inventario in un workbench di revisione intake PDF ti dirà quali documenti cadono in quel gruppo
Salvataggio: revisione incrementale, poi un ricaricamento indipendente
TPdfOutlineEditor.SaveIncremental aggiunge una revisione incrementale sparsa invece di riscrivere il file. Le voci caricate tengono il loro riferimento a oggetto indiretto originale inclusa la generazione esatta, così i riferimenti incrociati esistenti restano validi; solo le voci che hai aggiunto tirano un numero fresco, allocato da uno oltre il numero massimo di oggetti della revisione. Il catalogue viene aggiornato nella stessa revisione, e una voce /Outlines mancante vi viene aggiunta quando la sorgente non aveva nessun outline
Ciò che avviene dopo la scrittura è la parte da copiare. PDFiumPas riapre lo stream di destinazione con un editor completamente indipendente e confronta lalbero ricaricato con quello in memoria — numero di voci, titoli, numeri di pagina, suffissi di destinazione, forma azione-contro-destinazione diretta, stili, stato di espansione, e relazioni col padre. Qualsiasi mancata corrispondenza, o qualsiasi fallimento di caricamento, svuota lo stream di destinazione e restituisce poviVerificationFailure invece di consegnarti un file dallaspetto plausibile. Le sorgenti cifrate vengono rifiutate allinizio con poviEncryptedInput, dato che nuovi titoli e destinazioni creano contenuto stringa che non può essere prodotto copiando in avanti il trailer /Encrypt
if not Editor.SaveIncremental(Source, Dest, Report) then
case Report.IssueKind of
poviEncryptedInput:
Log('Source is encrypted; outline editing needs an unprotected copy');
poviInvalidDestination:
Log(Format('Item %d %d targets a missing page',
[Report.ObjectNumber, Report.Generation]));
poviVerificationFailure:
Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
else
Log(Report.ErrorMessage);
end;
Tratta lo outline per ciò che è — un grafo di oggetti collegati con i suoi invarianti — e leliminazione di pagine smette di essere un disastro di segnalibri e diventa una mappa di pagine da passare a una chiamata di metodo. TPdfOutlineEditor, ApplyPageMap e il writer incrementale verificato sono in PDFiumPas dalla v3.98.0 per Delphi, C++Builder e Lazarus; puoi rivedere la API completa e scaricare una prova sulla pagina del prodotto PDFium Delphi Component