Unire due PDF a mano, spostare un singolo oggetto pagina nel documento di destinazione, e la copia inciampa subito in una violazione di accesso. PDFlibPas lo corregge in CopyForeignObject: esegue la copia profonda di un oggetto indiretto più l'intera sua chiusura di riferimenti, e risolve i back-reference ciclici come /Parent a null invece di ricorrere
Perché copiare una pagina tra documenti va in crash?
Perché un albero di pagine PDF è un albero solo se lo leggi verso il basso. Percorri come fa un copiatore ricorsivo, seguendo ogni valore di ogni dizionario, e il dizionario pagina ti porge /Parent, che punta al nodo /Pages da cui sei arrivato, e quel nodo ti porge /Kids, che punta alla pagina. ISO 32000-1 §7.7.3 rende /Parent obbligatorio su ogni nodo dell'albero pagine tranne la radice, quindi non è un file malformato da rifiutare — è la forma normale di ogni documento che ti capiterà mai tra le mani
La seconda metà del problema è la numerazione. Gli oggetti indiretti sono identificati da un numero di oggetto locale a un file (ISO 32000-1 §7.3.10), quindi un oggetto trainato dal documento A al documento B va rinumerato, e ogni riferimento dentro la chiusura copiata va rinumerato allo stesso modo, o due riferimenti che puntavano a un unico font condiviso ora puntano a due cose senza relazione. Quella rinumerazione è lo stesso lavoro che un merge veloce fa a livello di byte, e vale la pena leggerli affiancati: lo spostamento di riferimenti a livello di byte per un merge PDF veloce lo risolve traducendo file interi, mentre una copia a livello di oggetto deve risolverlo un arco alla volta
Cosa copia davvero CopyForeignObject di PDFlibPas
TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) clona un oggetto indiretto e tutto ciò che da esso è raggiungibile — dizionari annidati, array, stringhe, nomi, numeri e stream con i loro dizionari intatti — nel documento attualmente selezionato, e restituisce un handle non nullo verso il nuovo riferimento indiretto. I numeri di oggetto sorgente vengono rimappati attraverso una mappa viva tenuta per la durata della chiamata, così un oggetto raggiunto due volte nella chiusura viene clonato una volta e condiviso due. Restituisce zero, senza sollevare eccezioni, quando l'ID del documento sorgente è sconosciuto, quando la sorgente è il documento selezionato stesso, o quando ObjectNumber è sotto 1
var
Lib: TPDFlib;
SourceDoc, TargetDoc, Handle: Integer;
begin
Lib := TPDFlib.Create;
try
TargetDoc := Lib.NewDocument;
if Lib.LoadFromFile('source.pdf', '') <> 1 then
Exit; // LoadFromFile restituisce 1 in caso di successo
SourceDoc := Lib.SelectedDocument; // il load seleziona ciò che ha caricato
Lib.SelectDocument(TargetDoc); // la copy punta al documento selezionato
Handle := Lib.CopyForeignObject(SourceDoc, 12);
if Handle = 0 then
raise Exception.Create('cross-document copy rejected');
finally
Lib.Free;
end;
end;
Due dettagli mordono al primo giro. LoadFromFile risponde 1 o 0, non un ID documento, quindi l'handle che serve arriva da SelectedDocument subito dopo il caricamento; e la copy scrive sempre in ciò che SelectDocument ha reso corrente per ultimo, mai nel documento da cui hai caricato. Internamente la ricorsione porta anche un tetto rigido di profondità di 64, che è un paracadute contro annidamenti patologici, non il meccanismo che gestisce i cicli — la gestione dei cicli è separata e deliberata
Perché riservare una mappatura Nil non spezza il ciclo?
Perché Nil nella tabella di mappatura significa due cose diverse insieme, e il codice non sa distinguerle. La difesa ovvia contro un ciclo è aggiungere la voce di mappa prima di ricorrere nell'oggetto, così chiunque rientri indietro trova la voce e si ferma. Ma la voce non può ancora contenere il target vero — il target non esiste finché la chiusura sotto di esso non è stata scritta — quindi contiene Nil, e la lookup che dovrebbe intercettare l'arco di ritorno legge Nil e conclude che l'oggetto non è mai stato mappato
// Rotto: un target Nil riservato è indistinguibile da "non ancora mappato"
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
SetLength(Map, Length(Map) + 1);
Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
Map[High(Map)].Target := nil; // riservato, ancora Nil
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // riempito solo all'uscita
end;
Seguite questo attraverso il ciclo delle pagine. Il clone della pagina raggiunge /Parent, ricorre nel nodo /Pages, che raggiunge /Kids, che ricorre di nuovo nella pagina — la cui voce riservata legge ancora Nil, così viene clonata una seconda volta, e una terza, ogni livello che spinge un frame nuovo e un oggetto mezzo costruito nuovo. Quello che osservi non è nemmeno uno stack overflow pulito: i frame esterni stanno su riferimenti i cui target non sono mai stati assegnati, così la prima scrittura attraverso uno di quegli slot è una violazione di accesso in un punto che non somiglia per niente alla copia di pagina che l'ha causata
La correzione: uno stato in-progress esplicito
La riparazione è smettere di sovraccaricare Nil e fare la domanda direttamente. Una voce di mappa il cui target è ancora non assegnato significa questo oggetto è attualmente in fase di clonazione, e un predicato InProgress testa esattamente quello prima che la lookup ordinaria giri. Quando è vero, l'arco è un ciclo che rientra in un antenato del clone corrente, e PDFlibPas emette per esso un oggetto null invece di seguirlo
// Una voce di mappa con target Nil marca un clone in corso
function InProgress(Num: Integer): Boolean;
var
I: Integer;
begin
Result := False;
for I := 0 to High(Map) do
if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
Exit(True);
end;
// ... dentro CloneObject, per un riferimento indiretto:
if InProgress(SrcRef.ObjNum) then
Exit(FStructure.NewNull); // arco di ritorno ciclico, non ricorrere
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
Exit(FStructure.NewNull); // riferimento sorgente appeso
SetLength(Map, Length(Map) + 1);
Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
Map[High(Map)].Target := nil; // riserva, poi ricorri
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // completamento differito
end;
Exit(NewRef);
Questo è generalizzabile senza rischi solo grazie a un fatto strutturale del PDF: i cicli nel grafo degli oggetti compaiono sui back-link, non sugli archi di contenuto. /Parent nell'albero pagine e /Prev in una catena di outline puntano in alto o all'indietro verso qualcosa di già visitato; la chiusura di un font, di un image XObject o di un form XObject scorre verso il basso e termina. Così la copia di un font descriptor, di uno spazio colore o di un dizionario di shading non è toccata dalla sostituzione con null — niente in quelle chiusure incontra mai InProgress. Il costo, detto apertamente, è che l'arco ciclico non sopravvive alla copia. Un dizionario pagina clonato così arriva con /Parent come oggetto null, che ISO 32000-1 §7.3.9 rende equivalente a una voce assente, quindi la pagina copiata è un oggetto valido che non appartiene ad alcun albero pagine finché non lo colleghi tu al nodo /Pages di destinazione e sistemi /Count da solo. Un elemento outline copiato perde il suo /Prev allo stesso modo e richiede la catena dei fratelli ricostruita. È il compromesso onesto: CopyForeignObject ti dà una chiusura corretta e lascia il re-parenting strutturale al chiamante, che è lo stesso confine entro cui lavora la sostituzione di pagine preservando i numeri di oggetto
Perché la voce di mappa va riservata prima di NewObjRef
Un'alternativa ovvia aggirerebbe tutta la danza dell'in-progress: allocare prima un oggetto guscio vuoto, registrarne il numero vero nella mappa, poi riempire il guscio una volta clonati i figli. Qui non funziona, perché TPDFIndObj.Obj è di sola lettura e il suo contenuto non può essere sostituito dopo la costruzione — non c'è nessun guscio da riempire. Il numero e il contenuto vengono decisi insieme da NewObjRef, il che significa che la voce di mappa va creata prima della chiamata ricorsiva e completata dopo, e l'intervallo tra quei due momenti è esattamente ciò che InProgress deve coprire. Una conseguenza da conoscere prima di fare il diff dell'output: siccome NewObjRef gira dopo che la chiusura dei figli è stata scritta, la numerazione nel target esce bottom-up, e i numeri di oggetto non rispecchieranno l'ordine della sorgente. Nulla nel formato di file se ne cura, ma un confronto byte a byte contro un'attesa costruita a mano sì. Se un'esecuzione lascia oggetti che hai deciso di non collegare a nulla, sono non referenziati anziché corrotti, e la collezione mark-and-sweep degli oggetti PDF non raggiungibili è lo strumento che li ripulisce prima del salvataggio
La regressione che copre questo richiede un dettaglio che sorprende chi scrive test contro TPDFlib: il costruttore tiene già un documento predefinito, quindi DocumentCount parte da 1 e un fixture a due documenti deve asserire >= 2, non = 2. Accanto alla copia riuscita, il test fissa i tre rifiuti — un ID sorgente sconosciuto, il documento selezionato come sorgente di se stesso, e un numero di oggetto pari a zero — tutti che restituiscono 0 anziché sollevare, perché un ciclo di merge è un posto sbagliato dove scoprire che una guard clause lancia eccezioni
Dove questo si colloca in una pipeline di merge
La copia a livello di oggetto è la primitiva a cui attingere quando il merge di file interi è troppo grossolano: estrarre un font programme da un template, pescare un singolo form XObject dentro un documento per timbri, o spostare un'annotazione con i suoi appearance stream tra file senza trascinarsi dietro il resto della pagina. PDFlibPas la espone come un'unica chiamata su documenti caricati, e puoi vedere come si colloca con il resto dell'API di oggetti di basso livello nella referencia della PDFlibPas Delphi PDF Library