Artículo técnico

Mantener /ParentTree y /StructParents en sincronía en Delphi

PDFium Component etiqueta contenido PDF existente desde Delphi a través de EditPdfDocumentStructure en FPdfDocumentStructure.pas. Las operaciones psekAddMarkedContent y psekAddObjectReference hacen más que anexar un MCR u OBJR al /K de un elemento de estructura: también asignan la clave /StructParents o /StructParent y escriben la entrada /ParentTree correspondiente, que es el índice inverso que exige ISO 32000-1 §14.7.4.4

Esa segunda mitad es donde la readaptación de etiquetas suele salir mal. Agregar un envolvente BDC y un MCR es fácil, y un visor que recorre el árbol de estructura desde arriba encontrará felizmente el contenido nuevo. La tecnología asistencial a menudo va al revés, sin embargo: parte de un pedazo de contenido en la página y pregunta qué elemento de estructura lo posee. Si la página no tiene clave /StructParents, o el /ParentTree no tiene entrada para el nuevo MCID, esa búsqueda falla, y los verificadores lo reportan como un parent tree faltante o inconsistente. El archivo parece etiquetado y aun así no funciona

¿Qué mapea realmente /ParentTree?

El /ParentTree es un number tree que mapea claves enteras a elementos de estructura, y las claves vienen del contenido, no del árbol. ISO 32000-1 §14.7.4.4 da a cada página o Form XObject que contiene marked content un entero /StructParents, y el valor del parent tree bajo esa clave es un arreglo indexado por MCID: la entrada 3 del arreglo es el elemento de estructura que posee <</MCID 3>>. Una anotación o un XObject usado como ítem de contenido completo recibe en cambio una única clave /StructParent, y su valor del parent tree es el elemento de estructura mismo, no un arreglo

Índice inverso de parent tree PDF/UA escrito por PDFium Component en Delphi: una marca de contenido que lleva MCID 3 se resuelve a través de la clave /StructParents 17 de la página hasta el number tree /ParentTree, cuya entrada 3 del arreglo es el elemento de estructura que posee el marked content
El orden de lectura importa para el PDF etiquetado: la tecnología asistencial camina desde el MCID en la página por /StructParents hasta el arreglo /ParentTree, y una clave o entrada faltante rompe esa búsqueda aunque el árbol de estructura mismo se vea bien

El formato contenedor es el number tree genérico de §7.9.7. Una hoja contiene /Nums, un arreglo plano de pares clave y valor ordenado en orden ascendente de claves. Los nodos intermedios y hoja bajo la raíz llevan /Limits con la clave más pequeña y más grande que cubren, mientras que la raíz misma no debe tener /Limits. Encima de eso, la raíz del árbol de estructura puede llevar /ParentTreeNextKey (§14.7.2), la siguiente clave que un escritor debería entregar. Cada edición que etiqueta contenido nuevo toca por tanto hasta cuatro objetos que tienen que coincidir: el content stream, el diccionario de página o form, la hoja del parent tree, y la raíz del árbol de estructura

La dirección hacia adelante tiene sus propias reglas, establecidas en §14.7.4.2 y §14.7.4.3. Una referencia de marked content (MCR) para contenido en el content stream de la propia página solo necesita /Pg y /MCID. Cuando el marked content vive dentro de un Form XObject, la MCR también necesita /Stm para nombrar ese stream, porque los MCIDs se numeran por content stream. Una referencia de objeto (OBJR) nombra la anotación o el XObject con /Obj y su página con /Pg

Etiquetar un content stream con EditPdfDocumentStructure

EditPdfDocumentStructure es un editor por lotes con guarda de instantánea. Primero llame a ReadPdfDocumentStructure para obtener un TPdfDocumentStructureSnapshot, luego pase su SourceSha256 de vuelta junto con un arreglo de registros TPdfStructureEditOperation. Si los bytes del origen cambiaron en el medio, la edición se rechaza sin salida. Los elementos existentes se direccionan por su número de objeto en NodeID, y 0 denota la raíz del árbol de estructura

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 de página o Form XObject de 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 esa única operación, EditPdfDocumentStructure envuelve el stream de destino en /P <</MCID n>> BDC ... EMC, anexa la MCR al /K del elemento, le da a la página (o al form) una clave /StructParents si no tiene ninguna, y escribe el elemento n en el arreglo del parent tree para esa clave. El resultado se escribe como una actualización incremental después de los bytes originales, y Report.CreatedMarkedContents le dice cuántas MCRs se crearon. Cuando el stream ya contiene pares BDC/EMC balanceados, solo la cola sin etiquetar después del último EMC de nivel superior se envuelve; de lo contrario se envuelve todo el stream

¿Cómo se eligen los nuevos MCIDs y claves del parent tree?

Un nuevo MCID es el máximo actual más uno, y una nueva clave de parent tree es Max(MaxKey + 1, ParentTreeNextKey). Para el MCID, EditPdfDocumentStructure toma el máximo entre las MCRs ya registradas para esa página (o ese Form XObject) y cada /MCID que encuentre en las listas de propiedades del propio stream. Contar ambos importa: un árbol de estructura puede haber perdido una MCR mientras el contenido todavía lleva la marca, y reutilizar ese número haría que dos elementos reclamaran el mismo contenido

La lógica de claves sigue la misma idea defensiva. Si el dueño ya tiene un valor /StructParents, el arreglo existente se extiende en el índice MCID, con null llenando cualquier hueco, y no se asigna ninguna clave nueva. Si no, el componente escanea cada hoja /Nums buscando la clave más alta, la compara con /ParentTreeNextKey, y toma la mayor. Un /ParentTreeNextKey obsoleto más bajo que claves ya en uso y uno que reserve claves más allá de la última entrada están ambos cubiertos, porque el mayor de los dos valores siempre está libre. Tras la asignación, /ParentTreeNextKey avanza a la nueva clave más uno

Asignación de MCID y claves de parent tree en el editor de estructuras de PDFium Component para Delphi: el nuevo MCID es uno más que el máximo ya registrado en MCRs y listas de propiedades del stream, y la clave es la mayor entre la clave /Nums más alta más uno y /ParentTreeNextKey, con null llenando los huecos del arreglo
EditPdfDocumentStructure deriva el nuevo MCID tanto de las MCRs como de las marcas en el stream y asigna la clave del parent tree de forma defensiva, así un /ParentTreeNextKey obsoleto o un MCID reutilizado no pueden hacer que dos elementos reclamen el mismo contenido

El nuevo par se inserta en /Nums en orden ascendente en lugar de anexarse, porque las búsquedas de number tree dependen de ese orden. Si la hoja lleva /Limits, las cotas se recalculan a partir del arreglo reescrito. Cuando la raíz del árbol de estructura no tiene /ParentTree en absoluto, algo común en archivos etiquetados por herramientas que solo escribieron el árbol hacia adelante, EditPdfDocumentStructure sintetiza un nuevo objeto indirecto << /Nums [] >> y lo enlaza desde /StructTreeRoot antes de insertar la primera entrada

Form XObjects, /StmOwn y referencias de objeto

El marked content dentro de un Form XObject se soporta solo cuando ese form lo usa exactamente una página. La MCR que EditPdfDocumentStructure escribe para un form lleva /Pg, /Stm nombrando el stream del form, y /StmOwn nombrando la página, y la clave /StructParents va en el diccionario del form en lugar de en la página. Un form dibujado por tres páginas tiene tres dueños plausibles, así que cualquier /StmOwn único estaría mal para dos de ellos. El componente rechaza la edición con un error en lugar de adivinar

Regla de un solo dueño para etiquetar contenido de Form XObject con PDFium Component en Delphi: una MCR en un form usado por una sola página lleva /Pg, /Stm y /StmOwn con /StructParents en el diccionario del form, mientras que un form dibujado por tres páginas hace ambiguo /StmOwn y la edición se rechaza
La clave /StmOwn solo tiene sentido cuando una página posee el form, razón por la que los forms compartidos y las anotaciones compartidas se rechazan en lugar de etiquetarse con un dueño adivinado

La misma regla de unicidad aplica a psekAddObjectReference. El destino debe ser una anotación que aparezca en el /Annots de exactamente una página, o un Form o Image XObject que aparezca en los recursos de exactamente una página; cualquier otra cosa se rechaza. La OBJR recibe /Obj y /Pg, el destino recibe una clave /StructParent (reutilizada si está presente), y el valor del parent tree es una referencia simple al elemento. Este es el arreglo usual para una anotación de enlace o una imagen de figura que se etiquetó visualmente pero nunca se conectó al árbol

// Envuelva una imagen sin etiquetar en un elemento Figure nuevo, luego átela de dos formas:
// como un OBJR para el image XObject y como marked content en la página
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1;              // ID temporal, debe ser negativo y único
Ops[0].ParentNodeID := SectID;    // elemento Sect existente
Ops[0].Value := 'Figure';         // rol estándar o una entrada RoleMap

Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1;              // se refiere al contenedor creado arriba
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]));

Los valores negativos de NodeID nombran contenedores creados antes en el mismo lote, y Report.InsertedNodes mapea cada ID solicitado al número de objeto que recibió. psekInsertContainer acepta roles estándar de PDF 1.7, PDF 2.0 y PDF/UA como P, H1 a H6, Figure, Table o LI, más roles personalizados ya mapeados en /RoleMap. Fijar BeforeNodeID en las dos operaciones de contenido coloca la nueva MCR u OBJR antes de un hijo existente en lugar de al final

¿Por qué editar el mismo stream dos veces en un lote requiere cuidado?

Un lote puede golpear el mismo objeto repetidamente, y cada operación posterior tiene que ver el resultado de las anteriores. Dos operaciones psekAddMarkedContent contra la misma página reescriben ambas su content stream, y ambas actualizan la misma hoja del parent tree. Una implementación que prepara cada edición como un objeto anexado separado escribe dos versiones del objeto 12 en la actualización. Dependiendo de cuál conserve el lector, usted pierde el primer envolvente BDC o el primer par /Nums, y el segundo MCID puede colisionar con el primero porque se calculó desde bytes obsoletos

EditPdfDocumentStructure indexa las actualizaciones preparadas por (ObjectNumber, Generation) y sobrescribe una entrada existente en el lugar, y al mismo tiempo refresca la copia en memoria del objeto. La segunda operación por tanto parsea el stream ya envuelto, ve el MCID 0, asigna el MCID 1, y extiende el mismo arreglo del parent tree. Un objeto, una versión final. Antes de devolver algo, la salida se rele y compara con el modelo en memoria: el conteo de elementos, los conteos de ítems de contenido y cada MCR u OBJR retenido deben coincidir, o la llamada falla sin salida

Los streams comprimidos siguen la misma ruta. Un content stream con /Filter /FlateDecode (o un arreglo [/FlateDecode] de un elemento) y sin predictor /DecodeParms se infla con un decodificador acotado con tope de 32 MiB o MaxOutputBytes, el que sea menor, se envuelve, y se recomprime de vuelta a /FlateDecode con un nuevo /Length. Un stream que se inflaría pasado el tope se rechaza en lugar de truncarse, lo que importa para archivos de fuentes no confiables

Límites que vale conocer antes de una corrida de reparación PDF/UA

EditPdfDocumentStructure es conservador por diseño, y la mayoría de sus límites aparecen como un Report.ErrorMessage claro en lugar de un archivo silenciosamente dañado. Téngalos presentes cuando planee una reparación por lotes:

  • Solo los streams sin filtro y con FlateDecode simple pueden envolverse; otros filtros, o Flate con predictor, se rechazan
  • Un content stream debe pertenecer al /Contents de exactamente una página, y los Form XObjects, las anotaciones y los XObjects deben usarlos exactamente una página
  • El envolvente siempre usa la etiqueta /P; el rol semántico viene del /S del elemento de estructura, no del nombre de la etiqueta BDC
  • La sincronización del parent tree lee una raíz /Nums plana o un nivel de hojas /Kids; los nodos intermedios más profundos no se descienden, así que revalide esos archivos después de editar
  • Los archivos que contienen firmas o campos de firma se rechazan de plano, así que firme después de la reparación de estructura, no antes

El paso de validación no es opcional para un pipeline de reparación. Corra la validación del árbol de estructura PDF/UA en PDFium Component antes y después del lote, y use la API de marked content de objetos de página para leer etiquetas BDC y MCIDs cuando necesite confirmar qué objetos de página acabaron dentro de las marcas nuevas. Un parent tree limpio le dice que las búsquedas se resuelven; no le dice que el orden de lectura o el texto alternativo estén bien

FAQ

¿EditPdfDocumentStructure reconstruye todo el /ParentTree?

No. EditPdfDocumentStructure actualiza solo las entradas del contenido que etiqueta en el lote actual, reutilizando claves /StructParents existentes y asignando nuevas donde haga falta, así que las entradas existentes del parent tree quedan intactas

¿Qué pasa si el PDF tiene un árbol de estructura pero ningún /ParentTree?

El componente crea un number tree indirecto << /Nums [] >>, lo enlaza desde /StructTreeRoot, e inserta la primera clave para el contenido que se etiqueta

¿Puedo etiquetar contenido en un Form XObject compartido por varias páginas?

No con esta operación. Un form compartido vuelve ambiguo a /StmOwn, así que psekAddMarkedContent exige que el form esté referenciado por exactamente una página y reporta un error en caso contrario

La lectura y edición del árbol de estructura, la validación PDF/UA y el acceso a marked content son parte del PDFium Component for Delphi y C++Builder, que envuelve PDFium para proyectos Delphi, C++Builder y Lazarus