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
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
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
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
FlateDecodeplano se pueden envolver; otros filtros, o Flate con predictor, se rechazan - Un content stream debe pertenecer al
/Contentsde 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/Sdel elemento de estructura, no del nombre de la etiquetaBDC - La sincronización del parent tree lee una raíz
/Numsplana 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