技術記事

DelphiのExcel chart theme color:GelFrame

literal RGBでfillしたchart seriesはworkbook themeに従いません。themeを変更してもseriesは古いcolorを保持します。HotXLSはbinary XLSでtheme-colored chart series fillを扱えます。AreaFormatの直後、series blockの中へGelFrame record、4198または$1066を書き、OfficeArt scheme indexとtintを持たせます。Excelはtheme fillを自分で書いた場合と同じようにseriesをrenderします

GelFrame record numberの出所

GelFrame record numberは4198($1066)で、record自身のspecification sectionには見つかりません。[MS-XLS] 2.4.131はGelFrameの内容を説明しますが、ほとんどのrecord sectionと違いrt valueを記載していません。chart substream ABNFも役に立ちません。GELFRAME = 1*2GelFrame *Continueというproductionを示し、recordをnumberingせずにnameだけを挙げています。numberはpayloadをdocumentするsectionから数ページ離れたrecord-number enumeration tableにあります。このproductionはreaderを書く人がもう一度見る価値があります。1つまたは2つのGelFrame recordを許し、それぞれにContinue recordがoptionalで続くため、productionごとに1 recordだけと仮定するparserは自分が書いていないfileを誤処理します。HotXLSはsimple solid theme fillでExcelが生成する形に合わせ、theme seriesごとにGelFrameを正確に1つemitします。decoderはrecordをself-contained payloadとして扱い、fixed countを仮定しません

GelFrame payloadの中身:2つのOfficeArt property table

GelFrame payloadは2つのOfficeArt property tableが連続したものです。OfficeArtFOPT(OPT1と呼ぶ)に続いてOfficeArtTertiaryFOPT(OPT2)があります。各tableは2-byte property countと、その数の6-byte FOPTE entryからなります。各entryは2-byte opidと4-byte opです。opidのbit 15はfComplexで、setされるとop valueがbyte lengthになり、fixed entryの後ろにvariable tailが続きます。このtailを無視するdecoderはdesynchronizeし、最初のcomplex property以後のすべてでgarbage opidを読みます

theme fillは2つのtableに分かれた3つのpropertyと、fill kindを宣言する1つのpropertyで表します。HotXLSはcomplex tailなしで、28 byte内に4 propertyを書きます

  • OPT1のfillType $0180。1(msofillSolid)にsetします
  • OPT1のfillColor $0181。olderまたはtheme-unaware consumerが描くflattened RGBです
  • OPT2のfillColorExt $019E。base theme colorです
  • OPT2のfillColorExtMod $01A0。baseへ適用するtintまたはshadeです

このsplitはimplementationの偶然ではなくformatの意図です。[MS-ODRAW] 2.2.2はtheme tripleをflat color、base color、modificationとして記述します。そのためthemeを理解するconsumerはfillを再計算でき、理解しないconsumerもreasonableなものを描けます。周囲のopidも同じpatternに従い、oldとcurrentの[MS-ODRAW] editionで同じnumberingを持ちます。2つのrevisionをcross-readするとき便利です。fillOpacity $0182、fillBackColor $0183、fillShadeType $019C、fillBackColorExt $01A2、fillBackColorExtMod $01A4です

scheme indexがred byteに入る理由

OfficeArtCOLORREFはnumeric valueではなくbyte offsetで定義されるためです。byte 0がred、byte 1がgreen、byte 2がblue、byte 3がflagsです。このstructureをlittle-endian DWORDとして読むと、すべてのFOPTE opがそうであるように、redがleast significant byteになります。[MS-ODRAW]のworked lineColor exampleもこれを確認します。そのためfSchemeIndex、flags bit Eのnumeric valueは$08000000で、scheme index自身はred byteに置き、greenとblueはzeroにしなければなりません。したがってAccent1のop valueは$08000004であり、$00000004ではなく、もちろん$04000000でもありません

specificationが定義を拒むtheme index order

specificationはscheme index orderをhost-definedと呼び、tableを与えません。そのためbyte layoutだけではExcelとのinteropに足りません。HotXLSはreal Excel fileとのround-tripに合うspreadsheet theme orderを使います

  • 0 = lt1、1 = dk1、2 = lt2、3 = dk2
  • 4から9 = accent1からaccent6
  • 10 = hlink、11 = folHlink

tintとshade:MSOTINTSHADE payload

fillColorExtMod opはMSOTINTSHADE valueで、directionとamountをsigned fractionではなく1つのDWORDにencodeします。$20000000はunmodifiedを意味します。lightening tintは$02F4 shl 16 or amount shl 8 or $10(MSOTINT)、darkening tintはhigh wordに$01F4を置く同じ形(MSOSHADE)です。amount byteはintuitionと逆に動きます。$FFがunchanged、$00がfull modificationです。HotXLSはそれをDrawingML-style doubleへnormalizeし、positiveをlighten、negativeをdarkenとして、plusまたはminus (255 - amount) / 255を使います。Excel UIが実際に提供するvalueについてmappingはexactなので、round-tripはapproximately losslessではなくlosslessです。おなじみの「Lighter 40%」はamount 153で、(255 - 153) / 255はどちら向きにもrounding errorなしに0.4です。amount 191のshadeは-64/255として戻ります。次がlegal rangeへclampしたencoderです

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

Delphiからtheme fillを設定して読む

write sideではtheme fillはper-series style recordへの2つのextra fieldです。TXLSChartSeriesStyleInfoHasFillThemeFillThemeColorFillThemeTintが追加され、builderはHasStyleHasFillThemeの両方がsetされたときだけGelFrameをemitします。explicitなFillRgbも設定すると、そのvalueはOPT1のfillColorへそのまま入ります。設定しなければHotXLSはbuilt-in default Office theme tableを通じてcolorを自分でflattenし、tintも適用します。theme-only seriesもOPT2を無視するconsumer向けにreasonableなflat colorを持つことになります。managed fieldを含むTXLSChartSeriesInfoのplain Boolean memberはstack garbageになり得るため、Default() initializationも重要です

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

    Series[0] := Default(TXLSChartSeriesInfo);   // このrecordを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、low byteがred
    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なし:flattenされる
    Series[1].Style.FillThemeColor := 8;         // accent5

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

read backはHotXLSの残りのchart inspectionが使うのと同じchart modelを通ります。GetChartModelはownerであるTXLSChartModelを返し、callerがfreeします。各TXLSChartSeriesはFillRgbと並べてHasFillThemeFillThemeColorFillThemeTintを公開します。FillRgbはOPT1のfillColorからdecodeされ、seriesのAreaFormat colorより優先されます。同じ3つのvalueはcanonical semantic snapshotにもSolidFillThemeSetSolidFillThemeColorSolidFillThemeTintとして届きます。そのためworkbook diffはtheme changeを説明のないRGB driftではなくtheme changeとして見ます。XLSX sideから来たなら、これはDelphiでのHotXLS Excel chart、image、drawing guideが扱うstyleのbinary-format counterpartです

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;

binary XLSのtheme fillがpromiseしないもの

正直に3つあります。第一に、そしてこのcodeをauditする人にとって最重要なのは、local corpusのsample fileにGelFrame recordが1つもないことです。conditional-formatting sampleにあるbyte pair 66 10の11 occurrencesはrecord boundaryではなく、full-stream record dumpでhitは0件です。ここで説明するbit layoutはspecからderiveし、その後3方向で固定しました。builder outputとのdecode symmetry、synthetic $1066 payloadをdecoderへ直接feedするhand-built byte test、exact flattened RGBのassertです。captured Excel fileより弱いevidenceであり、そのことを別のように装うべきではありません。第二に、theme-only fillのflatteningはworkbookからreadしたtheme partではなく、built-in default Office theme tableを使います。packaged XLSXのような意味でbinary XLSにはtheme partがないためです。workbook自身のthemeでflat colorを決める必要があるなら、自分でFillRgbを供給してください。第三に、decoderはseries block内のGelFrameだけを受け入れます。同じrecordはchart areaやaxis frameにも現れます。そこで受け入れるとbackground fillをseriesへ黙って帰属するため、無視します。$08000000 flagなしのfillColorExtもplain extended colorとして扱い、HasFillThemeはsetしません。chartがXLSX worldでauthorされ、ただ通過するだけのworkbookでは、ChartMLを失わずにExcel chartを編集するpreservation pathのほうが安全です。これらのrecordが入るcontainerはCOM IStorageなしでDelphiからOLE2 compound fileを読む方法で扱っています

theme-colored chart fill、GelFrame encoderとdecoder、full BIFF8 chart substream builderは、DelphiとC++Builder向けHotXLS Delphi spreadsheet componentの一部です。ExcelをinstallせずにXLS、XLSX、ODSをreadとwriteできます