Artículo técnico

/ParentTree y /StructParents sincronizados en Delphi

PDFium Component etiqueta contenido PDF existente desde Delphi mediante EditPdfDocumentStructure en FPdfDocumentStructure.pas. Las operaciones psekAddMarkedContent y psekAddObjectReference hacen más que añadir 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 ISO 32000-1 §14.7.4.4 exige

Esa segunda mitad es donde el etiquetado retroactivo suele torcerse. Añadir un envoltorio BDC y un MCR es fácil, y un visor que recorre el árbol de estructura desde arriba encontrará contento el contenido nuevo. La tecnología de asistencia suele ir al revés, sin embargo: parte de un trozo 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 informan como un parent tree ausente o inconsistente. El fichero parece etiquetado y sigue sin funcionar

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 marcado de contenido un entero /StructParents, y el valor del parent tree bajo esa clave es un array indexado por MCID: la entrada 3 del array es el elemento de estructura que posee <</MCID 3>>. Una anotación o XObject usado como elemento de contenido completo recibe en su lugar una única clave /StructParent, y su valor del parent tree es el propio elemento de estructura, no un array

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

El formato del contenedor es el number tree genérico de §7.9.7. Una hoja lleva /Nums, un array plano de pares clave-valor ordenado por clave ascendente. Los nodos intermedios y hojas bajo la raíz llevan /Limits con la clave más pequeña y más grande que cubren, mientras que la raíz en sí 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 writer debería repartir. Cada edición que etiqueta contenido nuevo toca por tanto hasta cuatro objetos que tienen que estar de acuerdo: 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 directa tiene sus propias reglas, establecidas en §14.7.4.2 y §14.7.4.3. Una referencia de marcado de contenido (MCR) para contenido en el content stream de la propia página necesita solo /Pg y /MCID. Cuando el marcado de contenido vive dentro de un Form XObject, el MCR necesita además /Stm para nombrar ese stream, porque los MCIDs se numeran por content stream. Una referencia de objeto (OBJR) nombra la anotación o 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 llamáis a ReadPdfDocumentStructure para obtener un TPdfDocumentStructureSnapshot, y luego devolvéis su SourceSha256 junto a un array de registros TPdfStructureEditOperation. Si los bytes de origen cambiaron mientras, 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 objetivo en /P <</MCID n>> BDC ... EMC, añade el MCR al /K del elemento, da a la página (o al form) una clave /StructParents si no tiene ninguna, y escribe el elemento n en el array del parent tree para esa clave. El resultado se escribe como una actualización incremental después de los bytes originales, y Report.CreatedMarkedContents os dice cuántos MCRs se crearon. Cuando el stream ya contiene pares BDC/EMC equilibrados, solo se envuelve la cola sin etiquetar después del último EMC de nivel superior; si no, se envuelve el stream entero

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

Un MCID nuevo es el máximo actual más uno, y una clave de parent tree nueva es Max(MaxKey + 1, ParentTreeNextKey). Para el MCID, EditPdfDocumentStructure toma el máximo entre los MCRs ya registrados 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 un 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 propietario ya tiene un valor /StructParents, el array existente se extiende en el índice MCID, con null rellenando cualquier hueco, y no se asigna 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 caducado más bajo que claves ya en uso y uno que reserva claves más allá de la última entrada están ambos manejados, porque el mayor de los dos valores está siempre libre. Tras la asignación, /ParentTreeNextKey avanza a la clave nueva más uno

Asignación de MCIDs y claves del parent tree en el editor de estructuras de PDFium Component para Delphi: el MCID nuevo es uno por encima del 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 rellenando los huecos del array
EditPdfDocumentStructure deriva el nuevo MCID tanto de los MCRs como de las marcas dentro del stream y asigna la clave del parent tree de forma defensiva, así que un /ParentTreeNextKey caducado o un MCID reutilizado no pueden hacer que dos elementos reclamen el mismo contenido

El par nuevo se inserta en /Nums en orden ascendente en lugar de añadirse al final, porque las búsquedas de number tree dependen de ese orden. Si la hoja lleva /Limits, los límites se recalculan desde el array reescrito. Cuando la raíz del árbol de estructura no tiene /ParentTree en absoluto, cosa común en ficheros etiquetados por herramientas que solo escribieron el árbol directo, 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 marcado de contenido dentro de un Form XObject está soportado solo cuando ese form lo usa exactamente una página. El 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 dueño único para etiquetar contenido de Form XObject con PDFium Component en Delphi: un MCR sobre un form usado por una única página lleva /Pg, /Stm y /StmOwn con /StructParents en el diccionario del form, mientras un form dibujado por tres páginas hace ambiguo el /StmOwn y la edición se rechaza
La clave /StmOwn solo tiene sentido cuando una página es dueña del form, por eso los forms compartidos y las anotaciones compartidas se rechazan en lugar de etiquetarse con un dueño adivinable

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

// Envolved una imagen sin etiquetar en un elemento Figure nuevo, y ligadla de dos formas:
// como OBJR del image XObject y como contenido marcado 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 NodeID negativos nombran contenedores creados antes en el mismo lote, y Report.InsertedNodes mapea cada ID pedido 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 custom ya mapeados en /RoleMap. Poner BeforeNodeID en las dos operaciones de contenido coloca el nuevo 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 añadido aparte escribe dos versiones del objeto 12 en la actualización. Según cuál conserve el lector, perdéis el primer envoltorio BDC o el primer par /Nums, y el segundo MCID puede colisionar con el primero porque se calculó desde bytes caducados

EditPdfDocumentStructure indexa las actualizaciones preparadas por (ObjectNumber, Generation) y sobrescribe una entrada existente en su sitio, y refresca la copia en memoria del objeto a la vez. La segunda operación parsea por tanto el stream ya envuelto, ve el MCID 0, asigna el MCID 1, y extiende el mismo array del parent tree. Un objeto, una versión final. Antes de devolver nada, la salida se lee de vuelta y se compara con el modelo en memoria: recuento de elementos, recuentos de elementos de contenido y cada MCR u OBJR retenido deben casar, o la llamada falla sin salida

Los streams comprimidos siguen la misma ruta. Un content stream con /Filter /FlateDecode (o un array [/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 más allá del tope se rechaza en lugar de truncarse, lo que importa para ficheros de fuentes no fiables

Límites que conviene conocer antes de una pasada de reparación PDF/UA

EditPdfDocumentStructure es conservador por diseño, y la mayoría de sus límites se manifiestan como un Report.ErrorMessage claro en lugar de un fichero roto en silencio. Tened esto presente cuando planeéis una reparación por lotes:

  • Solo los streams sin filtro y con FlateDecode plano se pueden envolver; otros filtros, o Flate con predictor, se rechazan
  • Un content stream debe pertenecer al /Contents de exactamente una página, y los Form XObjects, anotaciones y XObjects deben usarlos exactamente una página
  • El envoltorio usa siempre 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 recorren, así que revalidad tales ficheros después de editar
  • Los ficheros que contienen firmas o campos de firma se rechazan de plano, así que firmad después de la reparación de estructura, no antes

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

FAQ

¿Reconstruye EditPdfDocumentStructure 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 á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 el /StmOwn, así que psekAddMarkedContent exige que el form lo referencie exactamente una página e informa un error en caso contrario

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