Artículo técnico

Eliminar anotaciones y OBJR con PDFium Component en Delphi

Quite una anotación Link de un PDF etiquetado con un editor que solo toca la página, y la página se renderiza bien mientras el árbol de estructura todavía guarda un OBJR, una entrada del ParentTree y un elemento Link para una anotación que ya no existe. RemovePdfPageAnnotations de PDFium Component retira la anotación de la página y, si se le pide, elimina con ella esas piezas de estructura

Dónde se conecta una anotación etiquetada al árbol de estructura

Una anotación etiquetada se asienta en la unión de cuatro objetos, y una eliminación limpia tiene que visitarlos todos. ISO 32000-1 §14.7.4.3 dice que cuando el contenido de un elemento de estructura es un objeto PDF completo, como una anotación, el arreglo /K del elemento lo nombra con un diccionario de referencia a objeto (Tabla 325): /Type /OBJR, un /Obj indirecto obligatorio que apunta a la anotación y un /Pg opcional que anula la página del propio elemento. Eso cubre la dirección de ida, del árbol a la página

Cómo se conecta una anotación Link etiquetada con el arreglo Annots de la página PDF, la referencia a objeto OBJR, la clave StructParent y la entrada del ParentTree en PDFium Component para Delphi
Una anotación etiquetada se conecta a cuatro objetos, y una eliminación limpia visita la referencia de página, el OBJR, la clave StructParent y el par del ParentTree

El camino de vuelta es §14.7.4.4. El árbol de padres es un number tree (§7.9.7) con una entrada para cada objeto que es un ítem de contenido, indexada por el entero que ese objeto lleva. Una anotación lleva /StructParent, y su valor en el árbol de padres es una única referencia indirecta al elemento dueño; una página cuyo flujo de contenido guarda contenido marcado lleva /StructParents en cambio, y su valor es un arreglo indexado por MCID. La Tabla 326 admite a lo sumo una de las dos claves en cualquier objeto, porque un objeto es o bien un ítem de contenido en su totalidad o bien un contenedor de contenido marcado. Para los hipervínculos, §14.8.4.4.2 permite que un elemento Link guarde tanto las palabras visibles, como referencias de contenido marcado (MCR), como un OBJR hacia la anotación del enlace. Elimine la anotación y este es el inventario:

UbicaciónQué guardaQué hace una eliminación limpia
Página /AnnotsReferencia indirecta a la anotaciónSuelta la referencia; las que sobreviven conservan su orden
Elemento Link /KOBJR con /Obj y /PgQuita el ítem de contenido OBJR
Diccionario de la anotaciónClave entera /StructParentQuita la clave, porque el objeto ya no es un ítem de contenido
Hoja /Nums del ParentTreeClave emparejada con una referencia al elemento LinkQuita el par clave y valor
Raíz del árbol de estructura/ParentTreeNextKeyNo se toca

¿Cómo sobrevive un enlace fantasma a su anotación?

Un editor de nivel de página elimina una anotación sacándola de /Annots, y a partir de ahí el asunto va por uno de dos caminos. Si la herramienta también libera el objeto de la anotación, el /Obj del OBJR pasa a apuntar a un objeto no definido, que §7.3.10 le dice al lector que trate como null: el elemento Link es dueño de un ítem de contenido que no resuelve a nada. Si la herramienta deja el objeto en el archivo, el OBJR todavía resuelve, pero su /Pg nombra una página cuyo /Annots ya no lo lista. En ambos casos el elemento Link conserva sus hijos de contenido marcado, así que las palabras "Descargue la ficha técnica" siguen etiquetadas como enlace, y un lector de pantalla que recorre el árbol en orden de lectura puede anunciar un enlace que nada en la página va a activar. Los validadores PDF/UA como PAC y veraPDF, que validan contra ISO 14289-1, comparan lo que la página lista con lo que el árbol declara, así que una discrepancia entre ambos puede salir a la luz ahí incluso cuando la página renderizada se ve perfecta

Los dos caminos que dejan un enlace fantasma en un PDF etiquetado tras un borrado de solo página, y cómo los limpia PDFium Component en Delphi
Tanto si el objeto de la anotación se libera como si queda en el archivo, el elemento Link conserva sus palabras y los lectores de pantalla pueden anunciar un enlace que no lleva a ninguna parte

Eliminar contenido tiene su propia versión del problema. Quite los objetos de texto dentro de /P <</MCID 4>> BDC … EMC y deje los operadores, y la página se queda con una secuencia de contenido marcado vacía cuyo MCR sigue sentado en un elemento <P>; el lector recibe un párrafo sin palabras. Quite el BDC pero no su EMC y el flujo de contenido queda roto sin más. §14.6 exige que toda secuencia de contenido marcado quede acotada por un BDC o un BMC y un EMC que corresponda dentro de un único flujo de contenido, y un EMC huérfano deja cada secuencia posterior de la página emparejada con la apertura equivocada

Eliminar anotaciones con RemovePdfPageAnnotations

RemovePdfPageAnnotations de FPdfDocumentStructure toma un flujo de origen, un índice de página de base cero en el orden del árbol de páginas, los números de objeto de las anotaciones por quitar, un registro TPdfAnnotationRemovalOptions y dos parámetros out para el archivo nuevo y un TPdfAnnotationRemovalReport. Con TPdfAnnotationRemovalOptions.Default solo toca la página: una revisión incremental lleva el diccionario de página con el arreglo /Annots acortado (o sin clave /Annots cuando se va la última anotación) más el catálogo sin cambios, así que los números de objeto, el ID del documento y todas las demás claves de página sobreviven. El objeto de la anotación conserva su cuerpo, y quien llamó y todavía conserva su número puede leerlo. El árbol de estructura se deja como estaba, lo que mantiene el etiquetado intacto para el caso en que usted está por apuntar ese elemento Link hacia una anotación de reemplazo. La entrada se valida antes de escribir nada:

  • Cada número debe aparecer en el /Annots de la página seleccionada y nombrar un objeto /Type /Annot; un número listado dos veces en una misma llamada se rechaza
  • Las anotaciones widget se rechazan, porque un widget pertenece a su campo de formulario y arrancarlo de la página dejaría un campo cuyo widget ninguna página muestra
  • Un origen cifrado se rechaza, porque la edición de estructura necesita bytes descifrados; descifre el archivo de antemano
  • MaxOutputBytes, MaxWork y CancellationToken acotan el trabajo, y cualquier fallo devuelve False sin salida y con un ErrorMessage que nombra la fase donde se detuvo

Para un documento etiquetado el árbol de estructura ya le dice qué números de anotación importan. Cada ítem OBJR en una instantánea de ReadPdfDocumentStructure lleva el número del objeto referenciado, y su número de página sale del /Pg del propio OBJR o, si no lo trae, del del elemento. La rutina de abajo reúne todas las anotaciones a las que un elemento Link de una página apunta y las elimina con las dos opciones de limpieza activadas. Conserve el filtro de página: un número tomado de otra página se rechaza para todo el lote

uses
  System.SysUtils, System.Classes, System.IOUtils,
  FPdfCompress, FPdfDocumentStructure;

function RemoveTaggedLinks(const InName, OutName: string;
  PageIndex: Integer): TPdfAnnotationRemovalReport;
var
  Source: TMemoryStream;
  Pages: TPdfSparsePageDictionaries;
  ReadOptions: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  ReadReport: TPdfStructureEditReport;
  Item: TPdfDocumentStructureContentItem;
  Targets: array of Integer;
  Options: TPdfAnnotationRemovalOptions;
  Report: TPdfAnnotationRemovalReport;
  Output: TBytes;
  N, I, J: Integer;
  Known: Boolean;
begin
  Report := Default(TPdfAnnotationRemovalReport);
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(InName);
    if not ReadPdfSparsePageDictionaries(Source, Pages) or
       (PageIndex < 0) or (PageIndex > High(Pages)) then
      raise Exception.Create('Page index is outside the page tree');
    ReadOptions := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, ReadOptions, Snapshot, ReadReport) then
      raise Exception.Create(ReadReport.ErrorMessage);

    // Toda anotación a la que apunta un elemento Link de esta página
    for N := 0 to High(Snapshot.Nodes) do
      if Snapshot.Nodes[N].RoleName = 'Link' then
        for I := 0 to High(Snapshot.Nodes[N].ContentItems) do
        begin
          Item := Snapshot.Nodes[N].ContentItems[I];
          if (Item.Kind <> psckObjectReference) or
             (Item.PageObjectNumber <> Pages[PageIndex].ObjectNumber) then
            Continue;
          Known := False;
          for J := 0 to High(Targets) do
            if Targets[J] = Item.ReferencedObjectNumber then Known := True;
          if not Known then
          begin
            SetLength(Targets, Length(Targets) + 1);
            Targets[High(Targets)] := Item.ReferencedObjectNumber;
          end;
        end;
    if Length(Targets) = 0 then
      Exit(Report);

    Options := TPdfAnnotationRemovalOptions.Default;
    Options.RemoveOwnedStructurePeers := True;    // ítems OBJR y entradas del ParentTree
    Options.RemoveEmptiedStructureNodes := True;  // elementos que quedaron sin nada adentro
    if not RemovePdfPageAnnotations(Source, PageIndex, Targets, Options,
      Output, Report) then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
    Result := Report;
  finally
    Source.Free;
  end;
end;

Qué pasa con la clave del ParentTree

Con RemoveOwnedStructurePeers activada, cada OBJR cuyo /Obj es una anotación eliminada en esa página sale de su elemento a través del editor de estructura, lo que mantiene al día el índice inverso. El par clave y valor guardado bajo el /StructParent de la anotación se saca de su hoja /Nums, y la clave /StructParent en sí se elimina del diccionario de la anotación, ya que la Tabla 326 la pide solo en objetos que son ítems de contenido estructural. /ParentTreeNextKey no se reduce. §14.7.4.4 solo exige que sea mayor que toda clave en uso, cosa que una eliminación no puede romper, y las claves de un number tree no necesitan ser contiguas, así que un writer que sigue /ParentTreeNextKey nunca vuelve a repartir la clave liberada. Cada anotación sobreviviente conserva su número de objeto, su posición en /Annots y su clave del ParentTree; la función relee la página que escribió y rechaza el resultado si un sobreviviente desapareció o cambió de lugar. Resultado esperado: en una página con un enlace etiquetado, Report.RemovedAnnotations y Report.RemovedObjectReferences valen ambos 1, y Report.SourceSha256 y Report.OutputSha256 registran qué bytes entraron y cuáles salieron

¿Cuándo también se va el elemento vaciado?

RemoveEmptiedStructureNodes elimina los elementos que la eliminación del OBJR dejó sin ítems de contenido y sin elementos hijos, primero los más profundos. Funciona por rondas: cada ronda relee la estructura, elimina los elementos que quedaron vacíos y añade a sus padres a los candidatos de la ronda siguiente, de modo que una cadena de envoltorios se derrumba de abajo hacia arriba mientras la raíz del árbol de estructura siempre se queda. Un elemento Annot que etiqueta una nota adhesiva normalmente no guarda nada más que el OBJR (§14.8.4.4.3 lo permite), así que se vacía y se va. Un elemento Link es distinto. Todavía es dueño de las referencias de contenido marcado de sus palabras visibles, así que no está vacío y sobrevive, como un Link sin anotación. RemovePdfPageAnnotations no lo renombra, porque si esas palabras deben seguir siendo un enlace es una pregunta editorial y no estructural. Para un borrado definitivo, active ambas opciones y luego degrade a Span cualquier elemento Link que quede sin OBJR: la tecnología de asistencia entonces lee esas palabras como texto plano en vez de ofrecer un enlace que no lleva a ninguna parte

Flujo de decisión para elementos de estructura vaciados: un elemento Annot se derrumba ronda tras ronda, un elemento Link sobrevive y PDFium Component puede degradarlo a Span
Los envoltorios vaciados se derrumban empezando por los más profundos mientras la raíz se queda, y un Link sin su OBJR puede degradarse a Span o conservarse para una anotación de reemplazo
function DemoteUnlinkedLinks(const Data: TBytes; out Fixed: TBytes): Integer;
var
  Source: TBytesStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  N: Integer;
begin
  Result := 0;
  Fixed := Data;
  Source := TBytesStream.Create(Data);
  try
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);
    for N := 0 to High(Snapshot.Nodes) do
      if (Snapshot.Nodes[N].RoleName = 'Link') and
         (Snapshot.Nodes[N].ObjectReferenceCount = 0) and
         (Length(Snapshot.Nodes[N].ContentItems) > 0) then
      begin
        SetLength(Ops, Length(Ops) + 1);
        Ops[High(Ops)] := Default(TPdfStructureEditOperation);
        Ops[High(Ops)].Kind := psekSetRole;
        Ops[High(Ops)].NodeID := Snapshot.Nodes[N].NodeID;
        Ops[High(Ops)].Value := 'Span';
      end;
    if Length(Ops) = 0 then Exit;
    // El digest de la instantánea protege contra cambios de bytes en el medio
    if not EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops,
      Options, Fixed, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Result := Length(Ops);
  finally
    Source.Free;
  end;
end;

Conserve los valores por defecto cuando el borrado es la mitad de un reemplazo. Un elemento vaciado que se deja en su sitio todavía carga su rol, su título, su texto alternativo y su ID de elemento, y si un paso posterior le adjunta una anotación nueva con una operación psekAddObjectReference, que le da a la anotación una clave /StructParent fresca y avanza /ParentTreeNextKey según las mismas reglas que registran ítems MCR y OBJR nuevos en el árbol de padres, el elemento conserva su lugar en el orden de lectura. Eliminar y recrear ese elemento descartaría esos valores y le daría un número de objeto nuevo

Eliminar contenido etiquetado: la propiedad va del BDC a su EMC

Las anotaciones no son objetos de página, así que texto, trazados e imágenes pasan por otra API: el editor de objetos por lotes, PlanStyledPdfObjectEdits y ExecuteStyledPdfObjectEdits en FPdfStyledEdit, cuyo TPdfObjectEditOptions carga RemoveOwnedStructurePeers y RemoveEmptiedStructureNodes con el mismo significado. Una secuencia de contenido marcado etiquetado solo se va junto con su entrada de estructura cuando todos los objetos entre su BDC y el EMC correspondiente están siendo eliminados. En ese caso ambos operadores salen del flujo de contenido, el MCR sale de su elemento y la ranura de ese MCID en el arreglo ParentTree de la página se pone en null en lugar de cerrarse, porque todos los demás MCID de la página indexan en el mismo arreglo. Una secuencia que además envuelve contenido que sobrevive se queda, junto con su MCR. Una página que reutiliza el MCID eliminado para contenido sobreviviente se rechaza sin más, ya que los MCID tienen el alcance del flujo de contenido de la página y soltar la entrada de estructura dejaría huérfano al sobreviviente. Con la opción en False, el lote conserva en cambio los pares BDC y EMC en la frontera de la eliminación, así que el árbol de estructura sigue resolviendo y nada en él cambia

La propiedad hay que medirla desde el BDC hasta el final de su EMC correspondiente. Nuestra primera versión la midió sobre el rango de bytes del solo operador BDC, que es donde una marca parseada registra su posición, y no encontró ningún objeto encerrado. Un error relacionado es marcar el BDC para eliminación y perderle la pista a su compañero, lo que escribe exactamente el flujo desbalanceado descrito arriba, así que el editor siempre elimina las dos mitades de un par juntas. La rutina de abajo elimina cada objeto de nivel superior que lleva un MCID. ExecuteStyledPdfObjectEdits recarga Pdf con el resultado salvo que EditOptions.PreviewOnly esté activo, y Report.RemovedStructurePeers y Report.RemovedStructureNodes cuentan lo que salió del árbol; la función devuelve ese reporte

uses
  System.SysUtils, PDFium, FPdfStyledEdit;

function DeleteTaggedRun(Pdf: TPdf; PageIndex, Mcid: Integer): TPdfObjectEditReport;
var
  Ops: TPdfObjectEditOperations;
  NoSemanticEdits: TPdfObjectSemanticEdits;
  Options: TPdfObjectEditOptions;
  Plan: TPdfObjectEditPlan;
  Preview: TBytes;
  Report: TPdfObjectEditReport;
  I: Integer;
begin
  Pdf.PageNumber := PageIndex + 1;          // PageNumber es de base uno
  for I := 0 to Pdf.ObjectCount - 1 do
    if Pdf.PageObjectMarkedContentID(I) = Mcid then
    begin
      SetLength(Ops, Length(Ops) + 1);
      Ops[High(Ops)] := Default(TPdfObjectEditOperation);
      Ops[High(Ops)].Kind := poekRemove;
      Ops[High(Ops)].ObjectIndex := I;
    end;
  if Length(Ops) = 0 then
    raise Exception.CreateFmt('No object on page %d carries MCID %d',
      [PageIndex + 1, Mcid]);
  NoSemanticEdits := nil;
  Options := TPdfObjectEditOptions.Default;
  Options.RemoveOwnedStructurePeers := True;
  Options.RemoveEmptiedStructureNodes := True;
  Plan := PlanStyledPdfObjectEdits(Pdf, PageIndex, Ops, NoSemanticEdits, Options);
  if not ExecuteStyledPdfObjectEdits(Pdf, Plan, Options, Preview, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Result := Report;
end;

Una anotación eliminada todavía ocupa su objeto en el archivo después de cualquiera de las dos llamadas, porque solo se retira la referencia de página. Una vez que el OBJR se fue, nada en el grafo del catálogo lo alcanza, así que TPdf.SaveAsReachable puede barrer ese objeto huérfano fuera de una copia reescrita; con las opciones por defecto el OBJR mantiene la anotación alcanzable y el barrido también la conserva. Ejecute la eliminación con ambas opciones de estructura activadas siempre que el borrado sea definitivo, y la página, el árbol de estructura y el árbol de padres del archivo que PDFium Component para Delphi escribe de vuelta describirán todos el mismo conjunto de anotaciones