Artykuł techniczny

Paleta 56 kolorów BIFF8: mapowanie OKLab w HotXLS

Ciemny niebieski $000033 leży 51 jednostek od czerni i 77 jednostek od klasycznego granatu $000080, gdy odległość mierzy się zwykłą odległością euklidesową na wartościach kanałów RGB. Konwerter zapisujący klasyczne pliki .xls z dopasowaniem najbliższego RGB maluje więc ciemnoniebieski wiersz nagłówka na czarno, i czerń pokazuje każdy czytnik, który kieruje się samym indeksem palety. Format nie zostawia zapisującemu pola manewru: format komórki przechowuje indeks palety, a paleta ma 56 wpisów. Żeby nagłówek wyszedł dobrze, potrzeba dwóch rzeczy: miary odległości, która śledzi to, co ludzie widzą, i sposobu na przepisanie samej palety, gdy skoroszyt używa więcej odrębnych kolorów, niż paleta pomieści

Skąd bierze się limit 56 kolorów

Rekord fontu, wypełnienia ani obramowania BIFF8 nigdy nie niesie wartości RGB. Niesie 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. Wartości powyżej 63 to wcale nie kolory, lecz tokeny: $40 to systemowy kolor pierwszego planu, $41 systemowe tło, $4D do $4F to tekst wykresu, tło wykresu i neutralny kolor wykresu, a $7FFF oznacza automatyczny. Excel 2007 dodał rekord XFExt ($087D, §2.4.355), który może dołączyć do formatu komórki pełny kolor RGB lub motywu, ale Excel 97–2003 i każdy parser czytający tylko rekord XF nadal nie widzą nic poza indeksem

HotXLS wystawia paletę przez publiczny ColorIndex od 1 do 56, czyli indeks fizyczny minus 7. W grze są trzy schematy numeracji, a ResolveIndexedColor rozdziela je przez TXLSIndexedColorSpace: xicsPublicColorIndex dla wartości API 1..56, xicsBiffIcv dla surowych indeksów z dysku i xicsOoxmlIndexed, gdzie 64 i 65 oznaczają systemowy pierwszy plan i tło. Surowe indeksy BIFF są też sprawdzane względem roli, którą przekazujesz jako TXLSIndexedColorRole, bo font, wypełnienie i wykres przyjmują różne zestawy tokenów; $51 jest legalne dla fontu i nieprawidłowe dla wypełnienia

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

Wynik Boolean z ResolveIndexedColor to miejsce, w którym wywołujący się potykają. Zwraca True tylko wtedy, gdy uzyskał konkretną wartość ARGB, a krótkie przeciążenie nigdy nie pyta systemu operacyjnego o kolory pulpitu. Token systemowy lub automatyczny wraca więc jako False z Res.Kind ustawionym na xickSystem lub xickAutomatic, a kod czytający False jako "brak koloru" po cichu wyrzuca znaczenie tokenu. Jedyny wyjątek to $4F, neutralny kolor wykresu, który rozwiązuje się do czerni bez pomocy. Dla prawdziwych wartości RGB pozostałych tokenów przekaż metodę TXLSTryResolveSystemColor do długiego przeciążenia i zastosuj własną politykę dla UI, eksportu lub usługi bez interfejsu:

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
  // Eksport bez interfejsu: stałe kolory zamiast bieżącego motywu pulpitu
  Result := True;
  case AColor of
    xscrWindowText, xscrChartText, xscrToolTipText:
      AArgb := $FF000000;
    xscrWindowBackground, xscrChartBackground:
      AArgb := $FFFFFFFF;
  else
    Result := False;
  end;
end;

var
  Colors: TExportColors;   // tworzone raz przez twój kod eksportu
  Res: TXLSIndexedColorResolution;
begin
  // $41 to token systemowego tła, prawidłowy dla wypełnienia
  if Workbook.ResolveIndexedColor($41, xicsBiffIcv, xicrFillBackground,
    Colors.ResolveSystemColor, Res) then
    PaintBackground(Res.ARGB)        // twoja własna procedura rysowania
  else if Res.Kind = xickInvalid then
    RejectToken(Res.RawIndex);       // twoja własna obsługa błędów
end;

Dlaczego odległość RGB wybiera zły wpis palety?

Wartości kanałów sRGB są zakodowane gammą, więc równe kroki w liczbach nie są równymi krokami w tym, co widzi człowiek, a błąd jest największy w ciemnych, nasyconych tonach, które uwielbiają firmowe szablony. W RGB $000033 leży 51 od czerni i 77 od granatu, więc wygrywa czerń. Gdy każdy kanał zostanie zlinearyzowany, a kolor rzutowany do OKLab, kwadraty odległości wynoszą około 0,0312 do czerni i 0,0235 do granatu, i wygrywa granat. Dopasowanie to ColorIndex 11 przy fizycznym slocie 18, a silniki Classic i XLSX dają tę samą odpowiedź

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 palecie

Domyślna paleta dokłada jeszcze jeden haczyk. Granat występuje dwa razy, pod ColorIndex 11 i ponownie pod 25, więc $000033 leży dokładnie tak samo blisko obu. Matcher skanujący w innej kolejności albo używający porównania mniejsze-lub-równe wybiera 25 w jednym przebiegu i 11 w innym buildzie, i dwa wygenerowane pliki przestają się czysto diffować. HotXLS skanuje od slotu 8 w górę ze ścisłym porównaniem mniejsze-niż, więc remis zawsze idzie do niższego indeksu

OKLab to świadomy kompromis. CIEDE2000 uszeregowałby niektóre pary odcieni dokładniej, ale jego kawałkowe poprawki czynią go wolnym i trudniejszym do rozumowania wewnątrz iteracyjnej pętli. OKLab kosztuje linearyzację, dwa małe iloczyny macierzowe i trzy pierwiastki sześcienne na kolor, po czym zwykły kwadrat odległości jest wystarczająco dobrym zamiennikiem postrzeganej różnicy. Dla skanu 56-wpisowej palety, który biegnie przy każdym przypisaniu koloru i wewnątrz przebiegu klasteryzacji, tańsza, gładsza metryka to właściwy wybór

Co zwraca NearestIndexedColor?

NearestIndexedColor przyjmuje wartość ARGB i wypełnia rekord TXLSNearestIndexedColorMatch, nie zmieniając skoroszytu. Każdy skoroszyt trzyma 56 kolorów palety razem z ich współrzędnymi OKLab i licznikiem generacji palety, więc zapytanie kosztuje jedną konwersję wejścia i jeden stały skan. Zresetowanie palety przebudowuje ten cache, zmiana pojedynczego wpisu aktualizuje tylko ten slot, a zapytanie do cache niezgodnego z bieżącą generacją zwraca False, zamiast zgadywać

Alfa podlega wąskiej regule. Bajt alfa równy zero jest traktowany jako nieprzezroczysty, co odpowiada temu, jak większość kodu Delphi przekazuje zwykłe wartości RGB, a $FF jest nieprzezroczysty z definicji. Cokolwiek pomiędzy jest odrzucane: funkcja zwraca False z ColorIndex 0 i PaletteSlot -1, bo wpis palety nie ma gdzie przechować przezroczystości

var
  Match: TXLSNearestIndexedColorMatch;
begin
  if Workbook.NearestIndexedColor($FF000033, Match) then
  begin
    // Domyślna paleta: Match.ColorIndex = 11, Match.PaletteSlot = 18,
    // Match.ARGB = $FF000080
    if not Match.ExactMatch then
      LogApproximation(Match.InputARGB, Match.ARGB,   // twój własny logger
        Match.DistanceSquared);
  end;
end;

Ten sam matcher siedzi za setterami RGB. Przypisanie Interior.Color, Font.Color lub Border.Color na skoroszycie Classic zachowuje dokładny kolor dla rekordu XFExt i wybiera tym skanem indeks zapisywany w rekordzie XF, a zapisujące wypełnienia i obramowania rozwiązują kolory RGB i motywu do indeksu w ten sam sposób. Cokolwiek NearestIndexedColor zgłosi dla koloru, to slot, który pokaże czytnik oparty wyłącznie na indeksie

Mieszczenie skoroszytu true color w 56 slotach

Dopasowanie do domyślnej palety wystarcza dla garści kolorów. Szablon marki z tuzinem niemal identycznych błękitów potrzebuje więcej: sama paleta musi się zmienić. BuildBiffPalettePlan liczy kompletną propozycję dla wszystkich 56 slotów i zostawia skoroszyt nietknięty, więc możesz ją zalogować, porównać z progiem albo wyrzucić

Planer zaczyna od ScanIndexedColorUsage. Każdy slot wskazywany indeksem z fontu, wypełnienia, obramowania, formatowania warunkowego, kształtu, komentarza, fontu wykresu lub linii siatki arkusza jest blokowany, bo zmiana tego wpisu przekolorowałaby naraz każdego konsumenta indeksu. Celami są bezpośrednie kolory RGB i rozwiązane kolory motywu z fontów, wypełnień, punktów gradientu, obramowań, stylów formatowania warunkowego, pasków danych i skal kolorów. Każdy cel jest ważony większą z liczby odwołań i liczby definicji, a formatowanie warunkowe liczy każdą komórkę objętą jego zakresami, więc wypełnienie pomalowane wzdłuż całej kolumny przeważa nad kolorem użytym w jednym komentarzu. Rozmieszczanie idzie potem w stałej kolejności:

  • Zablokowane sloty zachowują swój bieżący kolor bezwarunkowo
  • Cel, który już istnieje w palecie, zachowuje swój najniższy pasujący slot, a ten slot staje się stały
  • Jeśli pozostałe odrębne 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 k-średnich w OKLab przesuwa tylko wolne centra, aż przypisania przestaną się zmieniać
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

Ścieżka przepełnienia zasługuje na trzeźwe odczytanie. K-średnie są tu ograniczoną optymalizacją lokalną, a wolny slot kończy z centrum klastra przekonwertowanym z powrotem do sRGB z obcięciem, które może być kolorem nieużytym dosłownie przez żadną komórkę. Tym, co planer faktycznie dostarcza, jest powtarzalność: ten sam skoroszyt zawsze produkuje ten sam plan. Plan raportuje też, jak bardzo musiał się nagiąć, przez WeightedError, MaxDistanceSquared, ExactTargetWeight i TotalTargetWeight, co wystarcza, by zadanie wsadowe odmówiło zapisu, gdy przybliżenie staje się za grube dla wytycznych marki. Tablica Targets pokazuje dla każdego koloru wejściowego slot, na który się mapuje, i jego kwadrat odległości

const
  MaxAcceptedError = 0.002;   // twój próg, w kwadratach jednostek OKLab
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);           // twój własny logger
  if not Workbook.ApplyBiffPalettePlan(Plan) then
    raise Exception.Create('The palette changed after planning');
end;

Kiedy ApplyBiffPalettePlan odrzuca plan?

ApplyBiffPalettePlan sprawdza cały plan, zanim zapisze choć jeden slot, i zwraca False z nietkniętą paletą, jeśli cokolwiek nie zgadza się z bieżącym skoroszytem. Plan niesie SourcePaletteGeneration i SourcePaletteHash, 64-bitowy hash FNV-1a z 56 kolorów źródłowych. Walidacja sprawdza ponownie także każdy indeks publiczny i fizyczny, każdy kolor źródłowy, to, że żaden zablokowany slot nie jest oznaczony jako zmieniony, że liczby zablokowanych i zmienionych się sumują, i że każdy cel jest nieprzezroczysty

Każda prawdziwa zmiana palety między planowaniem a zastosowaniem czyni plan nieaktualnym, włącznie z udanym wcześniejszym zastosowaniem tego samego planu. Plany są w praktyce jednorazowe: zbuduj, obejrzyj, zastosuj i zbuduj ponownie, jeśli potrzebujesz kolejnego. Prawidłowy plan bez zmienionych slotów kończy się sukcesem bez podbicia generacji. Prawdziwa zmiana podbija ją raz i odświeża matcher raz. Na silniku Classic nowe kolory idą prosto do palety; na silniku XLSX stają się niestandardowymi kolorami indeksowanymi, które raportują HasCustomIndexedColor i CustomIndexedColorCount i które zapisujący XLSX emituje jako blok indexedColors

Włączanie dla zapisów XLS i konwersji XLSX do XLS

Właściwość BiffPaletteSavePolicy domyślnie ma wartość xbpsPreserve, więc paleta skoroszytu zmienia się tylko na twoje życzenie. Ustawienie xbpsOptimizeTrueColors sprawia, że skoroszyt Classic buduje i stosuje świeży plan wewnątrz SaveAs, i to tylko wtedy, 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 skoroszycie, więc późniejsze zapytania i zapisy widzą to samo mapowanie. Jeśli zapis się nie powiedzie, zostanie anulowany lub rzuci wyjątek, oryginalne 56 kolorów i oryginalna generacja są przywracane

Dla generowanych raportów włącz politykę optymalizacji. Te skoroszyty są budowane z kolorów RGB i motywu, nikt ręcznie nie stroił ich palety, a czytnik oparty wyłącznie na indeksie pokazuje je z nią znacznie bliżej oryginału. Zostaw xbpsPreserve dla skoroszytów, których paleta jest częścią kontraktu, na przykład szablonu, który kod niżej edytuje przez ColorIndex, gdzie slot po cichu zmieniający kolor zepsułby ten kod

Źródła XLSX idą inną drogą. SaveXLSXWorkbookAsXLS w lxXlsxExport buduje jeden plan z wczytanego TXLSXWorkbook i zapisuje go do docelowej palety przed konwersją jakiegokolwiek stylu, więc każda konwertowana komórka jest dopasowywana do finalnej palety. Demo warsztatu audytu i konwersji skoroszytów przechodzi dokładnie tę ścieżkę. Kolory motywu przechodzą przez ten sam planer, gdy ich odcień zostanie rozwiązany do RGB; wypełnienia wykresów, które mają zostać żywymi odwołaniami do motywu, to osobny przypadek, omówiony w artykule o wypełnieniach wykresów kolorami motywu GelFrame, gdzie binarny XLS przechowuje indeks schematu zamiast spłaszczonego koloru

// Skoroszyt Classic: włączenie dotyczy tylko zapisów BIFF8
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
  HandleSaveFailure;   // twój własny handler; paleta już przywrócona

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

Wywołania palety (ResolveIndexedColor, NearestIndexedColor, ScanIndexedColorUsage, BuildBiffPalettePlan i ApplyBiffPalettePlan) mają te same sygnatury na IXLSWorkbook i TXLSXWorkbook, z Delphi i C++Builder. Wersja próbna ze strony komponentu HotXLS Delphi Excel wystarczy, by uruchomić plan na twoim najbardziej kolorowym szablonie i odczytać MaxDistanceSquared, zanim cokolwiek zostanie zapisane