Техническая статья

Палитра 56 цветов BIFF8: OKLab-маппинг в HotXLS

HotXLS раскладывает произвольные RGB и цвета темы на палитру BIFF8 из 56 слотов в два слоя: NearestIndexedColor находит перцептуально ближайший существующий элемент палитры в пространстве OKLab, а BuildBiffPalettePlan с ApplyBiffPalettePlan переписывают свободные слоты палитры, чтобы книга в true color пережила сохранение в классический XLS. Спусковым крючком всегда служит один и тот же тикет из поддержки. Кто-то собирает отчёт в XLSX с корпоративными тёмно-синими заголовками и мягким бирюзовым акцентом, сохраняет его как .xls для легаси-потребителя — и заголовки возвращаются чисто чёрными, а бирюза превращается в кислотную. Ничего не упало, ни одного предупреждения не выскочило. Цветовая модель старого формата просто не вмещает то, что описал новый, а библиотеке пришлось выбрать что-то

Почему файл XLS может хранить всего 56 цветов?

Потому что формат ячейки BIFF8 никогда не хранит значение RGB: шрифты, заливки и границы несут индекс цвета, а глобальная для книги запись Palette ($0092, [MS-XLS] §2.4.188) поставляет ровно 56 непрозрачных RGB-элементов для индексов с 8 по 63. Индексы 0–7 — фиксированные копии восьми базовых цветов, а значения выше 63 — вовсе не цвета, а токены вроде системного цвета текста, системного фона и текста диаграмм. HotXLS выставляет палитру через публичный ColorIndex от 1 до 56, то есть физический индекс минус 7, а ResolveIndexedColor разводит три схемы нумерации через TXLSIndexedColorSpace: xicsPublicColorIndex для значений API 1..56, xicsBiffIcv для сырых дисковых индексов, которые проверяются на подмножество IcvFont, IcvXF или IcvChart в зависимости от переданной роли, и xicsOoxmlIndexed, где 64 и 65 означают системные текст и фон

HotXLS разводит три схемы индексированных цветов через TXLSIndexedColorSpace: сырые значения BIFF icv, от 0 до 7 — фиксированные восемь базовых цветов, 56 слотов палитры с 8 по 63 из записи Palette $0092, токены выше 63 вроде системного цвета текста, публичный ColorIndex от 1 до 56 со смещением минус 7 и xicsOoxmlIndexed, где 64 и 65 означают системные текст и фон
Один и тот же индекс цвета означает разные числа в каждой схеме, поэтому HotXLS проводит каждое значение через ResolveIndexedColor, а не позволяет сырому BIFF-токену рядиться в публичный ColorIndex
var
  Res: TXLSIndexedColorResolution;
begin
  // $40 — это BIFF-токен icv, а не слот палитры
  Workbook.ResolveIndexedColor($40, xicsBiffIcv, Res);
  case Res.Kind of
    xickPalette:   UseArgb(Res.ARGB);   // слот палитры, если разрешился
    xickAutomatic,
    xickSystem:    UseSystemColor(Res.SystemColorRole);
    xickInvalid:   RejectToken(Res.RawIndex);
  end;
end;

Обратите внимание: пример ветвится по Res.Kind и игнорирует булево возвращаемое значение. ResolveIndexedColor возвращает True, только когда получил конкретный ARGB, а короткая перегрузка никогда не читает рабочий стол Windows, поэтому токен automatic или system совершенно легально возвращается как False, оставаясь классифицированным как xickSystem. HotXLS сам на этом споткнулся в собственном сериализаторе книг: код, считающий False признаком «цвета нет», молча выбрасывает смысл токенов Automatic и System. Если вам нужны реальные значения RGB для таких токенов, зовите длинную перегрузку и передайте TXLSTryResolveSystemColor callback, применяющий вашу собственную политику UI, экспорта или headless-сценария

Почему HotXLS подбирает цвета в OKLab, а не в RGB?

Потому что значения каналов sRGB закодированы гаммой, так что евклидово расстояние в RGB не отслеживает то, что видит человек, и ошибка максимальна ровно в тех тёмных насыщенных тонах, которые так любят корпоративные палитры. Возьмите тёмно-синий $000033. В RGB расстояние до чёрного — 51, а до дефолтного элемента navy $000080 — 77, так что RGB-матчер уверенно красит ваш заголовок в чёрный. В OKLab квадраты расстояний — примерно 0.0312 до чёрного и 0.0235 до navy, и HotXLS выбирает navy, ColorIndex 11 на физическом слоте 18; именно этот случай закреплён в тестовом наборе для обоих движков, Classic и XLSX. Конверсия внутри ArgbToOklab линеаризует каждый канал sRGB, применяет LMS-матрицу OKLab, извлекает кубические корни и проецирует на L, a и b, после чего обычное квадратичное евклидово расстояние — разумная мера воспринимаемой разницы. OKLab — не CIEDE2000 и не притворяется им, но у него нет кусочных поправок оттенка, он стоит несколько умножений на цвет и достаточно стабилен, чтобы вести кластеризующий цикл — вот где он по-настоящему окупается

Как HotXLS подбирает место на палитре для тёмно-синего $000033: евклидово расстояние в гамма-кодированном RGB даёт 51 до чёрного и 77 до navy и покрасило бы заголовок в чёрный, а квадраты расстояний ArgbToOklab 0.0312 и 0.0235 позволяют NearestIndexedColor выбрать navy, ColorIndex 11 на физическом слоте 18
Гамма-кодированные значения каналов делают расстояние в RGB плохой мерой того, что видит человек, поэтому HotXLS один раз конвертирует в OKLab и пускает обычное квадратичное евклидово сравнение вести обход палитры

Что гарантирует NearestIndexedColor?

NearestIndexedColor даёт детерминированный ответ в режиме только чтения: одна конверсия входа, один фиксированный проход по 56 закэшированным элементам и минимальный публичный индекс всякий раз, когда два элемента равноудалены. Каждая книга кэширует нормализованный ARGB и координаты OKLab всех 56 физических слотов вместе со счётчиком поколений палитры. Сброс палитры перестраивает кэш, изменение одиночного слота обновляет только его, а запрос против устаревшего поколения возвращает False вместо догадок. Проход использует строгое сравнение «меньше» начиная со слота 8 — потому палитра, содержащая один и тот же цвет дважды, всегда отвечает меньшим индексом; это важно, когда вы диффите два сгенерированных файла и ждёте побайтово идентичный вывод. Входная альфа живёт по узкому контракту: нулевой байт альфы считается непрозрачным, а частично прозрачное значение отклоняется с ColorIndex 0 и PaletteSlot -1, ведь у элементов палитры нет альфы. Райтеры заливок и границ Classic-движка конвертируют RGB и цвета темы в индекс той же OKLab-процедурой подбора в момент сохранения, так что API и сохранённый файл сходятся в том, на какой слот ложится цвет

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;

Как BuildBiffPalettePlan умещает true color в 56 слотов?

BuildBiffPalettePlan вычисляет полный проект по всем 56 слотам, не трогая книгу, так что его можно изучить, залогировать или выбросить. Планировщик сначала вызывает ScanIndexedColorUsage: любой слот, на который шрифт, заливка, граница, условное форматирование, фигура, примечание или линии сетки листа ссылаются по индексу, блокируется, потому что изменение элемента палитры перекрашивает всех потребителей этого индекса разом. Цели — прямые RGB и разрешённые цвета темы из шрифтов, заливок, границ, дифференциальных стилей, гистограмм и цветовых шкал. Каждая цель получает вес по большему из числа ссылок в отрисовке и числа определений, причём условное форматирование считает ячейки, которые покрывают его диапазоны, так что цвет, залитый через весь столбец, перевешивает цвет из одного примечания. Дальше размещение идёт в фиксированном порядке:

  • Заблокированные слоты безусловно сохраняют свой исходный цвет
  • Цель, уже присутствующая в палитре, остаётся на своём минимальном совпадающем слоте, и этот слот фиксируется
  • Если оставшиеся уникальные цели влезают в свободные слоты, каждая получает точный слот, назначаемый в порядке возрастания ARGB
  • Иначе выставляется Quantized, каждый свободный слот засеивается целью, у которой расстояние до ближайшего существующего центра, умноженное на её вес, максимально, и до 16 раундов частотно-взвешенного k-means в OKLab двигают только свободные центры, пока назначения не перестанут меняться

Будьте честны с собой насчёт того, что даёт путь переполнения. Кластеризация — ограниченная локальная оптимизация, а не глобальный оптимум, и свободный слот в итоге держит центроид, сконвертированный обратно в sRGB с клампингом, то есть цвет, который ни одна ячейка не использовала дословно. Что вы получаете гарантированно — повторяемость: одна и та же книга всегда даёт один и тот же план, а план отчитывается о собственном ущербе через WeightedError, MaxDistanceSquared, ExactTargetWeight и TotalTargetWeight, так что батч-задача может отказаться сохранять, когда аппроксимация становится слишком грубой для брендбука

Конвейер палитры HotXLS для книги в true color: ScanIndexedColorUsage блокирует каждый слот, на который ссылается шрифт, заливка, граница, условное форматирование, фигура, примечание или линия сетки, BuildBiffPalettePlan размещает точные цвета в порядке возрастания ARGB или прогоняет до 16 раундов частотно-взвешенного k-means в OKLab, а ApplyBiffPalettePlan проверяет поколение и FNV-1a-хэш перед записью
Планирование только читает и повторяемо, план отчитывается о собственном ущербе через WeightedError и MaxDistanceSquared, а устаревший план отклоняется с нетронутой палитрой, потому что планы фактически одноразовые
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 валидирует весь план до того, как записать хоть один слот, и возвращает False с нетронутой палитрой, если что-то расходится с текущим состоянием книги. План несёт SourcePaletteGeneration и SourcePaletteHash — 64-битный FNV-1a-хэш по 56 исходным цветам; валидация также перепроверяет каждый публичный и физический индекс, каждый исходный цвет, что ни один заблокированный слот не помечен как изменённый, счётчики заблокированных и изменённых и то, что каждая цель непрозрачна. Любое фактическое изменение палитры в промежутке, включая успешное более раннее применение того же плана, делает план устаревшим, так что планы фактически одноразовые. Валидный план без изменённых слотов проходит успешно, не двигая поколение, а реальное изменение один раз поднимает поколение и один раз перестраивает OKLab-матчер — на Classic-движке перезаписью фиксированного массива палитры, на XLSX-движке подстановкой подготовленного списка переопределений индексированных цветов

Включаем для сохранений в BIFF8 и конвертации XLSX в XLS

Свойство BiffPaletteSavePolicy по умолчанию равно xbpsPreserve, так что обновление HotXLS никогда не переписывает ничью палитру за спиной. Установка в xbpsOptimizeTrueColors заставляет классическую книгу строить и применять свежий план внутри SaveAs, но только когда целевой формат — xlExcel97; райтеры BIFF5, CSV, HTML, PDF, XLSX и прочие настройку игнорируют. После успешного сохранения оптимизированная палитра остаётся в модели книги, так что последующие запросы и сохранения видят то же отображение. Если сохранение провалилось или отменено, исходные 56 цветов и исходное поколение восстанавливаются. Для источников XLSX SaveXLSXWorkbookAsXLS в lxXlsxExport строит один план из загруженной книги и записывает его в палитру приёмника до конвертации любого стиля — это тот детерминированный мост, который проходит демо аудита книги и конверсионного верстака. Цвета темы проходят через тот же планировщик после разрешения их tint в RGB; если хотите держать темы живыми в заливках диаграмм, статья про тематические цвета заливок диаграмм GelFrame рассказывает, как бинарный XLS хранит индекс схемы вместо сплющенного цвета

// Классическая книга: включаем явно, только BIFF8
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
  HandleSaveFailure;   // палитра уже восстановлена

// Модель XLSX в BIFF8 с одним детерминированным планом палитры
XWorkbook := TXLSXWorkbook.Create;
try
  if XWorkbook.Open('report.xlsx') = 1 then
    SaveXLSXWorkbookAsXLS(XWorkbook, 'report.xls');
finally
  XWorkbook.Free;
end;

Палитровые API HotXLS работают одинаково на IXLSWorkbook и TXLSXWorkbook, что из Delphi, что из C++Builder. Скачайте триал и направьте его на самую цветастую вашу таблицу со страницы компонента HotXLS Delphi Excel