Artículo técnico

Fuentes PDF/UA en Delphi: anchos, CharSet y CIDSet

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

¿Por qué no coinciden los anchos de glifo?

Porque los dos números comparados viven en sistemas de coordenadas distintos, y nada en el diccionario PDF indica la conversión. Un diccionario de fuente escribe /Widths en espacio de glifo, que el PDF fija en una milésima 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 para la mayoría de las fuentes TrueType, 1000 para las derivadas de CFF, y de vez en cuando algo completamente distinto. Compare los valores crudos y cada fuente de 2048 upem de su corpus parece rota. 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 hacer cualquier comparación
PDFlibPas escala cada avance hmtx a una milésima del em antes de compararlo con el ancho del diccionario, y solo reporta lo que dista más de una unidad

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

Parser := TPDFTrueTypeParser.Create;
try
  Parser.LoadFromString(FontProgram);
  if Symbolic then
  begin
    // Las fuentes simbólicas se direccionan por el cmap del programa
    // directamente, con la convención (3,0) de byte alto como respaldo
    GID := Parser.GetGlyphIndex(Code);
    if GID = 0 then
      GID := Parser.GetGlyphIndex($F000 + Code);
  end
  else
  begin
    // No simbólica: código -> nombre de glifo vía la encoding, nombre -> Unicode
    // vía la Adobe Glyph List, Unicode -> GID vía el cmap del programa
    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 tienen peso. La tolerancia es de una unidad, no cero, porque la normalización es división entera y un archivo legítimamente producido puede quedar a una unidad de distancia; es exactamente la redacción de "dentro de una milésima de em" que reporta el diagnóstico 10036. Y la guarda GID < Parser.GlyphCount no es decoración. GetWidth está escrita para ser indulgente con quienes renderizan: recorta un índice fuera de rango a la última entrada de hmtx y cae a 750 cuando la tabla no existe. Indulgente está bien para renderizar y mal para auditar, así que la auditoría rechaza el índice antes de pedir un ancho en lugar de confiar en el recorte

CIDFontType2 agrega una indirección más

PDFlibPas recorre las fuentes compuestas de la misma manera, con /CIDToGIDMap insertado entre el CID y el glifo. Los anchos llegan en el arreglo /W, al que ISO 32000-1 §9.7.4.3 da dos formas que se alternan libremente en un mismo arreglo: un CID inicial seguido de un arreglo de anchos consecutivos, o un primer CID, un último CID y un único ancho aplicado a toda la corrida. La auditoría parsea ambas formas, entrega cada par resultante a la misma comparación y reporta el total bajo el diagnóstico 10037. El paso de mapeo es donde difieren las fuentes compuestas, y es la razón por la que el diagnóstico 10021 de mapa ausente importa antes de leer cualquier ancho — un /CIDToGIDMap ausente o malformado no solo viola §7.21.3.2, sino que hace que la pregunta del ancho quede sin respuesta

Tres rutas que PDFlibPas toma 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 encoding 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 tipo de fuente llega a ese índice por una ruta distinta
// /CIDToGIDMap es el nombre /Identity o un stream de índices de glifo
// de 16 bits big-endian, 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 decodifica?

No decir nada. Las comprobaciones de completitud de /CharSet y /CIDSet que exige ISO 14289-1 §7.21.4.2 — diagnósticos 10038 y 10039 — son el lugar donde un validador demasiado ansioso se convierte en un problema, porque un informe de "su CharSet está incompleto" es indistinguible, para quien lo lee, de "nuestro decodificador Type 1 se rindió". Por eso PDFlibPas solo reporta una entrada faltante cuando tres cosas tienen éxito a la vez: el programa de fuente decodifica, el mapeo de código a glifo se resuelve y el propio conjunto decodifica. TPDFType1Decoder.LoadPFBFromString debe devolver True y producir un conteo de charstrings antes de contrastar cualquier nombre de glifo contra la cadena /CharSet; el camino de /CIDSet necesita que el stream se infle y que el conteo de glifos vuelva positivo antes de probar un solo bit. Cualquier excepción en el camino colapsa en "sin hallazgo", no en un defecto

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

Es un sesgo deliberado hacia los falsos negativos, y vale decirlo sin rodeos en lugar de enterrarlo. Una tabla CFF corrupta, una variante Type 1 no soportada o un /CIDSet más corto que el rango de glifos producen 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 confiar en todo el informe. 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 hacer un humano, y su checkpoint de Fonts (31) es donde viven estas. Si necesitan la lectura más estricta, usen PDFlibPas como la puerta rápida y un validador dedicado como segunda opinión — esa pareja es la misma que describe el recorrido de preflight PDF/A y PDF/UA

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

El error más caro en la auditoría de streams de contenido es tratar /Contents como un solo stream. ISO 32000-1 §7.7.3.3 permite que una página contenga un arreglo de streams cuya concatenación, con espacios en blanco entre las partes, es el programa de la página; los productores dividen 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. Si lo llaman una vez por miembro del arreglo, cada stream posterior al primero arranca sin fuente actual, y texto perfectamente etiquetado se lee como ruido sin etiquetas y sin fuente. PDFlibPas concatena primero y procesa una sola 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 realmente como no estructurados?

Solo los que una página realmente invoca, desde un sitio 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 — y reportando solo la intersección de los dos primeros menos el tercero. Cada uno de los dos atajos está mal de una forma que ustedes terminarían enviando: marcar cada Form con texto en /Resources castiga a una biblioteca de plantillas de la que nadie dibuja, y marcar cada Form invocado castiga a logos vectoriales que no llevan texto y no necesitan etiquetado. El sitio de invocación se resuelve por número de objeto y no por nombre de recurso, ya que el mismo Form se alcanza rutinariamente por nombres distintos en páginas distintas. El diagnóstico complementario 10041 recorre el mismo programa concatenado para §7.21.8, resolviendo cada operando que muestra texto a través de la fuente en vigor y contando los códigos que caen en .notdef, lo cual está prohibido sin importar el modo de renderizado del texto — incluido el modo invisible usado detrás de imágenes escaneadas. Cómo envolver los Forms que sobreviven es una pregunta de árbol de estructura, cubierta en el artículo sobre construir estructura PDF etiquetada

Fuentes sin ningún FontDescriptor

Una fuente no incrustada es una entrada legítima de esta auditoría, no un estado de error, y cada helper 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, que §7.21.4 NOTE 5 se niega puntualmente a exentar — y luego sigue con el resto del archivo. Ese es todo el punto de un informe: un autor quiere cada hallazgo en una sola pasada, no un hallazgo por ejecución. Así, la referencia al descriptor que se entrega a los helpers de anchos, cmap, CharSet y CIDSet puede ser Nil, y cada uno la prueba al entrar en lugar de asumir que una comprobación anterior abortó la auditoría. Si la corrección es incrustar lo que falta, la mecánica está en la nota sobre incrustar fuentes faltantes en un PDF existente

Ejecutar la auditoría

Una llamada, sobre un archivo que no necesariamente produjeron. TPDFlib.CheckFileCompliance toma un selector de prueba de cumplimiento — 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 streams de contenido que se discutieron aquí ocupan del 10020 al 10041 en ese rango, separados numéricamente de los códigos PDF/A 00xxx para que un registro mixto siga legible. Pasar 1 en Options corta en el primer hallazgo, que es lo que se quiere en una compuerta de build y no en una herramienta de autoría. Para un documento aún abierto en memoria, GetPDFUADiagnostics corre la inspección equivalente sin pasar por disco

var
  Issues, Count, I: Integer;
begin
  // ComplianceTest = 2 selecciona PDF/UA-1; Options = 0 reporta cada hallazgo
  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 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 API de cumplimiento y de 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 con las suites de prueba PDF/A, PDF/X y PDF/E