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
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
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
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
FlateDecodesimples podem ser embrulhados; outros filtros, ou Flate com preditor, são recusados - Um fluxo de conteúdo tem de pertencer ao
/Contentsde 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/Sdo elemento de estrutura, não do nome da etiquetaBDC - A sincronização da árvore de pais lê uma raiz
/Numsplana 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