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

Цвета темы Excel для заливок Chart в Delphi: HotXLS GelFrame

Series chart с literal RGB не следует теме workbook. Поменяйте тему — series сохранит старый цвет. HotXLS обрабатывает это в binary XLS через theme-colored fills series chart: record GelFrame, 4198 или $1066, записанный сразу после AreaFormat внутри блока series и содержащий OfficeArt scheme index и tint. Затем Excel рендерит series так же, как theme fill, записанный им самим

Откуда берётся номер record GelFrame?

Номер record GelFrame — 4198 ($1066), и в собственной секции спецификации record вы его не найдёте. [MS-XLS] 2.4.131 описывает содержимое GelFrame, но, в отличие от большинства record sections, не указывает значение rt. ABNF chart substream тоже не помогает: там есть только production GELFRAME = 1*2GelFrame *Continue, называющая record без номера. Номер живёт в таблице перечисления record numbers, на нескольких страницах от раздела, документирующего payload. Эту production стоит перечитать каждому, кто пишет reader: она допускает один или два records GelFrame, за каждым могут следовать Continue records, поэтому parser, предполагающий один record на production, неправильно обработает файл, которого сам не создавал. HotXLS выдаёт ровно один GelFrame на theme series, как Excel делает для простой solid theme fill, а decoder трактует record как self-contained payload, не предполагая фиксированное count

Внутри GelFrame payload: две таблицы свойств OfficeArt

GelFrame payload состоит из двух таблиц свойств OfficeArt, расположенных подряд: OfficeArtFOPT (называется OPT1), затем OfficeArtTertiaryFOPT (OPT2). Каждая таблица начинается с двухбайтного count свойств, за которым следуют столько шестибайтных FOPTE entries, сколько указано, а каждая entry содержит двухбайтный opid и четырёхбайтный op. Бит 15 в opid — это fComplex: если он установлен, значение op является длиной в байтах, и после фиксированных entries следует variable tail. Decoder, игнорирующий такие хвосты, рассинхронизируется и прочитает мусорные opid для всего после первого complex property

Theme fill выражается тремя properties, распределёнными по обеим таблицам, плюс одним, объявляющим тип fill. HotXLS записывает четыре свойства в 28 байт без complex tails:

  • fillType $0180 в OPT1, установленный в 1 (msofillSolid)
  • fillColor $0181 в OPT1, flattened RGB, который нарисует старый или не знающий о теме consumer
  • fillColorExt $019E в OPT2, базовый theme color
  • fillColorExtMod $01A0 в OPT2, tint или shade, применённый к базе

Такое разделение заложено в формате намеренно, а не является случайностью реализации: [MS-ODRAW] 2.2.2 описывает theme triple как flat color плюс base color плюс modification, поэтому consumer, понимающий темы, пересчитывает fill, а не понимающий всё равно рисует что-то разумное. Соседние opid следуют тому же шаблону и имеют одинаковую нумерацию в старой и текущей редакциях [MS-ODRAW], что удобно при сверке двух версий: fillOpacity $0182, fillBackColor $0183, fillShadeType $019C, fillBackColorExt $01A2 и fillBackColorExtMod $01A4

Почему scheme index находится в красном байте?

Потому что OfficeArtCOLORREF определяется смещением байта, а не числовым значением: red находится в byte 0, green в byte 1, blue в byte 2, flags в byte 3. Прочитайте эту структуру как little-endian DWORD, чем и является каждый FOPTE op, и red станет младшим значащим byte. Рабочий пример lineColor в [MS-ODRAW] это подтверждает. Значит, fSchemeIndex, являющийся flags bit E, имеет numeric value $08000000, а сам scheme index помещается в red byte, при этом green и blue обязаны быть нулём. Поэтому Accent1 имеет op value $08000004, а не $00000004 и тем более не $04000000

Порядок theme index, который спецификация отказывается определять

Спецификация объявляет порядок scheme index зависящим от host и не даёт таблицы, то есть одного byte layout недостаточно для interop с Excel. HotXLS использует порядок spreadsheet theme, именно он даёт round-trip с настоящими файлами Excel:

  • 0 = lt1, 1 = dk1, 2 = lt2, 3 = dk2
  • 4–9 = accent1–accent6
  • 10 = hlink, 11 = folHlink

Tint и shade: payload MSOTINTSHADE

Значение fillColorExtMod является MSOTINTSHADE и кодирует направление и величину в одном DWORD, а не в signed fraction. Значение $20000000 означает отсутствие изменения. Осветляющий tint имеет форму $02F4 shl 16 or amount shl 8 or $10 (MSOTINT), а затемняющий — ту же форму с $01F4 в старшем word (MSOSHADE). Значение byte amount идёт в направлении, противоположном интуиции: $FF означает «без изменений», а $00 — полное изменение. HotXLS нормализует это к одному double в стиле DrawingML, где положительное значение осветляет, а отрицательное затемняет, используя плюс или минус (255 - amount) / 255. Для значений, которые Excel реально предлагает в UI, mapping точен, поэтому round-trip получается lossless, а не приблизительно lossless: знакомое «Lighter 40%» — это amount 153, а (255 - 153) / 255 равно 0.4 без ошибки округления в любом направлении. Shade с amount 191 возвращается как -64/255. Ниже encoder с ограничением до допустимого диапазона:

if Tint > 0 then                       // MSOTINT — светлее
  TintOp := LongWord($02F4) shl 16 or
    (LongWord(Round(255 * (1 - Tint))) shl 8) or $10
else if Tint < 0 then                  // MSOSHADE — темнее
  TintOp := LongWord($01F4) shl 16 or
    (LongWord(Round(255 * (1 + Tint))) shl 8) or $10
else
  TintOp := $20000000;                 // MSOCOLORMODUNDEFINED

Как задать и прочитать theme fill в Delphi

На стороне записи theme fill — это два дополнительных поля в style record отдельной series. TXLSChartSeriesStyleInfo получил HasFillTheme, FillThemeColor и FillThemeTint, а builder выдаёт GelFrame только когда одновременно установлены HasStyle и HasFillTheme. Если вы также задаёте явный FillRgb, это значение без изменений попадает в OPT1 fillColor; если не задаёте, HotXLS сам flatten-ит цвет через встроенную таблицу default Office theme с применённым tint, поэтому series только с theme всё равно имеет разумный flat color для consumers, игнорирующих OPT2. Обратите внимание на инициализацию Default(): она важна, поскольку TXLSChartSeriesInfo содержит managed fields, а обычные Boolean members иначе являются мусором стека:

var
  Wb: TXLSWorkbook;
  Series: array [0..1] of TXLSChartSeriesInfo;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Sheets.Add.Name := 'Data';

    Series[0] := Default(TXLSChartSeriesInfo);   // никогда не использовать FillChar для этой записи
    Series[0].Name := 'Explicit';
    Series[0].Categories := 'Data!$A$1:$A$2';
    Series[0].Values := 'Data!$B$1:$B$2';
    Series[0].HasStyle := True;
    Series[0].Style.HasFill := True;
    Series[0].Style.FillRgb := $C47244;          // accent1, red в младшем byte
    Series[0].Style.HasFillTheme := True;
    Series[0].Style.FillThemeColor := 4;         // accent1
    Series[0].Style.FillThemeTint := 0.4;        // Lighter 40%

    Series[1] := Default(TXLSChartSeriesInfo);
    Series[1].Name := 'ThemeOnly';
    Series[1].Categories := 'Data!$A$1:$A$2';
    Series[1].Values := 'Data!$C$1:$C$2';
    Series[1].HasStyle := True;
    Series[1].Style.HasFillTheme := True;        // без явного RGB: значение flattened
    Series[1].Style.FillThemeColor := 8;         // accent5

    Wb.Sheets.AddChartSheet('Themed', xlsChartTypeColumn, '', '', '', Series);
    Wb.SaveAs('themed.xls');
  finally
    Wb.Free;
  end;
end;

Обратное чтение проходит через ту же chart model, которую использует остальная HotXLS chart inspection. GetChartModel возвращает owned TXLSChartModel, который нужно освободить, а каждый TXLSChartSeries предоставляет HasFillTheme, FillThemeColor и FillThemeTint рядом с FillRgb, декодированным из OPT1 fillColor, который имеет приоритет над цветом AreaFormat этой series. Те же три значения попадают и в canonical semantic snapshot как SolidFillThemeSet, SolidFillThemeColor и SolidFillThemeTint, поэтому workbook diff видит изменение темы как изменение темы, а не как необъяснимый RGB drift. Если вы пришли со стороны XLSX, это binary-format counterpart стилей, описанных в руководстве HotXLS по Excel charts, images и drawings в Delphi:

Wb := TXLSWorkbook.Create;
try
  Wb.Open('themed.xls');
  Model := Wb.Sheets[2]._Chart.GetChartModel;
  try
    Ser := Model.GetSeries(0);
    if Ser.HasFillTheme then
    begin
      WriteLn(Ser.FillThemeColor);            // 4 = accent1
      WriteLn(Ser.FillThemeTint:0:3);         // 0.400
      WriteLn(IntToHex(Ser.FillRgb, 6));      // C47244, OPT1 fillColor
    end;
  finally
    Model.Free;
  end;
finally
  Wb.Free;
end;

Чего не обещает theme fill в binary XLS?

Три честных ограничения. Первое и самое важное для любого, кто проверяет этот код: ни один sample file в локальном corpus вообще не содержит record GelFrame. Одиннадцать вхождений пары байт 66 10 в sample с conditional formatting находятся не на границах records, а полный dump stream находит ноль совпадений. Описанный здесь bit layout был выведен из спецификации, а затем закреплён тремя способами: decode symmetry на output builder, вручную собранные byte tests, подающие синтетический payload $1066 прямо в decoder, и проверка точного flattened RGB. Это более слабое свидетельство, чем захваченный Excel-файл, и стоит сказать об этом, а не создавать обратное впечатление. Второе: flattening fill, содержащего только theme, использует встроенную таблицу default Office theme, а не theme part, прочитанный из workbook, потому что binary XLS не имеет theme part в том смысле, в каком он есть у packaged XLSX — если flat color должен определяться собственной темой workbook, задайте FillRgb самостоятельно. Третье: decoder принимает GelFrame только внутри блока series; тот же record может находиться в chart area или axis frame, и его принятие там тихо приписало бы background fill series, поэтому такие случаи игнорируются. fillColorExt без флага $08000000 также считается обычным extended color и никогда не устанавливает HasFillTheme. Для workbook, где chart создан в мире XLSX и проходит через этот формат только транзитом, безопаснее использовать preservation path из статьи об редактировании Excel charts без потери ChartML, а контейнер, в котором находятся эти records, описан в статье о чтении compound OLE2 files в Delphi без COM IStorage

Theme-colored chart fills, GelFrame encoder и decoder, а также полный builder BIFF8 chart substream входят в табличный компонент HotXLS для Delphi для Delphi и C++Builder, который читает и записывает XLS, XLSX и ODS без установленного Excel