Artykuł techniczny

Paleta 56 kolorów BIFF8: mapowanie OKLab w HotXLS

HotXLS mapuje dowolne kolory RGB i kolory motywu na 56-slotową paletę barw BIFF8 w dwóch warstwach: NearestIndexedColor znajduje w przestrzeni OKLab percepcyjnie najbliższy istniejący wpis palety, a BuildBiffPalettePlan z ApplyBiffPalettePlan przepisuje wolne sloty palety, żeby skoroszyt z kolorami true color przeżył zapis do klasycznego XLS. Powodem jest zawsze ten sam ticket supportowy. Ktoś składa raport w XLSX z granatowymi nagłówkami zgodnymi z identyfikacją wizualną firmy i miękkim morskim akcentem, zapisuje go jako .xls dla klienta z legacy stackiem, a nagłówki wracają czystą czernią, a morski zamienia się w krzykliwy turkus. Nic się nie wysypało i żaden warning się nie zapalił. Model kolorów starego formatu po prostu nie mieści tego, co opisał nowy, a biblioteka musiała coś wybrać

Dlaczego plik XLS mieści tylko 56 kolorów?

Bo format komórki BIFF8 nigdy nie zapisuje wartości RGB: czcionki, wypełnienia i obramowania niosą indeks koloru, a globalny dla skoroszytu rekord Palette ($0092, [MS-XLS] §2.4.188) dostarcza dokładnie 56 nieprzezroczystych wpisów RGB dla indeksów od 8 do 63. Indeksy od 0 do 7 to stałe kopie ośmiu kolorów podstawowych, a wartości powyżej 63 w ogóle nie są kolorami, tylko tokenami takimi jak system foreground, system background i tekst wykresu. HotXLS wystawia paletę przez publiczny ColorIndex od 1 do 56 — to indeks fizyczny minus 7 — a ResolveIndexedColor rozdziela trzy schematy numeracji przez TXLSIndexedColorSpace: xicsPublicColorIndex dla wartości API 1..56, xicsBiffIcv dla surowych indeksów z dysku, walidowanych względem podzbioru IcvFont, IcvXF albo IcvChart zależnie od przekazanej roli, oraz xicsOoxmlIndexed, gdzie 64 i 65 znaczą system foreground i background

HotXLS rozdziela trzy schematy kolorów indeksowanych przez TXLSIndexedColorSpace: surowe wartości icv BIFF, od 0 do 7 przypięte do ośmiu kolorów podstawowych, 56 slotów palety od 8 do 63 w rekordzie Palette $0092, tokeny powyżej 63 jak system foreground, publiczny ColorIndex od 1 do 56 z przesunięciem minus 7 oraz xicsOoxmlIndexed, gdzie 64 i 65 znaczą system foreground i background
Ten sam indeks koloru oznacza w każdym schemacie inne liczby, więc HotXLS przepuszcza każdą wartość przez ResolveIndexedColor, zamiast pozwolić, by surowy token BIFF udawał publiczny ColorIndex
var
  Res: TXLSIndexedColorResolution;
begin
  // $40 to token icv BIFF, a nie slot palety
  Workbook.ResolveIndexedColor($40, xicsBiffIcv, Res);
  case Res.Kind of
    xickPalette:   UseArgb(Res.ARGB);   // slot palety, jeśli się rozwiąże
    xickAutomatic,
    xickSystem:    UseSystemColor(Res.SystemColorRole);
    xickInvalid:   RejectToken(Res.RawIndex);
  end;
end;

Zwróć uwagę, że przykład rozgałęzia się po Res.Kind i ignoruje zwracaną wartość Boolean. ResolveIndexedColor zwraca True tylko wtedy, gdy zdobył konkretny ARGB, a krótki overload nigdy nie czyta pulpitu Windows, więc token automatic albo system legalnie wraca jako False, choć wciąż jest klasyfikowany jako xickSystem. HotXLS sam się na tym przejechał we własnym serializatorze skoroszytów: kod traktujący False jako „brak koloru” cicho wyrzuca znaczenie Automatic i System tokenu. Jeśli potrzebujesz prawdziwych wartości RGB dla tych tokenów, wołaj dłuższy overload i przekaż callback TXLSTryResolveSystemColor, który zastosuje twoją własną politykę UI, eksportu albo pracy headless

Dlaczego HotXLS dopasowuje kolory w OKLab, a nie w RGB?

Bo wartości kanałów sRGB są zapisane z gamma-korekcją, więc odległość euklidesowa w RGB nie idzie za tym, co widzi człowiek, a błąd jest największy dokładnie w tych ciemnych, nasyconych tonach, które firmowe palety uwielbiają. Weź ciemny niebieski $000033. W RGB odległość do czerni to 51, a do domyślnego wpisu granatowego $000080 — 77, więc dopasowanie po RGB z pełnym przekonaniem pomaluje twój nagłówek na czarno. W OKLab kwadraty odległości to około 0,0312 do czerni i 0,0235 do granatu, a HotXLS wybiera granat, ColorIndex 11 przy fizycznym slocie 18; dokładnie ten przypadek jest przypięty w testach dla silnika Classic i XLSX. Konwersja w ArgbToOklab linearyzuje każdy kanał sRGB, nakłada macierz LMS z OKLab, wyciąga pierwiastki sześcienne i rzutuje na L, a i b, po czym zwykły kwadrat odległości euklidesowej jest sensownym przybliżeniem różnicy percepcyjnej. OKLab to nie CIEDE2000 i nie udaje, że jest, ale nie ma kawałkowych poprawek odcienia, kosztuje garść mnożeń na kolor i jest na tyle stabilny, żeby napędzać pętlę klastrowania — i właśnie tam naprawdę się broni

Jak HotXLS dopasowuje ciemny niebieski $000033 do palety: odległość euklidesowa w gamma-zakodowanym RGB wynosi 51 do czerni i 77 do granatu i pomalowałaby nagłówek na czarno, podczas gdy kwadraty odległości z ArgbToOklab wynoszące 0,0312 i 0,0235 pozwalają NearestIndexedColor wybrać granat, ColorIndex 11 przy fizycznym slocie 18
Gamma-zakodowane wartości kanałów czynią odległość w RGB słabym przybliżeniem tego, co widzi człowiek, więc HotXLS konwertuje raz do OKLab i pozwala, by zwykłe porównanie kwadratów odległości euklidesowej napędzało skan po palet

Co gwarantuje NearestIndexedColor?

NearestIndexedColor gwarantuje deterministyczną, tylko do odczytu odpowiedź: jedną konwersję wejścia, jeden stały skan po 56 zcache’owanych wpisach i najniższy publiczny indeks, gdy dwa wpisy są równie blisko. Każdy skoroszyt cache’uje znormalizowany ARGB i współrzędne OKLab wszystkich 56 slotów fizycznych razem z licznikiem generacji palety. Reset palety przebudowuje cache, zmiana pojedynczego slotu aktualizuje tylko ten slot, a zapytanie o nieaktualną generację zwraca False zamiast zgadywać. Skan używa ścisłego porównania „mniejsze niż” startując od slotu 8, dlatego paleta zawierająca ten sam kolor dwa razy zawsze odpowiada niższym indeksem; to istotne, gdy diffujesz dwa wygenerowane pliki i oczekujesz wyjścia identycznego bajt po bajcie. Alfa wejściowa podlega wąskiemu kontraktowi: zerowy bajt alfy jest traktowany jako pełne krycie, a częściowo przezroczysta wartość jest odrzucana z ColorIndex 0 i PaletteSlot -1, bo wpisy palety nie mają alfy. Zapisujący wypełnienia i obramowania silnika Classic konwertują przy zapisie kolory RGB i motywu na indeks tą samą rutyną dopasowania OKLab, więc API i zapisany plik zgadzają się co do tego, w który slot trafi kolor

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

Jak BuildBiffPalettePlan mieści kolory true color w 56 slotach?

BuildBiffPalettePlan wylicza kompletną propozycję dla wszystkich 56 slotów, nie dotykając skoroszytu, więc możesz ją obejrzeć, zalogować albo wyrzucić. Planer najpierw woła ScanIndexedColorUsage: każdy slot, na który czcionka, wypełnienie, obramowanie, formatowanie warunkowe, kształt, komentarz albo linie siatki arkusza powołują się indeksem, jest zablokowany, bo zmiana wpisu palety przemalowuje naraz każdego konsumenta tego indeksu. Celami są bezpośrednie kolory RGB i rozwiązane kolory motywu z czcionek, wypełnień, obramowań, stylów różnicowych, data bars i color scales. Każdy cel dostaje wagę równą większej z dwóch liczb: referencji w renderowanej treści i liczby definicji, a formatowanie warunkowe liczy komórki pokrywane przez swoje zakresy, więc kolor rozlany na całą kolumnę bije kolor użyty w jednej notatce. Rozmieszczenie idzie potem w ustalonej kolejności:

  • Zablokowane sloty bezwarunkowo zachowują swój kolor źródłowy
  • Cel, który już istnieje w palecie, zostaje na najniższym pasującym slocie, a ten slot staje się stały
  • Jeśli pozostałe unikalne cele mieszczą się w wolnych slotach, każdy dostaje dokładny slot, przydzielany w rosnącej kolejności ARGB
  • W przeciwnym razie ustawiane jest Quantized, każdy wolny slot jest zasiewany celem, którego odległość do najbliższego istniejącego centrum pomnożona przez wagę jest największa, a do 16 rund ważonych częstością k-means w OKLab przesuwa wyłącznie wolne centra, aż przypisania przestaną się zmieniać

Bądź szczery wobec siebie co do tego, co daje ścieżka przepełnienia. Klastrowanie to ograniczona optymalizacja lokalna, nie optimum globalne, a wolny slot kończy z centroidem przekonwertowanym z powrotem do sRGB z przycięciem do zakresu, czyli być może z kolorem, którego żadna komórka nie użyła dosłownie. To, co naprawdę dostajesz, to powtarzalność: ten sam skoroszyt zawsze daje ten sam plan, a plan raportuje własne szkody przez WeightedError, MaxDistanceSquared, ExactTargetWeight i TotalTargetWeight, więc zadanie wsadowe może odmówić zapisu, gdy przybliżenie robi się zbyt grube jak na wytyczne marki

Potok palety HotXLS dla skoroszytu z kolorami true color: ScanIndexedColorUsage blokuje każdy slot, na który czcionka, wypełnienie, obramowanie, formatowanie warunkowe, kształt, komentarz albo linia siatki powołuje się indeksem, BuildBiffPalettePlan rozmieszcza dokładne kolory w rosnącej kolejności ARGB albo uruchamia do 16 rund ważonych częstością k-means w OKLab, a ApplyBiffPalettePlan waliduje generację i hash FNV-1a przed zapisem
Planowanie jest tylko do odczytu i powtarzalne, plan raportuje własne szkody przez WeightedError i MaxDistanceSquared, a nieaktualny plan jest odrzucany z nietkniętą paletą, bo plany są w praktyce do jednorazowego użycia
var
  Plan: TXLSBiffPalettePlan;
  I: Integer;
begin
  Plan := Workbook.BuildBiffPalettePlan;   // tylko do odczytu
  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;

Jak ApplyBiffPalettePlan odrzuca nieaktualny plan?

ApplyBiffPalettePlan waliduje cały plan, zanim zapisze choć jeden slot, i zwraca False z nietkniętą paletą, gdy cokolwiek rozmija się z bieżącym skoroszytem. Plan niesie SourcePaletteGeneration i SourcePaletteHash, 64-bitowy hash FNV-1a po 56 kolorach źródłowych; walidacja sprawdza też od nowa każdy indeks publiczny i fizyczny, każdy kolor źródłowy, to, że żaden zablokowany slot nie jest oznaczony jako zmieniony, liczniki slotów zablokowanych i zmienionych oraz to, że każdy cel ma pełne krycie. Jakakolwiek faktyczna zmiana palety po drodze, w tym wcześniejsze udane zastosowanie tego samego planu, czyni plan nieaktualnym, więc plany są w praktyce jednorazowe. Poprawny plan bez zmienionych slotów przechodzi bez podbijania generacji, a prawdziwa zmiana podbija generację raz i raz przebudowuje matcher OKLab — w silniku Classic przez przepisanie stałej tablicy palety, a w silniku XLSX przez podmianę na przygotowaną listę nadpisań kolorów indeksowanych

Włączanie tego przy zapisach BIFF8 i konwersji XLSX na XLS

Właściwość BiffPaletteSavePolicy ma domyślnie wartość xbpsPreserve, więc aktualizacja HotXLS nigdy nikomu nie przepisze palety za plecami. Ustawienie jej na xbpsOptimizeTrueColors każe skoroszytowi Classic zbudować i zastosować świeży plan wewnątrz SaveAs, ale tylko gdy format docelowy to xlExcel97; BIFF5, CSV, HTML, PDF, XLSX i pozostałe zapisujące ignorują to ustawienie. Po udanym zapisie zoptymalizowana paleta zostaje w modelu skoroszytu, więc późniejsze zapytania i zapisy widzą to samo mapowanie. Gdy zapis się nie powiedzie albo zostanie anulowany, oryginalne 56 kolorów i oryginalna generacja wracają na miejsce. Dla źródeł XLSX SaveXLSXWorkbookAsXLS w lxXlsxExport buduje jeden plan z wczytanego skoroszytu i wpisuje go do palety docelowej, zanim jakikolwiek styl zostanie skonwertowany — to deterministyczny most, który ćwiczy demo audytu i konwersji skoroszytów. Kolory motywu przechodzą przez tego samego planera po rozwiązaniu ich tinty do RGB; jeśli wolisz trzymać motywy żywe w wypełnieniach wykresów, artykuł o wypełnieniach wykresów GelFrame z kolorami motywu wyjaśnia, jak binarny XLS zapisuje indeks schematu zamiast spłaszczonego koloru

// Skoroszyt Classic: opt-in, wyłącznie BIFF8
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
  HandleSaveFailure;   // paleta już przywrócona

// Model XLSX do BIFF8 z jednym deterministycznym planem palety
XWorkbook := TXLSXWorkbook.Create;
try
  if XWorkbook.Open('report.xlsx') = 1 then
    SaveXLSXWorkbookAsXLS(XWorkbook, 'report.xls');
finally
  XWorkbook.Free;
end;

API palety HotXLS działają tak samo na IXLSWorkbook i TXLSXWorkbook, zarówno z Delphi, jak i z C++Buildera. Pobierz wersję próbną i skieruj ją na swój najbardziej kolorowy arkusz ze strony komponentu Excel HotXLS dla Delphi