Artigo Técnico

Lendo e Escrevendo Marked Content de PDF em Delphi

Em um PDF tagged, o cabeçalho corrente no topo de cada página normalmente fica dentro de /Artifact <</Type /Pagination>> BDC … EMC, enquanto o primeiro parágrafo abaixo dele fica dentro de /P <</MCID 0>> BDC … EMC. Os dois são marked content no sentido da ISO 32000-1 §14.6. Só o parágrafo é alcançável pela árvore de estrutura, porque só o parágrafo carrega um identificador de marked content, e esse inteiro é o que liga os glifos pintados a um elemento de estrutura como /H1 ou /P. Código que audita ou repara marcação, portanto, precisa ler três coisas de cada objeto de página (a tag, a lista de propriedades e o MCID) e conferir o número contra os MCIDs que os elementos de estrutura daquela página de fato reivindicam

Lendo marcas BDC e listas de propriedades em Delphi

No PDFium Component, TPdf.PageObjectMarks(Index) devolve cada marca que envolve o objeto de página em Index como um array de records TPdfContentMark. Cada record carrega a tag em Name, um array Params com a lista de propriedades e um Handle. O nome volta sem a barra inicial, então uma sequência /P reporta P e um artefato reporta Artifact. Sequências de marked content podem se aninhar, e uma sequência de glifos dentro de /Sect, depois /P, depois /Span reporta três records no mesmo objeto, a mais externa primeiro

A ISO 32000-1 §14.6.2 permite que o operando de lista de propriedades do BDC assuma duas formas: um dicionário inline, como em /P <</MCID 3>> BDC, ou um nome como /OC /oc1 BDC que aponta para o subdicionário /Properties dos recursos da página. Optional content (§8.11.3.2) é escrito na segunda forma. O PDFium resolve a forma nomeada ao parsear a página, então nos dois casos Params lista as entradas do dicionário que se aplica à sequência

TPdf.PageObjectMarkedContentID(Index) responde à pergunta que interessa à árvore de estrutura. Ele devolve o MCID encontrado nas marcas do objeto, ou -1 quando nenhuma delas carrega um /MCID inteiro. O cabeçalho do exemplo de abertura devolve -1, e isso está correto: o §14.8.2.2 mantém artefatos fora da estrutura lógica, então um artefato não tem nada a que se ligar

Diagrama da anatomia de marked content de PDF em Delphi: uma tag /P com uma lista de propriedades MCID entre BDC e EMC, os campos do record TPdfContentMark e o link do MCID à árvore de estrutura
Uma marca é uma tag e uma lista de propriedades tipada entre BDC e EMC, reportada como um record cujo MCID se une à árvore de estrutura
var
  Marks: TPdfContentMarks;
  M: TPdfContentMark;
  P: TPdfContentMarkParam;
  I: Integer;
begin
  Pdf.PageNumber := 1;                    // PageNumber é baseado em 1
  for I := 0 to Pdf.ObjectCount - 1 do    // índices de objetos de página são baseados em 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                     // marca mais externa primeiro
    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;

O que pmpUnknown pode e não pode dizer

Cada TPdfContentMarkParam tem uma Key, um Kind e um campo de valor selecionado por esse kind. O mapeamento é curto o bastante para caber na cabeça:

  • Um número com valor integral vira pmpInt, com IntValue preenchido e FloatValue levando o mesmo número, então /MCID 3 e um 3.0 perdido são lidos como inteiros
  • Um número com parte fracionária vira pmpFloat
  • Strings PDF e nomes PDF viram pmpString; strings de texto que começam com uma BOM UTF-16 (FE FF ou FF FE) são decodificadas em StringValue, então /ActualText chega como texto legível
  • Outros tipos de valor voltam como pmpBlob quando o PDFium entrega os bytes brutos, e ficam como pmpUnknown caso contrário

pmpUnknown cobre dois casos que não dá para distinguir. Um é um valor que o PDFium reporta como FPDF_OBJECT_UNKNOWN e do qual não entrega nenhum byte. O outro é uma chave que aparece na lista mas não pode ser lida de volta: uma chave escrita com bytes não ASCII em um arquivo danificado pode aparecer na lista e ainda assim voltar como pmpUnknown. O record é o mesmo nos dois casos

Trate pmpUnknown como "nenhum valor utilizável" e reporte a marca em vez de aceitá-la. Se o seu fluxo depende de /Lang ou /ActualText estarem presentes, procure a chave com um kind que você reconheça; nunca conclua, a partir de um kind desconhecido, que a propriedade está ausente

Um Handle identifica uma marca dentro da página carregada no momento e fica obsoleto assim que a marca é removida, o objeto de página é destruído ou a página é descarregada. Copie Name e Params para as suas próprias estruturas antes de trocar de página. Para dados que precisam sobreviver à página atual, como uma list view ou uma auditoria em segundo plano, ReadPageObjectsSnapshot devolve records TPdfContentMarkSnapshot que guardam só o nome e os parâmetros, sem handle nenhum. A mesma regra vale para handles de objeto de página em geral, como cobrem as notas sobre handles de objeto de página ficando obsoletos após uma transformação

Adicionando, atualizando e removendo marcas

Quatro chamadas cobrem a edição. AddPageObjectMark(Index, Name, Params) cria uma nova marca no objeto, aplica os parâmetros em ordem e devolve o índice da nova marca naquele objeto, que as outras três chamadas recebem como MarkIndex. Os nomes entram sem barra: 'P', 'Span'. Para mudar uma chave, SetPageObjectMarkParam(Index, MarkIndex, Param) insere ou substitui e deixa todo o resto dos parâmetros da marca em paz. As remoções são mais diretas. RemovePageObjectMarkParam(Index, MarkIndex, Key) derruba uma chave; RemovePageObjectMark(Index, MarkIndex) derruba a marca

AddPageObjectMark trata a marca e os parâmetros dela como um passo só. Se um parâmetro for rejeitado (um kind pmpUnknown, uma chave vazia, um blob vazio), a marca recém-criada é removida de novo antes que a exceção se propague, então o objeto nunca fica com uma tag com metade da lista de propriedades

Cada uma dessas chamadas reconstrói o content stream da página antes de retornar. Isso importa porque o SaveAs grava o content stream como ele está e não o regenera a partir do modelo de objetos do PDFium. Uma marca que existisse só no objeto em memória deixaria o arquivo salvo sem um único operador BDC. Se você também edita objetos de página por imports crus do PDFium no ObjectHandle, chame UpdatePage você mesmo antes de salvar

Diagrama de adição de uma tag de marked content em Delphi: AddPageObjectMark reconstrói o fluxo de conteúdo da página antes de SaveAs, enquanto uma mudança apenas no modelo de objetos é silenciosamente descartada do arquivo salvo
AddPageObjectMark reconstrói o content stream para que SaveAs persista a tag, e uma mudança que para no modelo de objetos nunca chega ao arquivo

Reconstruir o stream inteiro a cada chamada funciona bem para poucas edições e é desperdício para algumas centenas. Marcar um relatório sem tags página por página é o segundo tipo de trabalho, e aí as edições pertencem a um BeginPageObjectEditTransaction e um CommitPageObjectEditTransaction. Dentro da transação cada edição só marca a página como alterada; o commit gera o content stream uma vez, e o RollbackPageObjectEditTransaction devolve a página ao estado anterior. Uma transação fica presa a uma página, e mudar o PageNumber para outra página enquanto ela está aberta gera exceção. Em um documento assinado a chamada de begin se recusa a começar a menos que você passe AllowSignedDocument = True, já que editar conteúdo de página quebra a assinatura de qualquer forma

Parâmetros string pedem uma decisão a mais. Valores pmpString são escritos como byte strings, que é exatamente o certo para valores ASCII como /Lang (en-US). Texto de substituição com acentos, ligaduras ou caracteres CJK pertence a uma PDF text string codificada como UTF-16BE com prefixo FE FF (§7.9.2.2). Passe esses bytes como pmpBlob: o PDFium os guarda como um objeto string, e o leitor acima os decodifica de volta em StringValue como pmpString

// Helper seu, não faz parte da biblioteca: PDF text string como UTF-16BE com 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;            // guardado como text string, lê de volta como pmpString
    Params[1].BlobValue := Utf16TextString(Spoken);
    MarkIndex := Pdf.AddPageObjectMark(ObjectIndex, 'Span', Params);

    Lang := Default(TPdfContentMarkParam);
    Lang.Key := 'Lang';
    Lang.Kind := pmpString;               // valor ASCII, uma byte string simples resolve
    Lang.StringValue := 'de-DE';
    Pdf.SetPageObjectMarkParam(ObjectIndex, MarkIndex, Lang);
  except
    Pdf.RollbackPageObjectEditTransaction;
    raise;
  end;
  Pdf.CommitPageObjectEditTransaction;    // uma reconstrução do content stream para o lote inteiro
end;

O commit fica fora do bloco try de propósito. Se a geração de conteúdo falha, o CommitPageObjectEditTransaction restaura a página por conta própria antes de re-relançar, e uma segunda chamada de rollback só levantaria "No page-object edit transaction is active" por cima do erro real

Por que um parágrafo acaba com três sequências MCID 5?

Porque cada chamada AddPageObjectMark cria um dicionário de marca novo e independente, mesmo quando os valores são idênticos. Um parágrafo composto como três objetos de texto, um por linha, marcado com três chamadas que passam /MCID 5, carrega três marcas separadas. Quando o PDFium regenera o content stream, ele mantém objetos consecutivos dentro de um par BDC/EMC só quando eles compartilham a mesma marca; ele compara a identidade da marca, e valores iguais não contam. A página salva então guarda /P <</MCID 5>> BDC … EMC três vezes seguidas

A ISO 32000-1 §14.7.4.2 descreve o MCID como identificando uma sequência de marked content de forma única dentro do content stream dela. Três sequências compartilhando um identificador não cumprem isso, e ferramentas que resolvem MCIDs pela entrada da parent tree da página ficam adivinhando qual das três o elemento de estrutura quer dizer

SharePageObjectMark(TargetIndex, SourceIndex, SourceMarkIndex) anexa o dicionário de propriedades da marca de origem ao objeto de destino em vez de construir uma cópia. Adicione a marca uma vez à primeira linha, compartilhe com as outras e mantenha os objetos consecutivos na ordem de pintura; o stream regenerado então envolve as três linhas em uma única sequência

procedure TagParagraph(Pdf: TPdf; const LineObjects: array of Integer; Mcid: Integer);
// LineObjects: índices dos objetos das linhas do parágrafo, consecutivos na ordem de pintura
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;

Compartilhar tem uma segunda vantagem quando você copia uma marca existente para conteúdo novo, por exemplo estendendo um artefato sobre um filete de rodapé redesenhado. Os kinds de parâmetro que o PDFium escreve são integer, float, string e byte blob, e não existe kind de nome. Um /Artifact <</Type /Pagination>> lido como pmpString e reconstruído via AddPageObjectMark sai com (Pagination), uma string, onde o §14.8.2.2 espera o nome /Pagination. Uma marca compartilhada é o dicionário original, então os nomes dela continuam nomes. Sempre que a marca de origem já existir na página, compartilhe; reconstrua a partir de Params só quando estiver escrevendo uma marca que nunca existiu

Confrontando MCIDs com a árvore de estrutura

Um MCID em um objeto de página significa algo só quando um elemento de estrutura na mesma página o reivindica. StructureElementsForPage[PageNumber] devolve os elementos da página como records TPdfStructureElement, e três campos carregam MCIDs: MarkedContentID (-1 quando o elemento não tem nenhum), MarkedContentIDs para elementos que têm várias sequências e ChildMarkedContentIDs, com -1 nas posições dos filhos que são elementos e não marked content. Uma auditoria precisa olhar os três, porque a entrada /K de um elemento de estrutura pode misturar elementos filhos e referências de marked content em um array só

A auditoria abaixo reporta dois achados. Texto ou imagens cujo MCID nenhum elemento reivindica, ou que não têm MCID nenhum, são conteúdo que tecnologia assistiva vai pular. Um Artifact cujo MCID é reivindicado contraria o §14.8.2.2 frontalmente. Os objetos de página vêm de ReadPageObjectsSnapshot, então os resultados não carregam handles e sobrevivem a uma troca de página

// Helper seu, não faz parte da biblioteca
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;

Caminhos ficam de fora de propósito. Filetes decorativos e bordas de tabela costumam estar sem tag, e sinalizar cada um deles enterra os achados de texto. Form XObjects pedem a cautela oposta: um MCID dentro de um form pertence ao namespace /StructParents do próprio form, então o MCID 0 da página e o MCID 0 dentro de um form são sequências diferentes. O guia sobre namespaces de MCID em Form XObjects tagged cobre como ler isso corretamente

Marcas ainda são só metade de um documento tagged. A ISO 32000-1 §14.7 pede uma árvore de estrutura cujos elementos referenciem esses MCIDs, um dicionário /MarkInfo no catalog com /Marked true, e tipos de estrutura que sejam padrão ou mapeados para padrão via role map. O Pdf.IsTagged diz se o catalog declara o documento como tagged. Uma marca /P cujo MCID nenhum elemento aponta produz conteúdo que alega ser tagged em uma página que a árvore de estrutura nunca menciona

Diagrama PDFium Component mostrando que marcas BDC sozinhas não fazem um PDF tagged, que também precisa de uma árvore de estrutura referenciando MCIDs, uma declaração MarkInfo e nomes de papéis padrão
Marcas apoiam auditoria e reparo de órfãos, enquanto um documento tagged conforme também precisa de uma árvore de estrutura, MarkInfo e nomes de papéis padrão

Para o lado da árvore de estrutura desse trabalho, o guia de validação de árvore de estrutura PDF/UA continua de onde a auditoria de MCID para. A experiência de leitura para a qual as tags existem é o assunto das notas sobre a construção de um leitor de PDF acessível em Delphi

As chamadas usadas aqui (leitura de marcas, transações de edição, marcas compartilhadas, snapshots de objetos de página e elementos de estrutura) fazem parte da mesma API VCL que o PDFium Component dá a projetos Delphi, C++Builder e Lazarus sobre o motor PDFium. A página do produto PDFium Component cobre o resto da API de acessibilidade e edição de páginas