Artículo técnico

Paleta de 56 colores BIFF8: mapeo OKLab en HotXLS

El azul oscuro $000033 está a 51 unidades del negro y a 77 unidades de la entrada navy clásica $000080 cuando la distancia se mide como distancia euclídea simple sobre los valores de canal RGB. Un conversor que escribe ficheros .xls clásicos con igualación por RGB más cercano pinta por tanto una fila de cabecera azul oscuro de negro, y negro es lo que muestra todo lector que va solo por el índice de paleta. El formato no deja sitio al escritor: un formato de celda guarda un índice de paleta, y la paleta tiene 56 entradas. Ajustar la cabecera lleva dos cosas, una medida de distancia que siga lo que la gente ve, y una forma de reescribir la propia paleta cuando un workbook usa más colores distintos de los que la paleta puede contener

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

Un registro BIFF8 de fuente, fill o borde nunca lleva un valor RGB. Lleva un índice de color, y el registro Palette global del workbook ($0092, [MS-XLS] §2.4.188) aporta exactamente 56 entradas RGB opacas para los índices 8 a 63. Los índices 0 a 7 son copias fijas de los ocho colores básicos. Los valores por encima de 63 no son colores sino tokens: $40 es el foreground del sistema, $41 el background del sistema, $4D a $4F son chart text, chart background y el color neutro de chart, y $7FFF significa automático. Excel 2007 añadió el registro 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 registro XF no ven nada salvo 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. Hay tres esquemas de numeración en juego, y ResolveIndexedColor los mantiene separados a través de TXLSIndexedColorSpace: xicsPublicColorIndex para los valores 1..56 de la API, xicsBiffIcv para los índices crudos en disco, y xicsOoxmlIndexed, donde 64 y 65 significan foreground y background del sistema. Los índices BIFF crudos se comprueban también contra el rol que pases como TXLSIndexedColorRole, porque una fuente, un fill y un chart aceptan conjuntos de tokens distintos; $51 es legal para una fuente e inválido para un fill

HotXLS mantiene separados los tres esquemas de color indexado mediante TXLSIndexedColorSpace: valores icv crudos de BIFF, 0 a 7 fijados a los ocho colores básicos, las 56 ranuras de paleta 8 a 63 del record Palette $0092, tokens por encima de 63 como system foreground, ColorIndex público de 1 a 56 desplazado menos 7, y xicsOoxmlIndexed donde 64 y 65 significan system foreground y system background
El mismo índice de color significa números distintos en cada esquema, así que HotXLS encamina cada valor a través de ResolveIndexedColor en vez de dejar que un token BIFF crudo se haga pasar por un ColorIndex público

El resultado booleano de ResolveIndexedColor es donde la gente tropieza. Devuelve True solo cuando ha obtenido un valor ARGB concreto, y la sobrecarga corta nunca le pide al sistema operativo los colores del escritorio. Un token de sistema o automático vuelve por tanto con False y con Res.Kind puesto a xickSystem o xickAutomatic, y el código que lee False como "no hay color" descarta en silencio el significado del token. La única excepción es $4F, el color neutro de chart, que resuelve a negro sin ayuda. Para valores RGB reales en los demás tokens, pasa un método TXLSTryResolveSystemColor a la sobrecarga larga y aplica tu 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
  // Exportación 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 tu código de exportación
  Res: TXLSIndexedColorResolution;
begin
  // $41 es el token de background del sistema, válido para un fill
  if Workbook.ResolveIndexedColor($41, xicsBiffIcv, xicrFillBackground,
    Colors.ResolveSystemColor, Res) then
    PaintBackground(Res.ARGB)        // tu propia rutina de dibujo
  else if Res.Kind = xickInvalid then
    RejectToken(Res.RawIndex);       // tu propio manejo de errores
end;

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

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

Cómo iguala HotXLS el azul oscuro $000033 en la paleta: la distancia euclídea en RGB con codificación gamma da 51 al negro y 77 al navy y pintaría la cabecera de negro, mientras que las distancias al cuadrado de ArgbToOklab, 0.0312 y 0.0235, permiten a NearestIndexedColor elegir el navy, ColorIndex 11 en la ranura física 18
La codificación gamma de los canales convierte la distancia RGB en un mal proxy de lo que ve una persona, así que HotXLS convierte una vez a OKLab y deja que una comparación euclídea al cuadrado sin más gobierne el barrido de la paleta

La paleta por defecto añade una arruga. El navy aparece dos veces, en ColorIndex 11 y otra vez en 25, así que $000033 está exactamente igual de cerca de ambos. Un matcher que barre en otro orden, o que usa menor-o-igual, elige 25 en una ejecución y 11 en otra build, y dos ficheros generados dejan de diferir limpiamente. HotXLS barre desde la ranura 8 hacia arriba con una comparación estricta de menor que, así que un empate siempre se va al índice más bajo

OKLab es un compromiso deliberado. CIEDE2000 clasificaría algunos pares de matiz con más precisión, pero sus correcciones por tramos lo hacen lento y más difícil de razonar dentro de un bucle 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 sin más es un sustituto suficiente de la diferencia percibida. Para un barrido de paleta de 56 entradas que se ejecuta 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 rellena un registro TXLSNearestIndexedColorMatch sin cambiar el workbook. Cada workbook guarda los 56 colores de la paleta junto con sus coordenadas OKLab y un contador de generación de paleta, así que una consulta cuesta una conversión de la entrada y un barrido fijo. Reiniciar la paleta reconstruye esa caché, cambiar una sola entrada actualiza solo esa ranura, y una consulta contra una caché que no va al ritmo de la generación actual devuelve False en lugar de adivinar

El alpha sigue una regla estrecha. Un byte de alpha a cero se trata como opaco, lo que coincide con cómo la mayoría del código Delphi pasa valores RGB planos, 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,   // tu 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 workbook Classic conserva el color exacto para el registro XFExt y elige con este barrido el índice guardado en el registro XF, y los escritores de fill y borde resuelven colores RGB y de tema a un índice de la misma forma. Lo que NearestIndexedColor reporte para un color es la ranura que mostrará un lector que solo ve índices

Encajar un workbook en true color en 56 ranuras

Igualar contra la paleta por defecto va bien para un puñado de colores. Una plantilla de marca con una docena de azules casi idénticos necesita más: la propia paleta tiene que cambiar. BuildBiffPalettePlan calcula una propuesta completa para las 56 ranuras y deja el workbook intacto, así que puedes registrarla, compararla contra un umbral o tirarla

El planner empieza con ScanIndexedColorUsage. Toda ranura referenciada por índice desde una fuente, un fill, un borde, un formato condicional, una forma, un comentario, la fuente de un chart o las líneas de cuadrícula de la hoja queda bloqueada, porque cambiar esa entrada recolorearía de golpe a todos los consumidores del índice. Los targets son los colores RGB directos y los colores de tema resueltos de fuentes, fills, paradas de degradado, bordes, estilos de formato condicional, data bars y color scales. Cada target se pondera por el mayor entre su número de referencias y su número de definiciones, y un formato condicional cuenta cada celda que cubren sus rangos, así que un fill pintado a lo largo de una columna entera pesa más que un color usado en un solo comentario. La colocación sigue entonces un orden fijo:

  • Las ranuras bloqueadas conservan su color actual incondicionalmente
  • Un target que ya existe en la paleta conserva su ranura coincidente más baja, y esa ranura pasa a fija
  • Si los targets distintos restantes caben en las ranuras libres, cada uno obtiene una ranura exacta, asignada en orden ascendente de ARGB
  • Si no, se activa Quantized, cada ranura libre se siembra con el target cuya distancia a su centro existente más cercano, multiplicada por su peso, sea 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 workbook en true color: ScanIndexedColorUsage bloquea toda ranura que referencien una fuente, un fill, un borde, un formato condicional, una forma, un comentario o una línea de cuadrícula, BuildBiffPalettePlan coloca colores exactos en orden ascendente de ARGB o ejecuta 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
Planificar es de solo lectura y repetible, el plan informa de su propio daño mediante WeightedError y MaxDistanceSquared, y un plan obsoleto se rechaza con la paleta intacta, porque los planes son en la práctica de un solo uso

El camino de desbordamiento merece una lectura sin autosatisfacción. K-means aquí es una optimización local acotada, y una ranura libre acaba albergando un centro de clúster convertido de vuelta a sRGB con clamping, que puede ser un color que ninguna celda usó literalmente. Lo que el planner sí entrega es repetibilidad: el mismo workbook produce siempre el mismo plan. El plan también informa de cuánto tuvo que doblarse a través de WeightedError, MaxDistanceSquared, ExactTargetWeight y TotalTargetWeight, que es suficiente para que un trabajo por lotes se niegue a guardar cuando la aproximación se queda demasiado basta para una guía de marca. El array Targets muestra, por color de entrada, la ranura a la que mapea y su distancia al cuadrado

const
  MaxAcceptedError = 0.002;   // tu 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);           // tu propio logger
  if not Workbook.ApplyBiffPalettePlan(Plan) then
    raise Exception.Create('The palette changed after planning');
end;

¿Cuándo rechaza ApplyBiffPalettePlan un plan?

ApplyBiffPalettePlan comprueba el plan entero antes de escribir una sola ranura y devuelve False, con la paleta intacta, si algo discrepa del workbook actual. El plan lleva SourcePaletteGeneration y SourcePaletteHash, un hash FNV-1a de 64 bits sobre los 56 colores de origen. La validación recompueba también cada índice público y físico, cada color de origen, que ninguna ranura bloqueada esté marcada como cambiada, que los recuentos de bloqueadas y cambiadas sumen, y que cada target sea opaco

Cualquier cambio real de la paleta entre planificar y aplicar deja el plan obsoleto, y eso incluye una aplicación anterior con éxito del mismo plan. Los planes son de un solo uso en la práctica: construye, inspecciona, aplica, y vuelve a construir si necesitas 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 entran directamente en la paleta; en el motor XLSX se convierten en colores indexados personalizados, que HasCustomIndexedColor y CustomIndexedColorCount reportan y que el escritor XLSX emite como un bloque indexedColors

Activarlo para guardados XLS y para conversión de XLSX a XLS

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

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

Los orígenes XLSX toman otra ruta. SaveXLSXWorkbookAsXLS en lxXlsxExport construye un único 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 de auditoría y conversión de workbooks ejecuta exactamente esta ruta. Los colores de tema pasan por el mismo planner una vez su tint se resuelve a RGB; los fills de chart que deberían seguir siendo referencias de tema vivas son un caso aparte, cubierto en el artículo de fills de gráficos con colores de tema GelFrame, donde el XLS binario guarda un índice de esquema en lugar de un color aplanado

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

// Modelo XLSX a BIFF8 con un único 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. La build de prueba de la página del componente Excel HotXLS para Delphi es suficiente para ejecutar un plan contra tu plantilla más colorista y leer MaxDistanceSquared antes de que se escriba nada