In un PDF tagged, l'intestazione corrente in cima a ogni pagina di solito sta dentro /Artifact <</Type /Pagination>> BDC … EMC, mentre il primo paragrafo sotto di lei sta dentro /P <</MCID 0>> BDC … EMC. Entrambi sono marked content nel senso di ISO 32000-1 §14.6. Solo il paragrafo è raggiungibile dall'albero di struttura, perché solo il paragrafo porta un marked-content identifier, e quell'unico intero è ciò che lega i glifi disegnati a un elemento di struttura come /H1 o /P. Il codice che fa audit o ripara il tagging deve quindi leggere tre cose da ogni oggetto di pagina (il tag, la sua property list e l'MCID) e poi confrontare il numero con gli MCID che gli elementi di struttura di quella pagina rivendicano davvero
Leggere mark BDC e property list da Delphi
In PDFium Component, TPdf.PageObjectMarks(Index) restituisce ogni mark che racchiude l'oggetto di pagina all'indice Index come un array di record TPdfContentMark. Ogni record porta il tag in Name, un array Params che contiene la property list, e un Handle. Il nome torna senza la barra iniziale, quindi una sequenza /P riporta P e un artifact riporta Artifact. Le sequenze di marked content possono annidarsi, e una sequenza di glifi dentro /Sect, poi /P, poi /Span riporta tre record sullo stesso oggetto, dal più esterno in giù
ISO 32000-1 §14.6.2 consente che l'operando property list del BDC prenda due forme: un dizionario inline, come in /P <</MCID 3>> BDC, oppure un nome come /OC /oc1 BDC che punta nel sottodizionario /Properties delle risorse di pagina. Optional content (§8.11.3.2) è scritto nella seconda forma. PDFium risolve la forma con nome mentre analizza la pagina, quindi in entrambi i casi Params elenca le voci del dizionario che si applica alla sequenza
TPdf.PageObjectMarkedContentID(Index) risponde alla domanda a cui l'albero di struttura tiene. Restituisce l'MCID trovato nei mark dell'oggetto, oppure -1 quando nessuno di essi porta un /MCID intero. L'intestazione dell'esempio iniziale restituisce -1, ed è corretto così: §14.8.2.2 tiene gli artifact fuori dalla struttura logica, quindi un artifact non ha nulla a cui collegarsi
var
Marks: TPdfContentMarks;
M: TPdfContentMark;
P: TPdfContentMarkParam;
I: Integer;
begin
Pdf.PageNumber := 1; // PageNumber è a base 1
for I := 0 to Pdf.ObjectCount - 1 do // gli indici degli oggetti di pagina sono a base 0
begin
Marks := Pdf.PageObjectMarks(I);
if Length(Marks) = 0 then
Continue;
Memo1.Lines.Add(Format('object %d, MCID %d',
[I, Pdf.PageObjectMarkedContentID(I)]));
for M in Marks do // il mark più esterno per primo
begin
Memo1.Lines.Add(' mark ' + M.Name);
for P in M.Params do
case P.Kind of
pmpInt: Memo1.Lines.Add(' ' + P.Key + ' = ' + IntToStr(P.IntValue));
pmpFloat: Memo1.Lines.Add(' ' + P.Key + ' = ' + FloatToStr(P.FloatValue));
pmpString: Memo1.Lines.Add(' ' + P.Key + ' = ' + P.StringValue);
pmpBlob: Memo1.Lines.Add(' ' + P.Key + ' = ' +
IntToStr(Length(P.BlobValue)) + ' bytes');
pmpUnknown: Memo1.Lines.Add(' ' + P.Key + ' = (no readable value)');
end;
end;
end;
end;
Ciò che pmpUnknown può e non può dirti
Ogni TPdfContentMarkParam ha una Key, un Kind e un solo campo valore selezionato da quel kind. La mappatura è abbastanza corta da stare in testa:
- Un numero con valore integrale diventa
pmpInt, conIntValueimpostato eFloatValueriempito con lo stesso numero, così/MCID 3e un3.0smarrito leggono entrambi come interi - Un numero con parte frazionaria diventa
pmpFloat - Le PDF string e i PDF name diventano entrambi
pmpString; le text string che iniziano con un byte order mark UTF-16 (FE FFoFF FE) vengono decodificate inStringValue, così/ActualTextarriva come testo leggibile - Gli altri tipi di valore tornano come
pmpBlobquando PDFium ne consegna i byte grezzi, e restanopmpUnknownaltrimenti
pmpUnknown copre due casi che non si possono distinguere. Uno è un valore che PDFium riporta come FPDF_OBJECT_UNKNOWN e per il quale non consegna alcun byte. L'altro è una chiave che compare nell'elenco ma non si riesce a rileggere: una chiave scritta con byte non ASCII in un file danneggiato può apparire nell'elenco e tornare comunque come pmpUnknown. Il record è identico in entrambi i casi
Tratta pmpUnknown come «nessun valore utilizzabile» e riporta il mark anziché accettarlo. Se il tuo flusso di lavoro dipende dalla presenza di /Lang o /ActualText, cerca la chiave con un kind che riconosci; non concludere mai da un kind unknown che la proprietà sia assente
Un Handle identifica un mark dentro la pagina attualmente caricata e diventa stale appena il mark viene rimosso, l'oggetto di pagina viene distrutto o la pagina viene scaricata. Copia Name e Params nelle tue strutture prima di cambiare pagina. Per i dati che devono sopravvivere alla pagina corrente, come una list view o un audit in background, ReadPageObjectsSnapshot restituisce record TPdfContentMarkSnapshot che contengono solo il nome e i parametri, senza alcun handle. La stessa regola vale per gli handle di oggetti di pagina in generale, come coprono le note sugli handle di oggetti di pagina che diventano stale dopo una trasformazione
Aggiungere, aggiornare e rimuovere mark
Quattro chiamate coprono la modifica. AddPageObjectMark(Index, Name, Params) crea un nuovo mark sull'oggetto, applica i parametri in ordine e restituisce l'indice del nuovo mark su quell'oggetto, che le altre tre chiamate prendono come MarkIndex. I nomi vanno inseriti senza barra: 'P', 'Span'. Per cambiare una chiave, SetPageObjectMarkParam(Index, MarkIndex, Param) la inserisce o la sostituisce e non tocca nessun altro parametro del mark. Le rimozioni sono più dirette. RemovePageObjectMarkParam(Index, MarkIndex, Key) elimina una chiave; RemovePageObjectMark(Index, MarkIndex) elimina il mark
AddPageObjectMark tratta il mark e i suoi parametri come un unico passo. Se un parametro viene rifiutato (un kind pmpUnknown, una chiave vuota, un blob vuoto), il mark appena creato viene rimosso di nuovo prima che l'eccezione si propaghi, così l'oggetto non resta mai con un tag che ha metà della sua property list
Ognuna di queste chiamate ricostruisce il content stream della pagina prima di tornare. Conta perché SaveAs scrive il content stream così com'è e non lo rigenera dal modello a oggetti di PDFium. Un mark esistito solo sull'oggetto in memoria lascerebbe il file salvato senza un solo operatore BDC. Se modifichi anche oggetti di pagina tramite le importazioni PDFium dirette su ObjectHandle, chiama tu UpdatePage prima di salvare
Ricostruire l'intero stream a ogni chiamata va bene per poche modifiche ed è uno spreco per alcune centinaia. Taggare un report non taggato pagina per pagina è il secondo tipo di lavoro, e lì le modifiche stanno dentro BeginPageObjectEditTransaction e CommitPageObjectEditTransaction. Dentro la transazione ogni modifica si limita a marcare la pagina come cambiata; il commit genera il content stream una volta sola, e RollbackPageObjectEditTransaction riporta la pagina com'era. Una transazione è legata a una pagina, e impostare PageNumber su un'altra pagina mentre è aperta solleva un'eccezione. Su un documento firmato la chiamata di begin rifiuta di partire a meno che tu non passi AllowSignedDocument = True, dato che modificare il contenuto di pagina rompe comunque la firma
I parametri string richiedono un'ulteriore decisione. I valori pmpString vengono scritti come byte string, che è esattamente ciò che serve per valori ASCII come /Lang (en-US). Il testo di sostituzione con accenti, legature o caratteri CJK sta in una PDF text string codificata UTF-16BE con prefisso FE FF (§7.9.2.2). Passa quei byte come pmpBlob: PDFium li archivia come string object, e il lettore qui sopra li decodifica di nuovo in StringValue come pmpString
// Un tuo helper, non fa parte della libreria: PDF text string come UTF-16BE con BOM
function Utf16TextString(const S: string): TBytes;
var
I: Integer;
begin
SetLength(Result, 2 + Length(S) * 2);
Result[0] := $FE;
Result[1] := $FF;
for I := 1 to Length(S) do
begin
Result[I * 2] := Byte(Ord(S[I]) shr 8);
Result[I * 2 + 1] := Byte(Ord(S[I]));
end;
end;
procedure TagWord(Pdf: TPdf; ObjectIndex, Mcid: Integer; const Spoken: string);
var
Params: TPdfContentMarkParams;
Lang: TPdfContentMarkParam;
MarkIndex: Integer;
begin
Pdf.BeginPageObjectEditTransaction;
try
SetLength(Params, 2);
Params[0].Key := 'MCID';
Params[0].Kind := pmpInt;
Params[0].IntValue := Mcid;
Params[1].Key := 'ActualText';
Params[1].Kind := pmpBlob; // archiviata come text string, si rilegge come pmpString
Params[1].BlobValue := Utf16TextString(Spoken);
MarkIndex := Pdf.AddPageObjectMark(ObjectIndex, 'Span', Params);
Lang := Default(TPdfContentMarkParam);
Lang.Key := 'Lang';
Lang.Kind := pmpString; // valore ASCII, una byte string semplice va bene
Lang.StringValue := 'de-DE';
Pdf.SetPageObjectMarkParam(ObjectIndex, MarkIndex, Lang);
except
Pdf.RollbackPageObjectEditTransaction;
raise;
end;
Pdf.CommitPageObjectEditTransaction; // una sola ricostruzione del content stream per l'intero batch
end;
Il commit sta fuori dal blocco try di proposito. Se la generazione del contenuto fallisce, CommitPageObjectEditTransaction ripristina la pagina da sé prima di rilanciare, e una seconda chiamata di rollback non farebbe che sollevare «No page-object edit transaction is active» sopra il vero errore
Perché un paragrafo finisce con tre sequenze MCID 5?
Perché ogni chiamata a AddPageObjectMark crea un dizionario di mark nuovo e indipendente, anche quando i valori sono identici. Un paragrafo composto come tre oggetti testo, uno per riga, taggato con tre chiamate che passano ciascuna /MCID 5, porta tre mark separati. Quando PDFium rigenera il content stream tiene oggetti consecutivi dentro una sola coppia BDC/EMC solo quando condividono lo stesso mark; confronta l'identità del mark, e i valori uguali non contano. La pagina salvata contiene quindi /P <</MCID 5>> BDC … EMC tre volte di fila
ISO 32000-1 §14.7.4.2 descrive l'MCID come l'identificatore di una sequenza di marked content in modo univoco all'interno del suo content stream. Tre sequenze che condividono uno stesso identificatore non rispettano questo requisito, e gli strumenti che risolvono gli MCID attraverso la voce parent tree della pagina restano a indovinare quale delle tre intendi l'elemento di struttura
SharePageObjectMark(TargetIndex, SourceIndex, SourceMarkIndex) aggancia al target il dizionario delle proprietà del mark sorgente invece di costruirne una copia. Aggiungi il mark una volta alla prima riga, condividilo con le altre e tieni gli oggetti consecutivi in ordine di disegno; lo stream rigenerato avvolge così tutte e tre le righe in un'unica sequenza
procedure TagParagraph(Pdf: TPdf; const LineObjects: array of Integer; Mcid: Integer);
// LineObjects: indici degli oggetti delle righe del paragrafo, consecutivi in ordine di disegno
var
Params: TPdfContentMarkParams;
MarkIndex, I: Integer;
begin
SetLength(Params, 1);
Params[0].Key := 'MCID';
Params[0].Kind := pmpInt;
Params[0].IntValue := Mcid;
Pdf.BeginPageObjectEditTransaction;
try
MarkIndex := Pdf.AddPageObjectMark(LineObjects[0], 'P', Params);
for I := 1 to High(LineObjects) do
Pdf.SharePageObjectMark(LineObjects[I], LineObjects[0], MarkIndex);
except
Pdf.RollbackPageObjectEditTransaction;
raise;
end;
Pdf.CommitPageObjectEditTransaction;
end;
La condivisione ha un secondo vantaggio quando copi un mark esistente su contenuto nuovo, per esempio estendendo un artifact su una riga di piè di pagina ridisegnata. I kind di parametri che PDFium sa scrivere sono intero, float, string e blob di byte, e non esiste un kind name. Un /Artifact <</Type /Pagination>> letto come pmpString e ricostruito tramite AddPageObjectMark esce con (Pagination), una stringa, dove §14.8.2.2 si aspetta il name /Pagination. Un mark condiviso è il dizionario originale, quindi i suoi name restano name. Ogni volta che il mark sorgente esiste già sulla pagina, condividilo; ricostruisci dai Params solo quando scrivi un mark che non è mai esistito
Confrontare gli MCID con l'albero di struttura
Un MCID su un oggetto di pagina significa qualcosa solo quando un elemento di struttura della stessa pagina lo rivendica. StructureElementsForPage[PageNumber] restituisce gli elementi della pagina come record TPdfStructureElement, e tre campi portano MCID: MarkedContentID (-1 quando l'elemento non ne ha), MarkedContentIDs per gli elementi che possiedono più sequenze, e ChildMarkedContentIDs, con -1 negli slot dei figli che sono elementi anziché marked content. Un audit deve guardare tutti e tre, perché la voce /K di un elemento di struttura può mescolare elementi figli e riferimenti a marked content in un unico array
L'audit qui sotto riporta due reperti. Testo o immagini il cui MCID nessun elemento rivendica, o che non hanno alcun MCID, sono contenuto che la tecnologia assistiva salterà. Un Artifact il cui MCID è rivendicato contraddice frontalmente §14.8.2.2. Gli oggetti di pagina vengono da ReadPageObjectsSnapshot, quindi i risultati non contengono handle e sopravvivono a un cambio di pagina
// Un tuo helper, non fa parte della libreria
function McidClaimed(const Elements: TPdfStructureElements; Mcid: Integer): Boolean;
var
E: TPdfStructureElement;
Id: Integer;
begin
for E in Elements do
begin
if E.MarkedContentID = Mcid then
Exit(True);
for Id in E.MarkedContentIDs do
if Id = Mcid then
Exit(True);
for Id in E.ChildMarkedContentIDs do
if Id = Mcid then
Exit(True);
end;
Result := False;
end;
procedure AuditPage(Pdf: TPdf; PageNo: Integer; Log: TStrings);
var
Snap: TPdfPageObjectsSnapshot;
Status: TPdfPageObjectSnapshotStatus;
Elements: TPdfStructureElements;
Obj: TPdfPageObjectSnapshot;
IsArtifact: Boolean;
J: Integer;
begin
Snap := Pdf.ReadPageObjectsSnapshot(PageNo,
TPdfPageObjectSnapshotOptions.Full, Status);
if not Status.Completed then
Log.Add(Format('page %d: snapshot hit a limit, results are partial', [PageNo]));
Elements := Pdf.StructureElementsForPage[PageNo];
for Obj in Snap.Objects do
begin
IsArtifact := False;
for J := 0 to High(Obj.Marks) do
if Obj.Marks[J].Name = 'Artifact' then
IsArtifact := True;
if IsArtifact then
begin
if (Obj.MarkedContentID >= 0) and McidClaimed(Elements, Obj.MarkedContentID) then
Log.Add(Format('object %d: artifact claimed by the structure tree', [Obj.Index]));
end
else if Obj.ObjectType in [otText, otImage] then
if (Obj.MarkedContentID < 0) or not McidClaimed(Elements, Obj.MarkedContentID) then
Log.Add(Format('object %d: content with no structure owner', [Obj.Index]));
end;
end;
I path sono lasciati fuori di proposito. Filetti decorativi e bordi di tabella spesso non sono taggati, e segnalarli tutti uno a uno seppellisce i reperti sul testo. I Form XObject meritano la cautela opposta: un MCID dentro un form appartiene allo spazio dei nomi /StructParents di quel form, quindi MCID 0 sulla pagina e MCID 0 dentro un form sono sequenze diverse. La guida sugli spazi dei nomi MCID nei Form XObject taggati copre come leggerli correttamente
I mark sono ancora solo metà di un documento tagged. ISO 32000-1 §14.7 chiede un albero di struttura i cui elementi referenzino questi MCID, un dizionario /MarkInfo nel catalogo con /Marked true, e tipi di struttura che siano standard o mappati a quelli standard tramite la role map. Pdf.IsTagged dice se il catalogo dichiara il documento taggato. Un mark /P il cui MCID non è puntato da alcun elemento produce contenuto che rivendica di essere taggato su una pagina che l'albero di struttura non menziona mai
Per il lato albero di struttura di quel lavoro, la guida sulla validazione dell'albero di struttura PDF/UA riprende da dove si ferma l'audit degli MCID. L'esperienza di lettura per cui i tag esistono è l'argomento delle note sulla costruzione di un lettore PDF accessibile in Delphi
Le chiamate usate qui (lettura dei mark, transazioni di modifica, mark condivisi, snapshot degli oggetti di pagina ed elementi di struttura) fanno parte della stessa API VCL che PDFium Component mette a disposizione dei progetti Delphi, C++Builder e Lazarus sul motore PDFium. La pagina prodotto PDFium Component copre il resto dell'API di accessibilità e modifica delle pagine