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

Excel chart theme colors в Delphi: HotXLS GelFrame

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

Откъде идва record number-ът на GelFrame?

Record number-ът на GelFrame е 4198 ($1066) и няма да го намерите в собствената specification section на record-а. [MS-XLS] 2.4.131 описва какво съдържа GelFrame, но за разлика от повечето record sections не посочва стойността rt. ABNF на chart substream също не помага: дава само production GELFRAME = 1*2GelFrame *Continue, което назовава record-а без да го номерира. Number-ът живее в enumeration table на record numbers, на няколко страници от section-а, който документира payload-а. Тази production заслужава втори поглед от всеки, който пише reader: тя позволява един или два GelFrame records, всеки по избор следван от Continue records, така че parser, който приема един record per production, ще обработи грешно file, който не е написал сам. HotXLS emit-ва точно един GelFrame на 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 е двубайтов property count, последван от толкова шестбайтови FOPTE entries, а всеки entry е two-byte opid плюс four-byte op. Bit 15 на opid е fComplex: когато е set, op value е 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, set на 1 (msofillSolid)
  • fillColor $0181 в OPT1, flattened RGB, който по-стар или theme-unaware consumer ще нарисува
  • fillColorExt $019E в OPT2, base theme color
  • fillColorExtMod $01A0 в OPT2, tint или shade, приложен към base-а

Това разделение е умишлено във format-а, а не случайност на implementation-а: [MS-ODRAW] 2.2.2 описва theme triple като flat color плюс base color плюс modification, така че consumer, който разбира themes, преизчислява fill-а, а този, който не разбира, все пак рисува нещо разумно. Околните opids следват същия pattern и носят идентична numbering в старото и current изданието на [MS-ODRAW], което е удобно при 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-ите, който spec-ът отказва да дефинира

Specification-ът нарича scheme index order host-defined и не дава table, което означава, че byte layout сам по себе си не е достатъчен за interop с Excel. HotXLS използва spreadsheet theme order, който round-trip-ва с реални Excel files:

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

Tint и shade: MSOTINTSHADE payload

fillColorExtMod op е MSOTINTSHADE value и encode-ва direction и amount в един DWORD, а не като signed fraction. Стойността $20000000 означава unmodified. Lightening tint е $02F4 shl 16 or amount shl 8 or $10 (MSOTINT), а darkening tint е същата shape с $01F4 в high word (MSOSHADE). amount byte върви в обратна посока на интуицията: $FF означава unchanged, а $00 — full modification. HotXLS нормализира това до един DrawingML-style double, в който positive lightens, negative darkens, чрез plus или minus (255 - amount) / 255. Mapping-ът е exact за values, които Excel действително предлага в UI, затова round-trip-ът е lossless, а не приблизително lossless: познатото „Lighter 40%“ е amount 153 и (255 - 153) / 255 е 0.4 без rounding error в нито една посока. Shade с amount 191 се връща като -64/255. Ето encoder-а, clamp-нат до 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 е две допълнителни fields в per-series style record. TXLSChartSeriesStyleInfo е получил HasFillTheme, FillThemeColor и FillThemeTint, а builder-ът emit-ва GelFrame само когато и HasStyle, и HasFillTheme са set. Ако зададете и 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 този 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;        // no explicit 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 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 страната, това е 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?

Три честни ограничения. Първо и най-важно за всеки, който 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 и после pinned по три начина: чрез decode symmetry върху builder output, чрез hand-built byte tests, които подават synthetic $1066 payload директно в decoder-а, и чрез assert на 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 — ако искате собствената theme на workbook-а да управлява flat color-а, подайте FillRgb сами. Трето, decoder-ът приема GelFrame само вътре в series block; същият record може да се появи върху chart area или axis frame, а приемането му там би приписало тихо background fill на series, затова тези случаи се игнорират. fillColorExt без flag $08000000 също се третира като plain extended color и никога не задава HasFillTheme. За workbook-и, чийто chart е authored в XLSX world и само минава през този path, preservation path-ът в editing на 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