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
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, conIntValueasignado yFloatValuerellenado con el mismo número, de modo que/MCID 3y un3.0suelto 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 FFoFF FE) se decodifican enStringValue, así que/ActualTextllega como texto legible - Los demás tipos de valor regresan como
pmpBlobcuando PDFium entrega bytes crudos para ellos, y se quedan comopmpUnknownen 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
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
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