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
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, comIntValuepreenchido eFloatValuelevando o mesmo número, então/MCID 3e um3.0perdido 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 FFouFF FE) são decodificadas emStringValue, então/ActualTextchega como texto legível - Outros tipos de valor voltam como
pmpBlobquando o PDFium entrega os bytes brutos, e ficam comopmpUnknowncaso 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
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
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