Artículo técnico

Paleta BIFF8 de 56 colores: mapeo OKLab en HotXLS

El azul oscuro $000033 queda a 51 unidades del negro y a 77 unidades de la entrada clásica de azul marino $000080 cuando la distancia se mide como distancia euclidiana simple sobre los valores de canal RGB. Un conversor que escribe archivos .xls clásicos con igualación por RGB más cercano pinta por tanto una fila de encabezado azul oscuro de negro, y negro es lo que muestra todo lector que va solo por el índice de paleta. El formato no le deja margen al escritor: un formato de celda guarda un índice de paleta, y la paleta tiene 56 entradas. Poner el encabezado bien requiere dos cosas, una medida de distancia que siga lo que la gente ve, y una manera de reescribir la paleta misma cuando un libro usa más colores distintos de los que la paleta puede contener

De dónde sale el límite de 56 colores

Un record BIFF8 de fuente, relleno o borde nunca lleva un valor RGB. Lleva un índice de color, y el registro Palette global del libro ($0092, [MS-XLS] §2.4.188) aporta exactamente 56 entradas RGB opacas para los índices 8 al 63. Los índices 0 al 7 son copias fijas de los ocho colores básicos. Los valores por encima de 63 no son colores sino tokens: $40 es el primer plano del sistema, $41 el fondo del sistema, $4D a $4F son texto de gráfico, fondo de gráfico y el color neutro de gráfico, y $7FFF significa automático. Excel 2007 agregó el record XFExt ($087D, §2.4.355), que puede adjuntar un RGB completo o un color de tema a un formato de celda, pero Excel 97–2003 y cualquier parser que lea solo el record XF no ven nada excepto el índice

HotXLS expone la paleta a través de un ColorIndex público de 1 a 56, que es el índice físico menos 7. Tres esquemas de numeración están en juego, y ResolveIndexedColor los mantiene separados mediante TXLSIndexedColorSpace: xicsPublicColorIndex para los valores 1..56 de la API, xicsBiffIcv para los índices crudos en disco, y xicsOoxmlIndexed, donde 64 y 65 significan primer plano y fondo del sistema. Los índices BIFF crudos también se validan contra el rol que usted pase como TXLSIndexedColorRole, porque una fuente, un relleno y un gráfico aceptan conjuntos de tokens distintos; $51 es legal para una fuente e inválido para un relleno

HotXLS mantiene separados los tres esquemas de color indexado mediante TXLSIndexedColorSpace: valores icv BIFF crudos, 0 a 7 fijos a los ocho colores básicos, las 56 ranuras 8 a 63 del registro Palette $0092, tokens por encima de 63 como primer plano del sistema, ColorIndex público 1 a 56 con desfase de menos 7, y xicsOoxmlIndexed donde 64 y 65 significan primer plano y fondo del sistema
El mismo índice de color significa números distintos en cada esquema, así que HotXLS enruta cada valor por ResolveIndexedColor en lugar de dejar que un token BIFF crudo se haga pasar por un ColorIndex público

El resultado Boolean de ResolveIndexedColor es donde los llamadores tropiezan. Devuelve True solo cuando obtuvo un valor ARGB concreto, y el overload corto nunca le pide al sistema operativo los colores de escritorio. Un token de sistema o automático por tanto vuelve False con Res.Kind en xickSystem o xickAutomatic, y el código que lee False como “sin color” descarta silenciosamente el significado del token. La única excepción es $4F, el color neutro de gráfico, que se resuelve a negro sin ayuda. Para valores RGB reales en los demás tokens, pase un método TXLSTryResolveSystemColor al overload largo y aplique su propia política para la UI, una exportación o un servicio 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: colores fijos en lugar del tema actual del escritorio
  Result := True;
  case AColor of
    xscrWindowText, xscrChartText, xscrToolTipText:
      AArgb := $FF000000;
    xscrWindowBackground, xscrChartBackground:
      AArgb := $FFFFFFFF;
  else
    Result := False;
  end;
end;

var
  Colors: TExportColors;   // creado una vez por su código de exportación
  Res: TXLSIndexedColorResolution;
begin
  // $41 es el token de fondo del sistema, válido para un fill
  if Workbook.ResolveIndexedColor($41, xicsBiffIcv, xicrFillBackground,
    Colors.ResolveSystemColor, Res) then
    PaintBackground(Res.ARGB)        // su propia rutina de dibujo
  else if Res.Kind = xickInvalid then
    RejectToken(Res.RawIndex);       // su propio manejo de errores
end;

¿Por qué la distancia RGB elige la entrada de paleta equivocada?

Los valores de canal sRGB están codificados con gamma, así que pasos iguales en los números no son pasos iguales en lo que una persona ve, y el error es mayor justo en los tonos oscuros y saturados que adoran las plantillas corporativas. En RGB, $000033 está a 51 del negro y a 77 del azul marino, así que gana el negro. Una vez que cada canal se linealiza y el color se proyecta a OKLab, las distancias al cuadrado son unos 0.0312 al negro y 0.0235 al azul marino, y gana el azul marino. El match es ColorIndex 11 en la ranura física 18, y los motores Classic y XLSX dan la misma respuesta

Cómo HotXLS lleva el azul oscuro $000033 a la paleta: la distancia euclidiana en RGB con gamma mide 51 al negro y 77 al azul marino y pintaría el encabezado de negro, mientras que las distancias OKLab al cuadrado, 0.0312 y 0.0235, permiten a NearestIndexedColor elegir azul marino, ColorIndex 11 en la ranura física 18
Los valores de canal con gamma hacen de la distancia RGB un mal sustituto de lo que una persona ve, así que HotXLS convierte una sola vez a OKLab y deja que una comparación euclidiana al cuadrado simple gobierne el barrido de la paleta

La paleta por defecto agrega una arruga. El azul marino aparece dos veces, en ColorIndex 11 y otra vez en 25, así que $000033 está exactamente igual de cerca de ambos. Un matcher que escanea en otro orden, o que usa menor o igual, elige 25 en una corrida y 11 en otro build, y dos archivos generados dejan de diff limpio. HotXLS escanea desde la ranura 8 hacia arriba con una comparación estricta de menor que, así que un empate siempre va al índice más bajo

OKLab es un trade deliberado. CIEDE2000 clasificaría algunos pares de matices con más precisión, pero sus correcciones por tramos lo hacen lento y más difícil de razonar dentro de un loop iterativo. OKLab cuesta una linealización, dos productos de matrices pequeños y tres raíces cúbicas por color, tras lo cual una distancia al cuadrado simple es un sustituto bastante bueno de la diferencia percibida. Para un barrido de paleta de 56 entradas que corre en cada asignación de color y dentro de una pasada de clustering, la métrica más barata y suave es la elección correcta

¿Qué devuelve NearestIndexedColor?

NearestIndexedColor toma un valor ARGB y llena un record TXLSNearestIndexedColorMatch sin cambiar el libro. Cada libro mantiene juntos los 56 colores de paleta con sus coordenadas OKLab y un contador de generación de paleta, así que una consulta cuesta una conversión de entrada y un barrido fijo. Resetear la paleta reconstruye esa caché, cambiar una sola entrada actualiza solo esa ranura, y una consulta contra una caché desalineada de la generación actual devuelve False en lugar de adivinar

El alfa sigue una regla estrecha. Un byte de alfa cero se trata como opaco, lo que coincide con cómo la mayoría del código Delphi pasa valores RGB simples, y $FF es opaco por definición. Todo lo intermedio se rechaza: la función devuelve False con ColorIndex 0 y PaletteSlot -1, ya que una entrada de paleta no tiene dónde guardar transparencia

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

El mismo matcher está detrás de los setters de RGB. Asignar Interior.Color, Font.Color o Border.Color en un libro Classic conserva el color exacto para el record XFExt y elige con este barrido el índice guardado en el record XF, y los escritores de relleno y borde resuelven colores RGB y de tema a un índice de la misma manera. Lo que NearestIndexedColor reporte para un color es la ranura que mostrará un lector de solo índices

Meter un libro en color verdadero en 56 ranuras

Igualar contra la paleta por defecto está bien para un puñado de colores. Una plantilla de marca con una docena de azules casi idénticos necesita más: la paleta misma tiene que cambiar. BuildBiffPalettePlan computa una propuesta completa para las 56 ranuras y deja el libro intacto, así que usted puede hacerle log, compararla contra un umbral o tirarla

El planificador arranca con ScanIndexedColorUsage. Toda ranura referenciada por índice desde una fuente, relleno, borde, formato condicional, forma, comentario, fuente de gráfico o línea de cuadrícula de la hoja queda bloqueada, porque cambiar esa entrada recolorearía de golpe a todos los consumidores del índice. Los objetivos son los RGB directos y los colores de tema resueltos de fuentes, rellenos, paradas de gradiente, bordes, estilos de formato condicional, barras de datos y escalas de color. Cada objetivo se pondera por el mayor entre su conteo de referencias y su conteo de definiciones, y un formato condicional cuenta cada celda que sus rangos cubren, así que un relleno pintado sobre toda una columna pesa más que un color usado en un comentario. La colocación luego sigue un orden fijo:

  • Las ranuras bloqueadas conservan su color actual incondicionalmente
  • Un objetivo que ya existe en la paleta conserva su ranura coincidente más baja, y esa ranura queda fija
  • Si los objetivos distintos restantes caben en las ranuras libres, cada uno recibe una ranura exacta, asignada en orden ascendente de ARGB
  • De lo contrario se activa Quantized, cada ranura libre se siembra con el objetivo cuya distancia a su centro existente más cercano, multiplicada por su peso, sea la mayor, y hasta 16 rondas de k-means ponderado en OKLab mueven solo los centros libres hasta que las asignaciones dejan de cambiar
El pipeline de paleta de HotXLS para un libro en color verdadero: ScanIndexedColorUsage bloquea toda ranura referenciada por fuente, relleno, borde, formato condicional, forma, comentario o línea de cuadrícula, BuildBiffPalettePlan coloca colores exactos en orden ascendente de ARGB o corre hasta 16 rondas de k-means ponderado por frecuencia en OKLab, y ApplyBiffPalettePlan valida la generación y el hash FNV-1a antes de escribir
La planificación es de solo lectura y repetible, el plan reporta su propio daño mediante WeightedError y MaxDistanceSquared, y un plan vencido se rechaza con la paleta intacta porque los planes son en la práctica de un solo uso

El camino de desborde merece una lectura sin ilusiones. El k-means de aquí es una optimización local acotada, y una ranura libre termina conteniendo un centro de clúster convertido de vuelta a sRGB con recorte de rango, que puede ser un color que ninguna celda usó textualmente. Lo que el planificador sí entrega es repetibilidad: el mismo libro siempre produce el mismo plan. El plan también reporta cuánto tuvo que doblarse mediante WeightedError, MaxDistanceSquared, ExactTargetWeight y TotalTargetWeight, que es suficiente para que un trabajo por lotes se niegue a guardar cuando la aproximación queda demasiado gruesa para una guía de marca. El arreglo Targets muestra, por color de entrada, la ranura a la que mapea y su distancia al cuadrado

const
  MaxAcceptedError = 0.002;   // su umbral, en unidades OKLab al cuadrado
var
  Plan: TXLSBiffPalettePlan;
  I: Integer;
begin
  Plan := Workbook.BuildBiffPalettePlan;   // solo lectura
  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);           // su propio logger
  if not Workbook.ApplyBiffPalettePlan(Plan) then
    raise Exception.Create('The palette changed after planning');
end;

¿Cuándo rechaza ApplyBiffPalettePlan un plan?

ApplyBiffPalettePlan verifica el plan completo antes de escribir una sola ranura y devuelve False, con la paleta intacta, si algo no coincide con el libro actual. El plan lleva SourcePaletteGeneration y SourcePaletteHash, un hash FNV-1a de 64 bits sobre los 56 colores de origen. La validación además re-verifica cada índice público y físico, cada color de origen, que ninguna ranura bloqueada esté marcada como cambiada, que los conteos de bloqueadas y cambiadas sumen, y que cada objetivo sea opaco

Cualquier cambio real de paleta entre planear y aplicar deja el plan vencido, y eso incluye una aplicación anterior exitosa del mismo plan. Los planes son de un solo uso en la práctica: construya, inspeccione, aplique, y construya otra vez si necesita otro. Un plan válido sin ranuras cambiadas tiene éxito sin avanzar la generación. Un cambio real la avanza una vez y refresca el matcher una vez. En el motor Classic los colores nuevos van directo a la paleta; en el motor XLSX se vuelven colores indexados personalizados, que HasCustomIndexedColor y CustomIndexedColorCount reportan y que el escritor XLSX emite como un bloque indexedColors

Optar por guardados XLS y conversión de XLSX a XLS

La propiedad BiffPaletteSavePolicy viene por defecto en xbpsPreserve, así que la paleta de un libro solo cambia cuando usted lo pide. Poner xbpsOptimizeTrueColors hace que un libro Classic construya y aplique un plan fresco dentro de SaveAs, y solo cuando el formato destino es xlExcel97; los escritores BIFF5, CSV, HTML, PDF, XLSX y demás ignoran el ajuste. Tras un guardado exitoso la paleta optimizada se queda en el libro, así que consultas y guardados posteriores ven el mismo mapeo. Si el guardado falla, se cancela o levanta, los 56 colores originales y la generación original se restauran

Para reportes generados, encienda la política de optimización. Esos libros se construyen desde colores RGB y de tema, nadie afinó su paleta a mano, y un lector de solo índices los muestra mucho más cerca del original con ella. Conserve xbpsPreserve para libros cuya paleta es parte del contrato, como una plantilla que código posterior edita por ColorIndex, donde una ranura que cambia silenciosamente de color rompería ese código

Las fuentes XLSX toman otra ruta. SaveXLSXWorkbookAsXLS en lxXlsxExport construye un plan desde el TXLSXWorkbook cargado y lo escribe en la paleta destino antes de convertir cualquier estilo, así que cada celda convertida se iguala contra la paleta final. La demo del workbench de auditoría y conversión de libros corre exactamente esta ruta. Los colores de tema pasan por el mismo planificador una vez que su tint se resuelve a RGB; los rellenos de gráfico que deberían quedar como referencias de tema vivas son un caso aparte, cubierto en el artículo de rellenos de gráfico con colores de tema GelFrame, donde el XLS binario guarda un índice de esquema en lugar de un color aplanado

// Libro Classic: opt-in, aplica solo a guardados BIFF8
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
  HandleSaveFailure;   // su propio handler; la paleta ya fue restaurada

// Modelo XLSX a BIFF8 con un plan de paleta determinista
XWorkbook := TXLSXWorkbook.Create;
try
  if XWorkbook.Open('report.xlsx') = 1 then
    if SaveXLSXWorkbookAsXLS(XWorkbook, 'report.xls') <> 1 then
      HandleSaveFailure;
finally
  XWorkbook.Free;
end;

Las llamadas de paleta (ResolveIndexedColor, NearestIndexedColor, ScanIndexedColorUsage, BuildBiffPalettePlan y ApplyBiffPalettePlan) tienen las mismas firmas en IXLSWorkbook y TXLSXWorkbook, desde Delphi y C++Builder. El build de prueba de la página del componente Excel HotXLS para Delphi es suficiente para correr un plan contra su plantilla más colorida y leer MaxDistanceSquared antes de que se escriba nada