Artigo Técnico

Paletes PDF Indexed em Delphi: espaços de base e ciclos

Uma tabela de consulta /Indexed guarda componentes do seu espaço de cor base, não triplos RGB. Uma base DeviceGray empacota um byte por entrada, DeviceCMYK quatro, Lab três que precisam de escalamento de range, e ICCBased o que o /N do perfil declarar. O HotPDF normaliza todas no momento do parse para a palete RGB pública que THPDFColorSpace expõe

Essa frase é a classe de bug inteira. Quase todos os renderizadores de paletes começam a vida a assumir um stride de três bytes, porque a esmagadora maioria dos espaços /Indexed reais assenta em /DeviceRGB e a suposição aguenta anos. Depois chega uma fatura digitalizada com [/Indexed /DeviceCMYK 15 <...>], o leitor percorre a string de consulta três bytes de cada vez, e todos os índices para lá de zero aterram no meio da cor de outra pessoa. A saída não é em branco e não rebenta. É lixo com aspeto plausível, que é muito pior, porque ninguém reporta um bug contra cores que apenas parecem estranhas

Porque é que uma palete Indexed sai errada?

O stride está errado porque o stride não é uma constante. A ISO 32000-1 §8.6.6.3 define a tabela de consulta como m × (hival + 1) bytes, onde m é o número de componentes do espaço de base, e não diz nada sobre essa base ser RGB. A mesma secção proíbe apenas duas bases liminarmente: o próprio /Indexed e /Pattern. Tudo o resto é jogo aberto, incluindo as quatro famílias baseadas em CIE de §8.6.5.2 até §8.6.5.5

Diagrama de uma tabela de consulta PDF Indexed no HotPDF onde o stride da entrada vem do espaço de cor base: uma consulta DeviceCMYK de 8 bytes cortada por um percurso RGB errado de 3 bytes produz entradas de lixo, o agrupamento correto de 4 bytes dá duas entradas CMYK normalizadas em triplos RGB, e uma tabela lista os componentes por entrada para DeviceGray, DeviceRGB, DeviceCMYK, Lab e ICCBased
O HotPDF resolve primeiro o espaço de cor base e pede-lhe a contagem de componentes, pelo que uma consulta CMYK percorre quatro bytes por entrada antes da normalização para RGB

Por isso o trabalho real não é ler bytes, é resolver a base primeiro e perguntar-lhe quão larga é uma entrada. O ResolveIndexedPalette no HotPDF faz exatamente isso: resolve a base num THPDFColorSpace, tira ComponentCount do record resolvido e rejeita o array liminarmente se a base voltar como csfIndexed, csfPattern ou csfUnsupported. Só então dimensiona o orçamento de bytes como PaletteCount * Count, que para o máximo legal de hival 255 e uma base de quatro componentes dá no máximo 1024 bytes. Dois detalhes nesse dimensionamento são onde um ficheiro hostil o vai sondar. O HotPDF limita hival a 0..255 antes de o usar, segundo o limite de §8.6.6.3, pelo que um array de consulta que alegue hival 100000 não se transforma numa alocação de um gigabyte; e trunca o orçamento para o que os dados de consulta contêm realmente, parando depois de preencher a palete na primeira entrada cujos bytes passem do fim. Uma tabela curta dá uma palete curta, não uma leitura para lá do buffer

Normalizar quatro espaços de base diferentes numa tabela RGB

O HotPDF converte paletes para RGB durante o parse em vez de na altura do desenho, e essa decisão conduz o resto do design. O campo público THPDFColorSpace.Palette é um TBytes de triplos RGB com comprimento 3 * PaletteCount, e tem essa forma desde muito antes de as bases CIE serem suportadas. Reescrever o layout do record para transportar componentes nativos da base teria partido todos os consumidores do campo. Converter no momento do parse mantém o layout intacto e, como bónus, encaminha entradas Gray, CMYK, Lab, CalGray, CalRGB e ICCBased pelo mesmo caminho HPDFResolveColor que o renderizador já usa para operadores de cor diretos

O passo de escalamento é onde os espaços de base deixam de ser intercambiáveis. Um byte de consulta é sempre 0..255, mas o que esse byte significa depende do range de componentes que a base declara. Componentes Device e calibrados mapeiam para 0..1. O Lab não: o componente L* corre 0..100, enquanto a* e b* correm sobre o /Range que o espaço declara. O HotPDF tira esses limites dos campos AMin, AMax, BMin e BMax de THPDFCIEParams, que o parser de Lab preenche a partir da entrada /Range do dicionário e usa como predefinição −108..101 quando a entrada falta, por isso não codifique à força um range simétrico de memória. As bases ICCBased acrescentam uma terceira fonte de verdade: a contagem de componentes vem do /N do stream do perfil, exposto como ICCN, e um stream ICCBased pode ele próprio transportar um array /Range que o HotPDF lê através de HPDFReadNumericArrayUpTo para a mesma tabela de limites por componente. Uma palete ICC de três componentes e uma palete Lab de três componentes percorrem por isso a string de consulta com strides idênticos e aritmética completamente diferente, que é exatamente o resultado que um stride RGB fixo não consegue produzir

var
  CS: THPDFColorSpace;
  Entry: Integer;
  R, G, B: Byte;
begin
  // ColorSpaceObj é a entrada /ColorSpace de um image XObject ou de um
  // dicionário de recursos; pode ser um nome, um array ou uma ref indireta
  CS := HPDFResolveColorSpace(ColorSpaceObj);
  if CS.Family <> csfIndexed then
    Exit;

  // A Palette já é RGB seja qual for o espaço de base
  for Entry := 0 to CS.PaletteCount - 1 do
  begin
    if (Entry * 3 + 2) >= Length(CS.Palette) then
      Break;                       // tabela de consulta curta, parar limpo
    R := CS.Palette[Entry * 3];
    G := CS.Palette[Entry * 3 + 1];
    B := CS.Palette[Entry * 3 + 2];
    UseColor(Entry, R, G, B);
  end;
end;

Quando a tabela de consulta vive num stream

A ISO 32000-1 §8.6.6.3 permite que a tabela de consulta seja uma string ou um stream, e a forma de stream é a que morde. Um stream pode transportar /FlateDecode, pode ser um objeto indireto partilhado com partes não relacionadas do ficheiro, e o seu TStream pode estar posicionado em qualquer lado no momento em que o seu parser lá chega. O HotPDF trata o caso comprimido delegando no callback de bytes descodificados registado através de HPDFSetColorStreamReader, o mesmo callback que o motor de sampled function Type 0 usa

Quando nenhum reader está registado, o fallback é deliberadamente estreito em vez de best-effort. O HotPDF verifica o dicionário do stream para /Filter e recusa a palete por completo se um estiver presente, em vez de alimentar bytes comprimidos na consulta e produzir uma tabela confiantemente errada. Sem filtro lê bytes crus, limitados ao orçamento de bytes calculado a partir de hival e da contagem de componentes da base. Todo o ramo do stream vive dentro de um try..finally que restaura o Stream.Position original em todos os caminhos de saída, incluindo as recusas, porque o objeto é partilhado e o próximo consumidor não lhe vai agradecer um cursor movido. O LookupStreamUsesDecodedReaderAndRestoresPosition na suíte de regressão fixa as duas metades desse comportamento

Diagrama de decisão para uma tabela de consulta Indexed do HotPDF guardada como stream PDF: um callback HPDFSetColorStreamReader registado descodifica bytes filtrados, um stream não registado com uma entrada /Filter é recusado liminarmente, e um stream sem filtro é lido cru até ao orçamento de bytes enquanto o try..finally restaura o Stream.Position em todas as saídas
Recusar um stream filtrado é melhor do que alimentar bytes comprimidos na consulta, e restaurar a posição original do stream mantém o objeto partilhado seguro para o próximo consumidor
// Registe os dois callbacks antes de resolver qualquer espaço de cor, senão
// bases indiretas e streams de consulta comprimidos serão simplesmente recusados
HPDFSetColorSpaceResolver(MyResolveIndirectObject);
HPDFSetColorStreamReader(MyDecodedStreamReader);
try
  CS := HPDFResolveColorSpace(ColorSpaceObj);
  if (CS.Family = csfIndexed) and (Length(CS.Palette) = 0) then
    LogWarning('lookup table unreadable: filtered stream or no reader');
finally
  HPDFSetColorStreamReader(nil);
  HPDFSetColorSpaceResolver(nil);
end;

O que impede um espaço de cor Indexed de recorrer para sempre?

Rejeitar /Indexed como base direta é necessário e está longe de ser suficiente. O ataque interessante vai pelo lado. Pegue num [/Indexed /Separation ...] válido e lembre-se de que §8.6.6.4 deixa um Separation nomear um espaço alternate e §8.6.6.5 deixa o DeviceN fazer o mesmo. Faça desse alternate uma referência indireta que aponta de volta para o array Indexed original. Nada na cadeia viola a regra da base direta em nenhum salto individual: a base é um Separation, o alternate do Separation é um array, e o array só por acaso é aquele de onde começou. Resolva-o ingenuamente e o parser percorre o loop até a stack ceder. A maquinaria de tint-transform em que esses espaços se apoiam é um tópico à parte, coberto em o artigo sobre renderizar cores spot Separation e DeviceN; aqui interessam apenas como um elo no ciclo

O HotPDF fecha isto no único ponto de entrada por onde todos os caminhos já passam. HPDFResolveColorSpace envolve o resolver interno num contador de profundidade limitado por HPDFColorSpaceMaxDepth, que é 64. Cruze esse limite e o guarda define um flag, e o flag é o que importa: no unwind, qualquer nível de aninhamento que o viu definido descarta o seu próprio record parcialmente resolvido e devolve csfUnsupported, pelo que o grafo inteiro é rejeitado em vez de meia parte retida, e os dois contadores repõem-se quando a chamada mais exterior regressa. Esses contadores são declarados como threadvar, não globais de unit, e isso não é arrumação incidental: GResolveObj e GReadStream vivem em armazenamento local de thread pela mesma razão, porque as páginas renderizam em paralelo. Um global partilhado deixaria uma thread lá no fundo de uma cadeia DeviceN aninhada disparar o limite para outra thread a fazer parse de um [/Indexed /DeviceRGB 255 <...>] perfeitamente normal, produzindo uma falha não determinística e dependente da carga — o tipo menos debuggável que existe. Sessenta e quatro é uma margem confortável para conteúdo legítimo, que raramente aninha para lá de quatro ou cinco níveis, por isso trate um csfUnsupported vindo de um array com aspeto estruturalmente válido como algo que vale a pena registar: na prática significa um ciclo ou uma profundidade que nenhum produtor real emite. IndirectAlternateCycleIsUnsupported, parametrizado sobre Separation e DeviceN, e ExcessivePatternDepthIsUnsupported cobrem os dois casos

Diagrama de ciclo e guarda para espaços de cor do HotPDF: um array Indexed com base Separation cujo alternate é uma referência indireta de volta ao mesmo array, resolvido pelo HPDFResolveColorSpace com um contador de profundidade limitado pelo HPDFColorSpaceMaxDepth em 64, depois do qual cada nível de unwind descarta o seu record e devolve csfUnsupported
Nada num único salto quebra a regra da base direta, pelo que o HotPDF limita a profundidade de resolução a 64 níveis e rejeita o grafo inteiro em vez de reter metade
function TryResolveImageSpace(Obj: THPDFObject;
  out CS: THPDFColorSpace): Boolean;
begin
  CS := HPDFResolveColorSpace(Obj);
  // csfUnsupported aqui cobre uma família genuinamente desconhecida, um ciclo
  // através de um alternate indireto e profundidade para lá de 64 níveis
  Result := CS.Family <> csfUnsupported;
  if not Result then
    CS := HPDFDefaultColorSpace;   // DeviceGray, o espaço inicial do PDF
end;

Uma fronteira de API que vale a pena documentar para os seus chamadores

O HotPDF aplica a configuração de conversão ICC em vigor no momento do parse quando constrói uma palete Indexed, e chamadas posteriores a HPDFSetColorSpaceRenderingIntent não regeneram uma palete que já está resolvida. Essa função percorre as transformações ICCBased e alternate do record que lhe entrega e reconstrói esses pipelines; os bytes RGB achatados em Palette são um artefacto do parse e ficam como estavam. Isto é uma consequência de normalizar cedo, não um descuido, e vale a pena escrevê-lo na sua própria documentação de API em vez de deixar um cliente descobrir. Se a sua aplicação deixa um utilizador mudar o rendering intent a meio da sessão, resolva os espaços de cor de novo depois da mudança; se define o intent uma vez durante a configuração do pipeline, que é o caso esmagadoramente comum, não há nada a fazer. A mesma ordem aplica-se aos perfis de proofing instalados através de HPDFSetICCColorPipeline: configure-os antes de começar a resolver, não depois

O bug espelho no lado da escrita

Tudo o que está acima é sobre ler. O defeito simétrico no lado da escrita é mais fácil de lançar e muito mais fácil de falhar. Quando o HotPDF constrói um array /Indexed a partir de um bitmap Windows de 8 bits chama GetPaletteEntries, e essa API preenche apenas tantas entradas quantas a palete realmente tem e devolve essa contagem. O código original testava o resultado para = 0 e depois iterava 256 entradas fixas sobre um array na stack. Para um bitmap de 16 cores isso escrevia 240 entradas de memória de stack não inicializada diretamente na string de consulta de um PDF que depois entrega a um cliente, com hival codificado à força como 255 para que todos os leitores tratassem obedientemente o resíduo como dados de cor reais. É um bug de correção e um bug de divulgação de informação com a mesma roupa. A correção é sem glamour e completa: ponha o array a zero antes da chamada e depois honre a contagem devolvida tanto para o limite do loop como para hival. Se gera imagens /Indexed em algum lado do seu próprio código, vá verificar esse hival agora

Duas peças adjacentes completam o quadro: as paletes Indexed e as sampled functions Type 0 partilham o reader de streams descodificados e a mesma disciplina de clamping, discutidas em o artigo sobre tabelas de consulta de cor de funções Type 0, e as regras de filtro que governam os streams de consulta são as mesmas que governam os dados de imagem em extrair imagens carregadas através de decode filters. O resolver de espaços de cor descrito aqui vem como parte do HotPDF Delphi Component para Delphi e C++Builder, com a referência completa de THPDFColorSpace na página do produto