Artigo Técnico

Manter /ParentTree e /StructParents sincronizados no Delphi

O PDFium Component etiqueta conteúdo PDF existente a partir do Delphi através do EditPdfDocumentStructure em FPdfDocumentStructure.pas. As operações psekAddMarkedContent e psekAddObjectReference fazem mais do que anexar um MCR ou OBJR ao /K de um elemento de estrutura: também alocam a chave /StructParents ou /StructParent e escrevem a entrada /ParentTree correspondente, que é o índice inverso que a ISO 32000-1 §14.7.4.4 exige

Essa segunda metade é onde a adaptação de etiquetas costuma correr mal. Acrescentar um embrulho BDC e um MCR é fácil, e um visualizador que percorra a árvore de estrutura a partir do topo encontra alegremente o conteúdo novo. A tecnologia de assistência muitas vezes vai no outro sentido, porém: parte de um pedaço de conteúdo na página e pergunta que elemento de estrutura o possui. Se a página não tiver chave /StructParents, ou o /ParentTree não tiver entrada para o novo MCID, essa procura falha, e os verificadores reportam-na como uma árvore de pais em falta ou inconsistente. O ficheiro parece etiquetado e ainda assim não funciona

O que mapeia afinal o /ParentTree?

O /ParentTree é uma árvore numérica 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 a página ou Form XObject que contenha conteúdo marcado um inteiro /StructParents, e o valor da árvore de pais sob essa chave é uma matriz indexada por MCID: a entrada 3 da matriz é o elemento de estrutura que possui <</MCID 3>>. Uma anotação ou XObject usado como um item de conteúdo inteiro recebe antes uma única chave /StructParent, e o seu valor na árvore de pais é o elemento de estrutura em si, não uma matriz

Índice inverso de árvore de pais PDF/UA escrito pelo PDFium Component no Delphi: uma marca de conteúdo transportando MCID 3 resolve-se através da chave /StructParents 17 da página para a árvore numérica /ParentTree, cuja entrada 3 da matriz é o elemento de estrutura que possui o conteúdo marcado
A ordem de leitura importa para PDF etiquetado: a tecnologia de assistência caminha do MCID na página através do /StructParents para a matriz /ParentTree, e uma chave ou entrada em falta parte essa procura mesmo que a árvore de estrutura em si pareça bem

O formato do contentor é a árvore numérica genérica do §7.9.7. Uma folha guarda /Nums, uma matriz plana de pares chave e valor ordenada por chave ascendente. Nós intermédios e folhas sob a raiz transportam /Limits com a menor e a maior chave que cobrem, enquanto a própria raiz não deve ter /Limits. Por cima disso, a raiz da árvore de estrutura pode transportar /ParentTreeNextKey (§14.7.2), a próxima chave que um escritor deve entregar. Toda a edição que etiquete conteúdo novo toca portanto até quatro objetos que têm de concordar: o fluxo de conteúdo, o dicionário de página ou formulário, a folha da árvore de pais, e a raiz da árvore de estrutura

A direção para a frente tem as suas próprias regras, enunciadas em §14.7.4.2 e §14.7.4.3. Uma referência de conteúdo marcado (MCR) para conteúdo no próprio fluxo de conteúdo da página precisa só de /Pg e /MCID. Quando o conteúdo marcado vive dentro de um Form XObject, o MCR também precisa de /Stm para nomear esse fluxo, porque os MCIDs são numerados por fluxo de conteúdo. Uma referência de objeto (OBJR) nomeia a anotação ou XObject com /Obj e a sua página com /Pg

Etiquetar um fluxo de conteúdo com o EditPdfDocumentStructure

O EditPdfDocumentStructure é um editor em lote com guarda de fotografia. Primeiro chame o ReadPdfDocumentStructure para obter um TPdfDocumentStructureSnapshot, depois passe o seu SourceSha256 de volta juntamente com uma matriz de registos TPdfStructureEditOperation. Se os bytes da origem mudaram entretanto, a edição é recusada sem saída. Os elementos existentes são endereçados pelo seu número de objeto em NodeID, e 0 denota a raiz da árvore de estrutura

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; // fluxo de conteúdo 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 embrulha o fluxo de destino em /P <</MCID n>> BDC ... EMC, anexa o MCR ao /K do elemento, dá à página (ou ao formulário) uma chave /StructParents se não tiver nenhuma, e escreve o elemento n na matriz da árvore de pais dessa chave. O resultado é escrito como uma atualização incremental depois dos bytes originais, e o Report.CreatedMarkedContents diz-lhe quantos MCRs foram criados. Quando o fluxo já contém pares BDC/EMC equilibrados, só a cauda não etiquetada depois do último EMC de topo é embrulhada; caso contrário todo o fluxo é

Como são escolhidos os novos MCIDs e chaves da árvore de pais?

Um novo MCID é o máximo atual mais um, e uma nova chave de árvore de pais é Max(MaxKey + 1, ParentTreeNextKey). Para o MCID, o EditPdfDocumentStructure toma o máximo entre os MCRs já registados para essa página (ou esse Form XObject) e todos os /MCID que encontrar nas listas de propriedades do próprio fluxo. Contar ambos importa: uma árvore de estrutura pode ter perdido um MCR enquanto o conteúdo ainda transporta a marca, e reutilizar esse número faria dois elementos reclamarem o mesmo conteúdo

A lógica de chaves segue a mesma ideia defensiva. Se o proprietário já tem um valor /StructParents, a matriz existente é estendida no índice MCID, com null a encher qualquer fosso, e nenhuma chave nova é alocada. Se não, o componente varre todas as folhas /Nums à procura da chave mais alta, compara-a com /ParentTreeNextKey, e toma a maior. Um /ParentTreeNextKey obsoleto inferior a chaves já em uso e um que reserve chaves para 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 árvore de pais no editor de estrutura do PDFium Component para Delphi: o novo MCID é um acima do máximo já registado em MCRs e listas de propriedades do fluxo, e a chave é o maior entre a chave /Nums mais alta mais um e /ParentTreeNextKey, com null a encher fossos da matriz
O EditPdfDocumentStructure deriva o novo MCID tanto de MCRs como de marcas no fluxo e aloca a chave da árvore de pais defensivamente, pelo que um /ParentTreeNextKey obsoleto ou um MCID reutilizado não podem fazer dois elementos reclamarem o mesmo conteúdo

O novo par é inserido em /Nums por ordem ascendente em vez de anexado, porque as procuras na árvore numérica dependem dessa ordem. Se a folha transportar /Limits, os limites são recalculados a partir da matriz reescrita. Quando a raiz da árvore de estrutura não tem /ParentTree de todo, o que é comum em ficheiros etiquetados por ferramentas que só escreveram a árvore para a frente, o EditPdfDocumentStructure sintetiza um novo objeto indireto << /Nums [] >> e liga-o a partir do /StructTreeRoot antes de inserir a primeira entrada

Form XObjects, /StmOwn e referências de objetos

Conteúdo marcado dentro de um Form XObject só é suportado quando esse formulário é usado por exatamente uma página. O MCR que o EditPdfDocumentStructure escreve para um formulário transporta /Pg, /Stm a nomear o fluxo do formulário, e /StmOwn a nomear a página, e a chave /StructParents vai no dicionário do formulário em vez de na página. Um formulário desenhado por três páginas tem três proprietários plausíveis, pelo que qualquer /StmOwn único estaria errado para dois deles. O componente recusa a edição com um erro em vez de adivinhar

Regra de um único proprietário para etiquetar conteúdo de Form XObject com o PDFium Component no Delphi: um MCR sobre um formulário usado por uma única página transporta /Pg, /Stm e /StmOwn com /StructParents no dicionário do formulário, enquanto um formulário 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 possui o formulário, razão pela qual formulários partilhados e anotações partilhadas são recusados em vez de etiquetados com um proprietário adivinhável

A mesma regra de unicidade aplica-se ao psekAddObjectReference. O destino tem de ser uma anotação que aparece no /Annots de exatamente uma página, ou um Form ou Image XObject que apareça nos recursos de exatamente uma página; qualquer outra coisa é rejeitada. O OBJR recebe /Obj e /Pg, o destino recebe uma chave /StructParent (reutilizada se presente), e o valor na árvore de pais é uma referência simples ao elemento. Esta é a correção habitual para uma anotação de ligação ou uma imagem de figura que foi etiquetada visualmente mas nunca ligada à árvore

// Embrulhe uma imagem não etiquetada num elemento Figure novo, depois vincule-a de duas formas:
// como um OBJR para o image XObject e como conteúdo marcado na página
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1;              // ID temporário, tem de 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 o contentor 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 NodeID negativos nomeiam contentores criados mais cedo 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 de PDF 1.7, PDF 2.0 e PDF/UA como P, H1 a H6, Figure, Table ou LI, mais papéis personalizados já mapeados no /RoleMap. Definir BeforeNodeID nas duas operações de conteúdo coloca o novo MCR ou OBJR antes de um filho existente em vez de no fim

Por que é que editar o mesmo fluxo duas vezes num lote precisa de cuidado?

Um lote pode atingir o mesmo objeto repetidamente, e toda a operação posterior tem de ver o resultado das anteriores. Duas operações psekAddMarkedContent contra a mesma página ambas reescrevem o seu fluxo de conteúdo, e ambas atualizam a mesma folha da árvore de pais. Uma implementação que prepare cada edição como um objeto anexado separado escreve duas versões do objeto 12 na atualização. Consoante a que o leitor mantém, perde o primeiro embrulho BDC ou o primeiro par /Nums, e o segundo MCID pode colidir com o primeiro porque foi calculado a partir de bytes obsoletos

O EditPdfDocumentStructure indexa as atualizações preparadas por (ObjectNumber, Generation) e sobrescreve uma entrada existente no lugar, e refresca ao mesmo tempo a cópia em memória do objeto. A segunda operação por isso analisa o fluxo já embrulhado, vê o MCID 0, aloca o MCID 1, e estende a mesma matriz da árvore de pais. 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 todos os MCR ou OBJR retidos têm de coincidir, ou a chamada falha sem saída

Fluxos comprimidos seguem o mesmo caminho. Um fluxo de conteúdo com /Filter /FlateDecode (ou uma matriz [/FlateDecode] de um elemento) e nenhum preditor /DecodeParms é descomprimido com um descodificador delimitado com teto de 32 MiB ou MaxOutputBytes, o que for menor, embrulhado, e recomprimido de volta para /FlateDecode com um novo /Length. Um fluxo que se descomprimisse para além do teto é rejeitado em vez de truncado, o que importa para ficheiros de fontes não fidedignas

Limites que valem a pena conhecer antes de uma ronda de reparação PDF/UA

O EditPdfDocumentStructure é conservador por design, e a maioria dos seus limites aparece como um Report.ErrorMessage claro em vez de um ficheiro discretamente partido. Tenha isto em conta quando planear uma reparação em lote:

  • Só fluxos sem filtro e com FlateDecode simples podem ser embrulhados; outros filtros, ou Flate com preditor, são recusados
  • Um fluxo de conteúdo tem de pertencer ao /Contents de exatamente uma página, e Form XObjects, anotações e XObjects têm de ser usados por exatamente uma página
  • O embrulho usa sempre a etiqueta /P; o papel semântico vem do /S do elemento de estrutura, não do nome da etiqueta BDC
  • A sincronização da árvore de pais lê uma raiz /Nums plana ou um nível de folhas /Kids; nós intermédios mais profundos não são descidos, por isso revalide tais ficheiros depois de editar
  • Ficheiros que contenham assinaturas ou campos de assinatura são recusados de saída, por isso assine depois da reparação de estrutura, não antes

O passo de validação não é opcional para um pipeline de reparação. Corra a validação da árvore de estrutura PDF/UA no PDFium Component antes e depois do lote, e use a API de conteúdo marcado de objetos de página para ler etiquetas BDC e MCIDs quando precisar de confirmar que objetos de página acabaram dentro das novas marcas. Uma árvore de pais limpa diz-lhe que as procuras resolvem; não lhe 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 só as entradas para o conteúdo que etiqueta no lote atual, reutilizando chaves /StructParents existentes e alocando novas onde necessário, pelo que as entradas existentes da árvore de pais ficam intocadas

O que acontece se o PDF tiver uma árvore de estrutura mas nenhum /ParentTree?

O componente cria uma árvore numérica indireta << /Nums [] >>, liga-a a partir do /StructTreeRoot, e insere a primeira chave para o conteúdo a ser etiquetado

Posso etiquetar conteúdo num Form XObject partilhado por várias páginas?

Não com esta operação. Um formulário partilhado torna o /StmOwn ambíguo, pelo que o psekAddMarkedContent exige que o formulário seja referenciado por exatamente uma página e reporta um erro caso contrário

Leitura e edição da árvore de estrutura, validação PDF/UA e acesso a conteúdo marcado fazem parte do PDFium Component for Delphi and C++Builder, que embrulha o PDFium para projetos Delphi, C++Builder e Lazarus