Artykuł techniczny

Kolory motywu wykresów Excela w Delphi: GelFrame HotXLS

Seria wykresu wypełniona literalnym RGB nie podąża za motywem skoroszytu. Zmień motyw, a seria zachowa stary kolor. HotXLS obsługuje to w binarnym XLS przez wypełnienia serii wykresu kolorem motywu: rekord GelFrame, 4198 lub $1066, zapisywany bezpośrednio po AreaFormat wewnątrz bloku serii i niosący indeks schematu OfficeArt oraz odcień. Excel renderuje wtedy serię tak, jak renderuje wypełnienie motywu zapisane przez siebie

Skąd bierze się numer rekordu GelFrame?

Numer rekordu GelFrame to 4198 ($1066) i nie znajdziesz go w jego własnej sekcji specyfikacji. [MS-XLS] 2.4.131 opisuje zawartość GelFrame, ale w odróżnieniu od większości sekcji rekordów nie podaje wartości rt. Gramatyka podstrumienia wykresu też nie pomaga: daje tylko produkcję GELFRAME = 1*2GelFrame *Continue, która nazywa rekord, ale go nie numeruje. Numer mieszka w tabeli wyliczenia numerów rekordów, kilka stron dalej od sekcji opisującej payload. Tej produkcji warto przyjrzeć się drugi raz każdemu, kto pisze czytnik: pozwala na jeden albo dwa rekordy GelFrame, po każdym opcjonalnie mogą wystąpić rekordy Continue, więc parser zakładający jeden rekord na produkcję źle obsłuży plik, którego sam nie zapisał. HotXLS emituje dokładnie jeden GelFrame na serię z motywem, co odpowiada temu, co Excel produkuje dla prostego jednolitego wypełnienia motywu, a jego dekoder traktuje rekord jako samodzielny payload zamiast zakładać stałą liczbę

Wewnątrz payloadu GelFrame: dwie tabele właściwości OfficeArt

Payload GelFrame to dwie następujące po sobie tabele właściwości OfficeArt: OfficeArtFOPT (nazywany OPT1), a po nim OfficeArtTertiaryFOPT (OPT2). Każda tabela składa się z dwubajtowej liczby właściwości, a po niej z odpowiedniej liczby sześcio-bajtowych wpisów FOPTE, gdzie każdy wpis zawiera dwubajtowe opid i czterobajtowe op. Bit 15 w opid to fComplex: gdy jest ustawiony, wartość op jest długością w bajtach i po stałych wpisach następuje zmienna końcówka. Dekoder ignorujący te końcówki traci synchronizację i odczytuje śmieciowe opid dla wszystkiego po pierwszej złożonej właściwości

Wypełnienie motywu wyrażają trzy właściwości rozłożone między obie tabele oraz jedna określająca rodzaj wypełnienia. HotXLS zapisuje cztery właściwości w 28 bajtach, bez złożonych końcówek:

  • fillType $0180 w OPT1, ustawione na 1 (msofillSolid)
  • fillColor $0181 w OPT1, spłaszczony RGB, który narysuje starszy konsument albo konsument nieznający motywów
  • fillColorExt $019E w OPT2, bazowy kolor motywu
  • fillColorExtMod $01A0 w OPT2, odcień lub cień zastosowany do bazy
Anatomia payloadu GelFrame HotXLS dla wypełnienia serii wykresu kolorem motywu w binarnym XLS: OPT1 niesie fillType $0180 ustawione na msofillSolid oraz fillColor $0181, OPT2 niesie fillColorExt $019E i fillColorExtMod $01A0, każda tabela zawiera dwubajtową liczbę właściwości, po której następują sześciobajtowe wpisy FOPTE, a cztery właściwości dają razem 28 bajtów bez złożonych końcówek
HotXLS zapisuje wypełnienie serii kolorem motywu jako dwie tabele właściwości OfficeArt wewnątrz jednego rekordu GelFrame, utrzymując płaski RGB w OPT1 dla czytników nieznających motywów, a bazowy kolor motywu plus odcień w OPT2

Ten podział jest zamierzony w formacie, a nie przypadkowy w implementacji: [MS-ODRAW] 2.2.2 opisuje trójkę motywu jako płaski kolor oraz kolor bazowy z modyfikacją, więc konsument rozumiejący motywy przelicza wypełnienie, a ten, który ich nie rozumie, nadal maluje coś rozsądnego. Sąsiednie opidy mają ten sam wzorzec i identyczną numerację w starych i obecnych wydaniach [MS-ODRAW], co ułatwia porównywanie dwóch rewizji: fillOpacity $0182, fillBackColor $0183, fillShadeType $019C, fillBackColorExt $01A2 i fillBackColorExtMod $01A4

Dlaczego indeks schematu siedzi w czerwonym bajcie?

Ponieważ OfficeArtCOLORREF definiuje się przez offset bajtu, a nie przez wartość liczbową: czerwony na bajcie 0, zielony na bajcie 1, niebieski na bajcie 2, flagi na bajcie 3. Odczytaj tę strukturę jako little-endian DWORD, bo tym właśnie jest każde op FOPTE, a czerwony stanie się najmniej znaczącym bajtem. Potwierdza to przykład lineColor w [MS-ODRAW]. Zatem fSchemeIndex, czyli bit flag E, ma wartość liczbową $08000000, a sam indeks schematu trafia do czerwonego bajtu, przy czym zielony i niebieski muszą być zerowe. Accent1 ma więc wartość op $08000004, a nie $00000004 i tym bardziej nie $04000000

Dlaczego indeks schematu ląduje w czerwonym bajcie wypełnienia motywu HotXLS: OfficeArtCOLORREF umieszcza czerwony na bajcie 0, zielony na bajcie 1, niebieski na bajcie 2 i flagi na bajcie 3, więc odczyt little-endian czyni flagę fSchemeIndex o wartości $08000000 najwyższym bajtem, a accent1, indeks 4 w kolejności motywu, staje się $08000004
Ponieważ wartości op FOPTE to little-endianowe DWORD-y, bajt flagi siedzi najwyżej, a indeks schematu ląduje w najmniej znaczącym bajcie, dlatego accent1 zapisuje się jako $08000004, a nie $00000004

Kolejność indeksów motywu, której specyfikacja odmawia zdefiniowania

Specyfikacja nazywa kolejność indeksu schematu zależną od hosta i nie podaje tabeli, co oznacza, że sam layout bajtów nie wystarcza do współpracy z Excelem. HotXLS używa kolejności motywu arkusza, która poprawnie wykonuje cykl z prawdziwymi plikami Excela:

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

Odcień i cień: payload MSOTINTSHADE

Op fillColorExtMod jest wartością MSOTINTSHADE i koduje kierunek oraz wielkość w jednym DWORD, a nie jako ułamek ze znakiem. Wartość $20000000 oznacza brak modyfikacji. Rozjaśniający odcień to $02F4 shl 16 or amount shl 8 or $10 (MSOTINT), a przyciemniający odcień ma ten sam kształt z $01F4 w słowie wysokim (MSOSHADE). Bajt amount działa odwrotnie niż podpowiada intuicja: $FF oznacza brak zmiany, a $00 pełną modyfikację. HotXLS normalizuje to do jednej wartości double w stylu DrawingML, gdzie dodatnia wartość rozjaśnia, a ujemna przyciemnia, używając plus albo minus (255 - amount) / 255. Odwzorowanie jest dokładne dla wartości oferowanych przez interfejs Excela, dlatego cykl jest bezstratny, a nie tylko w przybliżeniu bezstratny: znane „Lighter 40%” to amount 153, a (255 - 153) / 255 daje 0.4 bez błędu zaokrąglenia w żadną stronę. Cień z amount 191 wraca jako -64/255. Oto encoder ograniczony do dozwolonego zakresu:

if Tint > 0 then                       // MSOTINT - jaśniej
  TintOp := LongWord($02F4) shl 16 or
    (LongWord(Round(255 * (1 - Tint))) shl 8) or $10
else if Tint < 0 then                  // MSOSHADE - ciemniej
  TintOp := LongWord($01F4) shl 16 or
    (LongWord(Round(255 * (1 + Tint))) shl 8) or $10
else
  TintOp := $20000000;                 // MSOCOLORMODUNDEFINED
Jak op fillColorExtMod HotXLS koduje odcień motywu w jednym DWORD MSOTINTSHADE: $02F4 oznacza rozjaśniający odcień MSOTINT, a $01F4 przyciemniający MSOSHADE, bajt amount działa odwróconie, od $FF bez zmiany do $00 pełnej modyfikacji, a HotXLS normalizuje go do plus albo minus (255 - amount) / 255, gdzie Lighter 40% to amount 153
Bajt amount payloadu MSOTINTSHADE działa przeciwnie do intuicji, więc HotXLS konwertuje go raz na wartość double w stylu DrawingML, gdzie dodatnia rozjaśnia, a ujemna przyciemnia, a wartości oferowane przez Excela odwzorowują się bez błędu zaokrąglenia

Ustawianie i odczytywanie wypełnienia motywu z Delphi

Po stronie zapisu wypełnienie motywu to dwa dodatkowe pola w rekordzie stylu serii. TXLSChartSeriesStyleInfo zyskał HasFillTheme, FillThemeColor i FillThemeTint, a builder emituje GelFrame tylko wtedy, gdy ustawione są jednocześnie HasStyle i HasFillTheme. Jeśli ustawisz także jawne FillRgb, wartość trafia bez zmian do fillColor OPT1; jeśli tego nie zrobisz, HotXLS sam spłaszcza kolor przez wbudowaną domyślną tabelę motywu Office z zastosowanym odcieniem, więc seria zawierająca tylko motyw nadal ma rozsądny płaski kolor dla konsumentów ignorujących OPT2. Zwróć uwagę na inicjalizację Default(), która ma znaczenie, ponieważ TXLSChartSeriesInfo zawiera pola zarządzane, a jego zwykłe składowe Boolean w przeciwnym razie są śmieciami ze stosu:

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

    Series[0] := Default(TXLSChartSeriesInfo);   // nigdy nie używaj FillChar dla tego rekordu
    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, czerwony w najmłodszym bajcie
    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;        // bez jawnego RGB: spłaszczone
    Series[1].Style.FillThemeColor := 8;         // accent5

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

Odczyt z powrotem przechodzi przez ten sam model wykresu, którego używa reszta inspekcji wykresów HotXLS. GetChartModel zwraca należący do wywołującego TXLSChartModel, który trzeba zwolnić, a każdy TXLSChartSeries udostępnia HasFillTheme, FillThemeColor i FillThemeTint obok FillRgb zdekodowanego z fillColor OPT1, który ma pierwszeństwo przed kolorem AreaFormat dla tej serii. Te same trzy wartości trafiają też do kanonicznego migawkowego modelu semantycznego jako SolidFillThemeSet, SolidFillThemeColor i SolidFillThemeTint, więc diff skoroszytu widzi zmianę motywu jako zmianę motywu, a nie niewyjaśniony dryf RGB. Jeśli przychodzisz ze świata XLSX, jest to binarny odpowiednik formatowania opisanego w przewodniku HotXLS po wykresach, obrazach i rysunkach Excela w 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, wypełnienie fillColor OPT1
    end;
  finally
    Model.Free;
  end;
finally
  Wb.Free;
end;

Czego nie obiecuje wypełnienie motywu w binarnym XLS?

Trzy uczciwe ograniczenia. Po pierwsze, najważniejsze dla każdego, kto audytuje ten kod: żaden przykładowy plik w lokalnym korpusie nie zawiera rekordu GelFrame. Jedenaście wystąpień pary bajtów 66 10 w próbce formatowania warunkowego leży na granicach innych niż rekordy, a pełny zrzut rekordów strumienia znajduje zero trafień. Opisany tutaj układ bitów wyprowadzono ze specyfikacji, a następnie przypięto na trzy sposoby: przez symetrię dekodowania na wyjściu buildera, ręcznie zbudowane testy bajtowe podające syntetyczny payload $1066 bezpośrednio do dekodera oraz asercję dokładnego spłaszczonego RGB. To słabsza forma dowodu niż przechwycony plik Excela i warto to powiedzieć, zamiast sugerować coś innego. Po drugie, spłaszczanie wypełnienia zawierającego wyłącznie motyw używa wbudowanej domyślnej tabeli motywu Office, a nie części motywu odczytanej ze skoroszytu, ponieważ binarny XLS nie ma części motywu w sensie spakowanego XLSX — jeśli kolor płaski ma pochodzić z własnego motywu skoroszytu, podaj FillRgb samodzielnie. Po trzecie, dekoder przyjmuje GelFrame tylko wewnątrz bloku serii; ten sam rekord może wystąpić w obszarze wykresu albo ramce osi, a przyjęcie go tam po cichu przypisałoby wypełnienie tła do serii, więc takie przypadki są ignorowane. fillColorExt bez flagi $08000000 również jest traktowane jako zwykły kolor rozszerzony i nigdy nie ustawia HasFillTheme. Dla skoroszytów, których wykres powstał w świecie XLSX i tylko przechodzi dalej, bezpieczniejsza jest ścieżka zachowania z artykułu o edycji wykresów Excela bez utraty ChartML, a kontener, w którym siedzą te rekordy, opisano w artykule o odczytywaniu złożonych plików OLE2 w Delphi bez COM IStorage

Wypełnienia wykresów kolorem motywu, encoder i decoder GelFrame oraz pełny builder podstrumienia wykresu BIFF8 są częścią komponentu arkuszy HotXLS dla Delphi dla Delphi i C++Builder, który odczytuje i zapisuje XLS, XLSX oraz ODS bez zainstalowanego Excela