Article technique

Palette BIFF8 de 56 couleurs : mapping OKLab dans HotXLS

Le bleu foncé $000033 se trouve à 51 unités du noir et à 77 unités de l'entrée bleu marine classique $000080 quand la distance se mesure comme une distance euclidienne simple sur les valeurs de canaux RGB. Un convertisseur qui écrit des fichiers .xls classiques avec un appariement au RGB le plus proche peint donc une ligne d'en-tête bleu foncé en noir, et le noir est ce que tout lecteur qui ne se fie qu'à l'index de palette affiche. Le format ne laisse aucune marge au rédacteur : un format de cellule stocke un index de palette, et la palette détient 56 entrées. Bien réussir l'en-tête demande deux choses, une mesure de distance qui suit ce que les gens voient, et un moyen de réécrire la palette elle-même quand un classeur emploie plus de couleurs distinctes que la palette ne peut en tenir

D'où vient la limite des 56 couleurs

Un enregistrement BIFF8 de police, de remplissage ou de bordure ne porte jamais de valeur RGB. Il porte un index de couleur, et l'enregistrement Palette global au classeur ($0092, [MS-XLS] §2.4.188) fournit exactement 56 entrées RGB opaques pour les index 8 à 63. Les index 0 à 7 sont des copies fixes des huit couleurs de base. Les valeurs au-dessus de 63 ne sont pas des couleurs du tout mais des jetons : $40 est le premier plan système, $41 l'arrière-plan système, $4D à $4F sont le texte de graphique, l'arrière-plan de graphique et la couleur de graphique neutre, et $7FFF signifie automatique. Excel 2007 a ajouté l'enregistrement XFExt ($087D, §2.4.355), qui peut attacher un RGB complet ou une couleur de thème à un format de cellule, mais Excel 97-2003 et tout parseur qui ne lit que l'enregistrement XF ne voient toujours rien d'autre que l'index

HotXLS expose la palette via un ColorIndex public de 1 à 56, à savoir l'index physique moins 7. Trois schémas de numérotation sont en jeu, et ResolveIndexedColor les maintient séparés grâce à TXLSIndexedColorSpace : xicsPublicColorIndex pour les valeurs d'API 1..56, xicsBiffIcv pour les index bruts sur disque, et xicsOoxmlIndexed, où 64 et 65 signifient premier plan et arrière-plan système. Les index BIFF bruts sont aussi confrontés au rôle que vous passez en TXLSIndexedColorRole, parce qu'une police, un remplissage et un graphique acceptent des ensembles de jetons différents ; $51 est légal pour une police et invalide pour un remplissage

HotXLS maintient les trois schémas de couleurs indexées séparés via TXLSIndexedColorSpace : valeurs BIFF icv brutes, 0 à 7 fixés aux huit couleurs de base, les 56 entrées de palette 8 à 63 de l'enregistrement Palette $0092, jetons au-dessus de 63 comme le premier plan système, ColorIndex public 1 à 56 décalé de moins 7, et xicsOoxmlIndexed où 64 et 65 signifient premier plan et arrière-plan système
Le même index de couleur désigne des nombres différents dans chaque schéma, donc HotXLS route chaque valeur via ResolveIndexedColor au lieu de laisser un jeton BIFF brut se faire passer pour un ColorIndex public

Le résultat booléen de ResolveIndexedColor est l'endroit où les appelants trébuchent. Il renvoie True seulement quand il a obtenu une valeur ARGB concrète, et la surcharge courte ne demande jamais les couleurs du bureau au système d'exploitation. Un jeton système ou automatique revient donc False avec Res.Kind réglé à xickSystem ou xickAutomatic, et du code qui lit False comme « pas de couleur » jette silencieusement la signification du jeton. L'unique exception est $4F, la couleur de graphique neutre, qui se résout en noir sans aide. Pour de vraies valeurs RGB sur les autres jetons, passez une méthode TXLSTryResolveSystemColor à la surcharge longue et appliquez votre propre politique pour l'interface, un export ou un service 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 : couleurs fixes au lieu du theme actuel du bureau
  Result := True;
  case AColor of
    xscrWindowText, xscrChartText, xscrToolTipText:
      AArgb := $FF000000;
    xscrWindowBackground, xscrChartBackground:
      AArgb := $FFFFFFFF;
  else
    Result := False;
  end;
end;

var
  Colors: TExportColors;   // créé une fois par votre code d'export
  Res: TXLSIndexedColorResolution;
begin
  // $41 est le jeton d'arrière-plan système, valide pour un remplissage
  if Workbook.ResolveIndexedColor($41, xicsBiffIcv, xicrFillBackground,
    Colors.ResolveSystemColor, Res) then
    PaintBackground(Res.ARGB)        // votre propre routine de dessin
  else if Res.Kind = xickInvalid then
    RejectToken(Res.RawIndex);       // votre propre gestion d'erreur
end;

Pourquoi la distance RGB choisit-elle la mauvaise entrée de palette ?

Les valeurs de canaux sRGB sont encodées en gamma, si bien que des pas égaux dans les nombres ne sont pas des pas égaux dans ce qu'une personne voit, et l'erreur est la plus grande dans les tons sombres et saturés que les modèles d'entreprise adorent. En RGB, $000033 est à 51 du noir et à 77 du bleu marine, donc le noir gagne. Une fois chaque canal linéarisé et la couleur projetée en OKLab, les distances au carré valent environ 0.0312 vers le noir et 0.0235 vers le bleu marine, et le bleu marine gagne. Le résultat est le ColorIndex 11 à l'emplacement physique 18, et les moteurs Classic et XLSX donnent la même réponse

Comment HotXLS ramène le bleu foncé $000033 sur la palette : la distance euclidienne en RGB encodé gamma mesure 51 vers le noir et 77 vers le bleu marine et peindrait l'en-tête en noir, tandis que les distances au carré de ArgbToOklab, 0.0312 et 0.0235, permettent à NearestIndexedColor de choisir le bleu marine, ColorIndex 11 à l'emplacement physique 18
Les valeurs de canaux encodées gamma font de la distance RGB un mauvais proxy de ce qu'un œil perçoit, donc HotXLS convertit une seule fois vers OKLab et laisse une comparaison euclidienne au carré ordinaire piloter le balayage de la palette

La palette par défaut ajoute une complication. Le bleu marine apparaît deux fois, au ColorIndex 11 et de nouveau au 25, si bien que $000033 est exactement aussi proche des deux. Un apparieur qui balaie dans un ordre différent, ou qui utilise inférieur ou égal, choisit 25 sur une exécution et 11 sur une autre build, et deux fichiers générés cessent de se comparer proprement. HotXLS balaie depuis l'emplacement 8 vers le haut avec une comparaison strictement inférieure, si bien qu'une égalité va toujours à l'index le plus bas

OKLab est un compromis délibéré. CIEDE2000 classerait certaines paires de teintes plus fidèlement, mais ses corrections par morceaux le rendent lent et plus difficile à raisonner à l'intérieur d'une boucle itérative. OKLab coûte une linéarisation, deux petits produits de matrices et trois racines cubiques par couleur, après quoi une distance au carré simple est un remplaçant assez bon de la différence perçue. Pour un balayage de palette à 56 entrées qui tourne à chaque affectation de couleur et à l'intérieur d'une passe de clustering, la métrique plus légère et plus régulière est le bon choix

Que renvoie NearestIndexedColor ?

NearestIndexedColor prend une valeur ARGB et remplit un enregistrement TXLSNearestIndexedColorMatch sans modifier le classeur. Chaque classeur garde les 56 couleurs de palette ensemble avec leurs coordonnées OKLab et un compteur de génération de palette, si bien qu'une requête coûte une conversion de l'entrée et un balayage fixe. Réinitialiser la palette reconstruit ce cache, changer une seule entrée ne met à jour que cet emplacement, et une requête contre un cache désynchronisé de la génération courante renvoie False au lieu de deviner

L'alpha suit une règle étroite. Un octet alpha de zéro est traité comme opaque, ce qui correspond à la façon dont la plupart du code Delphi passe des valeurs RGB simples, et $FF est opaque par définition. Tout ce qui est entre les deux est rejeté : la fonction renvoie False avec ColorIndex 0 et PaletteSlot -1, puisqu'une entrée de palette n'a nulle part où stocker la transparence

var
  Match: TXLSNearestIndexedColorMatch;
begin
  if Workbook.NearestIndexedColor($FF000033, Match) then
  begin
    // Palette par défaut : Match.ColorIndex = 11, Match.PaletteSlot = 18,
    // Match.ARGB = $FF000080
    if not Match.ExactMatch then
      LogApproximation(Match.InputARGB, Match.ARGB,   // votre propre logger
        Match.DistanceSquared);
  end;
end;

Le même apparieur se cache derrière les propriétés RGB. Affecter Interior.Color, Font.Color ou Border.Color sur un classeur Classic garde la couleur exacte pour l'enregistrement XFExt et choisit avec ce balayage l'index stocké dans l'enregistrement XF, et les rédacteurs de remplissage et de bordure résolvent RGB et couleurs de thème vers un index de la même façon. Ce que NearestIndexedColor signale pour une couleur est l'emplacement qu'affichera un lecteur qui ne connaît que les index

Faire tenir un classeur en couleurs vraies dans 56 emplacements

L'appariement contre la palette par défaut convient pour une poignée de couleurs. Une charte de marque avec une douzaine de bleus quasi identiques demande plus : c'est la palette elle-même qui doit changer. BuildBiffPalettePlan calcule une proposition complète pour les 56 emplacements et laisse le classeur intact, si bien que vous pouvez la journaliser, la confronter à un seuil ou la jeter

Le planificateur commence par ScanIndexedColorUsage. Tout emplacement référencé par index depuis une police, un remplissage, une bordure, une mise en forme conditionnelle, une forme, un commentaire, une police de graphique ou une ligne de grille de feuille est verrouillé, parce que changer cette entrée recolorerait d'un coup tous les consommateurs de l'index. Les cibles sont les couleurs RGB directes et les couleurs de thème résolues provenant des polices, remplissages, arrêts de dégradé, bordures, styles de mise en forme conditionnelle, barres de données et échelles de couleurs. Chaque cible est pondérée par le plus grand de son nombre de références et de son nombre de définitions, et une mise en forme conditionnelle compte chaque cellule que ses plages couvrent, si bien qu'un remplissage peint sur toute une colonne l'emporte sur une couleur utilisée dans un seul commentaire. Le placement suit ensuite un ordre fixe :

  • Les emplacements verrouillés gardent leur couleur actuelle sans condition
  • Une cible déjà présente dans la palette garde son emplacement correspondant le plus bas, et cet emplacement devient fixe
  • Si les cibles distinctes restantes tiennent dans les emplacements libres, chacune obtient un emplacement exact, attribué en ordre ARGB croissant
  • Sinon Quantized est positionné, chaque emplacement libre est amorcé avec la cible dont la distance au centre existant le plus proche, multipliée par son poids, est la plus grande, et jusqu'à 16 rounds de k-means pondéré en OKLab ne déplacent que les centres libres jusqu'à ce que les affectations cessent de changer
Le pipeline de palette HotXLS pour un classeur en couleurs vraies : ScanIndexedColorUsage verrouille chaque emplacement qu'une police, un remplissage, une bordure, une mise en forme conditionnelle, une forme, un commentaire ou une ligne de grille référence, BuildBiffPalettePlan place les couleurs exactes en ordre ARGB croissant ou lance jusqu'à 16 rounds de k-means pondéré par fréquence en OKLab, et ApplyBiffPalettePlan valide la génération et le hash FNV-1a avant d'écrire
La planification est en lecture seule et répétable, le plan signale ses propres dégâts via WeightedError et MaxDistanceSquared, et un plan périmé est rejeté avec la palette intacte parce que les plans sont en pratique à usage unique

Le chemin de débordement mérite une lecture sans illusion. Le k-means ici est une optimisation locale bornée, et un emplacement libre finit par détenir un centre de cluster reconverti en sRGB avec bornage, ce qui peut être une couleur qu'aucune cellule n'a utilisée telle quelle. Ce que le planificateur délivre, c'est la répétabilité : le même classeur produit toujours le même plan. Le plan signale aussi de combien il a dû plier via WeightedError, MaxDistanceSquared, ExactTargetWeight et TotalTargetWeight, ce qui suffit pour qu'un traitement par lots refuse une sauvegarde quand l'approximation devient trop grossière pour une charte de marque. Le tableau Targets montre, par couleur d'entrée, l'emplacement vers lequel elle se mappe et sa distance au carré

const
  MaxAcceptedError = 0.002;   // votre seuil, en unités OKLab au carré
var
  Plan: TXLSBiffPalettePlan;
  I: Integer;
begin
  Plan := Workbook.BuildBiffPalettePlan;   // lecture seule
  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);           // votre propre logger
  if not Workbook.ApplyBiffPalettePlan(Plan) then
    raise Exception.Create('The palette changed after planning');
end;

Quand ApplyBiffPalettePlan refuse-t-il un plan ?

ApplyBiffPalettePlan vérifie le plan entier avant d'écrire un seul emplacement et renvoie False, palette intacte, si quoi que ce soit diverge du classeur courant. Le plan transporte SourcePaletteGeneration et SourcePaletteHash, un hash FNV-1a 64 bits sur les 56 couleurs sources. La validation revérifie aussi chaque index public et physique, chaque couleur source, qu'aucun emplacement verrouillé n'est marqué comme changé, que les compteurs de verrouillés et de changés s'additionnent, et que chaque cible est opaque

N'importe quel changement réel de palette entre la planification et l'application rend le plan périmé, et cela inclut une application antérieure réussie du même plan. Les plans sont en pratique à usage unique : construisez, inspectez, appliquez, et reconstruisez si vous en avez besoin d'un autre. Un plan valide sans emplacement changé réussit sans faire avancer la génération. Un vrai changement la fait avancer une fois et rafraîchit l'apparieur une fois. Sur le moteur Classic les nouvelles couleurs vont directement dans la palette ; sur le moteur XLSX elles deviennent des couleurs indexées personnalisées, que HasCustomIndexedColor et CustomIndexedColorCount signalent et que le rédacteur XLSX émet en bloc indexedColors

Activation pour les sauvegardes XLS et la conversion XLSX vers XLS

La propriété BiffPaletteSavePolicy vaut xbpsPreserve par défaut, si bien que la palette d'un classeur ne change que sur demande. Mettre xbpsOptimizeTrueColors fait construire et appliquer un plan frais à l'intérieur de SaveAs pour un classeur Classic, et seulement quand le format cible est xlExcel97 ; BIFF5, CSV, HTML, PDF, XLSX et les autres rédacteurs ignorent ce réglage. Après une sauvegarde réussie, la palette optimisée reste dans le classeur, si bien que les requêtes et sauvegardes suivantes voient le même mapping. Si la sauvegarde échoue, est annulée ou lève, les 56 couleurs d'origine et la génération d'origine sont restaurées

Pour les rapports générés, activez la politique d'optimisation. Ces classeurs sont construits à partir de couleurs RGB et de thème, personne n'a peaufiné leur palette à la main, et un lecteur qui ne connaît que les index les affiche bien plus proche de l'original avec. Gardez xbpsPreserve pour les classeurs dont la palette fait partie du contrat, tel qu'un modèle que du code aval édite par ColorIndex, où un emplacement qui change discrètement de couleur casserait ce code

Les sources XLSX empruntent une autre voie. SaveXLSXWorkbookAsXLS dans lxXlsxExport construit un seul plan depuis le TXLSXWorkbook chargé et l'écrit dans la palette de destination avant de convertir le moindre style, si bien que chaque cellule convertie est appariée contre la palette finale. La démonstration d'atelier d'audit et de conversion de classeurs parcourt exactement ce chemin. Les couleurs de thème passent par le même planificateur une fois leur teinte résolue en RGB ; les remplissages de graphiques qui doivent rester des références de thème vivantes sont un cas à part, couvert dans l'article sur les remplissages de graphiques aux couleurs de thème GelFrame, où le XLS binaire stocke un index de schéma au lieu d'une couleur aplatie

// Classeur Classic : à activer, ne concerne que les sauvegardes BIFF8
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
  HandleSaveFailure;   // votre propre gestionnaire ; palette déjà restaurée

// Modèle XLSX vers BIFF8 avec un seul plan de palette déterministe
XWorkbook := TXLSXWorkbook.Create;
try
  if XWorkbook.Open('report.xlsx') = 1 then
    if SaveXLSXWorkbookAsXLS(XWorkbook, 'report.xls') <> 1 then
      HandleSaveFailure;
finally
  XWorkbook.Free;
end;

Les appels de palette (ResolveIndexedColor, NearestIndexedColor, ScanIndexedColorUsage, BuildBiffPalettePlan et ApplyBiffPalettePlan) ont les mêmes signatures sur IXLSWorkbook et TXLSXWorkbook, depuis Delphi et C++Builder. La build d'essai sur la page du composant Excel HotXLS pour Delphi suffit pour faire tourner un plan contre votre modèle le plus coloré et lire MaxDistanceSquared avant que quoi que ce soit soit écrit