Artigo Técnico

Paleta BIFF8 de 56 cores: mapeamento OKLab no HotXLS

O HotXLS mapeia cores RGB e de tema arbitrárias na paleta de 56 cores do BIFF8 em duas camadas: o NearestIndexedColor encontra em espaço OKLab a entrada já existente da paleta perceptualmente mais próxima, e o BuildBiffPalettePlan com o ApplyBiffPalettePlan reescreve as entradas livres da paleta para que um livro de trabalho a cores verdadeiras sobreviva a uma gravação em XLS clássico. O gatilho é sempre o mesmo ticket de suporte. Alguém constrói um relatório em XLSX com cabeçalhos azul-marinho institucionais e um realce turquesa suave, grava-o como .xls para um consumidor antigo, e os cabeçalhos voltam pretos puros enquanto o turquesa se transforma num turquesa berrante. Nada rebentou e nenhum aviso disparou. O modelo de cores do formato antigo simplesmente não consegue guardar o que o novo descreveu, e a biblioteca tinha de escolher alguma coisa

Porque é que um ficheiro XLS só consegue guardar 56 cores?

Porque o formato de célula do BIFF8 nunca guarda um valor RGB: os tipos de letra, os preenchimentos e os limites transportam um índice de cor, e o registo Palette global do livro ($0092, [MS-XLS] §2.4.188) fornece exatamente 56 entradas RGB opacas para os índices 8 a 63. Os índices 0 a 7 são cópias fixas das oito cores básicas, e os valores acima de 63 nem sequer são cores, mas tokens como primeiro plano do sistema, fundo do sistema e texto de gráfico. O HotXLS expõe a paleta através de um ColorIndex público de 1 a 56, que é o índice físico menos 7, e o ResolveIndexedColor mantém os três esquemas de numeração separados através do TXLSIndexedColorSpace: xicsPublicColorIndex para os valores de API 1..56, xicsBiffIcv para os índices brutos em disco, que são validados contra o subconjunto IcvFont, IcvXF ou IcvChart do papel que indicar, e xicsOoxmlIndexed, onde 64 e 65 significam primeiro plano e fundo do sistema

O HotXLS mantém os três esquemas de cor indexada separados através de TXLSIndexedColorSpace: valores icv BIFF brutos, 0 a 7 fixos nas oito cores básicas, as 56 entradas da paleta 8 a 63 do registo Palette $0092, tokens acima de 63 como o primeiro plano do sistema, o ColorIndex público 1 a 56 deslocado em menos 7, e xicsOoxmlIndexed onde 64 e 65 significam primeiro plano e fundo do sistema
O mesmo índice de cor significa números diferentes em cada esquema, por isso o HotXLS encaminha todos os valores pelo ResolveIndexedColor em vez de deixar um token BIFF bruto fazer-se passar por um ColorIndex público
var
  Res: TXLSIndexedColorResolution;
begin
  // $40 é um token icv do BIFF, não uma entrada da paleta
  Workbook.ResolveIndexedColor($40, xicsBiffIcv, Res);
  case Res.Kind of
    xickPalette:   UseArgb(Res.ARGB);   // entrada da paleta, se resolvida
    xickAutomatic,
    xickSystem:    UseSystemColor(Res.SystemColorRole);
    xickInvalid:   RejectToken(Res.RawIndex);
  end;
end;

Note que o exemplo comuta sobre o Res.Kind e ignora o valor de retorno Booleano. O ResolveIndexedColor só devolve True quando obteve um ARGB concreto, e a sobrecarga curta nunca lê o ambiente de trabalho do Windows, pelo que um token automático ou de sistema legitimamente volta False continuando classificado como xickSystem. O HotXLS topou com isto no seu próprio serializador de livros: código que trata False como «sem cor» deita fora silenciosamente o significado Automatic e System do token. Se precisar de valores RGB reais para esses tokens, chame a sobrecarga longa e forneça um callback TXLSTryResolveSystemColor que aplique a sua própria política de UI, de exportação ou headless

Porque é que o HotXLS faz corresponder cores em OKLab em vez de RGB?

Porque os valores dos canais sRGB estão codificados em gama, pelo que a distância euclidiana em RGB não acompanha o que uma pessoa vê, e o erro é pior precisamente nos tons escuros e saturados de que as paletas institucionais gostam. Pegue no azul-escuro $000033. Em RGB a distância ao preto é 51 e a distância à entrada azul-marinho predefinida $000080 é 77, pelo que um comparador em RGB pinta-lhe o cabeçalho de preto com toda a confiança. Em OKLab as distâncias ao quadrado são cerca de 0,0312 até ao preto e 0,0235 até ao azul-marinho, e o HotXLS escolhe o azul-marinho, ColorIndex 11 na entrada física 18; esse caso exato está afixado na suite de testes tanto no motor Classic como no XLSX. A conversão dentro do ArgbToOklab lineariza cada canal sRGB, aplica a matriz LMS do OKLab, tira raízes cúbicas e projeta em L, a e b, após o que uma distância euclidiana ao quadrado simples é uma aproximação razoável da diferença percebida. O OKLab não é CIEDE2000 e não finge que é, mas não tem correções de matiz por intervalos, custa um punhado de multiplicações por cor e é estável o suficiente para comandar um ciclo de clustering, que é onde ganha mesmo o seu lugar

Como o HotXLS faz corresponder o azul-escuro $000033 na paleta: a distância euclidiana em RGB codificado em gama mede 51 até ao preto e 77 até ao azul-marinho e pintaria o cabeçalho de preto, enquanto as distâncias ao quadrado do ArgbToOklab de 0,0312 e 0,0235 deixam o NearestIndexedColor escolher o azul-marinho, ColorIndex 11 na entrada física 18
Os valores de canal codificados em gama fazem da distância RGB um mau proxy do que uma pessoa vê, por isso o HotXLS converte uma vez para OKLab e deixa uma comparação euclidiana ao quadrado simples comandar o varrimento da paleta

O que garante o NearestIndexedColor?

O NearestIndexedColor garante uma resposta determinística e só de leitura: uma conversão da entrada, um varrimento fixo sobre 56 entradas em cache, e o índice público mais baixo sempre que duas entradas estejam igualmente próximas. Cada livro mantém em cache o ARGB normalizado e as coordenadas OKLab das 56 entradas físicas juntamente com um contador de geração da paleta. Um reset da paleta reconstrói a cache, uma alteração de uma só entrada atualiza apenas essa entrada, e uma consulta contra uma geração obsoleta devolve False em vez de adivinhar. O varrimento usa uma comparação estrita de menor que a começar na entrada 8, razão pela qual uma paleta que contenha a mesma cor duas vezes responde sempre com o índice mais baixo; isso importa quando faz diff entre dois ficheiros gerados e espera saída byte a byte idêntica. O alfa de entrada segue um contrato apertado: um byte de alfa zero é tratado como opaco, e um valor parcialmente transparente é recusado com ColorIndex 0 e PaletteSlot -1, já que as entradas da paleta não têm alfa. Os escritores de preenchimento e de limites do motor Classic convertem cores RGB e de tema num índice com a mesma rotina de correspondência OKLab no momento de gravar, pelo que a API e o ficheiro guardado concordam sobre a entrada em que uma cor acaba por cair

var
  Match: TXLSNearestIndexedColorMatch;
begin
  if Workbook.NearestIndexedColor($FF000033, Match) then
  begin
    // Match.ColorIndex = 11, Match.PaletteSlot = 18, Match.ARGB = $FF000080
    if not Match.ExactMatch then
      LogApproximation(Match.InputARGB, Match.ARGB, Match.DistanceSquared);
  end;
end;

Como é que o BuildBiffPalettePlan encaixa cores verdadeiras em 56 entradas?

O BuildBiffPalettePlan calcula uma proposta completa para as 56 entradas sem tocar no livro, pelo que a pode inspecionar, registar ou descartar. O planeador começa por chamar o ScanIndexedColorUsage: qualquer entrada que um tipo de letra, um preenchimento, um limite, um formato condicional, uma forma, um comentário ou uma linha de grelha da folha referencie por índice fica bloqueada, porque alterar uma entrada da paleta recolore de uma vez todos os consumidores desse índice. Os alvos são as cores RGB diretas e as cores de tema resolvidas de tipos de letra, preenchimentos, limites, estilos diferenciais, barras de dados e escalas de cores. Cada alvo é ponderado pelo maior entre a sua contagem de referências renderizadas e a sua contagem de definições, e um formato condicional conta as células que os seus intervalos cobrem, pelo que uma cor pintada ao longo de uma coluna inteira pesa mais do que uma usada numa única nota. A colocação prossegue depois numa ordem fixa:

  • As entradas bloqueadas conservam a sua cor de origem incondicionalmente
  • Um alvo que já exista na paleta é retido na sua entrada correspondente mais baixa e essa entrada fica fixa
  • Se os alvos únicos restantes couberem nas entradas livres, cada um recebe uma entrada exata, atribuída por ordem ARGB crescente
  • Caso contrário, o Quantized é ativado, cada entrada livre é semeada com o alvo cuja distância ao centro existente mais próximo, multiplicada pelo seu peso, é maior, e até 16 rondas de k-means ponderado por frequência em OKLab movem apenas os centros livres até as atribuições pararem de mudar

Seja honesto consigo próprio quanto ao que o caminho de transbordo entrega. O clustering é uma otimização local limitada, não um ótimo global, e uma entrada livre acaba por conter um centróide convertido de volta para sRGB com clamping, que pode ser uma cor que nenhuma célula usou literalmente. O que obtém é repetibilidade: o mesmo livro produz sempre o mesmo plano, e o plano reporta os seus próprios danos através de WeightedError, MaxDistanceSquared, ExactTargetWeight e TotalTargetWeight, pelo que um job em lote pode recusar gravar quando a aproximação fica demasiado grosseira para uma guia de marca

O pipeline da paleta do HotXLS para um livro a cores verdadeiras: o ScanIndexedColorUsage bloqueia todas as entradas que um tipo de letra, preenchimento, limite, formato condicional, forma, comentário ou grelha referencia, o BuildBiffPalettePlan coloca cores exatas por ordem ARGB crescente ou corre até 16 rondas de k-means ponderado por frequência em OKLab, e o ApplyBiffPalettePlan valida a geração e o hash FNV-1a antes de escrever
O planeamento é só de leitura e repetível, o plano reporta os seus próprios danos através de WeightedError e MaxDistanceSquared, e um plano obsoleto é recusado com a paleta intacta porque os planos são na prática de utilização única
var
  Plan: TXLSBiffPalettePlan;
  I: Integer;
begin
  Plan := Workbook.BuildBiffPalettePlan;   // só de leitura
  if Plan.Quantized and (Plan.MaxDistanceSquared > MaxAcceptedError) then
    raise Exception.Create('Too many distinct colors for a BIFF8 palette');
  for I := 0 to High(Plan.Slots) do
    if Plan.Slots[I].Changed then
      LogSlot(Plan.Slots[I].ColorIndex, Plan.Slots[I].SourceARGB,
        Plan.Slots[I].TargetARGB);
  if not Workbook.ApplyBiffPalettePlan(Plan) then
    raise Exception.Create('The palette changed after planning');
end;

Como é que o ApplyBiffPalettePlan recusa um plano obsoleto?

O ApplyBiffPalettePlan valida o plano inteiro antes de escrever uma única entrada, e devolve False com a paleta intacta se algo discordar do livro atual. O plano transporta SourcePaletteGeneration e SourcePaletteHash, um hash FNV-1a de 64 bits sobre as 56 cores de origem; a validação também reverifica cada índice público e físico, cada cor de origem, que nenhuma entrada bloqueada esteja marcada como alterada, as contagens de bloqueadas e de alteradas, e que todos os alvos são opacos. Qualquer alteração efetiva da paleta entretanto, incluindo uma aplicação anterior bem-sucedida do mesmo plano, torna o plano obsoleto, pelo que os planos são na prática de utilização única. Um plano válido sem entradas alteradas tem êxito sem avançar a geração, e uma alteração real avança a geração uma vez e reconstrói o comparador OKLab uma vez, no motor Classic reescrevendo a matriz fixa da paleta e no motor XLSX trocando por uma lista de substituição de cores indexadas preparada

Ativar para gravações BIFF8 e conversão de XLSX para XLS

A propriedade BiffPaletteSavePolicy assume por predefinição o valor xbpsPreserve, pelo que atualizar o HotXLS nunca reescreve a paleta de ninguém às escondidas. Defini-la como xbpsOptimizeTrueColors faz com que um livro Classic construa e aplique um plano fresco dentro do SaveAs, mas apenas quando o formato de destino é xlExcel97; os escritores BIFF5, CSV, HTML, PDF, XLSX e os restantes ignoram a definição. Depois de uma gravação bem-sucedida, a paleta otimizada fica no modelo do livro, pelo que consultas e gravações posteriores veem o mesmo mapeamento. Se a gravação falhar ou for cancelada, as 56 cores originais e a geração original são repostas. Para origens XLSX, o SaveXLSXWorkbookAsXLS em lxXlsxExport constrói um único plano a partir do livro carregado e escreve-o na paleta de destino antes de qualquer estilo ser convertido, que é a ponte determinística que a demonstração da bancada de auditoria e conversão de livros exercita. As cores de tema passam pelo mesmo planeador depois do seu tint ser resolvido para RGB; se preferir manter os temas vivos nos preenchimentos de gráficos, o artigo sobre preenchimentos de gráficos com cores de tema GelFrame explica como o XLS binário guarda um índice de esquema em vez de uma cor achatada

// Livro Classic: ativar à mão, só BIFF8
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
  HandleSaveFailure;   // paleta já reposta

// Modelo XLSX para BIFF8 com um plano de paleta determinístico
XWorkbook := TXLSXWorkbook.Create;
try
  if XWorkbook.Open('report.xlsx') = 1 then
    SaveXLSXWorkbookAsXLS(XWorkbook, 'report.xls');
finally
  XWorkbook.Free;
end;

As APIs de paleta do HotXLS funcionam da mesma forma no IXLSWorkbook e no TXLSXWorkbook, a partir do Delphi e do C++Builder indistintamente. Transfira a versão de avaliação e aponte-a para a sua folha de cálculo mais colorida a partir da página do componente Excel HotXLS para Delphi