Artigo Técnico

JPEG CMYK em PDF: ColorTransform do DCTDecode em Delphi

Um JPEG CMYK que sai de um PDF com cores enlameadas, deslocadas ou negativas quase sempre significa que o descodificador errou num de dois factos: se as amostras foram guardadas como YCCK e precisam de conversão, ou se foram guardadas invertidas pela convenção Adobe. Os descodificadores do HotPDF honram o parâmetro /ColorTransform do DCTDecode para imagens de três e quatro canais, rejeitam valores que a especificação não define, e deixam um marcador Adobe APP14 dentro dos dados JPEG prevalecer sobre essa entrada do dicionário, como o ISO 32000-1 Tabela 13 exige. Duas regras vizinhas pertencem à mesma família de armadilhas: streams multi-filtro têm de trazer arrays /DecodeParms alinhados por etapa, e imagens JPEG 2000 mantêm um mapeamento /Decode explícito

Os três partilham um tema. O PDF já diz o que quer dizer, e a armadilha é um renderer que responde a uma pergunta ligeiramente diferente

Porque é que um JPEG CMYK rende invertido ou desbotado num PDF?

Um JPEG CMYK corre mal num PDF por uma de duas razões, e parecem diferentes no ecrã. Uma imagem negativa, em que o papel branco fica preto e os tons claros escurecem, aponta para armazenamento invertido. Uma imagem desbotada ou com matizes deslocados, em que a pele fica verde e os pretos ficam cinza enquanto o detalhe do K se mantém nítido, aponta para uma transformação de cor aplicada quando não devia, ou saltada quando era exigida

Mapa de sintomas de um JPEG CMYK partido num PDF Delphi: negativos apontam para armazenamento Adobe invertido, matizes desbotados para um ColorTransform desalinhado
um negativo significa armazenamento invertido a pedir um array /Decode, deriva de matiz significa uma transformação desalinhada, e um plano K nítido aponta primeiro para a transformação

O problema da transformação vem de como o JPEG comprime cor. O JPEG baseline comprime melhor quando a luminância está separada da crominância, por isso os codificadores normalmente convertem RGB em YCbCr antes de comprimir, e os codificadores de quatro canais convertem CMY em YCC deixando o K passar intocado, que é o formato chamado YCCK. O descodificador tem de desfazer exatamente a conversão que o codificador aplicou. Desfaça uma que nunca aconteceu, ou salte uma que aconteceu, e cada pixel se move. O canal preto sobrevive de qualquer forma, e é por isso que uma imagem CMYK partida muitas vezes mantém texto preto nítido enquanto todo o resto deriva

O problema da inversão é histórico. O Photoshop e outras ferramentas Adobe escrevem JPEGs CMYK com cada amostra guardada como 255 - value, e assinalam o ficheiro com um marcador Adobe APP14. Nada no standard JPEG o diz; é uma convenção, e todos os consumidores têm de a conhecer. Num PDF, espera-se que o produtor expresse a inversão no dicionário de imagem em vez de deixar ao palpite

O que diz o /ColorTransform ao descodificador?

O /ColorTransform é a declaração ao nível do PDF de que conversão o descodificador tem de desfazer. O ISO 32000-1 §7.4.8, Tabela 13, define dois valores. Zero significa nenhuma transformação: o descodificador devolve as amostras como comprimidas. Um significa que o codificador aplicou a conversão standard, por isso uma imagem de três canais volta do YCbCr para RGB e uma de quatro canais volta do YCCK para CMYK. O parâmetro é ignorado para imagens de um e dois canais. A Tabela 13 também fixa uma regra de precedência fácil de perder: quando os dados codificados trazem um marcador Adobe com o seu próprio código de transformação, esse código governa e a entrada do dicionário é ignorada. Só um JPEG sem esse marcador recai no dicionário, e quando a entrada também está ausente, uma imagem de três canais assume por omissão a transformação e uma de quatro canais assume nenhuma

Matriz de decisão do ColorTransform do DCTDecode para Delphi: o marcador Adobe APP14 governa sobre o dicionário, e valores indefinidos são rejeitados pelo HotPDF
o marcador Adobe dentro dos dados JPEG prevalece sobre a entrada do dicionário, e só um JPEG sem marcador recai no /ColorTransform ou no default pela contagem de componentes

O erro fácil é um caminho de imagem que aceita o dicionário de parâmetros mas nunca o entrega ao descodificador JPEG. Um JPEG RGB de um pixel com a cor (128, 63, 31) expõe-o de imediato: tal descodificador devolve [128, 63, 31] tanto para /ColorTransform 0 como para /ColorTransform 1, enquanto a resposta correta para zero é o trio YCbCr cru [79, 101, 163]

O HotPDF aplica o parâmetro ao nível do byte. O HPDFDecodeHeadlessTerminalImage primeiro percorre a estrutura de segmentos JPEG até ao marcador start-of-scan e procura um segmento Adobe APP14; bytes que meramente se pareçam com um dentro de um segmento de comentário não contam. Se os dados trazem o marcador, o seu código de transformação decide e o valor do dicionário é ignorado. Se não trazem, o descodificador constrói uma cópia privada do JPEG com um segmento APP14 inserido imediatamente antes do marcador start-of-scan, trazendo o código de transformação 0, 1 ou, para uma imagem de quatro canais com valor 1, o código YCCK 2. Sempre que a transformação efetiva de uma imagem de três canais é 0, a cópia também desarma o cabeçalho JFIF, porque um cabeçalho JFIF por si só diz ao libjpeg-turbo que assuma YCbCr. O buffer do chamador nunca é tocado, e quando um segmento é inserido, os seus 16 bytes extra são debitados no orçamento de bytes de trabalho antes de o descodificador alocar qualquer coisa

Inserir só quando não existe marcador importa por causa de um detalhe do libjpeg: ele usa o último segmento APP14 que vê. Insira um segmento depois de um marcador existente e um valor explícito do dicionário sobrepõe-se silenciosamente a um marcador conflitante nos dados, que é exatamente o contrário da Tabela 13. O HotPDF aplica a ordem da Tabela 13 tanto no caminho do XObject de imagem completo como no caminho de imagem inline

Pode chamar o mesmo descodificador terminal diretamente quando uma única imagem parece errada e quer ver as suas amostras sem renderizar a página. A função vive na unit HPDFHeadlessImageCodecs, recebe o nome do filtro, os bytes codificados, um dicionário de parâmetros opcional e a geometria do dicionário de imagem, que tem de casar com o cabeçalho de frame do JPEG

uses
  SysUtils, HPDFObjs, HPDFHeadlessImageCodecs;

// ColorTransform < 0 significa "entrada ausente": deixar os marcadores JPEG decidir
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;

O descodificador é estrito quanto ao que conta como valor legal. Um -1 explícito, um 2, ou um não inteiro como 0.5 levanta EHPDFHeadlessImageError em vez de recair num default, porque um valor que a especificação não define não dá ao descodificador nada de confiança em que agir. A verificação mantém-se mesmo quando um marcador Adobe nos dados torna a entrada irrelevante, porque um valor indefinido é um erro estrutural no próprio documento. Um orçamento pequeno demais para a cópia transformada falha da mesma forma, com uma mensagem a nomear o orçamento de bytes de trabalho

Um null explícito é um caso diferente, porque não é um valor de todo. O ISO 32000-1 §7.3.9 diz que uma entrada de dicionário cujo valor é null equivale a uma entrada ausente, por isso o /ColorTransform null cai no marcador ou no default pela contagem de componentes exatamente como se a chave faltasse. O HotPDF trata-o assim em todos os caminhos, headless e VCL, stream e inline por igual

Mais um default merece menção, porque é onde o HotPDF deliberadamente vai além da letra da Tabela 13. Um JPEG de três componentes sem cabeçalho JFIF nem marcador Adobe, cujo cabeçalho de frame rotula os seus componentes R, G e B, diz nos seus próprios bytes que guarda RGB. A Tabela 13 ainda o deixaria no default da transformação, mas o libjpeg e os leitores construídos sobre ele descodificam-no sem transformação, e o HotPDF também. Os descodificadores headless e VCL ambos o tratam assim, por isso o vermelho e o azul ficam onde os dados os puseram. Um 0 ou 1 explícito no dicionário continua a sobrepor-se a esta inferência

uses
  System.IOUtils;

var
  Jpeg, Stored, Converted, ByMarkers: TBytes;
begin
  // bytes DCTDecode crus de uma imagem CMYK 640 x 480, guardados do stream do PDF
  Jpeg := TFile.ReadAllBytes('suspect-image.jpg');
  try
    // Com um marcador Adobe APP14 nos dados, as três chamadas concordam:
    // o marcador decide e o valor do dicionário é ignorado
    Stored    := DecodeDCT(Jpeg, 640, 480, 4, 0);   // sem marcador: amostras como comprimidas
    Converted := DecodeDCT(Jpeg, 640, 480, 4, 1);   // sem marcador: YCCK desfeito em CMYK
    ByMarkers := DecodeDCT(Jpeg, 640, 480, 4, -1);  // sem marcador: default de quatro canais, nenhuma
    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;

Um pixel de referência de quatro canais mostra o que procurar. Um pixel CMYK (10, 20, 30, 40) guardado como YCCK volta como [245, 235, 225, 215] quando a conversão YCCK não é desfeita e como [0, 116, 0, 215] quando é. O byte K, 215, é idêntico em ambos. Se o plano K parece certo e os outros três não, uma transformação desalinhada é o primeiro suspeito

A convenção CMYK invertida pertence ao /Decode

O descodificador terminal do HotPDF devolve amostras CMYK exatamente como o libjpeg-turbo as produz e não as inverte com base num marcador Adobe. Os números acima tornam a convenção visível: 245, 235, 225, 215 são simplesmente 255 menos 10, 20, 30, 40, a forma invertida que codificadores estilo Adobe guardam. Converter isso de volta em quantidades de tinta é trabalho do dicionário de imagem, onde um produtor que embute tal JPEG deve escrever /Decode [1 0 1 0 1 0 1 0]. O renderer aplica o array /Decode a cada componente, por isso um ficheiro corretamente escrito imprime corretamente

O outro lado vale a pena enunciar claramente. Um PDF que embute um JPEG CMYK invertido estilo Adobe sem o array /Decode correspondente vai renderizar como negativo no HotPDF, porque o próprio ficheiro descreve a imagem assim. Alguns viewers detetam o marcador APP14 e compensam silenciosamente. O HotPDF não acrescenta essa heurística, por isso a correção para tal ficheiro pertence ao software que o escreveu

Porque é que um dicionário DecodeParms único para dois filtros é um erro?

Um stream cujo /Filter é um array de várias etapas tem de fornecer /DecodeParms como um array do mesmo comprimento, um dicionário de parâmetros ou null por etapa. O ISO 32000-1 §7.3.8.2, Tabela 5, enuncia essa regra, e o HotPDF impõe-na: um stream multi-filtro que forneça um dicionário único partilhado é reportado como documento inválido em vez de descodificado

A alternativa tolerante é pior do que parece. Um descodificador que só verifica o comprimento quando /DecodeParms já é um array vai, perante um dicionário único e dois filtros, reutilizar silenciosamente esse dicionário em cada etapa. Isso é inofensivo quando o dicionário está vazio e destrutivo quando não: um /Predictor 12 destinado à etapa Flate interna seria também aplicado à externa, e a saída torna-se lixo de aparência plausível em vez de um erro limpo. Como a intenção de tal ficheiro não pode ser recuperada, o HotPDF recusa-o, mesmo no caso vazio, como um stream de conteúdo de página com /Filter [/FlateDecode /FlateDecode] e /DecodeParms << >>

Formas de DecodeParms que o HotPDF aceita para streams PDF multi-filtro em Delphi: arrays alinhados passam, um dicionário partilhado ou comprimento de array desalinhado é rejeitado
um stream multi-filtro precisa de um dicionário ou null por etapa, e um dicionário partilhado é recusado mesmo quando está vazio

As formas aceites e rejeitadas são fáceis de enumerar:

  • Aceite: /DecodeParms omitido, ou null, para qualquer número de filtros
  • Aceite: um array cujo comprimento seja igual à contagem de filtros, cada entrada um dicionário ou null
  • Aceite: um dicionário único quando o /Filter nomeia exatamente um filtro
  • Rejeitado: um dicionário único partilhado por dois ou mais filtros, com o diagnóstico "Stream filter chain requires array DecodeParms"
  • Rejeitado: um array cujo comprimento difere da contagem de filtros, com "DecodeParms array must match the filter chain"

A mesma verificação corre para streams de conteúdo comuns e para streams de imagem cujo último filtro é um codec terminal como o DCTDecode, por isso uma cadeia malformada falha no mesmo lugar seja o que for que o stream traga. A interação com os limites de descompressão está coberta em o artigo sobre orçamentos de cadeias de filtros e bombas de descodificação PDF. A partir do código da aplicação, o erro aparece quando a página é renderizada

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;

Imagens JPEG 2000 mantêm o seu /Decode explícito

Uma imagem JPEG 2000 ignora o /Decode só quando o seu dicionário não tem /ColorSpace e não é máscara de imagem. O ISO 32000-1 §8.9.5 torna isso condicional, e o HotPDF segue-o. Descartar o /Decode para toda a imagem JPX não stencil é o atalho fácil, e faz uma inversão explícita simplesmente evaporar: uma amostra JPX DeviceGray de 64 sob /Decode [1 0] tem de renderizar como 191, não 64

Acertar nessa única condição não chega por si. Ramos de espaço de cor como Lab e ICCBased que definem intervalos de componentes por omissão sempre que a imagem é JPX sobrescreveriam uma segunda vez um array legitimamente aplicado, por isso o renderer registra se um array /Decode válido foi realmente aplicado, e só preenche intervalos por omissão quando nenhum foi. Intervalos Lab e ICCBased explícitos sobrevivem portanto, e a declaração de cor do codestream continua a governar quando o dicionário cala. O lado de descodificação do formato está descrito em o artigo JPEG 2000 e JPXDecode

Que caminhos de descodificação seguem estas regras?

Ambos os pipelines as seguem. No lado headless isso significa o carregamento THPDFHeadlessDocument, o descodificador de streams partilhado e o renderer de páginas com base em Cairo atrás do HPDFRenderHeadlessPagePNGFile. No lado VCL significa extração de imagem carregada para um TBitmap e renderização de páginas VCL, que aplicam a mesma ordem da Tabela 13 na descodificação de imagem completa e de região reduzida por igual, por isso um cabeçalho JFIF não consegue forçar YCbCr sobre uma transformação Adobe de 0, e um valor ilegal torna a imagem indescodificável também aí. Imagens DCT inline seguem as mesmas regras de marcador, null, default e rejeição que os XObjects de imagem, com nomes de chave abreviados e completos por igual, incluindo um dicionário /DP << … >> e arrays de filtros abreviados como /F [/AHx /DCT], e uma imagem inline com parâmetros ilegais é saltada enquanto o resto da página rende. O guia para extrair imagens de um PDF carregado cobre o resto do tratamento de filtros dessa API

Algumas notas práticas antes de confiar neste comportamento:

  • A descodificação JPEG precisa de uma biblioteca TurboJPEG compatível em tempo de execução; HOTPDF_TURBOJPEG_LIBRARY sobrepõe-se ao turbojpeg.dll ou libturbojpeg.so.0 por omissão
  • As verificações estruturais estritas rejeitam ficheiros que leitores tolerantes abrem, notoriamente o dicionário /DecodeParms vazio partilhado, e a correção desses ficheiros fica no lado do produtor

Para um fluxo de impressão com gestão de cor, a conclusão prática é curta. Escreva JPEGs CMYK com um /ColorTransform explícito que concorde com qualquer marcador Adobe nos dados mais o array /Decode que case com o seu armazenamento, dê a cada stream multi-filtro um array /DecodeParms alinhado por etapa, e conte com o HotPDF para cobrar dos ficheiros aquilo que realmente dizem

O loader headless, os descodificadores de streams e o renderer de páginas Cairo mostrados aqui viajam com o HotPDF Delphi PDF component para Delphi, C++Builder e Lazarus, ao lado do motor clássico de documentos VCL