Технічна стаття

Theme colors Excel charts у Delphi: HotXLS GelFrame

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

Звідки береться record number GelFrame?

Record number GelFrame — 4198 ($1066), і в його власній specification section ви його не знайдете. [MS-XLS] 2.4.131 описує, що містить GelFrame, але, на відміну від більшості record sections, не вказує value rt. Chart substream ABNF теж не допомагає: він дає лише production GELFRAME = 1*2GelFrame *Continue, називаючи record без numbering. Number живе в record-number enumeration table, за кілька pages від section, що документує payload. На цю production варто поглянути вдруге кожному, хто пише reader: вона дозволяє один або два GelFrame records, кожен із optional Continue records після нього, тому parser, який припускає один record на production, неправильно обробить file, якого сам не записував. HotXLS emit-ить рівно один GelFrame per themed series, як це робить Excel для простого solid theme fill, а decoder трактує record як self-contained payload і не припускає fixed count

Усередині GelFrame payload: дві OfficeArt property tables

GelFrame payload — це дві OfficeArt property tables одна за одною: OfficeArtFOPT (названа OPT1), потім OfficeArtTertiaryFOPT (OPT2). Кожна table — це two-byte property count і стільки six-byte FOPTE entries, кожен entry має two-byte opid і four-byte op. Bit 15 у opid — це fComplex: коли він set, value op є byte length, і після fixed entries іде variable tail. Decoder, який ігнорує ці tails, desynchronize-иться і читає garbage opids для всього після першої complex property

Theme fill виражається трьома properties, розподіленими між обома tables, плюс ще однією, що оголошує fill kind. HotXLS записує чотири properties у 28 bytes без complex tails:

  • fillType $0180 в OPT1, встановлений у 1 (msofillSolid)
  • fillColor $0181 в OPT1, flattened RGB, який намалює старший або theme-unaware consumer
  • fillColorExt $019E в OPT2, base theme color
  • fillColorExtMod $01A0 в OPT2, tint або shade, застосований до base

Цей split навмисний і заданий format, а не випадковість implementation: [MS-ODRAW] 2.2.2 описує theme triple як flat color плюс base color плюс modification, тому consumer, що розуміє themes, перераховує fill, а той, що не розуміє, усе одно малює щось розумне. Навколишні opids дотримуються тієї самої pattern і мають ідентичне numbering у старому та current [MS-ODRAW] editions, що зручно під час cross-reading двох revisions: fillOpacity $0182, fillBackColor $0183, fillShadeType $019C, fillBackColorExt $01A2 і fillBackColorExtMod $01A4

Чому scheme index лежить у red byte?

Тому що OfficeArtCOLORREF визначено byte offset, а не numeric value: red у byte 0, green у byte 1, blue у byte 2, flags у byte 3. Прочитайте цю structure як little-endian DWORD, як це робить кожен FOPTE op, і red стане least significant byte. Worked lineColor example в [MS-ODRAW] це підтверджує. Тому fSchemeIndex, який є flags bit E, має numeric value $08000000, а scheme index сам лежить у red byte, тоді як green і blue мають бути zero. Отже Accent1 — це op value $08000004, а не $00000004 і точно не $04000000

Порядок theme index, який specification відмовляється визначати

Specification називає order scheme index host-defined і не дає table, тобто для interop з Excel одного byte layout недостатньо. HotXLS використовує spreadsheet theme order, який round-trips із реальними Excel files:

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

Tint і shade: payload MSOTINTSHADE

Op fillColorExtMod є значенням MSOTINTSHADE і кодує direction та amount в одному DWORD, а не як signed fraction. Value $20000000 означає unmodified. Lightening tint — $02F4 shl 16 or amount shl 8 or $10 (MSOTINT); darkening tint має ту саму shape з $01F4 у high word (MSOSHADE). amount byte іде проти intuition: $FF означає unchanged, а $00 — full modification. HotXLS normalize-ить це в один DrawingML-style double, де positive lightens, а negative darkens, використовуючи плюс або мінус (255 - amount) / 255. Mapping exact для values, які фактично пропонує Excel UI, тому round-trip lossless, а не approximately lossless: знайомий "Lighter 40%" — це amount 153, і (255 - 153) / 255 дорівнює 0.4 без rounding error в жоден бік. Shade з amount 191 повертається як -64/255. Ось encoder, clamped до legal range:

if Tint > 0 then                       // MSOTINT - lighter
  TintOp := LongWord($02F4) shl 16 or
    (LongWord(Round(255 * (1 - Tint))) shl 8) or $10
else if Tint < 0 then                  // MSOSHADE - darker
  TintOp := LongWord($01F4) shl 16 or
    (LongWord(Round(255 * (1 + Tint))) shl 8) or $10
else
  TintOp := $20000000;                 // MSOCOLORMODUNDEFINED

Як встановити й прочитати theme fill із Delphi

На write side theme fill — це два extra fields у per-series style record. TXLSChartSeriesStyleInfo отримав HasFillTheme, FillThemeColor і FillThemeTint, а builder emit-ить GelFrame лише коли одночасно set HasStyle і HasFillTheme. Якщо ви також встановите explicit FillRgb, це value потрапить у OPT1 fillColor verbatim; якщо ні, HotXLS сам flatten-ить color через built-in default Office theme table із застосованим tint, тому theme-only series усе одно має sane flat color для consumers, які ігнорують OPT2. Зверніть увагу на Default() initialization, яка важлива, бо TXLSChartSeriesInfo містить managed fields, а plain Boolean members інакше були б stack garbage:

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

    Series[0] := Default(TXLSChartSeriesInfo);   // ніколи не FillChar this record
    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 у low 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;        // без explicit RGB: flattened
    Series[1].Style.FillThemeColor := 8;         // accent5

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

Reading назад проходить через той самий chart model, який використовує решта HotXLS chart inspection. GetChartModel повертає owned TXLSChartModel, який ви звільняєте, а кожен TXLSChartSeries expose-ить HasFillTheme, FillThemeColor і FillThemeTint поруч із FillRgb, decoded із OPT1 fillColor, який має precedence над AreaFormat color для цієї series. Ті самі три values також доходять до canonical semantic snapshot як SolidFillThemeSet, SolidFillThemeColor та SolidFillThemeTint, тому workbook diff бачить theme change як theme change, а не як unexplained RGB drift. Якщо ви приходите з XLSX side, це binary-format counterpart styling, описаного в HotXLS guide про 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 не гарантує?

Три чесні limits. Перший і найважливіший для кожного, хто audit-ить цей code: жоден sample file у local corpus взагалі не містить GelFrame record. Одинадцять occurrences byte pair 66 10 у conditional-formatting sample лежать на non-record boundaries, а full-stream record dump знаходить zero hits. Описаний тут bit layout виведено зі specification, а потім зафіксовано трьома способами: decode symmetry на builder output, hand-built byte tests, що подають synthetic $1066 payload прямо в decoder, і assertion exact flattened RGB. Це слабший evidence, ніж captured Excel file, і краще сказати про це, ніж створювати інше враження. Другий: flattening для theme-only fill використовує built-in default Office theme table, а не theme part, прочитаний із workbook, бо binary XLS не має theme part у сенсі packaged XLSX — якщо потрібен власний workbook theme для flat color, передайте FillRgb самі. Третій: decoder приймає GelFrame лише всередині series block; той самий record може стояти на chart area або axis frame, і приймати його там означало б тихо приписати background fill series, тому такі records ігноруються. fillColorExt без прапорця $08000000 також трактують як plain extended color і він ніколи не встановлює HasFillTheme. Для workbooks, chart яких створено у XLSX world і який лише проходить через цей шлях, safer route — preservation path у редагуванні Excel charts без втрати ChartML, а container, де лежать ці records, описано в читанні OLE2 compound files у Delphi без COM IStorage

Theme-colored chart fills, GelFrame encoder і decoder та full BIFF8 chart substream builder постачаються у HotXLS Delphi spreadsheet component для Delphi та C++Builder, який читає й записує XLS, XLSX та ODS без установленого Excel