Artículo técnico

Paleta de 56 colores BIFF8: mapeo OKLab en HotXLS

HotXLS asigna colores RGB y de tema arbitrarios a la paleta de color BIFF8 de 56 entradas en dos capas: NearestIndexedColor busca en el espacio OKLab la entrada existente de la paleta perceptualmente más cercana, y BuildBiffPalettePlan junto con ApplyBiffPalettePlan reescribe las ranuras libres de la paleta para que un workbook en true color sobreviva a un guardado en XLS clásico. El desencadenante es siempre la misma incidencia de soporte: alguien monta un informe en XLSX con cabeceras azul marino corporativo y un acento verde azulado suave, lo guarda como .xls para un consumidor legado, y las cabeceras vuelven en negro puro mientras el verde azulado se convierte en un turquoise estridente. Nada se ha estrellado y no ha saltado ni un aviso. El modelo de color del formato viejo sencillamente no puede guardar lo que describía el nuevo, y la biblioteca tenía que elegir algo

¿Por qué un archivo XLS solo puede guardar 56 colores?

Porque un formato de celda BIFF8 nunca almacena un valor RGB: las fuentes, los fills y los bordes llevan un índice de color, y el record 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, y los valores por encima de 63 ni siquiera son colores, sino tokens como system foreground, system background y chart text. HotXLS expone la paleta mediante un ColorIndex público de 1 a 56, que es el índice físico menos 7, y ResolveIndexedColor mantiene separados los tres esquemas de numeración a través de TXLSIndexedColorSpace: xicsPublicColorIndex para los valores 1..56 de la API, xicsBiffIcv para los índices crudos en disco, que se validan contra el subconjunto IcvFont, IcvXF o IcvChart según el rol que le pases, y xicsOoxmlIndexed, donde 64 y 65 significan system foreground y system background

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
var
  Res: TXLSIndexedColorResolution;
begin
  // $40 es un token icv de BIFF, no una ranura de paleta
  Workbook.ResolveIndexedColor($40, xicsBiffIcv, Res);
  case Res.Kind of
    xickPalette:   UseArgb(Res.ARGB);   // ranura de paleta, si resuelve
    xickAutomatic,
    xickSystem:    UseSystemColor(Res.SystemColorRole);
    xickInvalid:   RejectToken(Res.RawIndex);
  end;
end;

Fíjate en que el ejemplo conmuta sobre Res.Kind e ignora el valor de retorno booleano. ResolveIndexedColor solo devuelve True cuando ha obtenido un ARGB concreto, y la sobrecarga corta nunca lee el escritorio de Windows, así que un token automatic o system puede legítimamente volver con False siguiendo clasificado como xickSystem. HotXLS se topó con esto en su propio serializador de workbooks: código que interpreta False como «no hay color» tira por la borda en silencio el significado Automatic y System del token. Si necesitas valores RGB reales para esos tokens, llama a la sobrecarga larga y pásale un callback TXLSTryResolveSystemColor que aplique tu propia política de UI, de exportación o headless

¿Por qué HotXLS iguala colores en OKLab y no en RGB?

Porque los valores de canal sRGB vienen con codificación gamma, así que la distancia euclídea en RGB no sigue lo que ve una persona, y el error es peor justo en los tonos oscuros y saturados que adoran las paletas corporativas. Coge el azul oscuro $000033. En RGB la distancia al negro es 51 y la distancia a la entrada navy por defecto, $000080, es 77, de modo que un matcher en RGB te pinta la cabecera de negro con toda la confianza. En OKLab las distancias al cuadrado son alrededor de 0.0312 al negro y 0.0235 al navy, y HotXLS se queda con el navy, ColorIndex 11 en la ranura física 18; ese caso exacto está anclado en la suite de tests tanto para el motor Classic como para el XLSX. La conversión dentro de ArgbToOklab linealiza cada canal sRGB, aplica la matriz LMS de OKLab, saca raíces cúbicas y proyecta sobre L, a y b, y a partir de ahí una distancia euclídea al cuadrado sin más es un proxy razonable de la diferencia percibida. OKLab no es CIEDE2000 ni pretende serlo, pero no tiene correcciones de matiz por tramos, cuesta un puñado de multiplicaciones por color y es lo bastante estable para gobernar un bucle de clustering, que es donde de verdad gana su sitio

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

¿Qué garantiza NearestIndexedColor?

NearestIndexedColor garantiza una respuesta determinista y de solo lectura: una conversión de la entrada, un barrido fijo sobre 56 entradas cacheadas y el índice público más bajo siempre que dos entradas estén igual de cerca. Cada workbook cachea el ARGB normalizado y las coordenadas OKLab de las 56 ranuras físicas junto con un contador de generación de paleta. Un reset de la paleta reconstruye la caché, un cambio en una sola ranura actualiza solo esa ranura, y una consulta contra una generación obsoleta devuelve False en lugar de inventar. El barrido usa una comparación estricta de menor que empezando en la ranura 8, que es por lo que una paleta que contiene el mismo color dos veces responde siempre con el índice más bajo; eso importa cuando comparas dos archivos generados y esperas una salida byte a byte idéntica. El alpha de entrada sigue un contrato estrecho: un byte de alpha a cero se trata como opaco, y un valor parcialmente transparente se rechaza con ColorIndex 0 y PaletteSlot -1, porque las entradas de la paleta no tienen alpha. Los escritores de fill y de borde del motor Classic convierten colores RGB y de tema a un índice con la misma rutina de igualación OKLab en el momento de guardar, así que la API y el archivo almacenado están de acuerdo en la ranura donde aterriza cada color

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;

¿Cómo mete BuildBiffPalettePlan los true colors en 56 ranuras?

BuildBiffPalettePlan calcula una propuesta completa para las 56 ranuras sin tocar el workbook, de modo que puedes inspeccionarla, registrarla o descartarla. El planner llama primero a ScanIndexedColorUsage: toda ranura que una fuente, un fill, un borde, un formato condicional, una forma, un comentario o las líneas de cuadrícula de la hoja referencien por índice queda bloqueada, porque cambiar una entrada de la paleta recolorea de golpe a todos los consumidores de ese índice. Los targets son los colores RGB directos y los colores de tema resueltos de fuentes, fills, bordes, estilos diferenciales, data bars y color scales. Cada target se pondera por el mayor entre su número de referencias renderizadas y su número de definiciones, y un formato condicional cuenta las celdas que cubren sus rangos, así que un color pintado a lo largo de una columna entera pesa más que uno usado en una sola nota. La colocación procede después en un orden fijo:

  • Las ranuras bloqueadas conservan su color de origen incondicionalmente
  • Un target que ya existe en la paleta se retiene en su ranura coincidente más baja y esa ranura pasa a fija
  • Si los targets únicos 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 por frecuencia en OKLab mueven solo los centros libres hasta que las asignaciones dejan de cambiar

Sé honesto contigo mismo acerca de lo que entrega el camino de desbordamiento. El clustering es una optimización local acotada, no un óptimo global, y una ranura libre acaba albergando un centroide convertido de vuelta a sRGB con clamping, que puede ser un color que ninguna celda usó literalmente. Lo que sí obtienes es repetibilidad: el mismo workbook produce siempre el mismo plan, y el plan informa de su propio daño mediante WeightedError, MaxDistanceSquared, ExactTargetWeight y TotalTargetWeight, de modo que un trabajo por lotes puede negarse a guardar cuando la aproximación se queda demasiado basta para una guía de marca

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
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);
  if not Workbook.ApplyBiffPalettePlan(Plan) then
    raise Exception.Create('The palette changed after planning');
end;

¿Cómo rechaza ApplyBiffPalettePlan un plan obsoleto?

ApplyBiffPalettePlan valida 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 también recompueba cada índice público y físico, cada color de origen, que ninguna ranura bloqueada esté marcada como cambiada, los conteos de bloqueadas y cambiadas, y que cada target sea opaco. Cualquier cambio efectivo de la paleta en medio, incluida una aplicación anterior exitosa del mismo plan, deja el plan obsoleto, así que los planes son en la práctica de un solo uso. Un plan válido sin ranuras cambiadas tiene éxito sin avanzar la generación, y un cambio real incrementa la generación una vez y reconstruye el matcher OKLab una vez, en el motor Classic reescribiendo el array fijo de paleta y en el motor XLSX sustituyendo una lista de overrides de color indexado preparada de antemano

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

La propiedad BiffPaletteSavePolicy vale xbpsPreserve por defecto, así que actualizar HotXLS nunca reescribe la paleta de nadie a sus espaldas. Ponerla a xbpsOptimizeTrueColors hace que un workbook Classic construya y aplique un plan fresco dentro de SaveAs, pero 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 exitoso, la paleta optimizada se queda en el modelo del workbook, así que consultas y guardados posteriores ven el mismo mapeo. Si el guardado falla o se cancela, se restauran los 56 colores originales y la generación original. Para orígenes XLSX, SaveXLSXWorkbookAsXLS en lxXlsxExport construye un único plan desde el workbook cargado y lo escribe en la paleta destino antes de convertir cualquier estilo, que es el puente determinista que ejercita la demo de auditoría y conversión de workbooks. Los colores de tema pasan por el mismo planner una vez su tint se resuelve a RGB; si prefieres mantener los temas vivos en los fills de gráficos, el artículo sobre fills de gráficos con colores de tema GelFrame cubre cómo el XLS binario guarda un índice de esquema en lugar de un color aplanado

// Workbook Classic: opt-in, solo BIFF8
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
  HandleSaveFailure;   // 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
    SaveXLSXWorkbookAsXLS(XWorkbook, 'report.xls');
finally
  XWorkbook.Free;
end;

Las APIs de paleta de HotXLS funcionan igual sobre IXLSWorkbook y TXLSXWorkbook, desde Delphi y desde C++Builder por igual. Descarga la versión de prueba y apúntala a tu hoja de cálculo más colorista desde la página del componente Excel HotXLS para Delphi