Artículo técnico

Borrar anotaciones con limpieza de OBJR y parent tree

Borras una anotación de enlace 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 conserva un OBJR, una entrada del parent tree y un elemento Link de 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 un borrado limpio tiene que visitarlos todos. La ISO 32000-1 §14.7.4.3 dice que cuando el contenido de un elemento de estructura es un objeto PDF entero, como una anotación, el array /K del elemento lo nombra con un diccionario de referencia de objeto (Tabla 325): /Type /OBJR, un /Obj indirecto obligatorio que apunta a la anotación, y un /Pg opcional que invalida la página propia del elemento. Eso cubre la dirección de ida, del árbol a la página

Cómo se conecta una anotación Link etiquetada al array Annots de la página PDF, a la referencia de objeto OBJR, a la clave StructParent y a la entrada del ParentTree en PDFium Component para Delphi
una anotación etiquetada se reparte entre cuatro objetos, y un borrado limpio visita la referencia de página, el OBJR, la clave StructParent y el par del ParentTree

El camino inverso es la §14.7.4.4. El parent tree es un number tree (§7.9.7) con una entrada para cada objeto que es elemento de contenido, indexada por el entero que ese objeto lleva. Una anotación lleva /StructParent, y su valor del parent tree es una única referencia indirecta al elemento dueño; una página cuyo flujo de contenido contiene marcado de contenido lleva /StructParents en su lugar, y su valor es un array indexado por MCID. La Tabla 326 permite como mucho una de las dos claves en cualquier objeto, porque un objeto es o bien elemento de contenido íntegro o bien contenedor de marcado de contenido. Para los hipervínculos, la §14.8.4.4.2 deja que un elemento Link sostenga a la vez las palabras visibles, como referencias de marcado de contenido (MCR), y un OBJR hacia la anotación de enlace. Borra la anotación y este es el inventario:

UbicaciónQué contieneQué hace una eliminación limpia
Página /AnnotsReferencia indirecta a la anotaciónRetira la referencia; las supervivientes conservan su orden
Elemento Link /KOBJR con /Obj y /PgElimina el elemento de contenido OBJR
Diccionario de anotaciónclave entera /StructParentElimina la clave, porque el objeto deja de ser elemento de contenido
Hoja /Nums del ParentTreeClave emparejada con una referencia al elemento LinkElimina el par clave y valor
Raíz del árbol de estructura/ParentTreeNextKeyLa deja como está

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

Un editor de nivel de página borra una anotación sacándola de /Annots, y desde ahí el asunto va por uno de dos caminos. Si la herramienta además libera el objeto de anotación, el /Obj del OBJR pasa a apuntar a un objeto indefinido, que la §7.3.10 manda tratar como null: el elemento Link es dueño de un elemento de contenido que no resuelve a nada. Si la herramienta deja el objeto en el fichero, el OBJR sigue resolviendo, pero su /Pg nombra una página cuyo /Annots ya no lo lista. En ambos casos el elemento Link conserva sus hijos de marcado de contenido, así que las palabras "Download the datasheet" 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 activará. Los verificadores PDF/UA como PAC y veraPDF, que validan contra la ISO 14289-1, comparan lo que la página lista con lo que el árbol declara, de modo que una discrepancia entre ambos puede aflorar allí incluso cuando la página renderizada parece perfecta

Dos caminos que dejan un enlace fantasma en un PDF etiquetado tras un borrado que solo toca la página, y cómo los limpia PDFium Component en Delphi
libere o no el objeto de anotación, el elemento Link conserva sus palabras y los lectores de pantalla pueden anunciar un enlace que no lleva a ninguna parte

Borrar contenido tiene su propia versión del problema. Quita los objetos de texto dentro de /P <</MCID 4>> BDC … EMC y conserva los operadores, y la página se queda con una secuencia de marcado de contenido vacía cuyo MCR sigue sentado en un elemento <P>; el lector recibe un párrafo sin palabras. Quita el BDC pero no su EMC y el flujo de contenido queda roto sin más. La §14.6 exige que toda secuencia de marcado de contenido vaya delimitada por un BDC o un BMC y su EMC correspondiente dentro de un único flujo de contenido, y un EMC huérfano deja todas las secuencias posteriores de la página emparejadas con el operador de apertura equivocado

Eliminar anotaciones con RemovePdfPageAnnotations

RemovePdfPageAnnotations en FPdfDocumentStructure toma un flujo de origen, un índice de página basado en cero en orden de árbol de páginas, los números de objeto de las anotaciones que hay que retirar, un registro TPdfAnnotationRemovalOptions y dos parámetros de salida para el fichero nuevo y un TPdfAnnotationRemovalReport. Con TPdfAnnotationRemovalOptions.Default solo toca la página: una única revisión incremental transporta el diccionario de página con el array /Annots acortado (o sin clave /Annots una vez que 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 anotación conserva su cuerpo, y quien llama conservando su número puede leerlo. El árbol de estructura se queda como estaba, lo que mantiene intacto el etiquetado para el caso en que vayas a apuntar ese elemento Link hacia una anotación de reemplazo. La entrada se valida antes de escribir nada:

  • Cada número tiene que 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 ya no muestra ninguna página
  • Una fuente cifrada se rechaza, porque la edición de estructura necesita bytes descifrados; descifra el fichero por adelantado
  • 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 te dice qué números de anotación importan. Cada elemento OBJR de una instantánea de ReadPdfDocumentStructure lleva el número de 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 se refiere y las elimina con las dos opciones de limpieza activadas. Conserva el filtro de página: un número tomado de otra página se rechaza para el lote entero

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;    // elementos OBJR y entradas del ParentTree
    Options.RemoveEmptiedStructureNodes := True;  // elementos que se quedaron sin nada dentro
    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 activado, cada OBJR cuyo /Obj es una anotación eliminada de esa página sale de su elemento a través del editor de estructura, que mantiene el índice inverso al día. El par clave y valor guardado bajo el /StructParent de la anotación se saca de su hoja /Nums, y la propia clave /StructParent se elimina del diccionario de anotación, puesto que la Tabla 326 solo la pide en objetos que son elementos de contenido estructural. /ParentTreeNextKey no se rebaja. La §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 tienen por qué ser contiguas, de modo que un escritor que sigue /ParentTreeNextKey nunca vuelve a repartir la clave liberada. Toda anotación superviviente conserva su número de objeto, su posición en /Annots y su clave del ParentTree; la función vuelve a leer la página que escribió y rechaza el resultado si una superviviente desapareció o cambió de sitio. 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 debe irse también el elemento vaciado?

RemoveEmptiedStructureNodes elimina los elementos que la retirada del OBJR dejó sin elementos de contenido y sin elementos hijos, primero los más profundos. Funciona por rondas: cada ronda vuelve a leer la estructura, elimina los elementos que ahora están vacíos y añade sus padres a los candidatos de la ronda siguiente, de modo que una cadena de envoltorios se derrumba de abajo arriba mientras la raíz del árbol de estructura se queda siempre. Un elemento Annot que etiqueta una nota adhesiva normalmente no contiene otra cosa que el OBJR (la §14.8.4.4.3 lo permite), así que se vacía y se va. Un elemento Link es distinto. Sigue siendo dueño de las referencias de marcado de contenido 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 puro, activa las dos opciones y después degrada a Span cualquier elemento Link que se haya quedado sin OBJR: la tecnología de asistencia leerá entonces 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 a ronda, un elemento Link sobrevive, y PDFium Component puede degradarlo a Span
los envoltorios vaciados se derrumban primero 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 un cambio de los bytes por el camino
    if not EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops,
      Options, Fixed, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Result := Length(Ops);
  finally
    Source.Free;
  end;
end;

Conserva los valores por defecto cuando el borrado es la mitad de un reemplazo. Un elemento vaciado dejado en su sitio sigue llevando su rol, título, texto alternativo e ID de elemento, y si un paso posterior le acopla una anotación nueva con una operación psekAddObjectReference, que da a la anotación una clave /StructParent nueva y avanza /ParentTreeNextKey según las mismas reglas que registran elementos MCR y OBJR nuevos en el parent tree, el elemento conserva su sitio en el orden de lectura. Eliminar y recrear ese elemento descartaría esos valores y le daría un número de objeto nuevo

Borrar contenido etiquetado: la propiedad se mide 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 lleva RemoveOwnedStructurePeers y RemoveEmptiedStructureNodes con el mismo significado. Una secuencia de marcado de contenido etiquetada se va junto con su entrada de estructura solo cuando todos los objetos entre su BDC y el EMC correspondiente están siendo eliminados. En ese caso los dos operadores abandonan el flujo de contenido, el MCR deja su elemento, y el hueco de ese MCID en el array del ParentTree de la página se establece a null en vez de cerrarse, porque todos los demás MCID de la página indexan en el mismo array. Una secuencia que además envuelva contenido superviviente se queda, junto con su MCR. Una página que reutilice el MCID eliminado para contenido superviviente se rechaza sin más, puesto que los MCID tienen como ámbito el flujo de contenido de la página y soltar la entrada de estructura dejaría huérfano al superviviente. Con la opción en False, el lote conserva en la frontera de eliminación los pares BDC y EMC, de modo que el árbol de estructura sigue resolviendo y nada cambia en él

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 operador BDC solo, que es donde una marca parseada registra su posición, y no encontró ningún objeto encerrado. Un error de la misma familia es marcar el BDC para eliminación y perder de vista a su pareja, que escribe exactamente el flujo descompensado descrito arriba, así que el editor elimina siempre juntas las dos mitades de un par. La rutina de abajo borra 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 informe

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 empieza en 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 sigue ocupando su objeto en el fichero tras cualquiera de las dos llamadas, porque solo se retira la referencia de página. Una vez fuera el OBJR, nada en el grafo del catálogo lo alcanza, de modo 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 la mantiene también. Ejecuta la eliminación con las dos opciones de estructura activadas siempre que el borrado sea definitivo, y la página, el árbol de estructura y el parent tree del fichero que PDFium Component para Delphi escribe de vuelta describirán todos el mismo conjunto de anotaciones