기술 문서

Delphi Excel 차트 테마 색상: HotXLS GelFrame

literal RGB로 채운 chart series는 workbook theme를 따라가지 않습니다. theme를 바꿔도 series는 예전 color를 유지합니다. HotXLS는 theme-colored chart series fill을 사용해 binary XLS에서 이를 처리합니다. 4198 또는 $1066인 GelFrame record를 series block의 AreaFormat 바로 뒤에 쓰고 OfficeArt scheme index와 tint를 담습니다. 그러면 Excel은 자체가 쓴 themed fill을 render하는 것과 같은 방식으로 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 name만 줍니다. number는 payload를 문서화한 section에서 여러 page 떨어진 record-number enumeration table에 있습니다. 이 production은 reader를 작성하는 사람이라면 다시 볼 가치가 있습니다. 하나 또는 두 개의 GelFrame record를 허용하고 각 record 뒤에 선택적으로 Continue record가 올 수 있으므로 자신이 쓰지 않은 file에서 parser가 production당 하나의 record만 가정하면 잘못 처리합니다. HotXLS는 단순한 solid theme fill에서 Excel이 생성하는 것과 같이 themed series마다 GelFrame 하나만 emit하며 decoder는 고정 count를 가정하지 않고 record를 self-contained payload로 취급합니다

GelFrame payload 내부: 두 OfficeArt property table

GelFrame payload는 서로 이어진 두 OfficeArt property table입니다. OfficeArtFOPT(OPT1) 뒤에 OfficeArtTertiaryFOPT(OPT2)가 옵니다. 각 table은 two-byte property count와 그 수만큼의 six-byte FOPTE entry로 구성되고 각 entry는 two-byte opid와 four-byte op입니다. opid의 bit 15는 fComplex입니다. set되면 op value가 byte length이고 fixed entry 뒤에 variable tail이 옵니다. 이 tail을 무시하는 decoder는 desynchronize되어 첫 complex property 뒤의 모든 opid를 garbage로 읽습니다

theme fill은 두 table에 분산된 세 property와 fill kind를 선언하는 하나의 property로 표현됩니다. HotXLS는 complex tail 없이 28 byte에 네 property를 씁니다:

  • OPT1의 fillType $0180이며 1 (msofillSolid)로 설정
  • OPT1의 fillColor $0181이며 older 또는 theme-unaware consumer가 그릴 flattened RGB
  • OPT2의 fillColorExt $019E이며 base theme color
  • OPT2의 fillColorExtMod $01A0이며 base에 적용한 tint 또는 shade
바이너리 XLS에서 테마 차트 계열 채우기를 위한 HotXLS GelFrame 페이로드 구조: OPT1은 msofillSolid로 설정된 fillType $0180과 fillColor $0181을, OPT2는 fillColorExt $019E와 fillColorExtMod $01A0을 실으며, 각 테이블은 2바이트 속성 개수에 6바이트 FOPTE 항목들이 뒤따르는 구조로 복잡한 꼬리 항목 없이 속성 4개가 총 28바이트를 이룸
HotXLS는 테마를 적용한 계열 채우기를 GelFrame 레코드 하나 안의 OfficeArt 속성 테이블 두 개로 기록합니다. 테마를 모르는 reader를 위해 OPT1에는 플랫 RGB를, OPT2에는 테마 기본 색과 틴트를 담아 둡니다

이 분할은 implementation의 우연이 아니라 format이 의도한 것입니다. [MS-ODRAW] 2.2.2는 theme triple을 flat color, base color와 modification으로 설명하므로 theme를 이해하는 consumer는 fill을 다시 계산하고 그렇지 않은 consumer도 합리적인 것을 그립니다. 주변 opid도 같은 pattern을 따르며 old와 current [MS-ODRAW] edition에서 번호가 동일합니다. 두 revision을 함께 읽을 때 편리한 부분이며 fillOpacity $0182, fillBackColor $0183, fillShadeType $019C, fillBackColorExt $01A2, fillBackColorExtMod $01A4가 그 예입니다

scheme index가 red byte에 들어가는 이유

OfficeArtCOLORREF는 numeric value가 아니라 byte offset으로 정의되기 때문입니다. red는 byte 0, green은 byte 1, blue는 byte 2, flag는 byte 3입니다. 모든 FOPTE op가 그렇듯 이 구조를 little-endian DWORD로 읽으면 red가 least significant byte가 됩니다. [MS-ODRAW]의 worked lineColor example도 이를 확인합니다. 따라서 flags bit E인 fSchemeIndex는 numeric value $08000000이고 scheme index 자체는 red byte에 들어가며 green과 blue는 zero여야 합니다. 그래서 Accent1의 op value는 $00000004가 아니라 $08000004이고 당연히 $04000000도 아닙니다

HotXLS 테마 채우기에서 스킴 인덱스가 red 바이트에 자리 잡는 이유: OfficeArtCOLORREF는 red를 0바이트, green을 1바이트, blue를 2바이트, flags를 3바이트에 놓으므로 리틀 엔디안으로 읽으면 fSchemeIndex 플래그 $08000000이 상위 바이트가 되고, 테마 순서에서 인덱스 4인 accent1은 $08000004가 됨
FOPTE op 값은 리틀 엔디안 DWORD이므로 플래그 바이트가 가장 높은 자리에 앉고 스킴 인덱스는 최하위 바이트에 떨어집니다. accent1이 $00000004가 아니라 $08000004로 기록되는 이유입니다

specification이 정의하지 않는 theme index 순서

specification은 scheme index order를 host-defined라고 부르고 table을 주지 않으므로 byte layout만으로는 Excel과 interop할 수 없습니다. HotXLS는 실제 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이 아닌 하나의 DWORD에 encode합니다. $20000000은 unmodified를 뜻합니다. lightening tint는 $02F4 shl 16 or amount shl 8 or $10 (MSOTINT)이고 darkening tint는 high word에 $01F4를 둔 같은 shape (MSOSHADE)입니다. amount byte는 직관과 반대로 움직입니다. $FF는 unchanged이고 $00은 full modification입니다. HotXLS는 이를 positive가 lighten하고 negative가 darken하는 하나의 DrawingML-style double로 normalize하며 plus 또는 minus (255 - amount) / 255를 사용합니다. mapping은 Excel UI가 실제로 제공하는 value에서 exact하므로 approximately lossless가 아니라 lossless round-trip입니다. 익숙한 "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
HotXLS의 fillColorExtMod op가 테마 틴트를 MSOTINTSHADE DWORD 하나로 인코딩하는 법: $02F4는 밝아지는 MSOTINT 틴트를, $01F4는 어두워지는 MSOSHADE 셰이드를 나타내고, amount 바이트는 $FF(무변경)에서 $00(완전 변경)으로 거꾸로 달리며, HotXLS는 이를 ±(255 - amount) / 255로 정규화하는데 Lighter 40%는 amount 153에 해당함
MSOTINTSHADE 페이로드의 amount 바이트는 직관과 반대로 달리므로, HotXLS는 이를 한 번 양수면 밝아지고 음수면 어두워지는 DrawingML식 double로 바꿉니다. Excel이 제공하는 값들은 반올림 오차 없이 대응됩니다

Delphi에서 theme fill 설정 및 읽기

write side에서 theme fill은 per-series style record의 extra field 두 개로 들어갑니다. TXLSChartSeriesStyleInfo에는 HasFillTheme, FillThemeColor, FillThemeTint가 추가되었고 builder는 HasStyle와 HasFillTheme가 둘 다 set된 경우에만 GelFrame을 emit합니다. explicit FillRgb도 함께 설정하면 그 value가 OPT1 fillColor에 그대로 들어가고 그렇지 않으면 HotXLS가 built-in default Office theme table을 통해 tint를 적용해 color를 flatten하므로 theme-only series도 OPT2를 무시하는 consumer에 합리적인 flat color를 가집니다. Default() initialization에도 주의해야 합니다. TXLSChartSeriesInfo는 managed field를 포함하고 plain Boolean member는 그렇지 않으면 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);   // 이 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 없음: flattened
    Series[1].Style.FillThemeColor := 8;         // accent5

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

읽기는 나머지 HotXLS chart inspection이 사용하는 것과 같은 chart model을 통과합니다. GetChartModel은 caller가 free해야 하는 owned TXLSChartModel을 반환하고 각 TXLSChartSeries는 HasFillTheme, FillThemeColor, FillThemeTint를 노출하며 FillRgb도 함께 제공합니다. 이 값은 OPT1 fillColor에서 decode되며 해당 series에서 AreaFormat color보다 우선합니다. 같은 세 value는 canonical semantic snapshot에도 SolidFillThemeSet, SolidFillThemeColor, SolidFillThemeTint로 도달하므로 workbook diff가 설명할 수 없는 RGB drift가 아니라 theme change를 theme change로 보게 됩니다. XLSX side에서 왔다면 이것은 Delphi용 HotXLS Excel chart, image와 drawing guide에서 설명한 styling의 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, the OPT1 fillColor
    end;
  finally
    Model.Free;
  end;
finally
  Wb.Free;
end;

binary XLS의 theme fill이 보장하지 않는 것

정직하게 말해야 할 제한 세 가지가 있습니다. 첫째이자 이 code를 audit하는 사람에게 가장 중요한 점으로 local corpus의 sample file에는 GelFrame record가 하나도 없습니다. conditional-formatting sample에 있는 byte pair 66 10 열한 개는 record boundary가 아닌 곳에 있으며 full-stream record dump에서도 hit은 0개입니다. 여기서 설명한 bit layout은 specification에서 유도한 뒤 세 가지 방식으로 고정했습니다. builder output의 decode symmetry, synthetic $1066 payload를 decoder에 직접 넣는 hand-built byte test, exact flattened RGB assert입니다. captured Excel file보다 약한 evidence이며 그렇지 않은 것처럼 암시해서는 안 됩니다. 둘째, theme-only fill의 flattening은 workbook에서 읽은 theme part가 아니라 built-in default Office theme table을 사용합니다. packaged XLSX와 같은 의미의 theme part가 binary XLS에는 없기 때문이며 workbook 자체의 theme가 flat color를 결정해야 한다면 FillRgb를 직접 제공하세요. 셋째, decoder는 series block 안의 GelFrame만 accept합니다. 같은 record가 chart area나 axis frame에 나타날 수 있는데 거기서 accept하면 background fill을 series에 조용히 귀속하므로 무시합니다. $08000000 flag가 없는 fillColorExt도 plain extended color로 취급하고 HasFillTheme을 set하지 않습니다. chart가 XLSX world에서 작성되어 중간만 거치는 workbook에는 ChartML을 잃지 않고 Excel chart를 편집하는 preservation path가 더 안전하고 이 record가 들어 있는 container는 COM IStorage 없이 Delphi에서 OLE2 compound file을 읽는 방법에서 다룹니다

theme-colored chart fill, GelFrame encoder와 decoder, 전체 BIFF8 chart substream builder는 Delphi와 C++Builder용 HotXLS Delphi spreadsheet component에 포함되어 있으며 Excel을 설치하지 않고 XLS, XLSX와 ODS를 읽고 씁니다