Uma tabela de lookup /Indexed armazena componentes do seu espaço de cor base, não trincas RGB. Uma base DeviceGray empacota um byte por entrada, DeviceCMYK quatro, Lab três que precisam de scaling de range, e ICCBased o que o /N do perfil declarar. O HotPDF normaliza todas elas no tempo de parse para a paleta RGB pública que THPDFColorSpace expõe
Essa frase é a classe de bug inteira. Quase todo renderizador de paleta nasce assumindo um stride de três bytes, porque a esmagadora maioria dos espaços /Indexed do mundo real fica em /DeviceRGB e a suposição se sustenta por anos. Então chega uma nota fiscal escaneada com [/Indexed /DeviceCMYK 15 <...>], o leitor percorre a string de lookup três bytes por vez, e todo índice depois de zero cai no meio da cor de outra pessoa. A saída não é em branco e não crasha. É lixo que parece plausível, o que é muito pior, porque ninguém reporta bug contra cores que apenas parecem estranhas
Por que uma paleta 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 lookup como m × (hival + 1) bytes, onde m é o número de componentes no espaço base, e ela não diz nada sobre essa base ser RGB. A mesma seção proíbe apenas duas bases de forma direta: o próprio /Indexed e /Pattern. Todo o resto vale, incluindo as quatro famílias baseadas em CIE da §8.6.5.2 até a §8.6.5.5
Então o trabalho real não é ler bytes, é resolver a base primeiro e perguntar a ela quão larga é uma entrada. ResolveIndexedPalette no HotPDF faz exatamente isso: resolve a base em um THPDFColorSpace, pega ComponentCount do record resolvido e rejeita o array na hora se a base voltou 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 chega a 1024 bytes. Dois detalhes nesse dimensionamento são onde um arquivo hostil vai te sondar. O HotPDF clampa hival em 0..255 antes de usá-lo, conforme o limite da §8.6.6.3, então um array de lookup alegando hival 100000 não vira uma alocação de gigabyte; e ele trunca o orçamento para o que os dados de lookup realmente contêm, parando de preencher a paleta na primeira entrada cujos bytes passam do fim. Uma tabela curta rende uma paleta curta, não uma leitura além do buffer
Normalizando quatro espaços base diferentes em uma única tabela RGB
O HotPDF converte paletas para RGB durante o parsing em vez de no momento do draw, e essa decisão dirige o resto do design. O campo público THPDFColorSpace.Palette é um TBytes de trincas RGB com comprimento 3 * PaletteCount, e tem esse formato desde muito antes de bases CIE serem suportadas. Reescrever o layout do record para carregar componentes nativos da base teria quebrado todo consumidor do campo. Converter no tempo de parse mantém o layout intacto e, de bônus, roteia 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 scaling é onde os espaços base deixam de ser intercambiáveis. Um byte de lookup é sempre 0..255, mas o que esse byte significa depende do range de componentes que a base declara. Componentes de Device e calibrados mapeiam para 0..1. Lab não: o componente L* vai de 0..100, enquanto a* e b* percorrem o /Range que o espaço declara. O HotPDF puxa 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 padroniza para −108..101 quando a entrada está ausente, então não hardcode um range simétrico de memória. Bases ICCBased adicionam uma terceira fonte de verdade: a contagem de componentes vem do /N do stream do perfil, exposta como ICCN, e um stream ICCBased pode ele mesmo carregar um array /Range que o HotPDF lê via HPDFReadNumericArrayUpTo na mesma tabela de limites por componente. Uma paleta ICC de três componentes e uma paleta Lab de três componentes portanto percorrem a string de lookup 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 XObject de imagem ou de um
// resource dictionary; it may be a name, an array, or an indirect ref
CS := HPDFResolveColorSpace(ColorSpaceObj);
if CS.Family <> csfIndexed then
Exit;
// Palette já é RGB independentemente do espaço base
for Entry := 0 to CS.PaletteCount - 1 do
begin
if (Entry * 3 + 2) >= Length(CS.Palette) then
Break; // tabela de lookup curta, encerrar de forma limpa
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 lookup vive em um stream
A ISO 32000-1 §8.6.6.3 permite que a tabela de lookup seja uma string ou um stream, e a forma de stream é a que morde. Um stream pode carregar /FlateDecode, pode ser um objeto indireto compartilhado com partes não relacionadas do arquivo, e seu TStream pode estar posicionado em qualquer lugar no momento em que seu parser o alcança. O HotPDF lida com o caso comprimido delegando para o callback de bytes decodificados registrado via HPDFSetColorStreamReader, o mesmo callback que o engine de sampled function Type 0 usa
Quando nenhum reader está registrado, o fallback é deliberadamente estreito em vez de best-effort. O HotPDF checa o dicionário do stream por /Filter e recusa a paleta por completo se houver um, em vez de alimentar o lookup com bytes comprimidos e produzir uma tabela confiantemente errada. Sem filtro ele lê bytes crus, limitados ao orçamento de bytes calculado a partir de hival e da contagem de componentes da base. Todo o ramo de stream fica dentro de um try..finally que restaura o Stream.Position original em todo caminho de saída, incluindo as recusas, porque o objeto é compartilhado e o próximo consumidor não vai te agradecer por um cursor movido. LookupStreamUsesDecodedReaderAndRestoresPosition na suíte de regressão fixa as duas metades desse comportamento
// Registre os dois callbacks antes de resolver qualquer espaço de cor, caso contrário
// bases indiretas e streams de lookup 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 recursar para sempre?
Rejeitar /Indexed como base direta é necessário e está longe de ser suficiente. O ataque interessante vai de lado. Pegue um [/Indexed /Separation ...] válido, e lembre que a §8.6.6.4 deixa um Separation nomear um espaço alternativo e a §8.6.6.5 deixa DeviceN fazer o mesmo. Faça esse alternativo ser uma referência indireta apontando de volta para o array Indexed original. Nada na cadeia viola a regra de base direta em nenhum salto isolado: a base é um Separation, o alternativo do Separation é um array, e o array só por acaso é aquele de onde você partiu. Resolva de forma ingênua 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 eles importam apenas como um elo no ciclo
O HotPDF fecha isso no único ponto de entrada pelo qual todo caminho já passa. HPDFResolveColorSpace embrulha o resolver interno em um contador de profundidade limitado por HPDFColorSpaceMaxDepth, que é 64. Cruze esse limite e a proteção define uma flag, e a flag é o que importa: no unwind, qualquer nível de aninhamento que a viu definida descarta seu próprio record parcialmente resolvido e retorna csfUnsupported, então o grafo inteiro é rejeitado em vez de metade dele retido, e ambos os contadores resetam quando a chamada mais externa retorna. Esses contadores são declarados como threadvar, não globais de unit, e isso não é organização incidental: GResolveObj e GReadStream vivem em armazenamento thread-local pelo mesmo motivo, porque páginas renderizam em paralelo. Um global compartilhado deixaria uma thread fundo em uma cadeia DeviceN aninhada estourar o limite para outra thread fazendo parse de um [/Indexed /DeviceRGB 255 <...>] perfeitamente comum, produzindo uma falha não determinística dependente de carga — o tipo menos debuggável que existe. Sessenta e quatro é folga confortável para conteúdo legítimo, que raramente aninha além de quatro ou cinco níveis, então trate csfUnsupported vindo de um array de aparência estruturalmente válida como algo que vale logar: 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
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 alternativo indireto, e profundidade além 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 documentar para seus chamadores
O HotPDF aplica a configuração de conversão ICC em vigor no tempo de parse quando monta uma paleta Indexed, e chamadas posteriores a HPDFSetColorSpaceRenderingIntent não regeneram uma paleta que já está resolvida. Essa função percorre os transforms ICCBased e alternativos do record que você entrega e reconstrói esses pipelines; os bytes RGB achatados em Palette são um artefato do tempo de parse e ficam como estavam. Isso é uma consequência de normalizar cedo, não um descuido, e vale escrever na sua própria documentação de API em vez de deixar um cliente descobrir. Se sua aplicação deixa o usuário trocar o rendering intent no meio da sessão, resolva os espaços de cor de novo depois da troca; se ela define o intent uma vez durante o setup do pipeline, que é o caso esmagadoramente comum, não há nada a fazer. A mesma ordenação vale para os perfis de proofing instalados via HPDFSetICCColorPipeline: configure-os antes de começar a resolver, não depois
O bug espelhado no lado da escrita
Tudo acima é sobre leitura. O defeito simétrico no lado da escrita é mais fácil de publicar e muito mais fácil de deixar passar. Quando o HotPDF monta um array /Indexed a partir de um bitmap Windows de 8 bits, ele chama GetPaletteEntries, e essa API preenche apenas quantas entradas a paleta realmente tem e retorna essa contagem. O código original testava o resultado por = 0 e então iterava 256 entradas fixas sobre um array na stack. Para um bitmap de 16 cores isso gravava 240 entradas de memória de stack não inicializada direto na string de lookup de um PDF que você então entrega a um cliente, com hival hard-coded em 255 para que todo leitor tratasse diligentemente o resíduo como dado de cor real. É um bug de correção e um bug de divulgação de informação vestindo as mesmas roupas. O fix é sem glamour e completo: zere o array antes da chamada, e então honre a contagem retornada tanto no limite do loop quanto no hival. Se você gera imagens /Indexed em qualquer lugar do seu código, vá checar esse hival agora
Duas peças adjacentes completam o quadro: paletas Indexed e sampled functions Type 0 compartilham o reader de stream decodificado e a mesma disciplina de clamping, discutidas em a peça sobre tabelas de lookup de cor de funções Type 0, e as regras de filtro que governam streams de lookup são as mesmas que governam dados de imagem em extraindo imagens carregadas através de decode filters. O resolver de espaço 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