Articolo tecnico

Palette BIFF8 a 56 colori: mappatura OKLab in HotXLS

Il blu scuro $000033 sta a 51 unità dal nero e a 77 unità dalla classica voce navy $000080 quando la distanza viene misurata come semplice distanza euclidea sui valori dei canali RGB. Un convertitore che scrive file .xls classici con matching nearest-RGB quindi dipinge di nero una riga di intestazione blu scuro, e il nero è quello che mostra ogni reader che si basa sul solo indice di palette. Il formato non lascia allo scrittore alcun margine: un formato cella memorizza un indice di palette, e la palette contiene 56 voci. Ottenere l'intestazione giusta richiede due cose, una misura di distanza che segua ciò che le persone vedono, e un modo per riscrivere la palette stessa quando un workbook usa più colori distinti di quanti la palette possa contenere

Da dove viene il limite dei 56 colori

Un record BIFF8 di font, fill o bordo non porta mai un valore RGB. Porta un indice di colore, e il record Palette globale del workbook ($0092, [MS-XLS] §2.4.188) fornisce esattamente 56 voci RGB opache per gli indici da 8 a 63. Gli indici da 0 a 7 sono copie fisse degli otto colori base. I valori sopra 63 non sono affatto colori ma token: $40 è il system foreground, $41 il system background, da $4D a $4F sono testo del grafico, sfondo del grafico e il colore neutro del grafico, e $7FFF significa automatic. Excel 2007 aggiunse il record XFExt ($087D, §2.4.355), che può attaccare un RGB pieno o un colore del tema a un formato cella, ma Excel 97–2003 e qualsiasi parser che legge solo il record XF continuano a non vedere nulla oltre l'indice

HotXLS espone la palette tramite un ColorIndex pubblico da 1 a 56, che è l'indice fisico meno 7. Tre schemi di numerazione sono in gioco, e ResolveIndexedColor li tiene separati tramite TXLSIndexedColorSpace: xicsPublicColorIndex per i valori API 1..56, xicsBiffIcv per gli indici grezzi su disco, e xicsOoxmlIndexed, dove 64 e 65 significano system foreground e system background. Gli indici BIFF grezzi vengono anche controllati contro il ruolo che passate come TXLSIndexedColorRole, perché un font, un fill e un grafico accettano insiemi di token diversi; $51 è legale per un font e invalido per un fill

HotXLS tiene separati i tre schemi di colore indicizzato tramite TXLSIndexedColorSpace: valori icv BIFF grezzi, da 0 a 7 fissati sugli otto colori base, i 56 slot di palette da 8 a 63 del record Palette $0092, token oltre 63 come system foreground, ColorIndex pubblico da 1 a 56 con offset meno 7, e xicsOoxmlIndexed dove 64 e 65 significano system foreground e system background
Lo stesso indice di colore significa numeri diversi in ciascuno schema, così HotXLS instrada ogni valore tramite ResolveIndexedColor invece di lasciare che un token BIFF grezzo si spacci per un ColorIndex pubblico

Il risultato booleano di ResolveIndexedColor è dove i chiamanti inciampano. Restituisce True solo quando ha ottenuto un valore ARGB concreto, e l'overload corto non chiede mai al sistema operativo i colori del desktop. Un token system o automatic quindi torna False con Res.Kind impostato a xickSystem o xickAutomatic, e il codice che legge False come "nessun colore" scarta in silenzio il significato del token. L'unica eccezione è $4F, il colore neutro del grafico, che si risolve da solo in nero. Per valori RGB reali sugli altri token, passate un metodo TXLSTryResolveSystemColor all'overload lungo e applicate la vostra policy per la UI, un export o un servizio 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: colori fissi invece del tema corrente del desktop
  Result := True;
  case AColor of
    xscrWindowText, xscrChartText, xscrToolTipText:
      AArgb := $FF000000;
    xscrWindowBackground, xscrChartBackground:
      AArgb := $FFFFFFFF;
  else
    Result := False;
  end;
end;

var
  Colors: TExportColors;   // creato una volta dal vostro codice di export
  Res: TXLSIndexedColorResolution;
begin
  // $41 è il token system background, valido per un fill
  if Workbook.ResolveIndexedColor($41, xicsBiffIcv, xicrFillBackground,
    Colors.ResolveSystemColor, Res) then
    PaintBackground(Res.ARGB)        // la vostra routine di disegno
  else if Res.Kind = xickInvalid then
    RejectToken(Res.RawIndex);       // la vostra gestione degli errori
end;

Perché la distanza RGB sceglie la voce di palette sbagliata?

I valori dei canali sRGB sono gamma-encoded, quindi passi uguali nei numeri non sono passi uguali in ciò che una persona vede, e l'errore è massimo nei toni scuri e saturi che i template aziendali adorano. In RGB, $000033 sta a 51 dal nero e a 77 dal navy, quindi vince il nero. Una volta che ogni canale è linearizzato e il colore è proiettato in OKLab, le distanze quadrate sono circa 0.0312 verso il nero e 0.0235 verso il navy, e vince il navy. Il match è ColorIndex 11 allo slot fisico 18, e gli engine Classic e XLSX danno la stessa risposta

Come HotXLS abbina il blu scuro $000033 alla palette: la distanza euclidea in RGB gamma-encoded misura 51 verso il nero e 77 verso il navy e dipingerebbe l'intestazione di nero, mentre le distanze quadrate di ArgbToOklab pari a 0.0312 e 0.0235 fanno scegliere a NearestIndexedColor il navy, ColorIndex 11 allo slot fisico 18
I valori di canale gamma-encoded rendono la distanza RGB un pessimo proxy di ciò che un occhio vede, così HotXLS converte una volta sola in OKLab e lascia che un semplice confronto euclideo al quadrato guidi la scansione della palette

La palette di default aggiunge un dettaglio insidioso. Il navy compare due volte, al ColorIndex 11 e di nuovo al 25, quindi $000033 è esattamente alla stessa distanza da entrambi. Un matcher che scansione in un ordine diverso, o usa less-than-or-equal, sceglie 25 in una esecuzione e 11 in un'altra build, e due file generati smettono di fare diff puliti. HotXLS scandisce dallo slot 8 verso l'alto con un confronto less-than rigoroso, così un pareggio va sempre all'indice più basso

OKLab è una scelta deliberata. CIEDE2000 ordinerebbe alcune coppie di tinte con più precisione, ma le sue correzioni a tratti lo rendono lento e più difficile da ragionare dentro un loop iterativo. OKLab costa una linearizzazione, due piccoli prodotti di matrici e tre radici cubiche per colore, dopodiché una semplice distanza al quadrato è un sostituto abbastanza buono della differenza percepita. Per una scansione della palette a 56 voci che gira a ogni assegnazione di colore e dentro un passaggio di clustering, la metrica più economica e più regolare è la scelta giusta

Cosa restituisce NearestIndexedColor?

NearestIndexedColor prende un valore ARGB e riempie un record TXLSNearestIndexedColorMatch senza toccare il workbook. Ogni workbook tiene insieme i 56 colori di palette con le loro coordinate OKLab e un contatore di generazione della palette, quindi una query costa una conversione dell'input e una scansione fissa. Un reset della palette ricostruisce quella cache, la modifica di una sola voce aggiorna solo quello slot, e una query contro una cache fuori passo con la generazione corrente restituisce False invece di tirare a indovinare

L'alpha segue una regola stretta. Un byte alpha pari a zero viene trattato come opaco, il che corrisponde a come la maggior parte del codice Delphi passa valori RGB semplici, e $FF è opaco per definizione. Tutto ciò che sta in mezzo viene rifiutato: la funzione restituisce False con ColorIndex 0 e PaletteSlot -1, visto che una voce di palette non ha dove memorizzare la trasparenza

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

Lo stesso matcher sta dietro ai setter RGB. Assegnare Interior.Color, Font.Color o Border.Color su un workbook Classic conserva il colore esatto per il record XFExt e sceglie con questa scansione l'indice memorizzato nel record XF, e i writer di fill e bordi risolvono colori RGB e del tema in un indice allo stesso modo. Ciò che NearestIndexedColor riporta per un colore è lo slot che mostrerà un reader basato sul solo indice

Far stare un workbook true-color in 56 slot

Il matching contro la palette di default va bene per una manciata di colori. Un template di brand con una dozzina di blu quasi identici ha bisogno di più: la palette stessa deve cambiare. BuildBiffPalettePlan calcola una proposta completa per tutti i 56 slot e lascia il workbook intatto, così potete loggarla, confrontarla con una soglia o buttarla via

Il planner parte con ScanIndexedColorUsage. Ogni slot referenziato per indice da un font, un fill, un bordo, una formattazione condizionale, una forma, un commento, un font di grafico o una gridline del foglio di lavoro viene bloccato, perché cambiare quella voce ricolorerebbe in una volta tutti i consumatori dell'indice. I target sono i colori RGB diretti e i colori del tema risolti provenienti da font, fill, stop dei gradienti, bordi, stili di formattazione condizionale, data bar e color scale. Ogni target è pesato col maggiore tra il conteggio dei riferimenti e quello delle definizioni, e una formattazione condizionale conta ogni cella che i suoi range coprono, quindi un fill steso su un'intera colonna pesa più di un colore usato in un solo commento. Il posizionamento poi segue un ordine fisso:

  • Gli slot bloccati conservano il loro colore attuale incondizionatamente
  • Un target già presente nella palette conserva il suo slot corrispondente più basso, e quello slot diventa fisso
  • Se i target distinti rimanenti stanno negli slot liberi, ognuno ottiene uno slot esatto, assegnato in ordine ARGB crescente
  • Altrimenti viene impostato Quantized, ogni slot libero viene inizializzato col target la cui distanza dal centro esistente più vicino, moltiplicata per il suo peso, è massima, e fino a 16 round di k-means pesati in OKLab spostano solo i centri liberi finché le assegnazioni smettono di cambiare
La pipeline della palette HotXLS per un workbook true-color: ScanIndexedColorUsage blocca ogni slot referenziato da font, fill, bordo, formattazione condizionale, forma, commento o gridline, BuildBiffPalettePlan colloca i colori esatti in ordine ARGB crescente o esegue fino a 16 round di k-means pesati per frequenza in OKLab, e ApplyBiffPalettePlan valida generazione e hash FNV-1a prima di scrivere
La pianificazione è read-only e ripetibile, il piano riporta i propri danni tramite WeightedError e MaxDistanceSquared, e un piano non aggiornato viene rifiutato con la palette intatta perché i piani sono di fatto monouso

Il percorso di overflow merita una lettura a occhi aperti. Il k-means qui è un'ottimizzazione locale limitata, e uno slot libero finisce per ospitare un centro di cluster riconvertito in sRGB con clamping, che può essere un colore che nessuna cella ha usato alla lettera. Ciò che il planner consegna davvero è la ripetibilità: lo stesso workbook produce sempre lo stesso piano. Il piano riporta anche quanto ha dovuto piegarsi tramite WeightedError, MaxDistanceSquared, ExactTargetWeight e TotalTargetWeight, il che basta a un job batch per rifiutare un salvataggio quando l'approssimazione diventa troppo grossolana per una brand guideline. L'array Targets mostra, per ogni colore di input, lo slot a cui mappa e la sua distanza al quadrato

const
  MaxAcceptedError = 0.002;   // la vostra soglia, in unità OKLab al quadrato
var
  Plan: TXLSBiffPalettePlan;
  I: Integer;
begin
  Plan := Workbook.BuildBiffPalettePlan;   // read-only
  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);           // il vostro logger
  if not Workbook.ApplyBiffPalettePlan(Plan) then
    raise Exception.Create('The palette changed after planning');
end;

Quando ApplyBiffPalettePlan rifiuta un piano?

ApplyBiffPalettePlan controlla l'intero piano prima di scrivere un solo slot e restituisce False, palette intatta, se qualcosa non quadra col workbook corrente. Il piano porta con sé SourcePaletteGeneration e SourcePaletteHash, un hash FNV-1a a 64 bit sui 56 colori sorgente. La validazione riverifica anche ogni indice pubblico e fisico, ogni colore sorgente, che nessuno slot bloccato sia segnato come modificato, che i conteggi di bloccati e modificati tornino, e che ogni target sia opaco

Qualsiasi modifica reale alla palette tra pianificazione e applicazione rende il piano stantio, e questo include una precedente applicazione riuscita dello stesso piano. I piani sono di fatto monouso: costruite, ispezionate, applicate, e ricostruite se vi serve un altro. Un piano valido senza slot modificati riesce senza far avanzare la generazione. Una modifica reale la fa avanzare una volta e aggiorna una volta il matcher. Sull'engine Classic i nuovi colori finiscono direttamente nella palette; su quello XLSX diventano colori indicizzati personalizzati, che HasCustomIndexedColor e CustomIndexedColorCount riportano e che il writer XLSX emette come blocco indexedColors

Attivazione per i salvataggi XLS e la conversione da XLSX a XLS

La proprietà BiffPaletteSavePolicy vale xbpsPreserve per impostazione predefinita, quindi la palette di un workbook cambia solo su richiesta. Impostare xbpsOptimizeTrueColors fa sì che un workbook Classic costruisca e applichi un piano fresco dentro SaveAs, e solo quando il formato di destinazione è xlExcel97; i writer BIFF5, CSV, HTML, PDF, XLSX e gli altri ignorano l'impostazione. Dopo un salvataggio riuscito la palette ottimizzata resta nel workbook, quindi query e salvataggi successivi vedono la stessa mappatura. Se il salvataggio fallisce, viene annullato o solleva, i 56 colori originali e la generazione originale vengono ripristinati

Per i report generati, attivate la policy di ottimizzazione. Quei workbook sono costruiti da colori RGB e del tema, nessuno ha tarato a mano la loro palette, e un reader basato sul solo indice li mostra molto più vicini all'originale con essa. Tenete xbpsPreserve per i workbook la cui palette fa parte del contratto, come un template che codice a valle modifica per ColorIndex, dove uno slot che cambia colore in silenzio romperebbe quel codice

Le sorgenti XLSX prendono una strada diversa. SaveXLSXWorkbookAsXLS in lxXlsxExport costruisce un unico piano dal TXLSXWorkbook caricato e lo scrive nella palette di destinazione prima di convertire qualsiasi stile, così ogni cella convertita viene confrontata con la palette finale. La demo del workbench di audit e conversione dei workbook gira esattamente questo percorso. I colori del tema passano per lo stesso planner una volta che il loro tint è stato risolto in RGB; i fill dei grafici che devono restare riferimenti vivi al tema sono un caso a parte, trattato nell'articolo sui fill dei grafici con i colori del tema GelFrame, dove l'XLS binario memorizza un indice di schema invece di un colore appiattito

// Workbook Classic: opt in, vale solo per i salvataggi BIFF8
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
  HandleSaveFailure;   // il vostro handler; palette già ripristinata

// modello XLSX verso BIFF8 con un unico piano di palette deterministico
XWorkbook := TXLSXWorkbook.Create;
try
  if XWorkbook.Open('report.xlsx') = 1 then
    if SaveXLSXWorkbookAsXLS(XWorkbook, 'report.xls') <> 1 then
      HandleSaveFailure;
finally
  XWorkbook.Free;
end;

Le chiamate della palette (ResolveIndexedColor, NearestIndexedColor, ScanIndexedColorUsage, BuildBiffPalettePlan e ApplyBiffPalettePlan) hanno le stesse firme su IXLSWorkbook e TXLSXWorkbook, da Delphi e C++Builder. La build di prova sulla pagina del componente Excel HotXLS per Delphi basta per girare un piano contro il vostro template più colorato e leggere MaxDistanceSquared prima che qualsiasi cosa venga scritta