O PDFium Component marca conteúdo PDF existente a partir do Delphi por meio do EditPdfDocumentStructure em FPdfDocumentStructure.pas. As operações psekAddMarkedContent e psekAddObjectReference fazem mais que anexar um MCR ou OBJR ao /K de um elemento de estrutura: elas também alocam a chave /StructParents ou /StructParent e gravam a entrada correspondente no /ParentTree, que é o índice reverso que a ISO 32000-1 §14.7.4.4 exige
Essa segunda metade é onde retrofitar marcas costuma dar errado. Adicionar um wrapper BDC e um MCR é fácil, e um viewer que percorre a structure tree do topo felizmente acha o conteúdo novo. Tecnologia assistiva frequentemente vai no outro sentido, porém: ela parte de um pedaço de conteúdo na página e pergunta qual elemento de estrutura o possui. Se a página não tem chave /StructParents, ou o /ParentTree não tem entrada para o novo MCID, essa busca falha, e os verificadores a reportam como uma parent tree ausente ou inconsistente. O arquivo parece marcado e ainda assim não funciona
O que o /ParentTree realmente mapeia?
O /ParentTree é uma number tree que mapeia chaves inteiras para elementos de estrutura, e as chaves vêm do conteúdo, não da árvore. A ISO 32000-1 §14.7.4.4 dá a toda página ou Form XObject que contém marked content um inteiro /StructParents, e o valor da parent tree sob aquela chave é um array indexado por MCID: a entrada 3 do array é o elemento de estrutura que possui <</MCID 3>>. Uma anotação ou XObject usado como item de conteúdo inteiro recebe uma única chave /StructParent em vez disso, e seu valor na parent tree é o elemento de estrutura em si, não um array
O formato do contêiner é a number tree genérica da §7.9.7. Uma folha guarda /Nums, um array plano de pares chave e valor ordenado em ordem crescente de chave. Nós intermediários e folhas sob a raiz carregam /Limits com a menor e a maior chave que cobrem, enquanto a raiz em si não deve ter /Limits. Por cima disso, a raiz da structure tree pode carregar /ParentTreeNextKey (§14.7.2), a próxima chave que um writer deve entregar. Toda edição que marca conteúdo novo portanto toca até quatro objetos que precisam concordar: o content stream, o dicionário de página ou de form, a folha da parent tree, e a raiz da structure tree
A direção para frente tem suas próprias regras, postas na §14.7.4.2 e §14.7.4.3. Uma referência de marked content (MCR) para conteúdo no próprio content stream da página precisa apenas de /Pg e /MCID. Quando o marked content vive dentro de um Form XObject, o MCR também precisa de /Stm para nomear aquele stream, porque MCIDs são numerados por content stream. Uma referência de objeto (OBJR) nomeia a anotação ou XObject com /Obj e sua página com /Pg
Marcando um content stream com EditPdfDocumentStructure
O EditPdfDocumentStructure é um editor em lote com guarda de snapshot. Você primeiro chama o ReadPdfDocumentStructure para obter um TPdfDocumentStructureSnapshot, então devolve seu SourceSha256 junto com um array de registros TPdfStructureEditOperation. Se os bytes de origem mudaram nesse meio-tempo, a edição é recusada sem saída. Elementos existentes são endereçados por seu número de objeto no NodeID, e 0 denota a raiz da structure tree
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 existente, por número de objeto
Ops[0].TargetObjectNumber := StreamObj; // content stream da página ou Form XObject da página
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;
Para essa única operação, o EditPdfDocumentStructure envolve o stream alvo em /P <</MCID n>> BDC ... EMC, anexa o MCR ao /K do elemento, dá à página (ou ao form) uma chave /StructParents se não tiver nenhuma, e grava o elemento n no array da parent tree para aquela chave. O resultado é gravado como uma atualização incremental depois dos bytes originais, e o Report.CreatedMarkedContents diz quantos MCRs foram criados. Quando o stream já contém pares BDC/EMC balanceados, apenas a cauda sem marca depois do último EMC de nível superior é envolvida; caso contrário, o stream inteiro é
Como novos MCIDs e chaves de parent tree são escolhidos?
Um novo MCID é o máximo atual mais um, e uma nova chave de parent tree é Max(MaxKey + 1, ParentTreeNextKey). Para o MCID, o EditPdfDocumentStructure toma o máximo entre os MCRs já registrados para aquela página (ou aquele Form XObject) e todo /MCID que achar nas listas de propriedades do próprio stream. Contar ambos importa: uma structure tree pode ter perdido um MCR enquanto o conteúdo ainda carrega a marca, e reutilizar aquele número faria dois elementos reivindicarem o mesmo conteúdo
A lógica de chave segue a mesma ideia defensiva. Se o dono já tem um valor /StructParents, o array existente é estendido no índice MCID, com null preenchendo qualquer buraco, e nenhuma chave nova é alocada. Se não, o componente varre toda folha /Nums pela chave mais alta, compara-a com o /ParentTreeNextKey, e toma o maior. Um /ParentTreeNextKey obsoleto abaixo de chaves já em uso e um que reserve chaves além da última entrada são ambos tratados, porque o maior dos dois valores está sempre livre. Depois da alocação, o /ParentTreeNextKey avança para a nova chave mais um
O novo par é inserido no /Nums em ordem crescente em vez de anexado, porque as buscas em number trees dependem dessa ordem. Se a folha carrega /Limits, os limites são recomputados a partir do array regravado. Quando a raiz da structure tree não tem /ParentTree de forma alguma, o que é comum em arquivos marcados por ferramentas que só gravaram a árvore para frente, o EditPdfDocumentStructure sintetiza um novo objeto indireto << /Nums [] >> e o liga do /StructTreeRoot antes de inserir a primeira entrada
Form XObjects, /StmOwn e referências de objeto
Marked content dentro de um Form XObject é suportado apenas quando aquele form é usado por exatamente uma página. O MCR que o EditPdfDocumentStructure grava para um form carrega /Pg, /Stm nomeando o stream do form, e /StmOwn nomeando a página, e a chave /StructParents vai no dicionário do form em vez de na página. Um form desenhado por três páginas tem três donos plausíveis, então qualquer /StmOwn único estaria errado para dois deles. O componente recusa a edição com um erro em vez de chutar
A mesma regra de unicidade vale para o psekAddObjectReference. O alvo precisa ser uma anotação que aparece no /Annots de exatamente uma página, ou um XObject Form ou Image que aparece nos recursos de exatamente uma página; qualquer outra coisa é rejeitada. O OBJR recebe /Obj e /Pg, o alvo recebe uma chave /StructParent (reutilizada se presente), e o valor da parent tree é uma referência simples ao elemento. Esse é o conserto usual para uma anotação de link ou uma imagem de figura que foi marcada visualmente mas nunca conectada à árvore
// Envolva uma imagem sem marca em um elemento Figure novo, depois ata-a de duas formas:
// como um OBJR para o image XObject e como marked content na página
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1; // ID temporário, precisa ser negativo e único
Ops[0].ParentNodeID := SectID; // elemento Sect existente
Ops[0].Value := 'Figure'; // papel padrão ou uma entrada RoleMap
Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1; // refere ao contêiner criado acima
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]));
Valores negativos de NodeID nomeiam contêineres criados antes no mesmo lote, e o Report.InsertedNodes mapeia cada ID pedido para o número de objeto que recebeu. O psekInsertContainer aceita papéis padrão PDF 1.7, PDF 2.0 e PDF/UA como P, H1 a H6, Figure, Table ou LI, mais papéis customizados já mapeados no /RoleMap. Definir BeforeNodeID nas duas operações de conteúdo posiciona o novo MCR ou OBJR antes de um filho existente em vez de no fim
Por que editar o mesmo stream duas vezes em um lote exige cuidado?
Um lote pode atingir o mesmo objeto repetidamente, e toda operação posterior precisa ver o resultado das anteriores. Duas operações psekAddMarkedContent contra a mesma página ambas regravam seu content stream, e ambas atualizam a mesma folha da parent tree. Uma implementação que prepare cada edição como um objeto anexado separado grava duas versões do objeto 12 na atualização. Dependendo de qual o leitor mantém, você perde o primeiro wrapper BDC ou o primeiro par de /Nums, e o segundo MCID pode colidir com o primeiro porque foi computado de bytes obsoletos
O EditPdfDocumentStructure indexa as atualizações preparadas por (ObjectNumber, Generation) e sobrescreve uma entrada existente no lugar, e ele atualiza a cópia em memória do objeto ao mesmo tempo. A segunda operação portanto interpreta o stream já envolto, vê MCID 0, aloca MCID 1, e estende o mesmo array da parent tree. Um objeto, uma versão final. Antes de qualquer coisa ser devolvida, a saída é lida de volta e comparada com o modelo em memória: contagem de elementos, contagens de itens de conteúdo e todo MCR ou OBJR retido precisam casar, ou a chamada falha sem saída
Streams comprimidos seguem o mesmo caminho. Um content stream com /Filter /FlateDecode (ou um array de um elemento [/FlateDecode]) e nenhum predictor de /DecodeParms é descomprimido com um decodificador limitado com teto de 32 MiB ou MaxOutputBytes, o que for menor, envolto, e recomprimido de volta para /FlateDecode com um novo /Length. Um stream que inflaria além do teto é rejeitado em vez de truncado, o que importa para arquivos de origens não confiáveis
Limites que valem conhecer antes de uma rodada de reparo PDF/UA
O EditPdfDocumentStructure é conservador por design, e a maioria de seus limites aparece como um Report.ErrorMessage claro em vez de um arquivo silenciosamente quebrado. Tenha estes em mente ao planejar um reparo em lote:
- Apenas streams sem filtro e com
FlateDecodesimples podem ser envolvidos; outros filtros, ou Flate com predictor, são recusados - Um content stream precisa pertencer ao
/Contentsde exatamente uma página, e Form XObjects, anotações e XObjects precisam ser usados por exatamente uma página - O wrapper sempre usa a tag
/P; o papel semântico vem do/Sdo elemento de estrutura, não do nome da tagBDC - A sincronização da parent tree lê uma raiz
/Numsplana ou um nível de folhas/Kids; nós intermediários mais fundos não são descendidos, então revalide tais arquivos depois de editar - Arquivos que contêm assinaturas ou campos de assinatura são recusados de cara, então assine depois do reparo de estrutura, não antes
O passo de validação não é opcional para um pipeline de reparo. Rode a validação de structure tree PDF/UA no PDFium Component antes e depois do lote, e use a API de marked content de page objects para ler tags BDC e MCIDs quando precisar confirmar quais page objects acabaram dentro das novas marcas. Uma parent tree limpa diz que as buscas resolvem; não diz que a ordem de leitura ou o texto alternativo estão certos
FAQ
O EditPdfDocumentStructure reconstrói o /ParentTree inteiro?
Não. O EditPdfDocumentStructure atualiza apenas as entradas para o conteúdo que marca no lote atual, reutilizando chaves /StructParents existentes e alocando novas onde necessário, então as entradas existentes da parent tree ficam intocadas
O que acontece se o PDF tem uma structure tree mas nenhum /ParentTree?
O componente cria uma number tree indireta << /Nums [] >>, liga-a do /StructTreeRoot, e insere a primeira chave para o conteúdo sendo marcado
Posso marcar conteúdo em um Form XObject compartilhado por várias páginas?
Não com esta operação. Um form compartilhado torna o /StmOwn ambíguo, então o psekAddMarkedContent exige que o form seja referenciado por exatamente uma página e reporta um erro caso contrário
Leitura e edição de structure tree, validação PDF/UA e acesso a marked content fazem parte do PDFium Component for Delphi and C++Builder, que embrulha o PDFium para projetos Delphi, C++Builder e Lazarus