Artículo técnico

Auditoría de fuentes PDF/UA en Delphi: anchos y CharSet

Un informe de veraPDF que dice que el ancho de un glifo no concuerda con el programa de fuente incrustado apenas te dice qué glifo es ni por qué. PDFlibPas responde a esa pregunta resolviendo cada código de carácter a través del cmap incrustado hasta un índice de glifo, normalizando la métrica del programa a 1000 unidades por em y comparando ahí

¿Por qué no concuerdan los anchos de glifo?

Porque los dos números que se comparan viven en sistemas de coordenadas distintos, y nada en el diccionario PDF te dice la conversión. Un diccionario de fuente escribe /Widths en espacio de glifo, que PDF fija en la milésima parte del em (ISO 32000-1 §9.2.4). La tabla hmtx dentro del programa TrueType incrustado escribe los avances en unidades de diseño de la fuente, y la tabla head decide cuántas de esas unidades forman un em: 2048 en la mayoría de las fuentes TrueType, 1000 en las derivadas de CFF y de vez en cuando otra cosa completamente distinta. Compara los valores en bruto y todas las fuentes de 2048 upem de tu corpus parecen rotas. Esa es la trampa que ISO 14289-1 §7.21.5 tiende a quien intenta auditar anchos leyendo campos del diccionario

Auditoría de anchos de glifo de PDFlibPas en Delphi: una entrada /Widths en espacio de glifo y un avance hmtx en unidades de diseño de fuente se llevan al mismo sistema de coordenadas escalando la métrica del programa a 1000 unidades por em antes de cualquier comparación
PDFlibPas escala cada avance de hmtx a la milésima parte del em antes de compararlo con el ancho del diccionario, y solo informa de lo que dista más de una unidad

PDFlibPas normaliza al cargar. TPDFTrueTypeParser guarda Advance * 1000 div unitsPerEm en su array de anchos, de modo que Parser.GetWidth(GID) ya responde en las mismas milésimas de em que usa el PDF, y GetRawWidth sigue disponible cuando necesitas las unidades de diseño. Eso deja todavía la mitad más difícil: llegar de un código de carácter a un índice de glifo. En una fuente TrueType simple la ruta depende del flag Symbolic del FontDescriptor, el bit 3 de /Flags

Parser := TPDFTrueTypeParser.Create;
try
  Parser.LoadFromString(FontProgram);
  if Symbolic then
  begin
    // Las fuentes simbólicas se direccionan directamente por el cmap del
    // programa, con la convención de byte alto (3,0) como respaldo
    GID := Parser.GetGlyphIndex(Code);
    if GID = 0 then
      GID := Parser.GetGlyphIndex($F000 + Code);
  end
  else
  begin
    // No simbólicas: código -> nombre de glifo vía la codificación,
    // nombre -> Unicode vía la Adobe Glyph List, Unicode -> GID vía el cmap
    UnicodeValue := GetGlyphUnicode(EncodingNames[Code and $FF]);
    if UnicodeValue = 0 then
      Continue;
    GID := Parser.GetGlyphIndex(UnicodeValue);
  end;
  if (GID > 0) and (GID < Parser.GlyphCount) then
    if Abs(PDFWidth - Parser.GetWidth(GID)) > 1 then
      Inc(MismatchCount);
finally
  Parser.Free;
end;

Dos detalles de ese fragmento pesan. La tolerancia es de una unidad, no cero, porque la normalización es división entera y un archivo legítimamente producido puede desviarse una unidad; es exactamente la redacción de «dentro de una milésima de em» que informa el diagnóstico 10036. Y la guarda GID < Parser.GlyphCount no es decoración. GetWidth está escrito para ser condescendiente con quienes llaman para renderizar: recorta un índice fuera de rango a la última entrada de hmtx y cae a 750 cuando la tabla no existe. Condescender es lo correcto para renderizar y lo incorrecto para auditar, así que la auditoría rechaza el índice antes de pedir un ancho en lugar de fiarse del recorte

CIDFontType2 añade una indirección más

PDFlibPas recorre las fuentes compuestas del mismo modo, con /CIDToGIDMap insertada entre el CID y el glifo. Los anchos llegan en el array /W, al que ISO 32000-1 §9.7.4.3 da dos formas que alternan libremente en un mismo array: un CID inicial seguido de un array de anchos consecutivos, o un primer CID, un último CID y un único ancho aplicado a toda la racha. La auditoría analiza ambas formas, entrega cada par resultante a la misma comparación e informa del total bajo el diagnóstico 10037. El paso de mapeo es donde difieren las fuentes compuestas, y es por eso que el diagnóstico 10021 de mapa ausente importa antes de leer ancho alguno — una /CIDToGIDMap ausente o malformada no solo viola §7.21.3.2, sino que hace la pregunta del ancho irrespondible

Tres rutas que PDFlibPas sigue de un código de carácter a un índice de glifo en Delphi: el cmap del programa para fuentes TrueType simbólicas, un rodeo por la codificación y la Adobe Glyph List para las no simbólicas, y un paso por CMap más /CIDToGIDMap para CIDFontType2
La comparación de anchos no puede empezar hasta que el código de carácter se resuelve a un índice de glifo, y cada clase de fuente llega a ese índice por una ruta distinta
// /CIDToGIDMap es el nombre /Identity o un flujo de índices de glifo
// big-endian de 16 bits, uno por CID (ISO 32000-1 sección 9.7.4.2)
Obj := DerefIndRef(FDoc, CIDFont.FindValueByKeyName('CIDToGIDMap'));
if (Obj is TPDFName) and (TPDFName(Obj).Name = 'Identity') then
begin
  GID := CID;
  Result := True;
end
else if Obj is TPDFStream then
begin
  Data := TPDFStream(Obj).GetDecodedStream;
  P := CID * 2 + 1;                       // Las cadenas Pascal empiezan en 1
  if (P >= 1) and (P + 1 <= Length(Data)) then
  begin
    GID := (Integer(Byte(Data[P])) shl 8) or Integer(Byte(Data[P + 1]));
    Result := True;
  end;
end;

¿Qué debe hacer un auditor cuando el programa de fuente no se decodifica?

Callar. Las comprobaciones de completitud de /CharSet y /CIDSet que exige ISO 14289-1 §7.21.4.2 — diagnósticos 10038 y 10039 — son el punto donde un validador demasiado celoso se vuelve un pasivo, porque un informe de «tu CharSet está incompleto» es indistinguible, para quien lo lee, de «nuestro decodificador Type 1 se rindió». Por eso PDFlibPas solo informa de una entrada ausente cuando tres cosas triunfan a la vez: el programa de fuente se decodifica, el mapeo de código a glifo se resuelve y el propio conjunto se decodifica. TPDFType1Decoder.LoadPFBFromString debe devolver True y producir un recuento de charstrings antes de contrastar nombre de glifo alguno con la cadena /CharSet; la ruta de /CIDSet necesita que el flujo se infle y que el recuento de glifos vuelva positivo antes de probar un solo bit. Cualquier excepción por el camino colapsa en «sin hallazgo», no en un defecto

La regla de informe conservador de la auditoría PDF/UA de PDFlibPas: una entrada /CharSet o /CIDSet ausente solo se informa cuando el programa de fuente se decodifica, el mapeo de código a glifo se resuelve y el propio conjunto se decodifica, y cualquier fallo produce silencio
Hacen falta tres éxitos independientes antes de emitir un hallazgo de entrada ausente, así que un decodificador que se rinde te cuesta un falso negativo y no una acusación falsa

Es un sesgo deliberado hacia los falsos negativos, y conviene decirlo sin rodeos en lugar de enterrarlo. Una tabla CFF corrupta, una variante Type 1 no soportada o una /CIDSet más corta que el rango de glifos producen todos silencio en lugar de un diagnóstico. El razonamiento es que las auditorías PDF/UA se reenvían a autores que no construyeron las herramientas, y una acusación falsa cuesta más que un hallazgo perdido: el autor quema un día demostrando que un archivo conforme es conforme, y deja de fiarse del informe entero. El Matterhorn Protocol hace la misma distinción en otra forma cuando separa las comprobaciones que una máquina puede decidir de las que debe decidir un humano, y su checkpoint de Fonts (31) es donde viven estas. Si necesitas la lectura más estricta, usa PDFlibPas como puerta rápida y un validador dedicado como segunda opinión — ese emparejamiento es el mismo que describe el recorrido de preflight PDF/A y PDF/UA

El /Contents de página es una lista, no un flujo

El error más caro al auditar flujos de contenido es tratar /Contents como un único flujo. ISO 32000-1 §7.7.3.3 permite que una página contenga un array de flujos cuya concatenación, con espacio en blanco entre las partes, es el programa de la página; los productores parten en puntos arbitrarios, y un BT puede quedar en un miembro con su ET correspondiente en el siguiente. Un procesador de contenido conserva estado — la profundidad de anidamiento del marked-content, la fuente seleccionada por el último Tf, el flag de objeto de texto — y Process reinicia ese estado al entrar. Llámalo una vez por miembro del array y cada flujo posterior al primero arranca sin fuente actual, así que un texto perfectamente etiquetado se lee como ruido sin etiquetas ni fuente. PDFlibPas concatena primero y procesa una vez

function ContentObjectData(FDoc: TSmartPDFDocument; Obj: TPDFObject): AnsiString;
var
  I: Integer;
begin
  Result := '';
  Obj := DerefIndRef(FDoc, Obj);
  if Obj is TPDFStream then
    Result := TPDFStream(Obj).GetDecodedStream
  else if Obj is TPDFArray then
    for I := 0 to TPDFArray(Obj).Count - 1 do
      Result := Result + ContentObjectData(FDoc, TPDFArray(Obj).Item[I]) + #10;
end;

// Una llamada Process sobre toda la concatenación, nunca una por miembro
Scanner.Process(ContentObjectData(FDoc, PageDict.FindValueByKeyName('Contents')));

¿Qué Form XObjects cuentan de verdad como sin estructura?

Solo los que una página invoca de verdad, desde un punto de llamada fuera del marked content, y cuyo propio contenido muestra texto. El diagnóstico 10040 hace cumplir ISO 14289-1 §7.20 registrando tres hechos independientes por número de objeto — tiene texto, fue invocado, fue invocado dentro de marked content — e informando solo de la intersección de los dos primeros menos el tercero. Cada uno de los dos atajos está mal de una forma que acabarías enviando: marcar todo Form con texto de /Resources castiga a una biblioteca de plantillas que nadie dibuja, y marcar todo Form invocado castiga a logos vectoriales sin texto que no necesitan etiquetado. El punto de invocación se resuelve por número de objeto y no por nombre de recurso, porque el mismo Form se alcanza rutinariamente con nombres distintos en páginas distintas. El diagnóstico compañero 10041 recorre el mismo programa concatenado para §7.21.8, resolviendo cada operando que muestra texto a través de la fuente en scope y contando los códigos que caen en .notdef, lo cual está prohibido sea cual sea el modo de renderizado del texto — incluido el modo invisible que se usa tras imágenes escaneadas. Cómo envolver los Forms que sobreviven es una pregunta de árbol de estructura, tratada en el artículo sobre construir estructura PDF etiquetada

Fuentes sin FontDescriptor alguno

Una fuente no incrustada es una entrada legítima para esta auditoría, no un estado de error, y cada ayudante por debajo de la comprobación de incrustación tiene que sobrevivirla. Cuando PDFlibPas no encuentra /FontDescriptor, o encuentra un descriptor sin FontFile, FontFile2 ni FontFile3, registra el diagnóstico 10020 — o 10022 cuando el nombre es uno de los Standard 14, a los que §7.21.4 NOTE 5 se niega rotundamente a eximir — y sigue recorriendo el resto del archivo. Ese es el sentido de un informe: un autor quiere todos los hallazgos en una pasada, no un hallazgo por ejecución. Así que la referencia al descriptor que se entrega a los ayudantes de anchos, cmap, CharSet y CIDSet puede ser Nil, y cada uno lo comprueba al entrar en vez de asumir que una comprobación anterior abortó la auditoría. Si el arreglo es incrustar lo que falta, la mecánica está en la nota sobre incrustar fuentes ausentes en un PDF existente

Ejecutar la auditoría

Una llamada, sobre un archivo que no has producido necesariamente. TPDFlib.CheckFileCompliance toma un selector de prueba de conformidad — 2 para PDF/UA-1 bajo ISO 14289-1:2014 — y devuelve cero o un handle de lista de cadenas cuyas entradas son un código numérico, dos puntos y un mensaje legible. Los hallazgos de fuentes y de flujos de contenido que tratamos aquí ocupan del 10020 al 10041 en ese rango, mantenidos numéricamente aparte de los códigos 00xxx de PDF/A para que un registro mixto siga siendo legible. Pasar 1 en Options hace cortocircuito en el primer hallazgo, que es lo que quieres en una puerta de build y no en una herramienta de autoría. Para un documento aún abierto en memoria, GetPDFUADiagnostics ejecuta la inspección equivalente sin pasar por disco

var
  Issues, Count, I: Integer;
begin
  // ComplianceTest = 2 selecciona PDF/UA-1; Options = 0 informa de todos los hallazgos
  Issues := PDF.CheckFileCompliance('delivery.pdf', '', 2, 0);
  if Issues = 0 then
    WriteLn('delivery.pdf: PDF/UA-1 conformant')
  else
  begin
    Count := PDF.GetStringListCount(Issues);
    for I := 1 to Count do
      WriteLn('  ', PDF.GetStringListItem(Issues, I));   // p. ej. 10037 CIDFontType2 ...
  end;
end;

Nada de esto necesita un binario de validador externo en la máquina, que es la diferencia entre una comprobación que corre en cada build y una que corre cuando alguien se acuerda. Las APIs de conformidad y diagnóstico descritas aquí se entregan en la PDFlibPas Delphi PDF Library estándar, cuya página de producto lleva la tabla completa de códigos de diagnóstico de PDF/UA-1 junto a las suites de prueba PDF/A, PDF/X y PDF/E