Articolo tecnico

Modifica outline PDF e rimappatura pagine in Delphi

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

Modifica outline con PDFiumPas in Delphi: spostare il Capitolo 3 fuori dalla Parte I e sotto la radice del documento riscrive il puntatore /Parent del nodo spostato più i collegamenti /First e i fratelli /Prev e /Next attorno al taglio e al punto di inserimento
Una chiamata Move riscrive il puntatore al padre del subtree sollevato e i collegamenti dei fratelli su entrambi i lati del taglio e del punto di inserimento

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

Come PDFiumPas codifica lo stato di espansione dello outline in Delphi: un /Count positivo significa voce aperta e conta i discendenti visibili, un /Count negativo significa chiusa, e un conteggio senza segno forza ogni reader a espandere lintero albero
Il segno di /Count è lo stato di espansione e il modulo è il conteggio dei discendenti visibili, così un conteggio senza segno forza 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

Come ApplyPageMap di PDFiumPas reindirizza i segnalibri PDF in Delphi: una mappa di pagine indicizzata per pagina vecchia meno uno manda le destinazioni sopravvissute ai loro nuovi numeri di pagina, mentre le voci mappate a zero vengono o eliminate col loro subtree o private del loro target
La mappa di pagine è indicizzata per pagina vecchia meno uno, e una voce a zero o elimina il subtree pendente o lascia la voce col suo target rimosso

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, /Launch o 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