Artigo Técnico

JPEG CMYK em PDF: ColorTransform do DCTDecode no Delphi

Um JPEG CMYK que sai de um PDF com cores turvas, deslocadas ou negativas quase sempre significa que o decodificador errou um de dois fatos: se as samples foram armazenadas como YCCK e precisam de conversão, ou se foram armazenadas invertidas pela convenção Adobe. Os decodificadores 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 marker Adobe APP14 dentro dos dados JPEG ter precedência sobre aquela entrada de dicionário, como a Tabela 13 da ISO 32000-1 exige. Duas regras vizinhas pertencem à mesma família de armadilhas: streams multi-filtro precisam carregar arrays /DecodeParms alinhados por etapa, e imagens JPEG 2000 mantêm um mapeamento /Decode explícito

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

Por que um JPEG CMYK renderiza invertido ou lavado num PDF?

Um JPEG CMYK dá errado num PDF por um de dois motivos, e eles parecem diferentes na tela. Uma imagem negativa, onde o papel branco vira preto e tons claros ficam escuros, aponta para armazenamento invertido. Uma imagem lavada ou com matiz deslocada, onde pele vira verde e pretos ficam cinza enquanto o detalhe do K continua nítido, aponta para uma conversão de cor aplicada quando não devia, ou pulada quando era exigida

Mapa de sintomas para um JPEG CMYK quebrado num PDF Delphi: negativos apontam para armazenamento Adobe invertido, matizes lavadas para um ColorTransform destoante
um negativo significa armazenamento invertido pedindo um array /Decode, desvio de matiz significa uma transform destoante, e um plano K nítido aponta primeiro para a transform

O problema da transform vem de como o JPEG comprime cor. O JPEG baseline comprime melhor quando luminância é separada de crominância, então encoders normalmente convertem RGB para YCbCr antes de comprimir, e encoders de quatro canais convertem CMY para YCC passando o K intocado, que é o formato chamado YCCK. O decodificador precisa desfazer exatamente a conversão que o encoder aplicou. Desfaça uma que nunca aconteceu, ou pule uma que aconteceu, e todo pixel se move. O canal preto sobrevive de qualquer forma, e é por isso que uma imagem CMYK quebrada costuma manter texto preto nítido enquanto todo o resto deriva

O problema da inversão é histórico. O Photoshop e outras ferramentas Adobe gravam JPEGs CMYK com cada sample armazenada como 255 - value, e marcam o arquivo com um marker Adobe APP14. Nada no padrão JPEG diz isso; é uma convenção, e todo consumidor precisa conhecê-la. Num PDF, espera-se que o produtor expresse a inversão no dicionário da imagem em vez de deixar para o chute

O que o /ColorTransform diz ao decodificador?

O /ColorTransform é a declaração em nível PDF de qual conversão o decodificador precisa desfazer. A ISO 32000-1 §7.4.8, Tabela 13, define dois valores. Zero significa nenhuma transformação: o decodificador devolve as samples como comprimidas. Um significa que o encoder aplicou a conversão padrão, então uma imagem de três canais vai de YCbCr de volta a RGB e uma imagem de quatro canais vai de YCCK de volta a CMYK. O parâmetro é ignorado para imagens de um e dois canais. A Tabela 13 também estabelece uma regra de precedência fácil de perder: quando os dados codificados carregam um marker Adobe com o próprio código de transform, esse código governa e a entrada do dicionário é ignorada. Só um JPEG sem esse marker recua para o dicionário, e quando a entrada também está ausente, uma imagem de três canais tem como default a transform e uma de quatro canais tem como default nenhuma

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

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

O HotPDF aplica o parâmetro no nível de bytes. O HPDFDecodeHeadlessTerminalImage primeiro percorre a estrutura de segmentos JPEG até o marker start-of-scan e procura um segmento Adobe APP14; bytes que apenas se parecem com um dentro de um segmento de comentário não contam. Se os dados carregam o marker, o código de transform dele decide e o valor do dicionário é ignorado. Se não, o decodificador monta uma cópia privada do JPEG com um segmento APP14 inserido imediatamente antes do marker start-of-scan, carregando o código de transform 0, 1 ou, para uma imagem de quatro canais com valor 1, o código YCCK 2. Sempre que a transform efetiva de uma imagem de três canais é 0, a cópia também desarma o header JFIF, porque um header JFIF por si só diz ao libjpeg-turbo assumir YCbCr. O buffer do chamador nunca é tocado, e quando um segmento é inserido, os seus 16 bytes extras são debitados do orçamento de bytes de trabalho antes de o decodificador alocar qualquer coisa

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

Você pode chamar o mesmo decodificador terminal diretamente quando uma única imagem parece errada e você quer ver as samples dela 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 da imagem, que precisa bater com o frame header do JPEG

uses
  SysUtils, HPDFObjs, HPDFHeadlessImageCodecs;

// ColorTransform < 0 significa "entrada ausente": deixe os markers JPEG decidirem
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 decodificador é 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 recuar para um default, porque um valor que a especificação não define não dá ao decodificador nada confiável para agir. A checagem continua valendo mesmo quando um marker 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 do mesmo jeito, com uma mensagem nomeando o orçamento de bytes de trabalho

Um null explícito é um caso diferente, porque não é um valor de fato. A ISO 32000-1 §7.3.9 diz que uma entrada de dicionário cujo valor é null equivale a uma entrada ausente, então /ColorTransform null recua para o marker ou para o default por contagem de componentes exatamente como se a chave estivesse faltando. O HotPDF trata assim em todos os caminhos, headless e VCL, stream e inline igualmente

Um último default merece menção, porque é onde o HotPDF deliberadamente vai além da letra da Tabela 13. Um JPEG de três componentes sem header JFIF e sem marker Adobe, cujo frame header rotula os componentes R, G e B, diz nos próprios bytes que contém RGB. A Tabela 13 ainda o colocaria na transform por default, mas o libjpeg e os leitores construídos sobre ele o decodificam sem transform, e o HotPDF também. Os decodificadores headless e VCL o tratam assim, então vermelho e azul ficam onde os dados os puseram. Um 0 ou 1 explícito no dicionário ainda sobrepõe essa inferência

uses
  System.IOUtils;

var
  Jpeg, Stored, Converted, ByMarkers: TBytes;
begin
  // bytes DCTDecode brutos de uma imagem CMYK 640 x 480, salvos do stream do PDF
  Jpeg := TFile.ReadAllBytes('suspect-image.jpg');
  try
    // Com um marker Adobe APP14 nos dados, as três chamadas concordam:
    // o marker decide e o valor do dicionário é ignorado
    Stored    := DecodeDCT(Jpeg, 640, 480, 4, 0);   // sem marker: samples como comprimidas
    Converted := DecodeDCT(Jpeg, 640, 480, 4, 1);   // sem marker: YCCK desfeito para CMYK
    ByMarkers := DecodeDCT(Jpeg, 640, 480, 4, -1);  // sem marker: 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 de (10, 20, 30, 40) armazenado 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 nos dois. Se o plano K parece certo e os outros três não, uma transform destoante é a primeira suspeita

A convenção CMYK invertida pertence ao /Decode

O decodificador terminal do HotPDF devolve samples CMYK exatamente como o libjpeg-turbo as produz e não as inverte com base num marker 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 encoders estilo Adobe armazenam. Transformar isso de volta em quantidades de tinta é trabalho do dicionário da imagem, onde um produtor que embute tal JPEG deve gravar /Decode [1 0 1 0 1 0 1 0]. O renderer aplica o array /Decode a cada componente, então um arquivo corretamente gravado imprime corretamente

O reverso vale ser dito com clareza. 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 arquivo descreve a imagem assim. Alguns viewers detectam o marker APP14 e compensam silenciosamente. O HotPDF não adiciona essa heurística, então a correção de tal arquivo pertence ao software que o gravou

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

Um stream cujo /Filter é um array de várias etapas precisa fornecer /DecodeParms como um array do mesmo comprimento, um dicionário de parâmetros ou null por etapa. A ISO 32000-1 §7.3.8.2, Tabela 5, estabelece essa regra, e o HotPDF a faz cumprir: um stream multi-filtro que fornece um dicionário compartilhado único é reportado como documento inválido em vez de decodificado

A alternativa tolerante é pior do que parece. Um decodificador que confere o comprimento só quando /DecodeParms já é um array vai, diante de um dicionário único e dois filtros, reutilizar silenciosamente aquele 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 aplicado também à externa, e a saída vira lixo de aparência plausível em vez de um erro limpo. Como a intenção de tal arquivo não pode ser recuperada, o HotPDF o recusa, até no caso vazio, como um content stream de página com /Filter [/FlateDecode /FlateDecode] e /DecodeParms << >>

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

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

  • Aceito: /DecodeParms omitido, ou null, para qualquer número de filtros
  • Aceito: um array cujo comprimento iguala a contagem de filtros, cada entrada um dicionário ou null
  • Aceito: um dicionário único quando o /Filter nomeia exatamente um filtro
  • Rejeitado: um dicionário único compartilhado 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 checagem roda para content streams comuns e para streams de imagem cujo último filtro é um codec terminal como o DCTDecode, então uma cadeia malformada falha no mesmo lugar seja o que for que o stream carregue. A interação com limites de descompressão está coberta em o artigo sobre orçamentos de cadeia de filtros e bombas de decodificação PDF. 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 /Decode explícito

Uma imagem JPEG 2000 ignora o /Decode só quando o dicionário dela não tem /ColorSpace e ela não é image mask. A ISO 32000-1 §8.9.5 faz isso condicional, e o HotPDF segue. Descartar o /Decode para toda imagem JPX não stencil é o atalho fácil, e ele faz uma inversão explícita simplesmente desaparecer: um sample JPX DeviceGray de 64 sob /Decode [1 0] precisa renderizar como 191, não 64

Acertar essa única condição não basta por si só. Ramos de color space como Lab e ICCBased que definem faixas default de componentes sempre que a imagem é JPX sobrescreveriam uma segunda vez um array legitimamente aplicado, então o renderer registra se um array /Decode válido foi de fato aplicado, e preenche faixas default só quando nenhum foi. Faixas Lab e ICCBased explícitas, portanto, sobrevivem, e a declaração de cor do codestream ainda governa quando o dicionário está calado. O lado decodificador do formato está descrito em o artigo sobre JPEG 2000 e JPXDecode

Quais caminhos de decodificação seguem essas regras?

Os dois pipelines as seguem. No lado headless isso significa o carregamento do THPDFHeadlessDocument, o decodificador de streams compartilhado e o renderer de páginas baseado em Cairo por trá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 decodificação de imagem completa e de região reduzida igualmente, então um header JFIF não pode forçar YCbCr sobre uma transform Adobe de 0, e um valor ilegal torna a imagem indecodificável lá também. Imagens DCT inline seguem as mesmas regras de marker, null, default e rejeição que image XObjects, com nomes de chave abreviados e completos igualmente, incluindo um dicionário /DP << … >> e arrays de filtros abreviados como /F [/AHx /DCT], e uma imagem inline com parâmetros ilegais é pulada enquanto o resto da página renderiza. O guia de extração de imagens de um PDF carregado cobre o resto do tratamento de filtros dessa API

Algumas notas práticas antes de você confiar nesse comportamento:

  • A decodificação JPEG precisa de uma biblioteca TurboJPEG compatível em tempo de execução; HOTPDF_TURBOJPEG_LIBRARY sobrepõe o turbojpeg.dll ou libturbojpeg.so.0 padrão
  • Checagens estruturais estritas rejeitam arquivos que leitores tolerantes abrem, notavelmente o dicionário /DecodeParms vazio compartilhado, e a correção para esses arquivos fica no lado do produtor

Para um fluxo de impressão com gestão de cor, a conclusão prática é curta. Grave JPEGs CMYK com um /ColorTransform explícito que concorde com qualquer marker Adobe nos dados mais o array /Decode que bata com o armazenamento deles, dê a cada stream multi-filtro um array /DecodeParms alinhado por etapa, e espere que o HotPDF cobre dos arquivos o que eles realmente dizem

O loader headless, os decodificadores de streams e o renderer de páginas Cairo mostrados aqui vêm com o componente HotPDF Delphi PDF para Delphi, C++Builder e Lazarus, ao lado do engine clássico de documentos VCL