Artículo técnico

Leer y escribir contenido marcado en PDF desde Delphi

En un PDF etiquetado, la cabecera que corre por la parte alta de cada página suele vivir dentro de /Artifact <</Type /Pagination>> BDC … EMC, mientras que el primer párrafo situado bajo ella vive dentro de /P <</MCID 0>> BDC … EMC. Ambos son contenido marcado en el sentido de ISO 32000-1 §14.6. Solo el párrafo es alcanzable desde el árbol de estructura, porque solo el párrafo lleva un identificador de contenido marcado, y ese único entero es lo que ata los glifos pintados a un elemento de estructura como /H1 o /P. El código que audita o repara el etiquetado tiene pues que leer tres cosas de cada objeto de página (la etiqueta, su lista de propiedades y el MCID) y luego comprobar ese número contra los MCIDs que los elementos de estructura de esa página reclaman de verdad

Leer marcas BDC y listas de propiedades desde Delphi

En PDFium Component, TPdf.PageObjectMarks(Index) devuelve todas las marcas que envuelven el objeto de página situado en Index como un array de records TPdfContentMark. Cada record porta la etiqueta en Name, un array Params con la lista de propiedades y un Handle. El nombre llega sin la barra inicial, de modo que una secuencia /P informa P y un artifact informa Artifact. Las secuencias de contenido marcado pueden anidarse, y una tanda de glifos dentro de /Sect, luego /P y luego /Span informa tres records en el mismo objeto, la más externa primero

ISO 32000-1 §14.6.2 admite que el operando de lista de propiedades de BDC adopte dos formas: un diccionario inline, como en /P <</MCID 3>> BDC, o un nombre como /OC /oc1 BDC que apunta al subdiccionario /Properties de los recursos de página. El contenido opcional (§8.11.3.2) se escribe en la segunda forma. PDFium resuelve la forma nombrada mientras parsea la página, así que en ambos casos Params enumera las entradas del diccionario que se aplica a la secuencia

TPdf.PageObjectMarkedContentID(Index) responde a la pregunta que le importa al árbol de estructura. Devuelve el MCID que aparece en las marcas del objeto, o -1 cuando ninguna de ellas lleva un /MCID entero. La cabecera del ejemplo inicial devuelve -1, y es lo correcto: §14.8.2.2 mantiene los artifacts fuera de la estructura lógica, así que un artifact no tiene nada a lo que enlazarse

Anatomía del contenido marcado en PDF desde Delphi: una etiqueta /P con una lista de propiedades MCID entre BDC y EMC, los campos del record TPdfContentMark y el enlace MCID con el árbol de estructura
Una marca es una etiqueta y una lista de propiedades tipada entre BDC y EMC, devuelta como un record cuyo MCID la une con el árbol de estructura
var
  Marks: TPdfContentMarks;
  M: TPdfContentMark;
  P: TPdfContentMarkParam;
  I: Integer;
begin
  Pdf.PageNumber := 1;                    // PageNumber se basa en 1
  for I := 0 to Pdf.ObjectCount - 1 do    // los índices de objeto de página se basan en 0
  begin
    Marks := Pdf.PageObjectMarks(I);
    if Length(Marks) = 0 then
      Continue;
    Memo1.Lines.Add(Format('object %d, MCID %d',
      [I, Pdf.PageObjectMarkedContentID(I)]));
    for M in Marks do                     // la marca más externa primero
    begin
      Memo1.Lines.Add('  mark ' + M.Name);
      for P in M.Params do
        case P.Kind of
          pmpInt:     Memo1.Lines.Add('    ' + P.Key + ' = ' + IntToStr(P.IntValue));
          pmpFloat:   Memo1.Lines.Add('    ' + P.Key + ' = ' + FloatToStr(P.FloatValue));
          pmpString:  Memo1.Lines.Add('    ' + P.Key + ' = ' + P.StringValue);
          pmpBlob:    Memo1.Lines.Add('    ' + P.Key + ' = ' +
                        IntToStr(Length(P.BlobValue)) + ' bytes');
          pmpUnknown: Memo1.Lines.Add('    ' + P.Key + ' = (no readable value)');
        end;
    end;
  end;
end;

Qué puede y qué no puede decirte pmpUnknown

Cada TPdfContentMarkParam tiene un Key, un Kind y un campo de valor elegido por ese kind. La correspondencia es lo bastante corta para llevarla en la cabeza:

  • Un número con valor entero se convierte en pmpInt, con IntValue asignado y FloatValue rellenado con el mismo número, de modo que /MCID 3 y un 3.0 suelto se leen ambos como enteros
  • Un número con parte fraccionaria se convierte en pmpFloat
  • Las cadenas PDF y los nombres PDF se convierten ambos en pmpString; las cadenas de texto que empiezan con una marca de orden de bytes UTF-16 (FE FF o FF FE) se decodifican en StringValue, de modo que /ActualText llega como texto legible
  • Los demás tipos de valor llegan como pmpBlob cuando PDFium entrega bytes en bruto para ellos, y se quedan en pmpUnknown en caso contrario

pmpUnknown cubre dos casos imposibles de distinguir. Uno es un valor que PDFium informa como FPDF_OBJECT_UNKNOWN y del que no entrega bytes. El otro es una clave que aparece en la lista pero que no se puede leer: una clave escrita con bytes no ASCII en un archivo dañado puede figurar en la lista y aun así volver como pmpUnknown. El record se ve igual en ambos casos

Tratad pmpUnknown como "valor no utilizable" e informad de la marca en vez de aceptarla. Si vuestro flujo de trabajo depende de que /Lang o /ActualText estén presentes, buscad la clave con un kind que reconozcáis; nunca concluyáis a partir de un kind desconocido que la propiedad está ausente

Un Handle identifica una marca dentro de la página cargada en este momento y queda obsoleto en cuanto se elimina la marca, se destruye el objeto de página o se descarga la página. Copiad Name y Params a vuestras propias estructuras antes de cambiar de página. Para datos que tienen que sobrevivir a la página actual, como una lista visual o una auditoría en segundo plano, ReadPageObjectsSnapshot devuelve records TPdfContentMarkSnapshot que solo guardan el nombre y los parámetros, sin ningún handle. La misma regla se aplica a los handles de objeto de página en general, como cuentan las notas sobre handles de objeto de página que quedan obsoletos tras una transformación

Añadir, actualizar y eliminar marcas

Cuatro llamadas cubren la edición. AddPageObjectMark(Index, Name, Params) crea una marca nueva en el objeto, aplica los parámetros en orden y devuelve el índice de la marca nueva en ese objeto, que las otras tres llamadas toman como MarkIndex. Los nombres van sin barra: 'P', 'Span'. Para cambiar una clave, SetPageObjectMarkParam(Index, MarkIndex, Param) la inserta o la sustituye y deja quietos todos los demás parámetros de la marca. Los borrados son más contundentes: RemovePageObjectMarkParam(Index, MarkIndex, Key) suelta una clave; RemovePageObjectMark(Index, MarkIndex) suelta la marca

AddPageObjectMark trata la marca y sus parámetros como un solo paso. Si un parámetro se rechaza (un kind pmpUnknown, una clave vacía, un blob vacío), la marca que acaba de crear se elimina de nuevo antes de que la excepción se propague, así que el objeto nunca se queda con una etiqueta a la que le falta media lista de propiedades

Todas estas llamadas reconstruyen el flujo de contenido de la página antes de devolver. Eso importa porque SaveAs escribe el flujo de contenido tal como está y no lo regenera desde el modelo de objetos de PDFium. Una marca que existiera solo en el objeto en memoria dejaría el archivo guardado sin un solo operador BDC. Si además editáis objetos de página a través de las importaciones PDFium en bruto sobre ObjectHandle, llamad vosotros mismos a UpdatePage antes de guardar

Diagrama de cómo añadir una etiqueta de contenido marcado en Delphi: AddPageObjectMark reconstruye el flujo de contenido antes de SaveAs, mientras que un cambio que solo toca el modelo de objetos desaparece en silencio del archivo guardado
AddPageObjectMark reconstruye el flujo de contenido para que SaveAs persista la etiqueta, y un cambio que se detiene en el modelo de objetos nunca llega al archivo

Reconstruir el flujo entero en cada llamada está bien para unas pocas ediciones y es un desperdicio para unos cientos. Etiquetar un informe sin etiquetar página a página es el segundo tipo de trabajo, y ahí las ediciones van dentro de BeginPageObjectEditTransaction y CommitPageObjectEditTransaction. Dentro de la transacción cada edición solo marca la página como cambiada; el commit genera el flujo de contenido una vez, y RollbackPageObjectEditTransaction devuelve la página a como estaba. Una transacción se ata a una página, y fijar PageNumber en otra página mientras está abierta lanza una excepción. En un documento firmado la llamada begin se niega a empezar a menos que paséis AllowSignedDocument = True, porque editar contenido de página rompe la firma de todas formas

Los parámetros de cadena necesitan una decisión más. Los valores pmpString se escriben como cadenas de bytes, que es justo lo que quieren valores ASCII como /Lang (en-US). El texto de reemplazo con tildes, ligaduras o caracteres CJK pertenece a una cadena de texto PDF codificada como UTF-16BE con prefijo FE FF (§7.9.2.2). Pasad esos bytes como pmpBlob: PDFium los guarda como un objeto de cadena, y el lector de arriba los decodifica de vuelta en StringValue como pmpString

// Ayudante propio, no forma parte de la biblioteca: cadena de texto PDF como UTF-16BE con BOM
function Utf16TextString(const S: string): TBytes;
var
  I: Integer;
begin
  SetLength(Result, 2 + Length(S) * 2);
  Result[0] := $FE;
  Result[1] := $FF;
  for I := 1 to Length(S) do
  begin
    Result[I * 2] := Byte(Ord(S[I]) shr 8);
    Result[I * 2 + 1] := Byte(Ord(S[I]));
  end;
end;

procedure TagWord(Pdf: TPdf; ObjectIndex, Mcid: Integer; const Spoken: string);
var
  Params: TPdfContentMarkParams;
  Lang: TPdfContentMarkParam;
  MarkIndex: Integer;
begin
  Pdf.BeginPageObjectEditTransaction;
  try
    SetLength(Params, 2);
    Params[0].Key := 'MCID';
    Params[0].Kind := pmpInt;
    Params[0].IntValue := Mcid;
    Params[1].Key := 'ActualText';
    Params[1].Kind := pmpBlob;            // se guarda como cadena de texto, se lee de vuelta como pmpString
    Params[1].BlobValue := Utf16TextString(Spoken);
    MarkIndex := Pdf.AddPageObjectMark(ObjectIndex, 'Span', Params);

    Lang := Default(TPdfContentMarkParam);
    Lang.Key := 'Lang';
    Lang.Kind := pmpString;               // valor ASCII, una cadena de bytes llana sirve
    Lang.StringValue := 'de-DE';
    Pdf.SetPageObjectMarkParam(ObjectIndex, MarkIndex, Lang);
  except
    Pdf.RollbackPageObjectEditTransaction;
    raise;
  end;
  Pdf.CommitPageObjectEditTransaction;    // una sola reconstrucción del flujo de contenido para todo el lote
end;

El commit queda fuera del bloque try a propósito. Si la generación de contenido falla, CommitPageObjectEditTransaction restaura la página por su cuenta antes de relanzar la excepción, y una segunda llamada rollback solo lanzaría "No page-object edit transaction is active" por encima del error real

¿Por qué un párrafo acaba con tres secuencias MCID 5?

Porque cada llamada a AddPageObjectMark crea un diccionario de marca nuevo e independiente, aun cuando los valores sean idénticos. Un párrafo compuesto como tres objetos de texto, uno por línea, etiquetado con tres llamadas que pasan cada una /MCID 5, carga tres marcas separadas. Cuando PDFium regenera el flujo de contenido solo mete objetos consecutivos dentro de un mismo par BDC/EMC cuando comparten la misma marca; compara la identidad de la marca, y los valores iguales no cuentan. La página guardada carga entonces /P <</MCID 5>> BDC … EMC tres veces seguidas

ISO 32000-1 §14.7.4.2 describe el MCID como el identificador de una secuencia de contenido marcado de forma única dentro de su flujo de contenido. Tres secuencias que comparten un mismo identificador no cumplen eso, y las herramientas que resuelven MCIDs a través de la entrada parent tree de la página se ven obligadas a adivinar cuál de las tres quiere decir el elemento de estructura

SharePageObjectMark(TargetIndex, SourceIndex, SourceMarkIndex) une al objeto destino el diccionario de propiedades propio de la marca origen en lugar de construir una copia. Añadid la marca una vez a la primera línea, compartidla con las demás y dejad los objetos consecutivos en el orden de pintado; el flujo regenerado envuelve entonces las tres líneas en una única secuencia

procedure TagParagraph(Pdf: TPdf; const LineObjects: array of Integer; Mcid: Integer);
// LineObjects: índices de objeto de las líneas del párrafo, consecutivos en el orden de pintado
var
  Params: TPdfContentMarkParams;
  MarkIndex, I: Integer;
begin
  SetLength(Params, 1);
  Params[0].Key := 'MCID';
  Params[0].Kind := pmpInt;
  Params[0].IntValue := Mcid;

  Pdf.BeginPageObjectEditTransaction;
  try
    MarkIndex := Pdf.AddPageObjectMark(LineObjects[0], 'P', Params);
    for I := 1 to High(LineObjects) do
      Pdf.SharePageObjectMark(LineObjects[I], LineObjects[0], MarkIndex);
  except
    Pdf.RollbackPageObjectEditTransaction;
    raise;
  end;
  Pdf.CommitPageObjectEditTransaction;
end;

Compartir tiene una segunda ventaja cuando copiáis una marca existente sobre contenido nuevo, por ejemplo extendiendo un artifact sobre una línea de pie de página redibujada. Los tipos de parámetro que PDFium sabe escribir son entero, float, cadena y blob de bytes, y no hay tipo nombre. Un /Artifact <</Type /Pagination>> leído como pmpString y reconstruido vía AddPageObjectMark sale con (Pagination), una cadena, donde §14.8.2.2 espera el nombre /Pagination. Una marca compartida es el diccionario original, así que sus nombres siguen siendo nombres. Siempre que la marca origen ya exista en la página, compartidla; reconstruid desde Params solo cuando escribáis una marca que nunca existió

Cruce de MCIDs con el árbol de estructura

Un MCID en un objeto de página solo significa algo cuando un elemento de estructura de la misma página lo reclama. StructureElementsForPage[PageNumber] devuelve los elementos de la página como records TPdfStructureElement, y tres campos portan MCIDs: MarkedContentID (-1 cuando el elemento no tiene ninguno), MarkedContentIDs para elementos que poseen varias secuencias, y ChildMarkedContentIDs, con -1 en las posiciones de hijos que son elementos y no contenido marcado. Una auditoría tiene que mirar los tres, porque la entrada /K de un elemento de estructura puede mezclar elementos hijos y referencias a contenido marcado en un solo array

La auditoría de abajo informa dos hallazgos. El texto o las imágenes cuyo MCID no reclama ningún elemento, o que directamente no tienen MCID, son contenido que la tecnología de asistencia se saltará. Un Artifact cuyo MCID está reclamado contradice de frente §14.8.2.2. Los objetos de página salen de ReadPageObjectsSnapshot, así que los resultados no cargan ningún handle y sobreviven a un cambio de página

// Ayudante propio, no forma parte de la biblioteca
function McidClaimed(const Elements: TPdfStructureElements; Mcid: Integer): Boolean;
var
  E: TPdfStructureElement;
  Id: Integer;
begin
  for E in Elements do
  begin
    if E.MarkedContentID = Mcid then
      Exit(True);
    for Id in E.MarkedContentIDs do
      if Id = Mcid then
        Exit(True);
    for Id in E.ChildMarkedContentIDs do
      if Id = Mcid then
        Exit(True);
  end;
  Result := False;
end;

procedure AuditPage(Pdf: TPdf; PageNo: Integer; Log: TStrings);
var
  Snap: TPdfPageObjectsSnapshot;
  Status: TPdfPageObjectSnapshotStatus;
  Elements: TPdfStructureElements;
  Obj: TPdfPageObjectSnapshot;
  IsArtifact: Boolean;
  J: Integer;
begin
  Snap := Pdf.ReadPageObjectsSnapshot(PageNo,
    TPdfPageObjectSnapshotOptions.Full, Status);
  if not Status.Completed then
    Log.Add(Format('page %d: snapshot hit a limit, results are partial', [PageNo]));
  Elements := Pdf.StructureElementsForPage[PageNo];
  for Obj in Snap.Objects do
  begin
    IsArtifact := False;
    for J := 0 to High(Obj.Marks) do
      if Obj.Marks[J].Name = 'Artifact' then
        IsArtifact := True;
    if IsArtifact then
    begin
      if (Obj.MarkedContentID >= 0) and McidClaimed(Elements, Obj.MarkedContentID) then
        Log.Add(Format('object %d: artifact claimed by the structure tree', [Obj.Index]));
    end
    else if Obj.ObjectType in [otText, otImage] then
      if (Obj.MarkedContentID < 0) or not McidClaimed(Elements, Obj.MarkedContentID) then
        Log.Add(Format('object %d: content with no structure owner', [Obj.Index]));
  end;
end;

Las rutas se dejan fuera a propósito. Las líneas decorativas y los bordes de tabla suelen ir sin etiqueta, y marcar cada una de ellas entierra los hallazgos de texto. Los Form XObjects merecen la cautela contraria: un MCID dentro de un formulario pertenece al espacio de nombres /StructParents del propio formulario, así que el MCID 0 de la página y el MCID 0 dentro de un formulario son secuencias distintas. El recorrido sobre espacios de nombres MCID en Form XObjects etiquetados cubre cómo leerlos bien

Las marcas siguen siendo solo la mitad de un documento etiquetado. ISO 32000-1 §14.7 pide un árbol de estructura cuyos elementos referencien esos MCIDs, un diccionario /MarkInfo en el catálogo con /Marked true, y tipos de estructura que sean estándar o que estén mapeados a estándares a través del role map. Pdf.IsTagged os dice si el catálogo declara el documento como etiquetado. Una marca /P cuyo MCID no apunta a ningún elemento produce contenido que dice estar etiquetado en una página que el árbol de estructura nunca menciona

Diagrama de PDFium Component que muestra que las marcas BDC por sí solas no hacen un PDF etiquetado, que también necesita un árbol de estructura que referencie MCIDs, una declaración MarkInfo y nombres de rol estándar
Las marcas sostienen la auditoría y la reparación de huérfanos, mientras que un documento etiquetado conforme también necesita un árbol de estructura, MarkInfo y nombres de rol estándar

Para la mitad de árbol de estructura de ese trabajo, el recorrido por la validación del árbol de estructura PDF/UA retoma el trabajo donde para la auditoría de MCIDs. La experiencia de lectura para la que existen las etiquetas es el tema de las notas sobre cómo construir un lector PDF accesible en Delphi

Las llamadas usadas aquí (lectura de marcas, transacciones de edición, marcas compartidas, snapshots de objetos de página y elementos de estructura) forman parte de la misma API VCL que PDFium Component pone a los proyectos Delphi, C++Builder y Lazarus sobre el motor PDFium. La página del producto PDFium Component cubre el resto de la API de accesibilidad y edición de páginas