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
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
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
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
FlateDecodesimple pueden envolverse; otros filtros, o Flate con predictor, se rechazan - Un content stream debe pertenecer al
/Contentsde 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/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 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