Articolo tecnico

Leggere e scrivere marked content PDF in Delphi

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

Diagramma dell'anatomia del contenuto marcato PDF in Delphi: un tag /P con una lista di proprietà MCID tra BDC ed EMC, i campi del record TPdfContentMark, e il collegamento MCID all'albero della struttura
Un mark è un tag e una lista di proprietà tipizzate tra BDC e EMC, riportata come record il cui MCID si unisce all'albero della struttura
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, con IntValue impostato e FloatValue riempito con lo stesso numero, così /MCID 3 e un 3.0 smarrito 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 FF o FF FE) vengono decodificate in StringValue, così /ActualText arriva come testo leggibile
  • Gli altri tipi di valore tornano come pmpBlob quando PDFium ne consegna i byte grezzi, e restano pmpUnknown altrimenti

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

Diagramma dell'aggiunta di un tag di contenuto marcato in Delphi: AddPageObjectMark ricostruisce lo stream di contenuto della pagina prima di SaveAs, mentre una modifica solo al modello a oggetti viene scartata silenziosamente dal file salvato
AddPageObjectMark ricostruisce lo stream di contenuto così SaveAs persiste il tag, e una modifica che si ferma al modello di oggetti non raggiunge mai il file

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

Diagramma PDFium Component che mostra che i soli marchi BDC non fanno un PDF taggato, che ha bisogno anche di un albero della struttura che riferisca MCID, una dichiarazione MarkInfo e nomi di ruolo standard
I mark supportano audit e riparazione degli orfani mentre un documento taggato conforme richiede anche un albero di struttura, MarkInfo e nomi di ruolo standard

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