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

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

Тёмно-синий $000033 отстоит от чёрного на 51 единицу, а от классического navy-цвета $000080 на 77, если мерить расстояние как простое евклидово расстояние по значениям каналов RGB. Конвертер, пишущий классические .xls файлы с подбором по ближайшему RGB, поэтому красит тёмно-синюю строку заголовка в чёрный, и чёрный показывают все читатели, которые судят по индексу палитры. Формат не оставляет писателю простора: формат ячейки хранит индекс палитры, а в палитре 56 записей. Чтобы заголовок был правильным, нужны две вещи: мера расстояния, отслеживающая то, что видят люди, и способ переписать саму палитру, когда книга использует больше различимых цветов, чем она может вместить

Откуда берётся предел в 56 цветов

Запись шрифта, заливки или границы BIFF8 никогда не несёт значения RGB. Она несёт индекс цвета, а глобальная для книги запись Palette ($0092, [MS-XLS] §2.4.188) поставляет ровно 56 непрозрачных RGB-значений для индексов с 8 по 63. Индексы с 0 по 7 — фиксированные копии восьми базовых цветов. Значения выше 63 — вовсе не цвета, а токены: $40 — системный цвет текста, $41 — системный фон, с $4D по $4F — текст диаграммы, фон диаграммы и нейтральный цвет диаграммы, а $7FFF означает «автоматически». Excel 2007 добавил запись XFExt ($087D, §2.4.355), способную привязать к формату ячейки полный RGB или цвет темы, но Excel 97–2003 и любой парсер, читающий только запись XF, по-прежнему не видят ничего, кроме индекса

HotXLS выставляет палитру через публичный ColorIndex от 1 до 56, что есть физический индекс минус 7. В игре три схемы нумерации, и ResolveIndexedColor разводит их через TXLSIndexedColorSpace: xicsPublicColorIndex для значений API 1..56, xicsBiffIcv для сырых дисковых индексов и xicsOoxmlIndexed, где 64 и 65 означают системные текст и фон. Сырые BIFF-индексы дополнительно сверяются с ролью, которую вы передаёте как TXLSIndexedColorRole, потому что шрифт, заливка и диаграмма принимают разные наборы токенов; $51 законен для шрифта и неверен для заливки

HotXLS разводит три схемы индексированных цветов через TXLSIndexedColorSpace: сырые значения BIFF icv, от 0 до 7 — фиксированные восемь базовых цветов, 56 слотов палитры с 8 по 63 из записи Palette $0092, токены выше 63 вроде системного цвета текста, публичный ColorIndex от 1 до 56 со смещением минус 7 и xicsOoxmlIndexed, где 64 и 65 означают системные текст и фон
Один и тот же индекс цвета означает разные числа в каждой схеме, поэтому HotXLS проводит каждое значение через ResolveIndexedColor, а не позволяет сырому BIFF-токену рядиться в публичный ColorIndex

Логический результат ResolveIndexedColor — место, где спотыкаются вызывающие. Он возвращает True только когда получил конкретное значение ARGB, а короткая перегрузка никогда не спрашивает у операционной системы цвета рабочего стола. Поэтому системный или автоматический токен возвращается с 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;   // создаётся один раз вашим экспортирующим кодом
  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 и от navy на 77, так что побеждает чёрный. Стоит линеаризовать каждый канал и спроецировать цвет в OKLab, и квадраты расстояний — около 0.0312 до чёрного и 0.0235 до navy — отдают победу navy. Совпадение — ColorIndex 11 на физическом слоте 18, и Classic-, и XLSX-движки дают одинаковый ответ

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

Палитра по умолчанию добавляет загвоздку. Navy встречается дважды — на ColorIndex 11 и снова на 25, так что $000033 находится ровно на одном расстоянии от обоих. Подборщик, сканирующий в другом порядке или использующий «меньше или равно», выберет 25 на одном прогоне и 11 на другой сборке, и два сгенерированных файла перестанут чисто сравниваться. HotXLS сканирует от слота 8 вверх со строгим «меньше», поэтому ничья всегда достаётся меньшему индексу

OKLab — осознанный компромисс. CIEDE2000 ранжировал бы некоторые пары оттенков точнее, но его кусочные поправки делают его медленным и трудным для рассуждений внутри итеративного цикла. OKLab стоит линеаризации, двух небольших матричных произведений и трёх кубических корней на цвет, после чего простое квадратичное расстояние — достаточно хорошая замена воспринимаемой разнице. Для скана палитры на 56 записей, выполняющегося при каждом назначении цвета и внутри прохода кластеризации, более дешёвая и гладкая метрика — правильный выбор

Что возвращает NearestIndexedColor?

NearestIndexedColor принимает значение ARGB и заполняет запись TXLSNearestIndexedColorMatch, не меняя книгу. Каждая книга держит 56 цветов палитры вместе с их OKLab-координатами и счётчиком поколения палитры, поэтому запрос стоит одного преобразования входа и одного фиксированного скана. Сброс палитры перестраивает этот кэш, изменение одной записи обновляет только этот слот, а запрос к кэшу, отставшему от текущего поколения, возвращает False вместо догадок

Альфа следует узкому правилу. Альфа-байт ноль трактуется как непрозрачный — так передаёт простые RGB большинство Delphi-кодов, а $FF непрозрачен по определению. Всё промежуточное отвергается: функция возвращает False с ColorIndex 0 и PaletteSlot -1, ведь записи палитры некуда складывать прозрачность

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 стоит тот же подборщик. Присвоение Interior.Color, Font.Color или Border.Color в Classic-книге сохраняет точный цвет для записи XFExt и выбирает этим сканом индекс, сохраняемый в записи XF, а писатели заливок и границ разрешают RGB и цвета темы в индекс тем же способом. Что бы NearestIndexedColor ни отчитал для цвета — именно этот слот покажет читатель, знающий только индексы

Уместить true-color книгу в 56 слотов

Подбор против палитры по умолчанию хорош для горстки цветов. Фирменному шаблону с дюжиной почти одинаковых синих нужно больше: сама палитра должна измениться. BuildBiffPalettePlan вычисляет полный план для всех 56 слотов и не трогает книгу, так что план можно залогировать, сверить с порогом или выбросить

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

  • Заблокированные слоты безусловно сохраняют текущий цвет
  • Цель, уже существующая в палитре, сохраняет свой низший совпадающий слот, и этот слот становится фиксированным
  • Если оставшиеся различимые цели влезают в свободные слоты, каждая получает точный слот, назначаемый в порядке возрастания ARGB
  • Иначе выставляется Quantized, каждый свободный слот засеивается целью, чьё расстояние до ближайшего существующего центра, умноженное на её вес, наибольшее, и до 16 раундов взвешенного k-means в OKLab двигают только свободные центры, пока назначения не перестанут меняться
Конвейер палитры HotXLS для книги в true color: ScanIndexedColorUsage блокирует каждый слот, на который ссылается шрифт, заливка, граница, условное форматирование, фигура, примечание или линия сетки, BuildBiffPalettePlan размещает точные цвета в порядке возрастания ARGB или прогоняет до 16 раундов частотно-взвешенного k-means в OKLab, а ApplyBiffPalettePlan проверяет поколение и FNV-1a-хэш перед записью
Планирование только читает и повторяемо, план отчитывается о собственном ущербе через WeightedError и MaxDistanceSquared, а устаревший план отклоняется с нетронутой палитрой, потому что планы фактически одноразовые

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

const
  MaxAcceptedError = 0.002;   // ваш порог, в квадратных OKLab-единицах
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 исходным цветам. Валидация также перепроверяет каждый публичный и физический индекс, каждый исходный цвет, что ни один заблокированный слот не помечен как изменённый, что счётчики заблокированных и изменённых сходятся и что каждая цель непрозрачна

Любое настоящее изменение палитры между планированием и применением делает план устаревшим, и это включает успешное более раннее применение того же плана. На практике планы одноразовые: постройте, осмотрите, примените — и стройте заново, если нужен ещё один. Валидный план без изменённых слотов проходит, не двигая поколение. Настоящее изменение двигает его один раз и один раз освежает подборщик. На 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 идут другим маршрутом. SaveXLSXWorkbookAsXLS в lxXlsxExport строит один план из загруженной TXLSXWorkbook и пишет его в палитру назначения до конверсии любого стиля, поэтому каждая сконвертированная ячейка сверяется с финальной палитрой. Демка рабочего места аудита и конверсии книг идёт ровно этим путём. Цвета темы проходят тот же планировщик, как только их tint разрешён в RGB; заливки диаграмм, которые должны остаться живыми ссылками на тему, — отдельный случай, разобранный в статье о theme-цветах заливок диаграмм 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
    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, прежде что-то будет записано