技術記事

HotXLSのBIFF8 56色パレットとOKLabカラーマッピング

暗い青の$000033は、距離をRGBチャンネル値上の素朴なユークリッド距離として測ると、黒から51、定番のネイビーエントリ$000080から77の位置にあります。したがって、最近傍RGBマッチングでクラシックな.xlsファイルを書き出すコンバーターは、暗い青の見出し行を黒で塗り、パレットインデックスだけを頼りにするすべてのリーダーが表示するのはその黒です。フォーマットはライターに余地を残しません。セル書式が保存するのはパレットインデックスで、パレットは56エントリしかありません。見出しを正しく着けるには2つのものが要ります。人が見るものを追いかける距離尺度と、ワークブックがパレットの容量より多くの異なる色を使うときに、パレット自体を書き直す手段です

56色という上限はどこから来るのか

BIFF8のフォント、塗りつぶし、罫線レコードはRGB値を一切運びません。運ぶのはカラーコードで、ワークブック全体で1つのPaletteレコード($0092、[MS-XLS] §2.4.188)が、インデックス8~63に対してちょうど56個の不透明RGBエントリを供給します。インデックス0~7は8つの基本色の固定コピーです。63を超える値は色ではなくトークンです。$40はシステム前景色、$41はシステム背景色、$4D~$4Fはチャートテキスト、チャート背景、中立チャート色、$7FFFは自動を意味します。Excel 2007はXFExtレコード($087D、§2.4.355)を追加し、セル書式に完全なRGBやテーマカラーを付加できますが、Excel 97~2003やXFレコードだけを読むパーサーには、インデックス以外は何も見えません

HotXLSはパレットを、1~56の公開ColorIndexとして公開します。物理インデックスから7を引いた値です。3つの採番方式が絡み合い、ResolveIndexedColorはそれらをTXLSIndexedColorSpaceで区別します。1~56のAPI値のためのxicsPublicColorIndex、生のディスク上インデックスのためのxicsBiffIcv、そして64と65がシステム前景色と背景色を意味するxicsOoxmlIndexedです。生のBIFFインデックスは、TXLSIndexedColorRoleとして渡すロールに対しても検査されます。フォント、塗りつぶし、チャートは受け付けるトークン集合が異なり、$51はフォントには正当でも塗りつぶしには不正だからです

HotXLSはTXLSIndexedColorSpaceで3つのインデックスカラースキームを区別します。生のBIFF icv値、8つの基本色に固定された0から7、Paletteレコード$0092の56個のパレットスロット8から63、システム前景色のような63超のトークン、マイナス7オフセットの公開ColorIndex 1から56、そして64と65がシステム前景色・背景色を意味するxicsOoxmlIndexedです
同じカラーインデックスでもスキームごとに意味する数値が違うため、HotXLSは生のBIFFトークンに公開ColorIndexのふりをさせず、すべての値をResolveIndexedColorに通します

ResolveIndexedColorのBoolean戻り値は、呼び出し側がつまずく場所です。Trueを返すのは、具体的なARGB値を得られたときだけで、短いオーバーロードはデスクトップの色をOSに問い合わせることは決してありません。したがってシステムや自動のトークンはFalseで帰り、Res.KindはxickSystemかxickAutomaticに設定されます。Falseを「色なし」と読むコードは、トークンの意味を静かに捨ててしまいます。唯一の例外が$4F、中立チャート色で、これは助けなしに黒へ解決します。他のトークンに対して実際のRGB値を得たいなら、長いオーバーロードにTXLSTryResolveSystemColorメソッドを渡し、UI、出力、ヘッドレスサービスそれぞれの方針を自分で適用します

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
  // ヘッドレス出力:現在のデスクトップテーマの代わりに固定色
  Result := True;
  case AColor of
    xscrWindowText, xscrChartText, xscrToolTipText:
      AArgb := $FF000000;
    xscrWindowBackground, xscrChartBackground:
      AArgb := $FFFFFFFF;
  else
    Result := False;
  end;
end;

var
  Colors: TExportColors;   // 出力コードが1回だけ作成する
  Res: TXLSIndexedColorResolution;
begin
  // $41はシステム背景色トークン、塗りつぶしで有効
  if Workbook.ResolveIndexedColor($41, xicsBiffIcv, xicrFillBackground,
    Colors.ResolveSystemColor, Res) then
    PaintBackground(Res.ARGB)        // 自分の描画ルーチン
  else if Res.Kind = xickInvalid then
    RejectToken(Res.RawIndex);       // 自分のエラー処理
end;

なぜRGB距離は間違ったパレットエントリを選ぶのか?

sRGBのチャンネル値はガンマエンコードされているため、数値上の等しいステップは、人が見るものの等しいステップではありません。誤差が最大になるのは、企業テンプレートが好む暗く彩度の高いトーンです。RGBでは$000033は黒から51、ネイビーから77で、黒が勝ちます。各チャンネルを線形化しOKLabへ投影すると、距離の2乗は黒におよそ0.0312、ネイビーにおよそ0.0235で、ネイビーが勝ちます。マッチは物理スロット18のColorIndex 11で、ClassicとXLSXの両エンジンが同じ答えを出します

HotXLSが暗い青$000033をパレットへ割り付ける仕組み:ガンマエンコードされたRGBのユークリッド距離は黒まで51、ネイビーまで77で見出しは黒く塗られますが、OKLabの距離の2乗0.0312と0.0235により、NearestIndexedColorは物理スロット18のColorIndex 11、ネイビーを選びます
ガンマエンコードされたチャンネル値では、RGB距離は人が見るものの近似として貧弱です。そこでHotXLSは一度OKLabへ変換し、自明なユークリッド距離の2乗比較にパレット走査を任せます

既定パレットにはひねりがあります。ネイビーは2回現れます。ColorIndex 11と、また25です。$000033は両方にまったく同じ距離です。走査順序が違うマッチャーや、以下でも等しいを使うマッチャーは、ある実行では25を、別のビルドでは11を選び、生成された2つのファイルがdiffできなくなります。HotXLSはスロット8から上へ、厳密な未満比較で走査するため、同点は常に小さいインデックスへ落ちます

OKLabは意図的な取引です。CIEDE2000のほうが一部の色相ペアを正確に評価しますが、区分的な補正のせいで遅く、反復ループの中では推論しにくくなります。OKLabは1色あたり線形化1回、小さな行列積2つ、立方根3つで済み、その後は素朴な距離の2乗が知覚差の十分な代役を務めます。色代入のたび、クラスタリングパスの中で走る56エントリのパレット走査には、安くて滑らかな尺度こそ正しい選択です

NearestIndexedColorは何を返すのか?

NearestIndexedColorはARGB値を受け取り、ワークブックを変更せずにTXLSNearestIndexedColorMatchレコードを埋めます。各ワークブックは56のパレット色を、それらのOKLab座標とパレット生成カウンタとともに保持しているため、問い合わせのコストは入力変換1回と固定走査1回です。パレットのリセットはキャッシュを作り直し、1エントリの変更はそのスロットだけを更新し、現在の生成と食い違ったキャッシュへの問い合わせは、推測する代わりにFalseを返します

アルファは狭い規則に従います。アルファバイトの0は不透明として扱われます。素のRGB値を渡す大部分のDelphiコードの流儀に合わせたもので、$FFは定義上不透明です。その間の値は拒否されます。関数はColorIndex 0とPaletteSlot -1でFalseを返します。パレットエントリには透明度を保存する場所がないからです

var
  Match: TXLSNearestIndexedColorMatch;
begin
  if Workbook.NearestIndexedColor($FF000033, Match) then
  begin
    // 既定パレットの場合:Match.ColorIndex = 11、Match.PaletteSlot = 18、
    // Match.ARGB = $FF000080
    if not Match.ExactMatch then
      LogApproximation(Match.InputARGB, Match.ARGB,   // 自分のロガー
        Match.DistanceSquared);
  end;
end;

同じマッチャーがRGBセッターの背後に座っています。ClassicワークブックのInterior.Color、Font.Color、Border.Colorへの代入は、XFExtレコードのために正確な色を保持しつつ、XFレコードに保存するインデックスをこの走査で選びます。塗りつぶしと罫線のライターも、RGBとテーマカラーを同じ道でインデックスへ解決します。ある色についてNearestIndexedColorが報告するものが、インデックス専用リーダーが表示するスロットです

トゥルーカラーのワークブックを56スロットに収める

既定パレットへのマッチングは、少数の色なら十分です。ほぼ同じ青が12色あるブランドテンプレートには足りません。パレット自体を変える必要があります。BuildBiffPalettePlanは56スロット全部の完全な提案を計算し、ワークブックには触れません。ログに取ることも、閾値と比較することも、捨てることも自由です

プランナーはScanIndexedColorUsageから始めます。フォント、塗りつぶし、罫線、条件付き書式、図形、コメント、チャートフォント、ワークシートグリッド線からインデックスで参照されるスロットはすべてロックされます。そのエントリを変えると、そのインデックスのすべての消費者が一度に色付き替えになるからです。ターゲットになるのは、フォント、塗りつぶし、グラデーションストップ、罫線、条件付き書式スタイル、データバー、カラースケールからの、直接のRGBと解決済みテーマカラーです。各ターゲットは参照数と定義数の大きい方で重み付けされ、条件付き書式はその範囲がカバーするすべてのセルを数えます。列全体に塗られた塗りつぶしは、1つのコメントで使われる色を上回ります。配置は固定の順序に従います

  • ロック済みスロットは現在の色を無条件に保持する
  • パレットに既に存在するターゲットは最も小さい一致スロットを保持し、そのスロットは固定になる
  • 残りの異なるターゲットが空きスロットに収まるなら、各ターゲットは正確なスロットを得る。ARGB昇順で割り当てられる
  • 収まらなければQuantizedが設定され、各空きスロットは、既存の最近接中心への距離に重みを掛けた値が最大のターゲットでシードされ、OKLabでの重み付きk-meansが最大16ラウンド、割り当てが変わり止むまで空き中心だけを動かす
トゥルーカラーのワークブックに対するHotXLSパレットパイプライン:ScanIndexedColorUsageがフォント、塗りつぶし、罫線、条件付き書式、図形、コメント、グリッド線が参照するスロットをすべてロックし、BuildBiffPalettePlanがARGB昇順で正確な色を配置するかOKLabで最大16ラウンドの頻度重み付きk-meansを実行し、ApplyBiffPalettePlanが書き込み前に生成カウンタとFNV-1aハッシュを検証します
計画は読み取り専用で再現可能であり、プランはWeightedErrorとMaxDistanceSquaredで自身の被害を報告します。実質的にプランは1回限りの使い捨てなので、古くなったプランはパレットに触れさせずに拒否されます

オーバーフローの経路は、目を曇らせずに読む価値があります。ここのk-meansは有界な局所最適化で、空きスロットには最終的に、クランプ付きでsRGBへ逆変換されたクラスタ中心が入り、どのセルもそのまま使わなかった色になり得ます。プランナーが実際に届けてくれるのは再現性です。同じワークブックは常に同じプランを作ります。プランはどれだけ曲げたかもWeightedError、MaxDistanceSquared、ExactTargetWeight、TotalTargetWeightで報告します。近似がブランドガイドラインに対して粗くなりすぎたら保存を拒否する、というバッチジョブには十分です。Targets配列は、入力色ごとに、割り当て先のスロットと距離の2乗を示します

const
  MaxAcceptedError = 0.002;   // 自分の閾値、OKLab距離の2乗単位
var
  Plan: TXLSBiffPalettePlan;
  I: Integer;
begin
  Plan := Workbook.BuildBiffPalettePlan;   // 読み取り専用
  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;

ApplyBiffPalettePlanはいつプランを拒否するのか?

ApplyBiffPalettePlanは1つのスロットを書く前にプラン全体を検査し、現在のワークブックと食い違うものがあればFalseを返します。パレットには触れません。プランはSourcePaletteGenerationとSourcePaletteHash、56のソース色にわたる64ビットFNV-1aハッシュを運びます。検証はさらに、すべての公開インデックスと物理インデックス、すべてのソース色を再確認し、ロック済みスロットが変更扱いになっていないこと、ロック数と変更数が合計として一致すること、すべてのターゲットが不透明であることも確かめます

計画と適用の間に実際のパレット変更があれば、プランは古くなります。同じプランの先行する成功した適用も含めてです。プランは実質的に1回限りです。組み立て、検査、適用、そしてもう1つ要るなら再構築。変更スロットのない有効なプランは、生成を進めずに成功します。実際の変更は生成を1回進め、マッチャーを1回更新します。Classicエンジンでは新しい色はパレットへ直行し、XLSXエンジンではカスタムインデックスカラーになり、HasCustomIndexedColorとCustomIndexedColorCountが報告し、XLSXライターがindexedColorsブロックとして書き出します

XLS保存とXLSXからXLSへの変換でオプトインする

BiffPaletteSavePolicyプロパティの既定値はxbpsPreserveで、ワークブックのパレットはあなたが求めたときにしか変わりません。xbpsOptimizeTrueColorsを設定すると、ClassicワークブックはSaveAsの中で新しいプランを組み立てて適用します。対象フォーマットがxlExcel97のときだけです。BIFF5、CSV、HTML、PDF、XLSXその他のライターはこの設定を無視します。成功した保存の後、最適化されたパレットはワークブックに残るため、以後の問い合わせと保存は同じ写像を見ます。保存が失敗したり、キャンセルされたり、例外を上げたりしたら、元の56色と元の生成が復元されます

生成されるレポートでは、最適化ポリシーをオンにしてください。そういうワークブックはRGBとテーマカラーから組み立てられ、誰もパレットを手で調整しておらず、このポリシーありではインデックス専用リーダーへの見え方がはるかに原色に近づきます。パレットが契約の一部であるワークブックにはxbpsPreserveを保ってください。下流コードがColorIndexで編集するテンプレートのように、スロットが静かに色を変えたらそのコードが壊れるものです

XLSXソースは別の道を通ります。lxXlsxExportのSaveXLSXWorkbookAsXLSは、読み込まれたTXLSXWorkbookから1つのプランを組み立て、スタイルを1つでも変換する前に宛先パレットへ書き込みます。したがって変換されたすべてのセルは最終パレットに対してマッチングされます。ワークブック監査と変換ワークベンチのデモがまさにこの道を走ります。テーマカラーは、ティントがRGBへ解決された後、同じプランナーを通ります。生きたテーマ参照のままにすべきチャート塗りつぶしは別のケースで、バイナリXLSが平坦化された色の代わりにスキームインデックスを保存する話は、GelFrameテーマカラーチャート塗りつぶしの記事が扱います

// Classicワークブック:オプトイン、BIFF8保存のみに適用
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
  HandleSaveFailure;   // 自分のハンドラ。パレットは既に復元済み

// XLSXモデルからBIFF8へ、決定論的なパレットプラン1つで
XWorkbook := TXLSXWorkbook.Create;
try
  if XWorkbook.Open('report.xlsx') = 1 then
    if SaveXLSXWorkbookAsXLS(XWorkbook, 'report.xls') <> 1 then
      HandleSaveFailure;
finally
  XWorkbook.Free;
end;

パレットの呼び出し(ResolveIndexedColor、NearestIndexedColor、ScanIndexedColorUsage、BuildBiffPalettePlan、ApplyBiffPalettePlan)は、IXLSWorkbookとTXLSXWorkbookで同じシグネチャを持ち、DelphiとC++Builderから使えます。HotXLS Delphi Excelコンポーネントページのトライアルビルドで、最も色数の多いテンプレートに対してプランを実行し、何かが書き込まれる前にMaxDistanceSquaredを読むには十分です