Artigo Técnico

Paleta de 56 cores do BIFF8: mapeamento OKLab no HotXLS

O azul escuro $000033 fica a 51 unidades do preto e a 77 unidades da entrada navy clássica $000080 quando a distância é medida como distância euclidiana simples sobre os valores de canal RGB. Um conversor que grava arquivos .xls clássicos com casamento nearest-RGB pinta, portanto, uma linha de cabeçalho azul escuro de preto, e preto é o que todo leitor que vai apenas pelo índice da paleta exibe. O formato não deixa espaço para o escritor: um formato de célula guarda um índice de paleta, e a paleta tem 56 entradas. Acertar o cabeçalho exige duas coisas, uma medida de distância que acompanhe o que as pessoas veem, e um jeito de reescrever a própria paleta quando uma pasta de trabalho usa mais cores distintas do que a paleta comporta

De onde vem o limite de 56 cores

Um record BIFF8 de fonte, preenchimento ou borda nunca carrega um valor RGB. Ele carrega um índice de cor, e o record Palette global da pasta de trabalho ($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. Valores acima de 63 nem são cores, mas tokens: $40 é o primeiro plano do sistema, $41 o fundo do sistema, $4D a $4F são texto de gráfico, fundo de gráfico e a cor neutra de gráfico, e $7FFF significa automático. O Excel 2007 acrescentou o record XFExt ($087D, §2.4.355), que pode anexar um RGB completo ou uma cor de tema a um formato de célula, mas o Excel 97–2003 e qualquer parser que lê apenas o record XF continuam não vendo nada além do índice

O HotXLS expõe a paleta por meio de um ColorIndex público de 1 a 56, que é o índice físico menos 7. Três esquemas de numeração estão em jogo, e o ResolveIndexedColor os mantém separados por meio do TXLSIndexedColorSpace: xicsPublicColorIndex para os valores de API 1..56, xicsBiffIcv para os índices crus do disco e xicsOoxmlIndexed, em que 64 e 65 significam primeiro plano e fundo do sistema. Índices BIFF crus também são checados contra o papel que você passar como TXLSIndexedColorRole, porque uma fonte, um preenchimento e um gráfico aceitam conjuntos de tokens diferentes; $51 é legal para uma fonte e inválido para um preenchimento

O HotXLS mantém os três esquemas de cor indexada separados por meio do TXLSIndexedColorSpace: valores icv crus do BIFF, 0 a 7 fixados nas oito cores básicas, os 56 slots da paleta 8 a 63 do registro Palette $0092, tokens acima de 63 como primeiro plano do sistema, ColorIndex público de 1 a 56 com deslocamento de menos 7, e xicsOoxmlIndexed em que 64 e 65 significam primeiro e segundo plano do sistema
O mesmo índice de cor significa números diferentes em cada esquema, então o HotXLS roteia todo valor pelo ResolveIndexedColor em vez de deixar um token cru do BIFF se passar por um ColorIndex público

O resultado Boolean do ResolveIndexedColor é onde quem chama tropeça. Ele devolve True apenas quando obteve um valor ARGB concreto, e a sobrecarga curta nunca pergunta ao sistema operacional pelas cores do desktop. Um token de sistema ou automático, portanto, volta False com Res.Kind definido como xickSystem ou xickAutomatic, e código que lê False como "sem cor" descarta silenciosamente o significado do token. A única exceção é $4F, a cor neutra de gráfico, que resolve para preto sem ajuda. Para valores RGB reais nos outros tokens, passe um método TXLSTryResolveSystemColor à sobrecarga longa e aplique a sua própria política para UI, export ou serviço headless:

type
  TExportColors = class
    function ResolveSystemColor(AColor: TXLSSystemColorRole;
      ARole: TXLSIndexedColorRole; out AArgb: LongWord): Boolean;
  end;

function TExportColors.ResolveSystemColor(AColor: TXLSSystemColorRole;
  ARole: TXLSIndexedColorRole; out AArgb: LongWord): Boolean;
begin
  // Export headless: cores fixas em vez do tema atual do desktop
  Result := True;
  case AColor of
    xscrWindowText, xscrChartText, xscrToolTipText:
      AArgb := $FF000000;
    xscrWindowBackground, xscrChartBackground:
      AArgb := $FFFFFFFF;
  else
    Result := False;
  end;
end;

var
  Colors: TExportColors;   // criado uma vez pelo seu código de export
  Res: TXLSIndexedColorResolution;
begin
  // $41 é o token de fundo do sistema, válido para um preenchimento
  if Workbook.ResolveIndexedColor($41, xicsBiffIcv, xicrFillBackground,
    Colors.ResolveSystemColor, Res) then
    PaintBackground(Res.ARGB)        // sua própria rotina de desenho
  else if Res.Kind = xickInvalid then
    RejectToken(Res.RawIndex);       // seu próprio tratamento de erro
end;

Por que a distância RGB escolhe a entrada errada da paleta?

Os valores de canal sRGB são codificados com gamma, então passos iguais nos números não são passos iguais no que uma pessoa vê, e o erro é maior nos tons escuros e saturados que os templates corporativos adoram. Em RGB, o $000033 está a 51 do preto e a 77 do navy, então o preto vence. Depois que cada canal é linearizado e a cor é projetada no OKLab, as distâncias ao quadrado são cerca de 0.0312 até o preto e 0.0235 até o navy, e o navy vence. O casamento é o ColorIndex 11 no slot físico 18, e os motores Classic e XLSX dão a mesma resposta

Como o HotXLS casa o azul escuro $000033 na paleta: a distância euclidiana em RGB com gamma mede 51 até o preto e 77 até o navy e pintaria o cabeçalho de preto, enquanto as distâncias ao quadrado em OKLab, 0.0312 e 0.0235, fazem o NearestIndexedColor escolher navy, ColorIndex 11 no slot físico 18
Valores de canal com gamma fazem da distância RGB uma proxy ruim do que uma pessoa vê, então o HotXLS converte uma vez para OKLab e deixa uma comparação euclidiana simples ao quadrado comandar a varredura da paleta

A paleta padrão acrescenta uma complicação. O navy aparece duas vezes, no ColorIndex 11 e de novo no 25, então o $000033 está exatamente à mesma distância dos dois. Um casador que varre em outra ordem, ou usa menor-ou-igual, escolhe 25 em uma execução e 11 em outro build, e dois arquivos gerados param de dar diff limpo. O HotXLS varre do slot 8 para cima com comparação estrita de menor, então um empate sempre vai para o índice menor

OKLab é uma troca deliberada. O CIEDE2000 ranquearia alguns pares de matiz com mais precisão, mas suas correções por partes o tornam lento e mais difícil de raciocinar dentro de um loop iterativo. O OKLab custa uma linearização, dois produtos de matrizes pequenas e três raízes cúbicas por cor, depois do que uma distância simples ao quadrado é um substituto bom o bastante para diferença percebida. Para uma varredura de paleta de 56 entradas que roda a cada atribuição de cor e dentro de uma passada de clustering, a métrica mais barata e suave é a escolha certa

O que o NearestIndexedColor devolve?

O NearestIndexedColor recebe um valor ARGB e preenche um record TXLSNearestIndexedColorMatch sem mudar a pasta de trabalho. Cada pasta de trabalho mantém as 56 cores da paleta junto com suas coordenadas OKLab e um contador de geração da paleta, então uma consulta custa uma conversão de entrada e uma varredura fixa. Redefinir a paleta reconstrói esse cache, mudar uma única entrada atualiza só esse slot, e uma consulta contra um cache fora de sincronia com a geração atual devolve False em vez de chutar

O alpha segue uma regra estreita. Um byte alpha zero é tratado como opaco, o que combina com como a maioria do código Delphi passa valores RGB puros, e $FF é opaco por definição. Qualquer coisa no meio é rejeitada: a função devolve False com ColorIndex 0 e PaletteSlot -1, já que uma entrada de paleta não tem onde guardar transparência

var
  Match: TXLSNearestIndexedColorMatch;
begin
  if Workbook.NearestIndexedColor($FF000033, Match) then
  begin
    // Paleta padrão: Match.ColorIndex = 11, Match.PaletteSlot = 18,
    // Match.ARGB = $FF000080
    if not Match.ExactMatch then
      LogApproximation(Match.InputARGB, Match.ARGB,   // seu próprio logger
        Match.DistanceSquared);
  end;
end;

O mesmo casador fica atrás dos setters de RGB. Atribuir Interior.Color, Font.Color ou Border.Color em uma pasta Classic mantém a cor exata para o record XFExt e escolhe, com essa varredura, o índice guardado no record XF, e os writers de preenchimento e borda resolvem RGB e cores de tema para um índice do mesmo jeito. O que o NearestIndexedColor reporta para uma cor é o slot que um leitor apenas por índice mostrará

Encaixando uma pasta true color em 56 slots

Casar contra a paleta padrão serve para um punhado de cores. Um template de marca com uma dúzia de azuis quase idênticos precisa de mais: a própria paleta tem que mudar. O BuildBiffPalettePlan calcula uma proposta completa para todos os 56 slots e deixa a pasta de trabalho intocada, então você pode registrá-la, compará-la com um limite ou descartá-la

O planner começa com o ScanIndexedColorUsage. Todo slot referenciado por índice a partir de uma fonte, preenchimento, borda, formatação condicional, shape, comentário, fonte de gráfico ou linha de grade da planilha fica travado, porque mudar essa entrada recoloria de uma vez todo consumidor do índice. Os alvos são os RGB diretos e as cores de tema resolvidas vindas de fontes, preenchimentos, pontos de gradiente, bordas, estilos de formatação condicional, data bars e color scales. Cada alvo é ponderado pelo maior entre sua contagem de referências e sua contagem de definições, e uma formatação condicional conta toda célula que seus intervalos cobrem, então um preenchimento aplicado por uma coluna inteira pesa mais que uma cor usada em um comentário. O posicionamento então segue uma ordem fixa:

  • Slots travados mantêm sua cor atual incondicionalmente
  • Um alvo que já existe na paleta mantém seu slot correspondente mais baixo, e esse slot vira fixo
  • Se os alvos distintos restantes cabem nos slots livres, cada um ganha um slot exato, atribuído em ordem crescente de ARGB
  • Caso contrário, Quantized é definido, cada slot livre é semeado com o alvo cuja distância até o centro existente mais próximo, multiplicada pelo seu peso, é a maior, e até 16 rodadas de k-means ponderado em OKLab movem apenas os centros livres até as atribuições pararem de mudar
O pipeline de paleta do HotXLS para uma pasta de trabalho true color: o ScanIndexedColorUsage trava todo slot que uma fonte, preenchimento, borda, formatação condicional, shape, comentário ou linha de grade referencia, o BuildBiffPalettePlan posiciona cores exatas em ordem crescente de ARGB ou roda até 16 rodadas de k-means ponderado por frequência em OKLab, e o ApplyBiffPalettePlan valida a geração e o hash FNV-1a antes de gravar
O planejamento é somente leitura e repetível, o plano relata o próprio estrago por meio de WeightedError e MaxDistanceSquared, e um plano obsoleto é rejeitado com a paleta intocada porque planos são efetivamente de uso único

O caminho de overflow merece uma leitura de olhos abertos. O k-means aqui é uma otimização local limitada, e um slot livre acaba guardando um centro de cluster convertido de volta para sRGB com clamping, que pode ser uma cor que nenhuma célula usou verbatim. O que o planner entrega de verdade é repetibilidade: a mesma pasta de trabalho sempre produz o mesmo plano. O plano também reporta até onde teve que dobrar por meio de WeightedError, MaxDistanceSquared, ExactTargetWeight e TotalTargetWeight, o que basta para um job em lote se recusar a salvar quando a aproximação fica grosseira demais para um guia de marca. O array Targets mostra, por cor de entrada, o slot em que ela cai e sua distância ao quadrado

const
  MaxAcceptedError = 0.002;   // seu limite, em unidades OKLab ao quadrado
var
  Plan: TXLSBiffPalettePlan;
  I: Integer;
begin
  Plan := Workbook.BuildBiffPalettePlan;   // somente 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);           // seu próprio logger
  if not Workbook.ApplyBiffPalettePlan(Plan) then
    raise Exception.Create('The palette changed after planning');
end;

Quando o ApplyBiffPalettePlan recusa um plano?

O ApplyBiffPalettePlan verifica o plano inteiro antes de gravar um único slot e devolve False, com a paleta intocada, se algo discordar da pasta de trabalho atual. O plano carrega SourcePaletteGeneration e SourcePaletteHash, um hash FNV-1a de 64 bits sobre as 56 cores de origem. A validação também reconfere todo índice público e físico, toda cor de origem, que nenhum slot travado esteja marcado como mudado, que as contagens de travados e mudados fechem, e que todo alvo seja opaco

Qualquer mudança real de paleta entre o planejamento e a aplicação torna o plano obsoleto, e isso inclui uma aplicação anterior bem-sucedida do mesmo plano. Planos são de uso único na prática: monte, inspecione, aplique, e monte de novo se precisar de outro. Um plano válido sem slots mudados tem sucesso sem avançar a geração. Uma mudança real a avança uma vez e atualiza o casador uma vez. No motor Classic as novas cores vão direto para a paleta; no motor XLSX elas viram cores indexadas personalizadas, que o HasCustomIndexedColor e o CustomIndexedColorCount reportam e que o writer XLSX emite como um bloco indexedColors

Optando por saves XLS e conversão de XLSX para XLS

A propriedade BiffPaletteSavePolicy tem xbpsPreserve como padrão, então a paleta de uma pasta de trabalho só muda quando você pede. Definir xbpsOptimizeTrueColors faz uma pasta Classic montar e aplicar um plano novo dentro do SaveAs, e apenas quando o formato de destino é xlExcel97; BIFF5, CSV, HTML, PDF, XLSX e os outros writers ignoram a configuração. Depois de um save bem-sucedido, a paleta otimizada fica na pasta de trabalho, então consultas e saves posteriores veem o mesmo mapeamento. Se o save falha, é cancelado ou levanta erro, as 56 cores originais e a geração original são restauradas

Para relatórios gerados, ligue a política de otimização. Essas pastas de trabalho são construídas a partir de RGB e cores de tema, ninguém ajustou a paleta delas à mão, e um leitor apenas por índice as mostra bem mais fiéis ao original com ela. Mantenha o xbpsPreserve para pastas de trabalho cuja paleta faz parte do contrato, como um template que código a jusante edita pelo ColorIndex, em que um slot mudar de cor silenciosamente quebraria esse código

Origens XLSX seguem outro caminho. O SaveXLSXWorkbookAsXLS em lxXlsxExport monta um único plano a partir da TXLSXWorkbook carregada e o grava na paleta de destino antes de converter qualquer estilo, então toda célula convertida é casada contra a paleta final. A demo de workbench de auditoria e conversão de pastas de trabalho roda exatamente esse caminho. Cores de tema passam pelo mesmo planner depois que o tint delas é resolvido para RGB; preenchimentos de gráfico que devem continuar referências vivas de tema são um caso à parte, coberto no artigo sobre cores de tema GelFrame em preenchimentos de gráfico, em que o XLS binário guarda um índice de esquema em vez de uma cor achatada

// Pasta Classic: opt in, vale apenas para saves BIFF8
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
  HandleSaveFailure;   // seu próprio handler; paleta já restaurada

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

As chamadas de paleta (ResolveIndexedColor, NearestIndexedColor, ScanIndexedColorUsage, BuildBiffPalettePlan e ApplyBiffPalettePlan) têm as mesmas assinaturas em IXLSWorkbook e TXLSXWorkbook, tanto do Delphi quanto do C++Builder. O build trial na página do HotXLS Delphi Excel component basta para rodar um plano contra o seu template mais colorido e ler o MaxDistanceSquared antes de qualquer coisa ser gravada