Artículo técnico

Leer y escribir contenido marcado de PDF en Delphi

En un PDF etiquetado, el encabezado corrido de la parte superior de cada página suele quedar dentro de /Artifact <</Type /Pagination>> BDC … EMC, mientras que el primer párrafo debajo queda 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 que leer entonces tres cosas de cada objeto de página (la etiqueta, su lista de propiedades y el MCID) y cotejar el 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 cada marca que envuelve al objeto de página en Index como un arreglo de registros TPdfContentMark. Cada registro lleva la etiqueta en Name, un arreglo Params con la lista de propiedades, y un Handle. El nombre llega sin la barra inicial, así que una secuencia /P reporta P y un artefacto reporta Artifact. Las secuencias de contenido marcado pueden anidarse, y una corrida de glifos dentro de /Sect, luego /P y luego /Span reporta tres registros en el mismo objeto, la más externa primero

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

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

Diagrama de la anatomía del contenido marcado PDF en Delphi: una etiqueta /P con una lista de propiedades MCID entre BDC y EMC, los campos del record TPdfContentMark, y el enlace MCID al árbol de estructura
Una marca es una etiqueta y una lista de propiedades tipada entre BDC y EMC, reportada como un registro cuyo MCID se une al árbol de estructura
var
  Marks: TPdfContentMarks;
  M: TPdfContentMark;
  P: TPdfContentMarkParam;
  I: Integer;
begin
  Pdf.PageNumber := 1;                    // PageNumber usa base 1
  for I := 0 to Pdf.ObjectCount - 1 do    // los índices de objeto de página usan base 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;

Lo que pmpUnknown puede y no puede decirle

Cada TPdfContentMarkParam tiene una Key, un Kind y un campo de valor significativo seleccionado por ese tipo. La asignación es lo bastante corta para tenerla presente:

  • Un número con valor integral 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, así que /ActualText llega como texto legible
  • Los demás tipos de valor regresan como pmpBlob cuando PDFium entrega bytes crudos para ellos, y se quedan como pmpUnknown en los demás casos

pmpUnknown cubre dos casos que no se pueden distinguir. Uno es un valor que PDFium reporta como FPDF_OBJECT_UNKNOWN y del que no entrega bytes. El otro es una clave que aparece en la lista pero no se puede leer de vuelta: una clave escrita con bytes que no son ASCII en un archivo dañado puede aparecer en la lista y aun así regresar como pmpUnknown. El registro se ve igual en ambos casos

Trate pmpUnknown como «ningún valor utilizable» y reporte la marca en vez de aceptarla. Si su flujo de trabajo depende de que /Lang o /ActualText estén presentes, busque la clave con un tipo que reconozca; nunca concluya a partir de un tipo desconocido que la propiedad está ausente

Un Handle identifica una marca dentro de la página cargada actualmente y se vuelve obsoleto en cuanto la marca se elimina, el objeto de página se destruye o la página se descarga. Copie Name y Params a sus propias estructuras antes de cambiar de página. Para datos que deben sobrevivir a la página actual, como una lista visual o una auditoría en segundo plano, ReadPageObjectsSnapshot devuelve registros TPdfContentMarkSnapshot que solo contienen el nombre y los parámetros, sin ningún identificador. La misma regla aplica a los identificadores de objetos de página en general, como cubren las notas sobre los identificadores de objetos de página que se vuelven obsoletos tras una transformación

Agregar, 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 reemplaza y deja en paz todos los demás parámetros de la marca. Las eliminaciones son más directas. 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 es rechazado (un tipo 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í el objeto jamás conserva una etiqueta con media lista de propiedades

Cada una de estas llamadas reconstruye el flujo de contenido de la página antes de regresar. 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 edita objetos de página mediante importaciones directas de PDFium sobre ObjectHandle, llame usted mismo a UpdatePage antes de guardar

Diagrama de agregar una etiqueta de contenido marcado en Delphi: AddPageObjectMark reconstruye el flujo de contenido de la página antes de SaveAs, mientras un cambio solo en el modelo de objetos se descarta silenciosamente del archivo guardado
AddPageObjectMark reconstruye el stream de contenido para que SaveAs persista la etiqueta, y un cambio que se detiene en el modelo de objetos jamás llega al archivo

Reconstruir el flujo completo en cada llamada está bien para unas cuantas ediciones y es un desperdicio para unos cuantos cientos. Etiquetar un reporte sin etiquetar página por 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 sola vez, y RollbackPageObjectEditTransaction devuelve la página a como estaba. Una transacción está ligada a una página, y asignar PageNumber a otra página mientras está abierta lanza una excepción. En un documento firmado la llamada inicial se niega a arrancar a menos que pase AllowSignedDocument = True, porque editar contenido de página rompe la firma de todos modos

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

// Su propio helper, no 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 almacena 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 simple está bien
    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 sí mismo antes de relanzar, y una segunda llamada de rollback solo lanzaría «No page-object edit transaction is active» sobre el error real

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

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

ISO 32000-1 §14.7.4.2 describe el MCID como el identificador único de una secuencia de contenido marcado dentro de su flujo de contenido. Tres secuencias que comparten un identificador no cumplen eso, y las herramientas que resuelven MCIDs por la entrada del árbol padre de la página se quedan adivinando cuál de las tres significa el elemento de estructura

SharePageObjectMark(TargetIndex, SourceIndex, SourceMarkIndex) adjunta el diccionario de propiedades propio de la marca origen al objeto destino en vez de construir una copia. Agregue la marca una vez a la primera línea, compártala con las demás y mantenga los objetos consecutivos en el orden de pintado; el flujo regenerado entonces envuelve las tres líneas en una sola 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 copia una marca existente sobre contenido nuevo, por ejemplo al extender un artefacto sobre una línea de pie de página redibujada. Los tipos de parámetro que PDFium puede escribir son entero, flotante, cadena y blob de bytes, y no hay tipo para nombres. Un /Artifact <</Type /Pagination>> leído como pmpString y reconstruido mediante 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, compártala; reconstruya desde Params solo cuando esté escribiendo una marca que nunca existió

Cotejar MCIDs contra el árbol de estructura

Un MCID en un objeto de página significa algo solo cuando un elemento de estructura de la misma página lo reclama. StructureElementsForPage[PageNumber] devuelve los elementos de la página como registros TPdfStructureElement, y tres campos cargan MCIDs: MarkedContentID (-1 cuando el elemento no tiene ninguno), MarkedContentIDs para elementos que poseen varias secuencias, y ChildMarkedContentIDs, con -1 en las posiciones de los hijos que son elementos en lugar de 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 de contenido marcado en un solo arreglo

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

// Su propio helper, no 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;

Los paths se dejan fuera a propósito. Las líneas decorativas y los bordes de tabla suelen estar sin etiquetar, y marcar cada uno de ellos entierra los hallazgos de texto. Los Form XObjects merecen la cautela contraria: un MCID dentro de un formulario pertenece al namespace de /StructParents de ese formulario, así que el MCID 0 en la página y el MCID 0 dentro de un formulario son secuencias distintas. El recorrido sobre los namespaces de MCID en Form XObjects etiquetados cubre cómo leerlos correctamente

Las marcas siguen siendo solo la mitad de un documento etiquetado. ISO 32000-1 §14.7 pide un árbol de estructura cuyos elementos referencien estos 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ándar mediante el mapa de roles. Pdf.IsTagged le dice si el catálogo declara el documento como etiquetado. Una marca /P cuyo MCID ningún elemento apunta produce contenido que afirma 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 roles estándar
Las marcas soportan auditoría y reparación de huérfanos, mientras que un documento etiquetado conforme también necesita un árbol de estructura, MarkInfo y nombres de roles estándar

Para la mitad del trabajo que corresponde al árbol de estructura, el recorrido por la validación del árbol de estructura PDF/UA retoma donde termina 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 de PDF accesible en Delphi

Las llamadas usadas aquí (lectura de marcas, transacciones de edición, marcas compartidas, instantáneas de objetos de página y elementos de estructura) son parte de la misma API VCL que PDFium Component da a los proyectos de 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