Artigo Técnico

Mantendo /ParentTree e /StructParents em sincronia em Delphi

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

Índice reverso de parent tree PDF/UA gravado pelo PDFium Component em Delphi: uma marca de conteúdo carregando MCID 3 resolve pela chave /StructParents 17 da página na number tree /ParentTree, cuja entrada 3 do array é o elemento de estrutura que possui o marked content
A ordem de leitura importa para PDF marcado: tecnologia assistiva caminha do MCID na página pelo /StructParents até o array /ParentTree, e uma chave ou entrada ausente quebra essa busca mesmo que a structure tree em si pareça bem

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

Alocação de MCID e chave de parent tree no editor de estrutura do PDFium Component para Delphi: o novo MCID é um acima do máximo já registrado em MCRs e listas de propriedades do stream, e a chave é o maior entre a chave mais alta de /Nums mais um e o /ParentTreeNextKey, com null preenchendo buracos do array
O EditPdfDocumentStructure deriva o novo MCID tanto de MCRs quanto de marcas no stream e aloca a chave da parent tree de forma defensiva, então um /ParentTreeNextKey obsoleto ou um MCID reutilizado não podem fazer dois elementos reivindicarem o mesmo conteúdo

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

Regra de dono único para marcar conteúdo de Form XObject com o PDFium Component em Delphi: um MCR em um form usado por uma única página carrega /Pg, /Stm e /StmOwn com /StructParents no dicionário do form, enquanto um form desenhado por três páginas torna o /StmOwn ambíguo e a edição é recusada
A chave /StmOwn só faz sentido quando uma página é dona do form, e é por isso que forms compartilhados e anotações compartilhadas são recusados em vez de marcados com um dono chutável

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 FlateDecode simples podem ser envolvidos; outros filtros, ou Flate com predictor, são recusados
  • Um content stream precisa pertencer ao /Contents de 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 /S do elemento de estrutura, não do nome da tag BDC
  • A sincronização da parent tree lê uma raiz /Nums plana 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