Das Dunkelblau $000033 liegt 51 Einheiten von Schwarz und 77 Einheiten vom klassischen Navy-Eintrag $000080 entfernt, wenn man den Abstand als schlichten euklidischen Abstand über die RGB-Kanalwerte misst. Ein Konverter, der klassische .xls-Dateien mit Nextest-RGB-Matching schreibt, färbt daher eine dunkelblaue Kopfzeile schwarz, und Schwarz ist es, was jeder Reader, der sich allein nach dem Palettenindex richtet, anzeigt. Das Format lässt dem Writer keinen Spielraum: Ein Zellformat speichert einen Palettenindex, und die Palette hält 56 Einträge. Die Kopfzeile richtig zu treffen verlangt zwei Dinge: ein Abstandsmaß, das dem folgt, was Menschen sehen, und einen Weg, die Palette selbst umzuschreiben, wenn eine Arbeitsmappe mehr unterschiedliche Farben nutzt, als die Palette halten kann
Woher die 56-Farben-Grenze kommt
Ein BIFF8-Font-, Fill- oder Border-Record trägt nie einen RGB-Wert. Er trägt einen Farbindex, und der arbeitsmappenglobale Palette-Record ($0092, [MS-XLS] §2.4.188) liefert exakt 56 opake RGB-Einträge für die Indizes 8 bis 63. Die Indizes 0 bis 7 sind feste Kopien der acht Grundfarben. Werte über 63 sind überhaupt keine Farben, sondern Tokens: $40 ist der Systemvordergrund, $41 der Systemhintergrund, $4D bis $4F sind Charttext, Charthintergrund und die neutrale Chartfarbe, und $7FFF bedeutet automatisch. Excel 2007 ergänzte den XFExt-Record ($087D, §2.4.355), der ein volles RGB oder eine Theme-Farbe an ein Zellformat hängen kann, aber Excel 97–2003 und jeder Parser, der nur den XF-Record liest, sehen außer dem Index nichts
HotXLS legt die Palette über einen öffentlichen ColorIndex von 1 bis 56 offen, was dem physikalischen Index minus 7 entspricht. Drei Nummerierungsschemata sind im Spiel, und ResolveIndexedColor hält sie über TXLSIndexedColorSpace auseinander: xicsPublicColorIndex für die API-Werte 1..56, xicsBiffIcv für die rohen On-Disk-Indizes und xicsOoxmlIndexed, wo 64 und 65 für Systemvordergrund und -hintergrund stehen. Rohe BIFF-Indizes werden zusätzlich gegen die Rolle geprüft, die man als TXLSIndexedColorRole übergibt, denn ein Font, eine Füllung und ein Chart akzeptieren unterschiedliche Tokenmengen; $51 ist für einen Font legal und für eine Füllung ungültig
Am booleschen Ergebnis von ResolveIndexedColor stolpern Aufrufer. Es gibt True nur dann zurück, wenn es ein konkretes ARGB ermittelt hat, und die kurze Überladung fragt nie das Betriebssystem nach Desktop-Farben. Ein System- oder Automatic-Token kommt daher mit False zurück und Res.Kind auf xickSystem oder xickAutomatic gesetzt, und Code, der False als „keine Farbe“ liest, wirft die Bedeutung des Tokens stillschweigend weg. Die eine Ausnahme ist $4F, die neutrale Chartfarbe, die ohne Hilfe zu Schwarz aufgelöst wird. Für echte RGB-Werte bei den anderen Tokens übergibt man der langen Überladung eine TXLSTryResolveSystemColor-Methode und wendet die eigene Policy für UI, Export oder Headless-Dienst an:
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
// Headless-Export: feste Farben statt des aktuellen Desktop-Themes
Result := True;
case AColor of
xscrWindowText, xscrChartText, xscrToolTipText:
AArgb := $FF000000;
xscrWindowBackground, xscrChartBackground:
AArgb := $FFFFFFFF;
else
Result := False;
end;
end;
var
Colors: TExportColors; // von Ihrem Export-Code einmal erzeugt
Res: TXLSIndexedColorResolution;
begin
// $41 ist das System-Hintergrund-Token, gültig für eine Füllung
if Workbook.ResolveIndexedColor($41, xicsBiffIcv, xicrFillBackground,
Colors.ResolveSystemColor, Res) then
PaintBackground(Res.ARGB) // Ihre eigene Zeichenroutine
else if Res.Kind = xickInvalid then
RejectToken(Res.RawIndex); // Ihre eigene Fehlerbehandlung
end;
Warum wählt der RGB-Abstand den falschen Paletteneintrag?
sRGB-Kanalwerte sind gamma-kodiert, also sind gleiche Schritte in den Zahlen nicht gleiche Schritte in dem, was ein Mensch sieht, und der Fehler ist ausgerechnet in den dunklen, gesättigten Tönen am größten, die Firmenpaletten so gerne einsetzen. In RGB liegt $000033 bei 51 zu Schwarz und 77 zu Navy, also gewinnt Schwarz. Sind die Kanäle erst linearisiert und die Farbe nach OKLab projiziert, liegen die quadrierten Abstände bei etwa 0.0312 zu Schwarz und 0.0235 zu Navy, und Navy gewinnt. Der Treffer ist ColorIndex 11 am physikalischen Platz 18, und die Classic- und XLSX-Engines geben dieselbe Antwort
Die Standardpalette bringt eine zusätzlich zu beachtende Kleinigkeit mit. Navy taucht zweimal auf, bei ColorIndex 11 und noch einmal bei 25, also ist $000033 beiden exakt gleich nah. Ein Matcher, der in einer anderen Reihenfolge scannt oder Kleiner-gleich benutzt, wählt 25 in einem Lauf und 11 in einem anderen Build, und zwei erzeugte Dateien diffen nicht mehr sauber. HotXLS scannt ab Platz 8 aufwärts mit striktem Kleiner-Vergleich, sodass ein Gleichstand immer zum niedrigeren Index geht
OKLab ist eine bewusste Abwägung. CIEDE2000 würde manche Hue-Paare genauer ranken, aber seine stückweisen Korrekturen machen es langsam und schwerer nachvollziehbar innerhalb einer iterativen Schleife. OKLab kostet eine Linearisierung, zwei kleine Matrixprodukte und drei Kubikwurzeln pro Farbe, danach ist eine schlicht quadrierte Distanz eine ausreichende Stellvertreterin für die wahrgenommene Differenz. Für einen 56-Einträge-Palettenscan, der bei jeder Farbzuweisung und innerhalb eines Clustering-Durchgangs läuft, ist die billigere, glattere Metrik die richtige Wahl
Was liefert NearestIndexedColor?
NearestIndexedColor nimmt einen ARGB-Wert und füllt einen TXLSNearestIndexedColorMatch-Record, ohne die Arbeitsmappe zu ändern. Jede Arbeitsmappe hält die 56 Palettenfarben zusammen mit ihren OKLab-Koordinaten und einem Paletten-Generationszähler, sodass eine Abfrage eine Eingabekonvertierung und einen festen Scan kostet. Ein Palette-Reset baut diesen Cache neu, eine Änderung an einem einzelnen Eintrag aktualisiert nur diesen Platz, und eine Abfrage gegen einen Cache, der nicht zur aktuellen Generation passt, gibt False zurück, statt zu raten
Das Alpha folgt einer engen Regel. Ein Alpha-Byte von null gilt als opak, was dazu passt, wie der meiste Delphi-Code schlichte RGB-Werte übergibt, und $FF ist per Definition opak. Alles dazwischen wird abgelehnt: Die Funktion gibt False mit ColorIndex 0 und PaletteSlot -1 zurück, denn ein Paletteneintrag hat nirgends Platz für Transparenz
var
Match: TXLSNearestIndexedColorMatch;
begin
if Workbook.NearestIndexedColor($FF000033, Match) then
begin
// Standardpalette: Match.ColorIndex = 11, Match.PaletteSlot = 18,
// Match.ARGB = $FF000080
if not Match.ExactMatch then
LogApproximation(Match.InputARGB, Match.ARGB, // Ihr eigener Logger
Match.DistanceSquared);
end;
end;
Derselbe Matcher sitzt hinter den RGB-Settern. Weist man Interior.Color, Font.Color oder Border.Color in einer Classic-Arbeitsmappe zu, bleibt die exakte Farbe für den XFExt-Record erhalten, und der im XF-Record gespeicherte Index wird mit diesem Scan gewählt; die Fill- und Border-Writer lösen RGB- und Theme-Farben auf dieselbe Weise zu einem Index auf. Was NearestIndexedColor für eine Farbe meldet, ist der Platz, den ein reinen Index lesender Reader zeigt
Eine True-Color-Arbeitsmappe in 56 Plätze unterbringen
Das Matching gegen die Standardpalette reicht für eine Handvoll Farben. Eine Marken-Template mit einem Dutzend beinahe identischer Blautöne braucht mehr: Die Palette selbst muss sich ändern. BuildBiffPalettePlan berechnet einen vollständigen Vorschlag für alle 56 Plätze und fasst die Arbeitsmappe nicht an, sodass man ihn loggen, gegen eine Schwelle vergleichen oder verwerfen kann
Der Planner beginnt mit ScanIndexedColorUsage. Jeder Platz, auf den eine Schriftart, Füllung, ein Rahmen, eine bedingte Formatierung, eine Shape, ein Kommentar, ein Chart-Font oder eine Worksheet-Gitterlinie per Index verweist, wird gesperrt, denn eine geänderte Adresse färbt jeden Konsumenten dieses Index auf einmal um. Ziele sind die direkten RGB- und die aufgelösten Theme-Farben aus Schriftarten, Füllungen, Gradient-Stops, Rahmen, Styles bedingter Formatierung, Data Bars und Color Scales. Jedes Ziel wird mit dem größeren Wert aus Referenzanzahl und Definitionsanzahl gewichtet, und eine bedingte Formatierung zählt jede Zelle, die ihre Bereiche abdecken, sodass eine Füllung über eine ganze Spalte schwerer wiegt als eine Farbe in einer einzelnen Notiz. Die Platzierung läuft dann in fester Reihenfolge ab:
- Gesperrte Plätze behalten ihre Quellfarbe bedingungslos
- Ein Ziel, das in der Palette bereits existiert, bleibt auf seinem niedrigsten passenden Platz liegen, und dieser Platz wird fest
- Passen die verbleibenden eindeutigen Ziele in die freien Plätze, bekommt jedes einen exakten Platz, vergeben in aufsteigender ARGB-Reihenfolge
- Andernfalls wird
Quantizedgesetzt, jeder freie Platz wird mit dem Ziel eingesät, dessen Abstand zum nächsten existierenden Zentrum, multipliziert mit seinem Gewicht, am größten ist, und bis zu 16 Runden gewichtetes k-Means in OKLab bewegen nur die freien Zentren, bis die Zuweisungen sich nicht mehr ändern
Dem Overflow-Pfad sollte man mit klarem Blick begegnen. K-means ist hier eine begrenzte lokale Optimierung, und ein freier Platz hält am Ende ein Zentrum, das mit Clamping zurück nach sRGB konvertiert wurde, was eine Farbe sein kann, die keine Zelle je wörtlich verwendet hat. Was der Planner liefert, ist Reproduzierbarkeit: Dieselbe Arbeitsmappe erzeugt immer denselben Plan. Der Plan meldet auch, wie weit er sich verbiegen musste, über WeightedError, MaxDistanceSquared, ExactTargetWeight und TotalTargetWeight, was für einen Batch-Job reicht, um ein Speichern zu verweigern, wenn die Approximation für eine Brand Guideline zu grob wird. Das Targets-Array zeigt pro Eingabefarbe den Platz, auf den sie gemappt wird, und ihre quadrierte Distanz
const
MaxAcceptedError = 0.002; // Ihre Schwelle, in quadrierten OKLab-Einheiten
var
Plan: TXLSBiffPalettePlan;
I: Integer;
begin
Plan := Workbook.BuildBiffPalettePlan; // nur lesend
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); // Ihr eigener Logger
if not Workbook.ApplyBiffPalettePlan(Plan) then
raise Exception.Create('The palette changed after planning');
end;
Wann verweigert ApplyBiffPalettePlan einen Plan?
ApplyBiffPalettePlan prüft den gesamten Plan, bevor auch nur ein Platz geschrieben wird, und gibt False mit unangetaster Palette zurück, wenn etwas nicht zur aktuellen Arbeitsmappe passt. Der Plan trägt SourcePaletteGeneration und SourcePaletteHash, einen 64-Bit-FNV-1a-Hash über die 56 Quellfarben. Die Validierung prüft außerdem jeden öffentlichen und physikalischen Index, jede Quellfarbe, dass kein gesperrter Platz als geändert markiert ist, dass die Zähler für gesperrte und geänderte Plätze aufgehen und dass jedes Ziel opak ist
Jede echte Palettenänderung zwischen Planung und Anwendung macht den Plan veraltet, und dazu zählt auch eine erfolgreiche frühere Anwendung desselben Plans. Pläne sind faktisch Einweg: bauen, prüfen, anwenden, und bei Bedarf neu bauen. Ein valider Plan ohne geänderte Plätze läuft durch, ohne die Generation voranzustellen. Eine echte Änderung stellt sie einmal vor und frischt den Matcher einmal auf. In der Classic-Engine gehen die neuen Farben direkt in die Palette; in der XLSX-Engine werden sie zu Custom Indexed Colors, die HasCustomIndexedColor und CustomIndexedColorCount melden und die der XLSX-Writer als indexedColors-Block ausgibt
Opt-in für XLS-Saves und die XLSX-nach-XLS-Konvertierung
Die Property BiffPaletteSavePolicy steht standardmäßig auf xbpsPreserve, die Palette einer Arbeitsmappe ändert sich also nur, wenn man darum bittet. xbpsOptimizeTrueColors lässt eine Classic-Arbeitsmappe innerhalb von SaveAs einen frischen Plan bauen und anwenden, und nur, wenn das Zielformat xlExcel97 ist; BIFF5, CSV, HTML, PDF, XLSX und die anderen Writer ignorieren die Einstellung. Nach einem erfolgreichen Speichern bleibt die optimierte Palette in der Arbeitsmappe, spätere Abfragen und Saves sehen also dieselbe Zuordnung. Schlägt das Speichern fehl, wird es abgebrochen oder es wirft, werden die ursprünglichen 56 Farben und die ursprüngliche Generation wiederhergestellt
Für erzeugte Berichte schaltet man die optimierende Policy ein. Diese Arbeitsmappen bauen sich aus RGB- und Theme-Farben, niemand hat ihre Palette von Hand justiert, und ein reinen Index lesender Reader zeigt sie damit deutlich näher am Original. xbpsPreserve behält man für Arbeitsmappen, deren Palette Teil des Vertrags ist, etwa eine Template, die nachgelagerter Code per ColorIndex bearbeitet, wo ein Platz, der stillschweigend seine Farbe wechselt, diesen Code brechen würde
XLSX-Quellen nehmen einen anderen Weg. SaveXLSXWorkbookAsXLS in lxXlsxExport baut einen einzigen Plan aus der geladenen TXLSXWorkbook und schreibt ihn in die Zielpalette, bevor irgendein Style konvertiert wird, sodass jede konvertierte Zelle gegen die finale Palette gematcht wird. Genau diesen Pfad fährt die Workbook-Audit- und Konvertierungs-Workbench-Demo. Theme-Farben laufen nach Auflösung ihres Tint nach RGB durch denselben Planner; Chart-Füllungen, die als lebendige Theme-Referenzen erhalten bleiben sollen, sind ein eigener Fall, behandelt im Artikel zu GelFrame-Designfarben in Chart-Füllungen, wo binäres XLS einen Schema-Index statt einer abgeflachten Farbe speichert
// Klassische Arbeitsmappe: Opt-in, gilt nur für BIFF8-Saves
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
HandleSaveFailure; // eigener Handler; Palette bereits wiederhergestellt
// XLSX-Modell nach BIFF8 mit einem deterministischen Palettenplan
XWorkbook := TXLSXWorkbook.Create;
try
if XWorkbook.Open('report.xlsx') = 1 then
if SaveXLSXWorkbookAsXLS(XWorkbook, 'report.xls') <> 1 then
HandleSaveFailure;
finally
XWorkbook.Free;
end;
Die Palettenaufrufe (ResolveIndexedColor, NearestIndexedColor, ScanIndexedColorUsage, BuildBiffPalettePlan und ApplyBiffPalettePlan) haben auf IXLSWorkbook und TXLSXWorkbook dieselben Signaturen, aus Delphi und C++Builder. Der Trial-Build auf der HotXLS-Delphi-Excel-Komponentenseite reicht, um einen Plan gegen die farbigste eigene Template zu fahren und MaxDistanceSquared zu lesen, bevor irgendetwas geschrieben wird