Artículo técnico

JPEG CMYK en PDF: ColorTransform de DCTDecode en Delphi

Un JPEG CMYK que sale de un PDF con colores turbios, desplazados o negativos casi siempre significa que el decodificador se equivocó con uno de dos datos: si las muestras se guardaron como YCCK y necesitan conversión, o si se guardaron invertidas por convención Adobe. Los decodificadores de HotPDF respetan el parámetro /ColorTransform de DCTDecode para imágenes de tres y cuatro canales, rechazan valores que la especificación no define, y dejan que un marcador APP14 de Adobe dentro de los datos JPEG tome precedencia sobre esa entrada del diccionario, como exige la Tabla 13 de ISO 32000-1. Dos reglas vecinas pertenecen a la misma familia de trampas: los streams multi-filtro deben llevar arreglos /DecodeParms alineados por etapa, y las imágenes JPEG 2000 conservan un mapeo /Decode explícito

Las tres comparten un tema. El PDF ya dice lo que quiere decir, y la trampa es un renderer que responde una pregunta ligeramente distinta

¿Por qué un JPEG CMYK se ve invertido o lavado en un PDF?

Un JPEG CMYK sale mal en un PDF por una de dos razones, y se ven distintas en pantalla. Una imagen negativa, donde el papel blanco se vuelve negro y los tonos claros se oscurecen, apunta a almacenamiento invertido. Una imagen lavada o con tonos desplazados, donde la piel se pone verde y los negros se van a gris mientras el detalle K se mantiene nítido, apunta a una transformación de color aplicada cuando no debía, u omitida cuando era requerida

Mapa de síntomas de un JPEG CMYK roto en un PDF de Delphi: los negativos apuntan a almacenamiento Adobe invertido, los tonos lavados a un ColorTransform mal emparejado
un negativo significa almacenamiento invertido que pide un arreglo /Decode, el desvío de tono significa una transformación mal emparejada, y un plano K nítido apunta primero a la transformación

El problema de transformación viene de cómo JPEG comprime el color. El JPEG baseline comprime mejor cuando la luminancia está separada de la crominancia, así que los codificadores suelen convertir RGB a YCbCr antes de comprimir, y los codificadores de cuatro canales convierten CMY a YCC mientras dejan pasar K intacto, que es el formato llamado YCCK. El decodificador tiene que deshacer exactamente la conversión que aplicó el codificador. Deshaga una que nunca ocurrió, u omita una que sí, y cada píxel se mueve. El canal negro sobrevive de cualquier forma, y por eso una imagen CMYK rota suele conservar texto negro nítido mientras todo lo demás se desvía

El problema de inversión es histórico. Photoshop y otras herramientas Adobe escriben JPEG CMYK con cada muestra guardada como 255 - value, y marcan el archivo con un marcador APP14 de Adobe. Nada en el estándar JPEG lo dice; es una convención, y cada consumidor tiene que conocerla. En un PDF, se espera que el productor exprese la inversión en el diccionario de la imagen en vez de dejarla a la adivinanza

¿Qué le dice /ColorTransform al decodificador?

/ColorTransform es el enunciado a nivel PDF de qué conversión debe deshacer el decodificador. ISO 32000-1 §7.4.8, Tabla 13, define dos valores. Cero significa sin transformación: el decodificador devuelve las muestras tal como se comprimieron. Uno significa que el codificador aplicó la conversión estándar, así que una imagen de tres canales va de YCbCr de vuelta a RGB y una de cuatro canales va de YCCK de vuelta a CMYK. El parámetro se ignora para imágenes de uno y dos canales. La Tabla 13 también fija una regla de precedencia fácil de pasar por alto: cuando los datos codificados llevan un marcador Adobe con su propio código de transformación, ese código manda y la entrada del diccionario se ignora. Solo un JPEG sin ese marcador recurre al diccionario, y cuando la entrada también está ausente, una imagen de tres canales toma por defecto la transformación y una de cuatro canales ninguna

Matriz de decisión de ColorTransform de DCTDecode para Delphi: el marcador APP14 de Adobe manda sobre el diccionario, y los valores indefinidos los rechaza HotPDF
el marcador Adobe dentro de los datos JPEG le gana a la entrada del diccionario, y solo un JPEG sin marcador recurre a /ColorTransform o al valor por defecto por conteo de componentes

El error fácil es una ruta de imagen que acepta el diccionario de parámetros pero jamás se lo entrega al decodificador JPEG. Un JPEG RGB de un píxel que contiene el color (128, 63, 31) lo expone de inmediato: tal decodificador devuelve [128, 63, 31] tanto para /ColorTransform 0 como para /ColorTransform 1, mientras que la respuesta correcta para cero es el trío YCbCr crudo [79, 101, 163]

HotPDF aplica el parámetro a nivel de bytes. HPDFDecodeHeadlessTerminalImage primero recorre la estructura de segmentos JPEG hasta el marcador de inicio de scan y busca un segmento APP14 de Adobe; los bytes que solo se le parecen dentro de un segmento de comentario no cuentan. Si los datos llevan el marcador, su código de transformación decide y el valor del diccionario se ignora. Si no, el decodificador construye una copia privada del JPEG con un segmento APP14 insertado justo antes del marcador de inicio de scan, con código de transformación 0, 1 o, para una imagen de cuatro canales con valor 1, el código YCCK 2. Siempre que la transformación efectiva de una imagen de tres canales es 0, la copia además desarma el encabezado JFIF, porque un encabezado JFIF por sí solo le dice a libjpeg-turbo que asuma YCbCr. El buffer de quien llama nunca se toca, y cuando se inserta un segmento, sus 16 bytes extra se cargan al presupuesto de bytes de trabajo antes de que el decodificador asigne nada

Insertar solo cuando no existe marcador importa por un detalle de libjpeg: usa el último segmento APP14 que ve. Inserte un segmento después de un marcador existente y un valor explícito del diccionario pisa silenciosamente un marcador conflictivo en los datos, que es exactamente lo contrario de la Tabla 13. HotPDF aplica el orden de la Tabla 13 por igual en la ruta de image XObject completo y en la de imagen inline

Puede llamar al mismo decodificador terminal directamente cuando una sola imagen se ve mal y quiere ver sus muestras sin renderizar la página. La función vive en la unidad HPDFHeadlessImageCodecs, recibe el nombre del filtro, los bytes codificados, un diccionario de parámetros opcional, y la geometría del diccionario de la imagen, que debe coincidir con el frame header del JPEG

uses
  SysUtils, HPDFObjs, HPDFHeadlessImageCodecs;

// ColorTransform < 0 significa "entrada ausente": deje que los marcadores JPEG decidan
function DecodeDCT(const Encoded: TBytes; Width, Height, Channels,
  ColorTransform: Integer): TBytes;
var
  Parms: THPDFDictionaryObject;
begin
  Parms := nil;
  if ColorTransform >= 0 then
  begin
    Parms := THPDFDictionaryObject.Create(nil);
    Parms.AddNumericValue('ColorTransform', ColorTransform);
  end;
  try
    Result := HPDFDecodeHeadlessTerminalImage('DCTDecode', Encoded, nil, Parms,
      Width, Height, Channels, 8, 64 * 1024 * 1024, nil);
  finally
    Parms.Free;
  end;
end;

El decodificador es estricto con lo que cuenta como valor legal. Un -1 explícito, un 2, o un no entero como 0.5 lanzan EHPDFHeadlessImageError en vez de recurrir a un valor por defecto, porque un valor que la especificación no define no le da al decodificador nada confiable en qué actuar. La verificación sigue en pie incluso cuando un marcador Adobe en los datos vuelve la entrada irrelevante, porque un valor indefinido es un error estructural en el propio documento. Un presupuesto demasiado pequeño para la copia transformada falla igual, con un mensaje que nombra el presupuesto de bytes de trabajo

Un null explícito es un caso distinto, porque no es un valor en absoluto. ISO 32000-1 §7.3.9 dice que una entrada de diccionario cuyo valor es null equivale a una entrada ausente, así que /ColorTransform null cae al marcador o al valor por defecto por conteo de componentes exactamente como si la clave faltara. HotPDF lo trata así en todas las rutas, headless y VCL, stream e inline por igual

Un valor por defecto más merece mención, porque es donde HotPDF deliberadamente va más allá de la letra de la Tabla 13. Un JPEG de tres componentes sin encabezado JFIF ni marcador Adobe, cuyo frame header etiqueta sus componentes como R, G y B, dice en sus propios bytes que contiene RGB. La Tabla 13 lo dejaría en la transformación por defecto, pero libjpeg y los lectores construidos sobre él lo decodifican sin transformar, y HotPDF también. Los decodificadores headless y VCL lo tratan así ambos, así que el rojo y el azul se quedan donde los datos los pusieron. Un 0 o 1 explícito del diccionario sigue pisando esta inferencia

uses
  System.IOUtils;

var
  Jpeg, Stored, Converted, ByMarkers: TBytes;
begin
  // bytes DCTDecode crudos de una imagen CMYK de 640 x 480, guardados del stream PDF
  Jpeg := TFile.ReadAllBytes('suspect-image.jpg');
  try
    // Con un marcador Adobe APP14 en los datos, las tres llamadas concuerdan:
    // el marcador decide y el valor del diccionario se ignora
    Stored    := DecodeDCT(Jpeg, 640, 480, 4, 0);   // sin marcador: muestras como se comprimieron
    Converted := DecodeDCT(Jpeg, 640, 480, 4, 1);   // sin marcador: YCCK deshecho a CMYK
    ByMarkers := DecodeDCT(Jpeg, 640, 480, 4, -1);  // sin marcador: valor por defecto de cuatro canales, ninguno
    Writeln(Format('pixel 0: stored %d,%d,%d,%d  converted %d,%d,%d,%d',
      [Stored[0], Stored[1], Stored[2], Stored[3],
       Converted[0], Converted[1], Converted[2], Converted[3]]));
  except
    on E: EHPDFHeadlessImageError do
      Writeln('Decoder refused the image: ', E.Message);
  end;
end;

Un píxel de referencia de cuatro canales muestra qué buscar. Un píxel CMYK de (10, 20, 30, 40) guardado como YCCK vuelve como [245, 235, 225, 215] cuando la conversión YCCK no se deshace y como [0, 116, 0, 215] cuando sí. El byte K, 215, es idéntico en ambos. Si el plano K se ve bien y los otros tres no, una transformación mal emparejada es el primer sospechoso

La convención CMYK invertida pertenece a /Decode

El decodificador terminal de HotPDF devuelve las muestras CMYK exactamente como las produce libjpeg-turbo y no las voltea por la fuerza de un marcador Adobe. Los números de arriba hacen visible la convención: 245, 235, 225, 215 son simplemente 255 menos 10, 20, 30, 40, la forma invertida que guardan los codificadores estilo Adobe. Devolver eso a cantidades de tinta es trabajo del diccionario de la imagen, donde un productor que incrusta tal JPEG debería escribir /Decode [1 0 1 0 1 0 1 0]. El renderer aplica el arreglo /Decode a cada componente, así que un archivo bien escrito imprime bien

El otro lado conviene decirlo sin rodeos. Un PDF que incrusta un JPEG CMYK invertido estilo Adobe sin el arreglo /Decode correspondiente se renderizará como negativo en HotPDF, porque el propio archivo describe la imagen así. Algunos visores detectan el marcador APP14 y compensan silenciosamente. HotPDF no agrega esa heurística, así que el arreglo de tal archivo pertenece al software que lo escribió

¿Por qué un diccionario DecodeParms único para dos filtros es un error?

Un stream cuyo /Filter es un arreglo de varias etapas debe proveer /DecodeParms como un arreglo de la misma longitud, un diccionario de parámetros o null por etapa. ISO 32000-1 §7.3.8.2, Tabla 5, enuncia esa regla, y HotPDF la hace cumplir: un stream multi-filtro que provee un diccionario compartido se reporta como documento inválido en vez de decodificarse

La alternativa indulgente es peor de lo que parece. Un decodificador que verifica la longitud solo cuando /DecodeParms ya es un arreglo, ante un diccionario único y dos filtros, reutilizará silenciosamente ese diccionario en cada etapa. Eso es inofensivo cuando el diccionario está vacío y destructivo cuando no: un /Predictor 12 pensado para la etapa Flate interna también se aplicaría a la externa, y la salida se vuelve basura de aspecto plausible en vez de un error limpio. Como la intención de tal archivo no puede recuperarse, HotPDF lo rechaza, incluso en el caso vacío, como un stream de contenido de página con /Filter [/FlateDecode /FlateDecode] y /DecodeParms << >>

Formas de DecodeParms que HotPDF acepta para streams PDF multi-filtro en Delphi: los arreglos alineados pasan, mientras que un diccionario compartido o una longitud de arreglo distinta se rechazan
un stream multi-filtro necesita un diccionario o null por etapa, y un diccionario compartido se rechaza incluso cuando está vacío

Las formas aceptadas y rechazadas son fáciles de enumerar:

  • Aceptado: /DecodeParms omitido, o null, para cualquier número de filtros
  • Aceptado: un arreglo cuya longitud iguala el número de filtros, cada entrada un diccionario o null
  • Aceptado: un diccionario único cuando /Filter nombra exactamente un filtro
  • Rechazado: un diccionario único compartido por dos o más filtros, con el diagnóstico "Stream filter chain requires array DecodeParms"
  • Rechazado: un arreglo cuya longitud difiere del número de filtros, con "DecodeParms array must match the filter chain"

La misma verificación corre para streams de contenido ordinarios y para streams de imagen cuyo último filtro es un codec terminal como DCTDecode, así que una cadena malformada falla en el mismo lugar sin importar qué lleve el stream. La interacción con los límites de descompresión está cubierta en el artículo sobre presupuestos de cadenas de filtros y bombas de decodificación PDF. Desde el código de la aplicación, el error aparece cuando la página se renderiza

uses
  SysUtils, HPDFHeadlessDocument, HPDFHeadlessCairoDevice;

var
  Document: THPDFHeadlessDocument;
  Options: THPDFHeadlessRenderOptions;
  Info: THPDFHeadlessRenderResult;
begin
  Document := THPDFHeadlessDocument.Create;
  try
    try
      Document.LoadFromFile('catalog-cmyk.pdf');
      Options := THPDFHeadlessRenderOptions.Default;
      Info := HPDFRenderHeadlessPagePNGFile(Document.GetRenderAccess(0),
        'page1.png', Options);
      Writeln('images=', Info.Images, ' decoded bytes=', Info.DecodedBytes);
    except
      on E: Exception do
        Writeln('Rejected: ', E.ClassName, ': ', E.Message);
    end;
  finally
    Document.Free;
  end;
end;

Las imágenes JPEG 2000 conservan su /Decode explícito

Una imagen JPEG 2000 ignora /Decode solo cuando su diccionario no tiene /ColorSpace y no es una máscara de imagen. ISO 32000-1 §8.9.5 hace eso condicional, y HotPDF lo sigue. Descartar /Decode para cada imagen JPX que no sea stencil es el atajo fácil, y hace que una inversión explícita simplemente desaparezca: una muestra JPX DeviceGray de 64 bajo /Decode [1 0] tiene que renderizar como 191, no como 64

Acertar con esa única condición no basta por sí solo. Las ramas de espacios de color como Lab e ICCBased que fijan rangos de componentes por defecto cada vez que la imagen es JPX pisarían por segunda vez un arreglo legítimamente aplicado, así que el renderer registra si un arreglo /Decode válido se aplicó realmente, y llena rangos por defecto solo cuando ninguno se aplicó. Los rangos explícitos Lab e ICCBased por tanto sobreviven, y la declaración de color del codestream sigue mandando cuando el diccionario calla. El lado decodificador del formato se describe en el artículo de JPEG 2000 y JPXDecode

¿Qué rutas de decodificación siguen estas reglas?

Ambas tuberías las siguen. Del lado headless eso significa la carga de THPDFHeadlessDocument, el decodificador de streams compartido y el renderer de páginas basado en Cairo detrás de HPDFRenderHeadlessPagePNGFile. Del lado VCL significa extracción de imágenes cargadas a un TBitmap y renderizado de páginas VCL, que aplican el mismo orden de la Tabla 13 por igual en decodificación de imagen completa y de región reducida, así que un encabezado JFIF no puede forzar YCbCr sobre una transformación Adobe de 0, y un valor ilegal vuelve la imagen indecodificable allí también. Las imágenes DCT inline siguen las mismas reglas de marcador, null, valor por defecto y rechazo que los image XObjects, con nombres de clave abreviados y completos por igual, incluido un diccionario /DP << … >> y arreglos de filtros abreviados como /F [/AHx /DCT], y una imagen inline con parámetros ilegales se salta mientras el resto de la página renderiza. La guía para extraer imágenes de un PDF cargado cubre el resto del manejo de filtros de esa API

Unas notas prácticas antes de depender de este comportamiento:

  • La decodificación JPEG necesita una biblioteca TurboJPEG compatible en tiempo de ejecución; HOTPDF_TURBOJPEG_LIBRARY pisa el turbojpeg.dll o libturbojpeg.so.0 por defecto
  • Las verificaciones estructurales estrictas rechazan archivos que los lectores indulgentes abren, notablemente el diccionario /DecodeParms vacío compartido, y el arreglo de esos archivos está del lado del productor

Para un flujo de impresión con gestión de color, la conclusión práctica es corta. Escriba JPEG CMYK con un /ColorTransform explícito que concuerde con cualquier marcador Adobe en los datos más el arreglo /Decode que coincida con su almacenamiento, dele a cada stream multi-filtro un arreglo /DecodeParms alineado por etapa, y espere que HotPDF exija a los archivos lo que realmente dicen

El loader headless, los decodificadores de streams y el renderer de páginas Cairo mostrados aquí se distribuyen con el componente PDF HotPDF para Delphi para Delphi, C++Builder y Lazarus, junto con el motor de documentos VCL clásico