PDFium Component etichetta il contenuto PDF esistente da Delphi attraverso EditPdfDocumentStructure in FPdfDocumentStructure.pas. Le operazioni psekAddMarkedContent e psekAddObjectReference fanno più che accodare un MCR o OBJR al /K di un elemento di struttura: allocano anche la chiave /StructParents o /StructParent e scrivono la voce /ParentTree corrispondente, che è l'indice inverso che ISO 32000-1 §14.7.4.4 richiede
Quella seconda metà è dove l'aggiunta a posteriori di etichette di solito va storta. Aggiungere un wrapper BDC e un MCR è facile, e un viewer che percorre l'albero di struttura dall'alto trova felicemente il nuovo contenuto. La tecnologia assistiva spesso però va nell'altro senso: parte da un pezzo di contenuto sulla pagina e chiede quale elemento di struttura lo possiede. Se la pagina non ha una chiave /StructParents, o il /ParentTree non ha una voce per il nuovo MCID, quella ricerca fallisce, e i checker lo riportano come parent tree mancante o incoerente. Il file sembra etichettato e continua comunque a non funzionare
Cosa mappa davvero il /ParentTree?
Il /ParentTree è un number tree che mappa chiavi intere su elementi di struttura, e le chiavi vengono dal contenuto, non dall'albero. ISO 32000-1 §14.7.4.4 dà a ogni pagina o Form XObject che contiene marked content un intero /StructParents, e il valore del parent tree sotto quella chiave è un array indicizzato per MCID: la voce 3 dell'array è l'elemento di struttura che possiede <</MCID 3>>. Un'annotazione o un XObject usato come contenuto intero riceve invece una singola chiave /StructParent, e il suo valore nel parent tree è l'elemento di struttura stesso, non un array
Il formato contenitore è il number tree generico del §7.9.7. Una foglia tiene /Nums, un array piatto di coppie chiave e valore ordinato per chiave crescente. I nodi intermedi e foglia sotto la radice portano /Limits con la chiave più piccola e più grande che coprono, mentre la radice stessa non deve avere /Limits. In aggiunta, la radice dell'albero di struttura può portare /ParentTreeNextKey (§14.7.2), la prossima chiave che un writer dovrebbe distribuire. Ogni modifica che etichetta nuovo contenuto tocca quindi fino a quattro oggetti che devono concordare: il content stream, il dizionario di pagina o form, la foglia del parent tree, e la radice dell'albero di struttura
La direzione in avanti ha le sue regole, stabilite in §14.7.4.2 e §14.7.4.3. Un riferimento a marked content (MCR) per contenuto nel content stream proprio della pagina ha bisogno solo di /Pg e /MCID. Quando il marked content vive dentro un Form XObject, l'MCR richiede anche /Stm per nominare quello stream, perché gli MCID si numerano per content stream. Un riferimento a oggetto (OBJR) nomina l'annotazione o l'XObject con /Obj e la sua pagina con /Pg
Etichettare un content stream con EditPdfDocumentStructure
EditPdfDocumentStructure è un editor batch con guardia a snapshot. Prima chiamate ReadPdfDocumentStructure per ottenere un TPdfDocumentStructureSnapshot, poi ripassate il suo SourceSha256 insieme a un array di record TPdfStructureEditOperation. Se i byte della sorgente sono cambiati nel frattempo, la modifica viene rifiutata senza output. Gli elementi esistenti si indirizzano per il loro numero di oggetto in NodeID, e 0 denota la radice dell'albero di struttura
uses
System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentStructure;
function TagContentStream(const FileName, OutName: string;
ElementID, StreamObj, StreamGen: Integer): Boolean;
var
Source: TMemoryStream;
Options: TPdfStructureEditOptions;
Snapshot: TPdfDocumentStructureSnapshot;
Ops: TPdfStructureEditOperations;
Report: TPdfStructureEditReport;
Output: TBytes;
begin
Source := TMemoryStream.Create;
try
Source.LoadFromFile(FileName);
Options := TPdfStructureEditOptions.Default;
if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
raise Exception.Create(Report.ErrorMessage);
SetLength(Ops, 1);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekAddMarkedContent;
Ops[0].NodeID := ElementID; // elemento esistente, per numero di oggetto
Ops[0].TargetObjectNumber := StreamObj; // content stream della pagina o Form XObject di pagina
Ops[0].TargetGeneration := StreamGen;
Result := EditPdfDocumentStructure(Source, Snapshot.SourceSha256,
Ops, Options, Output, Report);
if not Result then
raise Exception.Create(Report.ErrorMessage);
TFile.WriteAllBytes(OutName, Output);
finally
Source.Free;
end;
end;
Per quell'unica operazione, EditPdfDocumentStructure avvolge lo stream di destinazione in /P <</MCID n>> BDC ... EMC, accoda l'MCR al /K dell'elemento, dà alla pagina (o alla form) una chiave /StructParents se non ne ha, e scrive l'elemento n nell'array del parent tree per quella chiave. Il risultato viene scritto come aggiornamento incrementale dopo i byte originali, e Report.CreatedMarkedContents vi dice quanti MCR sono stati creati. Quando lo stream contiene già coppie BDC/EMC bilanciate, viene avvolta solo la coda non etichettata dopo l'ultimo EMC di primo livello; altrimenti lo è l'intero stream
Come si scelgono i nuovi MCID e le chiavi del parent tree?
Un nuovo MCID è il massimo corrente più uno, e una nuova chiave del parent tree è Max(MaxKey + 1, ParentTreeNextKey). Per l'MCID, EditPdfDocumentStructure prende il massimo tra gli MCR già registrati per quella pagina (o quel Form XObject) e ogni /MCID che trova negli elenchi di proprietà propri dello stream. Contare entrambi conta: un albero di struttura può aver perso un MCR mentre il contenuto porta ancora il marchio, e riusare quel numero farebbe dichiarare lo stesso contenuto a due elementi
La logica delle chiavi segue la stessa idea difensiva. Se il proprietario ha già un valore /StructParents, l'array esistente viene esteso all'indice MCID, con null a riempire eventuali buchi, e nessuna nuova chiave viene allocata. Se no, il componente scannerizza ogni foglia /Nums in cerca della chiave più alta, la confronta con /ParentTreeNextKey, e prende la maggiore. Una /ParentTreeNextKey stantia più bassa delle chiavi già in uso e una che riserva chiavi oltre l'ultima voce sono entrambe gestite, perché la maggiore dei due valori è sempre libera. Dopo l'allocazione, /ParentTreeNextKey viene avanzata alla nuova chiave più uno
La nuova coppia viene inserita in /Nums in ordine crescente invece che accodata, perché le ricerche nel number tree contano su quell'ordine. Se la foglia porta /Limits, i confini vengono ricalcolati dall'array riscritto. Quando la radice dell'albero di struttura non ha affatto un /ParentTree, cosa comune nei file etichettati da strumenti che hanno scritto solo l'albero in avanti, EditPdfDocumentStructure sintetizza un nuovo oggetto indiretto << /Nums [] >> e lo collega da /StructTreeRoot prima di inserire la prima voce
Form XObject, /StmOwn e riferimenti a oggetti
Il marked content dentro un Form XObject è supportato solo quando quella form è usata da esattamente una pagina. L'MCR che EditPdfDocumentStructure scrive per una form porta /Pg, /Stm che nomina lo stream della form, e /StmOwn che nomina la pagina, e la chiave /StructParents va sul dizionario della form invece che sulla pagina. Una form disegnata da tre pagine ha tre proprietari plausibili, quindi qualunque /StmOwn singolo sarebbe sbagliato per due di loro. Il componente rifiuta la modifica con un errore invece di tirare a indovinare
La stessa regola di unicità si applica a psekAddObjectReference. La destinazione deve essere un'annotazione che appare nello /Annots di esattamente una pagina, o un Form o Image XObject che appare nelle risorse di esattamente una pagina; tutto il resto viene rifiutato. L'OBJR riceve /Obj e /Pg, la destinazione riceve una chiave /StructParent (riusata se presente), e il valore nel parent tree è un semplice riferimento all'elemento. È la correzione consueta per un'annotazione di collegamento o un'immagine figura etichettata visivamente ma mai collegata all'albero
// Avvolgete un'immagine non etichettata in un nuovo elemento Figure, poi legatela in due modi:
// come OBJR per l'image XObject e come marked content sulla pagina
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1; // ID temporaneo, deve essere negativo e univoco
Ops[0].ParentNodeID := SectID; // elemento Sect esistente
Ops[0].Value := 'Figure'; // ruolo standard o una voce RoleMap
Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1; // si riferisce al contenitore creato sopra
Ops[1].TargetObjectNumber := ImageObj;
Ops[1].TargetGeneration := 0;
Ops[2] := Default(TPdfStructureEditOperation);
Ops[2].Kind := psekAddMarkedContent;
Ops[2].NodeID := -1;
Ops[2].TargetObjectNumber := ContentObj;
Ops[2].TargetGeneration := 0;
if EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops, Options,
Output, Report) then
Writeln(Format('Figure is object %d, %d OBJR, %d MCR', [
Report.InsertedNodes[0].ObjectNumber,
Report.CreatedObjectReferences, Report.CreatedMarkedContents]));
I valori NodeID negativi nominano contenitori creati prima nello stesso batch, e Report.InsertedNodes mappa ogni ID richiesto al numero di oggetto che ha ricevuto. psekInsertContainer accetta ruoli standard PDF 1.7, PDF 2.0 e PDF/UA come P, H1 a H6, Figure, Table o LI, più ruoli personalizzati già mappati in /RoleMap. Impostare BeforeNodeID sulle due operazioni di contenuto colloca il nuovo MCR o OBJR prima di un figlio esistente invece che in coda
Perché modificare lo stesso stream due volte in un batch richiede attenzione?
Un batch può colpire lo stesso oggetto ripetutamente, e ogni operazione successiva deve vedere il risultato delle precedenti. Due operazioni psekAddMarkedContent sulla stessa pagina riscrivono entrambe il suo content stream, e entrambe aggiornano la stessa foglia del parent tree. Un'implementazione che mette in scena ogni modifica come oggetto accodato separato scrive due versioni dell'oggetto 12 nell'aggiornamento. A seconda di quale il reader conserva, perdete il primo wrapper BDC o la prima coppia /Nums, e il secondo MCID può collidere con il primo perché è stato calcolato da byte stanti
EditPdfDocumentStructure chiava gli aggiornamenti in scena per (ObjectNumber, Generation) e sovrascrive una voce esistente sul posto, e rinfresca nello stesso tempo la copia in memoria dell'oggetto. La seconda operazione quindi analizza lo stream già avvolto, vede MCID 0, alloca MCID 1, ed estende lo stesso array del parent tree. Un oggetto, una versione finale. Prima che qualcosa venga restituito, l'output viene riletto e confrontato con il modello in memoria: il conteggio degli elementi, i conteggi degli elementi di contenuto e ogni MCR o OBJR trattenuto devono combaciare, o la chiamata fallisce senza output
Gli stream compressi seguono la stessa strada. Un content stream con /Filter /FlateDecode (o un array a un elemento [/FlateDecode]) e nessun predittore /DecodeParms viene decompresso con un decoder limitato con tetto a 32 MiB o MaxOutputBytes, chi è minore, avvolto, e ricompresso di nuovo in /FlateDecode con un nuovo /Length. Uno stream che si decomprimerebbe oltre il tetto viene rifiutato invece che troncato, il che conta per i file da fonti non fidate
Limiti da conoscere prima di un ciclo di riparazione PDF/UA
EditPdfDocumentStructure è conservativo per progetto, e la maggior parte dei suoi limiti affiora come un chiaro Report.ErrorMessage invece che come un file silenziosamente rotto. Tenete presenti questi punti quando pianificate una riparazione in batch:
- Solo stream non filtrati e
FlateDecodesemplici possono essere avvolti; gli altri filtri, o Flate con predittore, vengono rifiutati - Un content stream deve appartenere al
/Contentsdi esattamente una pagina, e Form XObject, annotazioni e XObject devono essere usati da esattamente una pagina - Il wrapper usa sempre il tag
/P; il ruolo semantico viene dal/Sdell'elemento di struttura, non dal nome del tagBDC - La sincronizzazione del parent tree legge una radice
/Numspiatta o un livello di foglie/Kids; i nodi intermedi più profondi non vengono percorsi, quindi rivalidate tali file dopo la modifica - I file che contengono firme o campi firma vengono rifiutati in toto, quindi firmate dopo la riparazione della struttura, non prima
Il passo di validazione non è facoltativo per una pipeline di riparazione. Eseguite la validazione dell'albero di struttura PDF/UA in PDFium Component prima e dopo il batch, e usate la API marked-content dei page object per leggere i tag BDC e gli MCID quando dovete confermare quali page object sono finiti dentro i nuovi marchi. Un parent tree pulito vi dice che le ricerche si risolvono; non vi dice che l'ordine di lettura o il testo alternativo sono giusti
FAQ
EditPdfDocumentStructure ricostruisce l'intero /ParentTree?
No. EditPdfDocumentStructure aggiorna solo le voci per il contenuto che etichetta nel batch corrente, riusando le chiavi /StructParents esistenti e allocandone di nuove dove serve, così le voci esistenti del parent tree restano intatte
Cosa accade se il PDF ha un albero di struttura ma nessun /ParentTree?
Il componente crea un number tree indiretto << /Nums [] >>, lo collega da /StructTreeRoot, e inserisce la prima chiave per il contenuto che si sta etichettando
Si può etichettare contenuto in un Form XObject condiviso da parecchie pagine?
Non con questa operazione. Una form condivisa rende ambiguo /StmOwn, quindi psekAddMarkedContent richiede che la form sia referenziata da esattamente una pagina e riporta un errore altrimenti
Lettura e modifica dell'albero di struttura, validazione PDF/UA e accesso al marked content fanno parte del PDFium Component per Delphi e C++Builder, che incapsula PDFium per progetti Delphi, C++Builder e Lazarus