Artículo técnico

JPEG CMYK en PDF: ColorTransform de DCTDecode en Delphi

Un JPEG CMYK que sale de un PDF con colores embarrados, desplazados o negativos casi siempre significa que el decodificador se equivocó en uno de dos datos: si las muestras se guardaron como YCCK y necesitan conversión, o si se guardaron invertidas por la 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 Adobe APP14 dentro de los datos JPEG tenga 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 arrays /DecodeParms alineados por etapa, y las imágenes JPEG 2000 conservan un mapeo /Decode explícito

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

¿Por qué un JPEG CMYK se renderiza 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 tintes claros 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 del K se mantiene nítido, apunta a una transformación de color aplicada cuando no tocaba, o saltada cuando era necesaria

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 desajustado
un negativo significa almacenamiento invertido que pide un array /Decode, el desvío de tonos significa una transformación desajustada, y un plano K nítido apunta primero a la transformación

El problema de transformación viene de cómo comprime el color el JPEG. 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 dejando pasar el K intacto, que es el formato llamado YCCK. El decodificador tiene que deshacer exactamente la conversión que aplicó el codificador. Deshaz una que nunca ocurrió, o sáltate 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 un texto negro nítido mientras todo lo demás se va desvaneciendo

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 Adobe APP14. 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 al azar

Qué le dice /ColorTransform al decodificador

/ColorTransform es la declaración 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 pasa de YCbCr de vuelta a RGB y una de cuatro canales pasa de YCCK a CMYK. El parámetro se ignora en imágenes de uno y dos canales. La Tabla 13 fija además una regla de precedencia que se pasa por alto con facilidad: 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 Adobe APP14 manda sobre el diccionario, y los valores indefinidos los rechaza HotPDF
el marcador Adobe dentro de los datos JPEG va por encima de la entrada del diccionario, y solo un JPEG sin él recurre a /ColorTransform o al valor por defecto por número de componentes

El error fácil es una ruta de imagen que acepta el diccionario de parámetros pero jamás se lo pasa al decodificador JPEG. Un JPEG RGB de un píxel con el color (128, 63, 31) lo destapa al instante: semejante decodificador devuelve [128, 63, 31] tanto para /ColorTransform 0 como para /ColorTransform 1, cuando 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 start-of-scan y busca un segmento Adobe APP14; los bytes que meramente 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 lo llevan, el decodificador construye una copia privada del JPEG con un segmento APP14 insertado justo antes del marcador de start-of-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 también desactiva la cabecera JFIF, porque una cabecera JFIF por sí sola le dice a libjpeg-turbo que asuma YCbCr. El buffer de quien llama jamás 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. Inserta un segmento tras un marcador existente y un valor explícito del diccionario pisa en silencio un marcador conflictivo en los datos, que es exactamente lo contrario de la Tabla 13. HotPDF aplica el orden de la Tabla 13 tanto en la ruta de image XObject completo como en la de imagen inline

Puedes llamar al mismo decodificador terminal directamente cuando una sola imagen se ve mal y quieres ver sus muestras sin renderizar la página. La función vive en la unidad HPDFHeadlessImageCodecs, toma 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 la cabecera de frame del JPEG

uses
  SysUtils, HPDFObjs, HPDFHeadlessImageCodecs;

// ColorTransform < 0 significa "entrada ausente": deja decidir a los marcadores JPEG
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 fiable en lo que apoyarse. El check sigue en pie incluso cuando un marcador Adobe en los datos vuelve irrelevante la entrada, porque un valor indefinido es un error estructural del documento mismo. 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 otro caso, porque no es un valor, sencillamente. 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 número 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 va deliberadamente más allá de la letra de la Tabla 13. Un JPEG de tres componentes sin cabecera JFIF ni marcador Adobe, cuya cabecera de frame etiqueta sus componentes como R, G y B, dice en sus propios bytes que contiene RGB. La Tabla 13 seguiría asignándole 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í los dos, así que el rojo y el azul se quedan donde los datos los pusieron. Un 0 o 1 explícito en el 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 del PDF
  Jpeg := TFile.ReadAllBytes('suspect-image.jpg');
  try
    // Con un marcador Adobe APP14 en los datos, las tres llamadas coinciden:
    // el marcador decide y el valor del diccionario se ignora
    Stored    := DecodeDCT(Jpeg, 640, 480, 4, 0);   // sin marcador: muestras tal 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 desajustada 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 gracia de un marcador Adobe. Los números de arriba hacen visible la convención: 245, 235, 225, 215 son sencillamente 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 semejante JPEG debería escribir /Decode [1 0 1 0 1 0 1 0]. El renderer aplica el array /Decode a cada componente, así que un archivo bien escrito se imprime bien

El reverso conviene decirlo sin rodeos. Un PDF que incrusta un JPEG CMYK invertido al estilo Adobe sin el array /Decode correspondiente se renderizará como un negativo en HotPDF, porque el archivo mismo describe la imagen así. Algunos visores detectan el marcador APP14 y compensan en silencio. HotPDF no añade esa heurística, así que el arreglo de semejante archivo pertenece al software que lo escribió

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

Un stream cuyo /Filter es un array de varias etapas debe suministrar /DecodeParms como un array 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 suministra un solo diccionario compartido se reporta como documento inválido en vez de decodificarse

La alternativa permisiva es peor de lo que parece. Un decodificador que comprueba la longitud solo cuando /DecodeParms ya es un array, ante un solo diccionario y dos filtros, reutilizará ese diccionario en silencio 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 se aplicaría también a la externa, y la salida se convierte en basura de aspecto plausible en vez de un error limpio. Como la intención de semejante archivo no se puede recuperar, 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 en streams PDF multi-filtro en Delphi: los arrays alineados pasan, mientras que un diccionario compartido o una longitud desajustada se rechazan
un stream multi-filtro necesita un diccionario o null por etapa, y un diccionario compartido se rechaza incluso estando 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 array cuya longitud iguala el número de filtros, cada entrada un diccionario o null
  • Aceptado: un solo diccionario cuando /Filter nombra exactamente un filtro
  • Rechazado: un solo diccionario compartido por dos o más filtros, con el diagnóstico «Stream filter chain requires array DecodeParms»
  • Rechazado: un array cuya longitud difiere del número de filtros, con «DecodeParms array must match the filter chain»

El mismo check corre para streams de contenido corrientes y para streams de imagen cuyo último filtro es un codec terminal como DCTDecode, así que una cadena malformada falla en el mismo sitio sea lo que sea lo que lleve el stream. La interacción con los límites de descompresión queda cubierta en el artículo sobre presupuestos de cadenas de filtros y bombas de decode PDF. Desde el código de aplicación, el error asoma cuando se renderiza la página

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 image mask. ISO 32000-1 §8.9.5 hace eso condicional, y HotPDF lo sigue. Descartar /Decode para cada imagen JPX no stencil es el atajo fácil, y hace que una inversión explícita sencillamente desaparezca: una muestra JPX DeviceGray de 64 bajo /Decode [1 0] tiene que renderizarse como 191, no como 64

Acertar esa única condición no basta por sí sola. Las ramas de espacios de color como Lab e ICCBased que ponen rangos de componentes por defecto siempre que la imagen es JPX pisarían una segunda vez un array legítimamente aplicado, así que el renderer anota si un array /Decode válido se aplicó realmente, y rellena rangos por defecto solo cuando no se aplicó ninguno. Los rangos explícitos Lab e ICCBased sobreviven por eso, 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 decode siguen estas reglas?

Ambas tuberías las siguen. En el lado headless eso significa la carga con THPDFHeadlessDocument, el decodificador de streams compartido y el renderer de páginas basado en Cairo detrás de HPDFRenderHeadlessPagePNGFile. En el lado VCL significa la extracción de imágenes cargadas a un TBitmap y el renderizado de páginas VCL, que aplican el mismo orden de la Tabla 13 tanto en decodificación de imagen completa como de región reducida, así que una cabecera JFIF no puede forzar YCbCr por encima de una transformación Adobe de 0, y un valor ilegal deja la imagen indecodificable también allí. 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 arrays de filtros abreviados como /F [/AHx /DCT], y una imagen inline con parámetros ilegales se salta mientras el resto de la página se renderiza. La guía para extraer imágenes de un PDF cargado cubre el resto del manejo de filtros de esa API

Unas cuantas notas prácticas antes de fiarte de este comportamiento:

  • El decode JPEG necesita una biblioteca TurboJPEG compatible en tiempo de ejecución; HOTPDF_TURBOJPEG_LIBRARY sobreescribe la turbojpeg.dll o libturbojpeg.so.0 por defecto
  • Los checks estructurales estrictos rechazan archivos que los lectores permisivos abren, en particular 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. Escribe JPEG CMYK con un /ColorTransform explícito que coincida con cualquier marcador Adobe en los datos más el array /Decode que corresponda a su almacenamiento, dale a cada stream multi-filtro un array /DecodeParms alineado por etapa, y espera que HotPDF exija a los archivos lo que de verdad 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 al motor clásico de documentos VCL