Technisch artikel

BIFF8-palet van 56 kleuren: OKLab-kleurenmatching in HotXLS

Het donkerblauwe $000033 zit 51 eenheden van zwart en 77 eenheden van de klassieke marineblauwe invoer $000080 als je afstand meet als kale Euclidische afstand over RGB-kanaalwaarden. Een converter die klassieke .xls-bestanden schrijft met nearest-RGB-matching verft een donkerblauwe kopregel dus zwart, en zwart is wat elke lezer die alleen naar de paletindex kijkt toont. Het formaat laat de schrijver geen ruimte: een celformaat slaat een paletindex op, en het palet bevat 56 invoeren. De kopregel goed krijgen vraagt twee dingen, een afstandsmaat die volgt wat mensen zien, en een manier om het palet zelf te herschrijven wanneer een werkmap meer verschillende kleuren gebruikt dan het palet aan kan

Waar het kleurlimiet van 56 vandaan komt

Een BIFF8-record voor lettertype, vulling of rand draagt nooit een RGB-waarde. Hij draagt een kleurindex, en het werkboekglobale Palette-record ($0092, [MS-XLS] §2.4.188) levert precies 56 ondoorzichtige RGB-invoeren voor de indexen 8 tot en met 63. Indexen 0 tot 7 zijn vaste kopieën van de acht basiskleuren. Waarden boven 63 zijn helemaal geen kleuren maar tokens: $40 is de systeemvoorgrond, $41 de systeemachtergrond, $4D tot $4F zijn charttekst, chartachtergrond en de neutrale chartkleur, en $7FFF betekent automatic. Excel 2007 voegde het XFExt-record toe ($087D, §2.4.355), dat een volledige RGB- of themakleur aan een celformaat kan hangen, maar Excel 97–2003 en elke parser die alleen het XF-record leest zien nog niets dan de index

HotXLS stelt het palet beschikbaar via een publieke ColorIndex van 1 tot 56, de fysieke index min 7. Drie nummeringsschema's zijn in het spel, en ResolveIndexedColor houdt ze uit elkaar via TXLSIndexedColorSpace: xicsPublicColorIndex voor de API-waarden 1..56, xicsBiffIcv voor rauwe indexen op schijf, en xicsOoxmlIndexed, waar 64 en 65 systeemvoorgrond en -achtergrond betekenen. Rauwe BIFF-indexen worden ook gecontroleerd tegen de rol die je als TXLSIndexedColorRole doorgeeft, want een lettertype, een vulling en een chart accepteren verschillende tokensets; $51 is legaal voor een lettertype en ongeldig voor een vulling

HotXLS houdt de drie geïndexeerde kleurenschema's uit elkaar via TXLSIndexedColorSpace: rauwe BIFF-icv-waarden, 0 tot 7 vastgelegd op de acht basiskleuren, de 56 paletslots 8 tot 63 van Palette-record $0092, tokens boven 63 zoals systeemvoorgrond, publieke ColorIndex 1 tot 56 met een offset van min 7, en xicsOoxmlIndexed waar 64 en 65 systeemvoorgrond en -achtergrond betekenen
Dezelfde kleurindex betekent in elk schema andere getallen, dus HotXLS leidt elke waarde door ResolveIndexedColor in plaats van een rauw BIFF-token als publieke ColorIndex toe te laten

De Booleaanse uitkomst van ResolveIndexedColor is waar aanroepers struikelen. Hij geeft True alleen terug wanneer hij een concrete ARGB-waarde heeft verkregen, en de korte overload vraagt het besturingssysteem nooit om desktopkleuren. Een system- of automatic-token komt daarom terug als False met Res.Kind op xickSystem of xickAutomatic, en code die False leest als "geen kleur" gooit stilletjes de betekenis van het token weg. De enige uitzondering is $4F, de neutrale chartkleur, die zonder hulp naar zwart resolveert. Voor echte RGB-waarden bij de andere tokens geef je een TXLSTryResolveSystemColor-methode door aan de lange overload en pas je je eigen beleid toe voor de UI, een export of een headless service:

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: vaste kleuren in plaats van het huidige desktopthema
  Result := True;
  case AColor of
    xscrWindowText, xscrChartText, xscrToolTipText:
      AArgb := $FF000000;
    xscrWindowBackground, xscrChartBackground:
      AArgb := $FFFFFFFF;
  else
    Result := False;
  end;
end;

var
  Colors: TExportColors;   // éénmaal aangemaakt door je exportcode
  Res: TXLSIndexedColorResolution;
begin
  // $41 is het systeemachtergrond-token, geldig voor een vulling
  if Workbook.ResolveIndexedColor($41, xicsBiffIcv, xicrFillBackground,
    Colors.ResolveSystemColor, Res) then
    PaintBackground(Res.ARGB)        // je eigen tekenroutine
  else if Res.Kind = xickInvalid then
    RejectToken(Res.RawIndex);       // je eigen foutafhandeling
end;

Waarom kiest RGB-afstand de verkeerde paletinvoer?

sRGB-kanaalwaarden zijn gamma-gecodeerd, dus gelijke stappen in de getallen zijn geen gelijke stappen in wat een mens ziet, en de fout is het grootst in precies de donkere, verzadigde tonen waar huisstijlsjablonen van houden. In RGB zit $000033 op 51 van zwart en 77 van marineblauw, dus zwart wint. Zodra elk kanaal is gelineariseerd en de kleur in OKLab is geprojecteerd, zijn de gekwadrateerde afstanden ongeveer 0.0312 tot zwart en 0.0235 tot marineblauw, en marineblauw wint. De match is ColorIndex 11 op fysiek slot 18, en de Classic- en XLSX-engines geven hetzelfde antwoord

Hoe HotXLS het donkerblauwe $000033 op het palet matcht: de Euclidische afstand in gamma-gecodeerd RGB meet 51 tot zwart en 77 tot marineblauw en zou de kopregel zwart verven, terwijl de gekwadrateerde OKLab-afstanden van 0.0312 en 0.0235 NearestIndexedColor marineblauw laten kiezen, ColorIndex 11 op fysiek slot 18
Gamma-gecodeerde kanaalwaarden maken RGB-afstand een slechte proxy voor wat een mens ziet, dus HotXLS converteert één keer naar OKLab en laat een gewone gekwadrateerde Euclidische vergelijking de paletscan aansturen

Het standaardpalet voegt een complicatie toe. Marineblauw staat twee keer in het palet, op ColorIndex 11 en opnieuw op 25, dus $000033 zit exact even ver van beide. Een matcher die in een andere volgorde scant, of kleiner-of-gelijk gebruikt, kiest 25 in de ene run en 11 in een andere build, en twee gegenereerde bestanden diffen niet meer netjes. HotXLS scant vanaf slot 8 omhoog met een strikte kleiner-dan-vergelijking, dus een gelijkspel gaat altijd naar de lagere index

OKLab is een bewuste afweging. CIEDE2000 zou sommige hue-paren nauwkeuriger rangschikken, maar zijn stuksgewijze correcties maken hem traag en lastiger te doorgronden binnen een iteratieve lus. OKLab kost een linearisatie, twee kleine matrixproducten en drie derdemachtswortels per kleur, waarna een kale gekwadrateerde afstand een goed genoeg plaatsvervanger is voor waargenomen verschil. Voor een paletscan over 56 invoeren die bij elke kleurtoewijzing en binnen een clusteringspas draait, is de goedkopere, gladdere metriek de juiste keuze

Wat geeft NearestIndexedColor terug?

NearestIndexedColor neemt een ARGB-waarde aan en vult een TXLSNearestIndexedColorMatch-record zonder het werkboek te veranderen. Elke werkmap houdt de 56 paletkleuren bij met hun OKLab-coördinaten en een paletgeneratieteller, dus een query kost één invoerconversie en één vaste scan. Het palet resetten bouwt die cache opnieuw, het veranderen van één invoer actualiseert alleen dat slot, en een query tegen een cache die uit de pas loopt met de huidige generatie geeft False terug in plaats van te gokken

Alpha volgt een smalle regel. Een alpha-byte van nul geldt als dekkend, wat aansluit bij hoe de meeste Delphi-code kale RGB-waarden doorgeeft, en $FF is per definitie dekkend. Alles daartussenin wordt geweigerd: de functie geeft False terug met ColorIndex 0 en PaletteSlot -1, want een paletinvoer heeft nergens transparantie op te slaan

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

Dezelfde matcher zit achter de RGB-setters. Het toekennen van Interior.Color, Font.Color of Border.Color op een Classic-werkmap houdt de exacte kleur voor het XFExt-record en kiest met deze scan de index die in het XF-record komt, en de vulling- en randwriters resolven RGB- en themakleuren op dezelfde manier naar een index. Wat NearestIndexedColor voor een kleur rapporteert, is het slot dat een index-only lezer toont

Een true-color-werkboek in 56 slots passen

Matchen tegen het standaardpalet is prima voor een handvol kleuren. Een huisstijlsjabloon met een dozijn bijna identieke blauwtinten vraagt meer: het palet zelf moet veranderen. BuildBiffPalettePlan berekent een compleet voorstel voor alle 56 slots en laat het werkboek onaangetast, zodat je het kunt loggen, tegen een drempel kunt afzetten of kunt weggooien

De planner begint met ScanIndexedColorUsage. Elk slot waarnaar een lettertype, vulling, rand, voorwaardelijke opmaak, shape, opmerking, chartlettertype of werkbladrasterlijn via een index verwijst, wordt vergrendeld, want het wijzigen van die invoer kleurt in één keer alle consumenten van de index opnieuw. De targets zijn de directe RGB- en opgeloste themakleuren uit lettertypen, vullingen, kleurverloopstops, randen, stijlen van voorwaardelijke opmaak, databalken en kleurschalen. Elke target wordt gewogen op het grotere van zijn referentieaantal en zijn definitieaantal, en een voorwaardelijke opmaak telt elke cel die zijn bereiken bestrijken, dus een vulling die een hele kolom afloopt weegt zwaarder dan een kleur in één opmerking. De plaatsing volgt daarna een vaste volgorde:

  • Vergrendelde slots behouden hun huidige kleur onvoorwaardelijk
  • Een target die al in het palet bestaat, houdt zijn laagste matchende slot, en dat slot wordt vastgelegd
  • Passen de resterende unieke targets in de vrije slots, dan krijgt elk daarvan een exact slot, toegekend in oplopende ARGB-volgorde
  • Anders wordt Quantized gezet, wordt elk vrij slot geseed met de target waarvan de afstand tot het dichtstbijzijnde bestaande middelpunt, vermenigvuldigd met zijn gewicht, het grootst is, en verschuiven maximaal 16 rondes gewogen k-means in OKLab alleen de vrije middelpunten tot de toewijzingen niet meer veranderen
De HotXLS-paletpijplijn voor een true-color-werkboek: ScanIndexedColorUsage vergrendelt elk slot waarnaar een lettertype, vulling, rand, voorwaardelijke opmaak, shape, opmerking of rasterlijn verwijst, BuildBiffPalettePlan plaatst exacte kleuren in oplopende ARGB-volgorde of draait maximaal 16 rondes frequentiegewogen k-means in OKLab, en ApplyBiffPalettePlan valideert de generatie en FNV-1a-hash vóór het wegschrijven
Plannen is alleen-lezen en herhaalbaar, het plan rapporteert zijn eigen schade via WeightedError en MaxDistanceSquared, en een verouderd plan wordt geweigerd met het palet onaangeroerd omdat plannen in de praktijk eenmalig bruikbaar zijn

Het overlooppad verdient een heldere lezing. K-means is hier een begrensde lokale optimalisatie, en een vrij slot houdt uiteindelijk een clustermiddelpunt vast dat met clamping terug naar sRGB is geconverteerd, wat een kleur kan zijn die geen enkele cel letterlijk gebruikte. Wat de planner wél levert is herhaalbaarheid: hetzelfde werkboek levert altijd hetzelfde plan op. Het plan rapporteert ook hoe ver hij moest buigen via WeightedError, MaxDistanceSquared, ExactTargetWeight en TotalTargetWeight, wat genoeg is voor een batchtaak om een opslag te weigeren zodra de benadering te grof wordt voor een huisstijlrichtlijn. De Targets-array toont per invoerkleur het slot waarnaar hij mapt en zijn gekwadrateerde afstand

const
  MaxAcceptedError = 0.002;   // jouw drempel, in gekwadrateerde OKLab-eenheden
var
  Plan: TXLSBiffPalettePlan;
  I: Integer;
begin
  Plan := Workbook.BuildBiffPalettePlan;   // alleen-lezen
  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);           // je eigen logger
  if not Workbook.ApplyBiffPalettePlan(Plan) then
    raise Exception.Create('The palette changed after planning');
end;

Wanneer weigert ApplyBiffPalettePlan een plan?

ApplyBiffPalettePlan controleert het volledige plan voordat hij één slot wegschrijft en geeft False terug, met het palet onaangeroerd, als er iets niet klopt met het huidige werkboek. Het plan draagt SourcePaletteGeneration en SourcePaletteHash, een 64-bit FNV-1a-hash over de 56 bronkleuren. De validatie controleert bovendien elke publieke en fysieke index, elke bronkleur, dat geen vergrendeld slot als gewijzigd staat gemarkeerd, dat de aantallen vergrendeld en gewijzigd optellen, en dat elke target dekkend is

Elke echte paletwijziging tussen plannen en toepassen maakt het plan verouderd, en dat geldt ook voor een eerdere geslaagde toepassing van hetzelfde plan. Plannen zijn in de praktijk eenmalig bruikbaar: bouwen, inspecteren, toepassen, en opnieuw bouwen als je er nog een nodig hebt. Een geldig plan zonder gewijzigde slots slaagt zonder de generatie op te schuiven. Een echte wijziging schuift hem één keer op en ververset de matcher één keer. Op de Classic-engine gaan de nieuwe kleuren rechtstreeks het palet in; op de XLSX-engine worden ze custom indexed colors, die HasCustomIndexedColor en CustomIndexedColorCount rapporteren en die de XLSX-writer als een indexedColors-blok uitzendt

Opt-in voor XLS-opslag en XLSX-naar-XLS-conversie

De eigenschap BiffPaletteSavePolicy staat standaard op xbpsPreserve, dus het palet van een werkmap verandert alleen als je erom vraagt. xbpsOptimizeTrueColors zetten laat een Classic-werkmap binnen SaveAs een vers plan bouwen en toepassen, en alleen wanneer het doelformaat xlExcel97 is; BIFF5, CSV, HTML, PDF, XLSX en de andere writers negeren de instelling. Na een geslaagde opslag blijft het geoptimaliseerde palet in de werkmap zitten, zodat latere queries en opslagen dezelfde mapping zien. Faalt de opslag, wordt die geannuleerd of geeft die een exception, dan worden de oorspronkelijke 56 kleuren en de oorspronkelijke generatie hersteld

Voor gegenereerde rapporten zet je het optimaliserende beleid aan. Die werkmappen zijn opgebouwd uit RGB- en themakleuren, niemand heeft hun palet met de hand afgestemd, en een index-only lezer toont ze er met dat beleid veel dichter bij het origineel. Houd xbpsPreserve voor werkmappen waarvan het palet deel van het contract uitmaakt, zoals een sjabloon die stroomafwaartse code via ColorIndex bewerkt, waar een slot dat stilletjes van kleur verandert die code zou breken

XLSX-bronnen nemen een andere route. SaveXLSXWorkbookAsXLS in lxXlsxExport bouwt één plan vanuit het geladen TXLSXWorkbook en schrijft dat in het bestemmingspalet voordat er ook maar één stijl wordt geconverteerd, zodat elke geconverteerde cel tegen het uiteindelijke palet wordt gematcht. De workbook audit- en conversie-workbenchdemo draait precies dit pad. Themakleuren gaan na het omrekenen van hun tint naar RGB door dezelfde planner; chartvullingen die levende themaverwijzingen moeten blijven zijn een apart geval, behandeld in het artikel over GelFrame-themakleur-chartvullingen, waar binair XLS een schema-index opslaat in plaats van een afgevlakte kleur

// klassiek werkboek: opt-in, geldt alleen voor BIFF8-opslag
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
  HandleSaveFailure;   // je eigen handler; palet al hersteld

// XLSX-model naar BIFF8 met één deterministisch paletplan
XWorkbook := TXLSXWorkbook.Create;
try
  if XWorkbook.Open('report.xlsx') = 1 then
    if SaveXLSXWorkbookAsXLS(XWorkbook, 'report.xls') <> 1 then
      HandleSaveFailure;
finally
  XWorkbook.Free;
end;

De paletaanroepen (ResolveIndexedColor, NearestIndexedColor, ScanIndexedColorUsage, BuildBiffPalettePlan en ApplyBiffPalettePlan) hebben dezelfde handtekeningen op IXLSWorkbook en TXLSXWorkbook, vanuit Delphi en C++Builder. De trial-build op de HotXLS Delphi Excel-componentpagina is genoeg om een plan tegen je kleurrijkste sjabloon te draaien en MaxDistanceSquared te lezen voordat er iets wordt weggeschreven