Teknisk artikel

Excel chart-temafarver i Delphi: HotXLS GelFrame

En chart-serie, der er udfyldt med en literal RGB-værdi, følger ikke workbookens tema. Skift temaet, og serien beholder den gamle farve. HotXLS håndterer dette i binær XLS med temafarvede chart-series-fills: en GelFrame-record, 4198 eller $1066, skrevet lige efter AreaFormat inde i series-blokken og med et OfficeArt scheme-indeks plus en tint. Excel renderer derefter serien på samme måde, som den renderer en temafyldt fill, det selv har skrevet

Hvor kommer GelFrame-recordnummeret fra?

GelFrame-recordnummeret er 4198 ($1066), og du finder det ikke i recordens egen specifikationssektion. [MS-XLS] 2.4.131 beskriver, hvad en GelFrame indeholder, men i modsætning til de fleste recordsektioner angiver den ikke rt-værdien. ABNFen for chart-substreamen hjælper heller ikke: Den giver kun produktionen GELFRAME = 1*2GelFrame *Continue, som navngiver recorden uden at nummerere den. Tallet ligger i tabellen over recordnummer-enumerationen flere sider væk fra den sektion, der dokumenterer payloaden. Produktionen fortjener et ekstra blik for alle, der skriver en reader: Den tillader én eller to GelFrame-records, som hver valgfrit efterfølges af Continue-records, så en parser, der antager én record pr. produktion, håndterer en fil, den ikke selv skrev, forkert. HotXLS udsender præcis én GelFrame pr. tematiseret serie, hvilket er det, Excel producerer for en enkel solid theme fill, og dens decoder behandler recorden som en selvstændig payload i stedet for at antage et fast antal

Inde i GelFrame-payloaden: to OfficeArt-property-tabeller

GelFrame-payloaden er to OfficeArt-property-tabeller back to back: en OfficeArtFOPT (kaldet OPT1) efterfulgt af en OfficeArtTertiaryFOPT (OPT2). Hver tabel er et property count på to bytes efterfulgt af det antal FOPTE-poster på seks bytes, og hver post er et opid på to bytes plus et op på fire bytes. Bit 15 i opid er fComplex: Når den er sat, er op-værdien en bytelængde, og en variabel hale følger efter de faste poster. En decoder, der ignorerer disse haler, desynkroniserer og læser skrald-opids for alt efter den første komplekse property

Temafill’en udtrykkes med tre properties fordelt over begge tabeller plus én, der erklærer fill-typen. HotXLS skriver fire properties i 28 bytes uden komplekse haler:

  • fillType $0180 i OPT1, sat til 1 (msofillSolid)
  • fillColor $0181 i OPT1, den flattenede RGB, som en ældre eller temauvidende consumer vil tegne
  • fillColorExt $019E i OPT2, grundtemafarven
  • fillColorExtMod $01A0 i OPT2, den tint eller shade, der anvendes på grundfarven

Opdelingen er bevidst i formatet og ikke et implementeringsuheld: [MS-ODRAW] 2.2.2 beskriver tematriaden som en flad farve plus en grundfarve plus en modifikation, så en consumer, der forstår temaer, beregner fill’en igen, mens en, der ikke gør, stadig maler noget fornuftigt. De omgivende opids følger det samme mønster og har identisk nummerering i de gamle og aktuelle udgaver af [MS-ODRAW], hvilket er praktisk, når du læser to revisioner på tværs: fillOpacity $0182, fillBackColor $0183, fillShadeType $019C, fillBackColorExt $01A2 og fillBackColorExtMod $01A4

Hvorfor sidder scheme-indekset i den røde byte?

Fordi en OfficeArtCOLORREF defineres af byte-offset og ikke af numerisk værdi: rød ved byte 0, grøn ved byte 1, blå ved byte 2 og flag ved byte 3. Læs strukturen som en little-endian DWORD, hvilket alle FOPTE-op-værdier er, og rød bliver den mindst betydende byte. Det gennemarbejdede lineColor-eksempel i [MS-ODRAW] bekræfter det. Derfor har fSchemeIndex, som er flagbit E, den numeriske værdi $08000000, og selve scheme-indekset ligger i den røde byte, mens grøn og blå skal være nul. Accent1 er derfor op-værdien $08000004 og ikke $00000004 og bestemt ikke $04000000

Rækkefølgen af theme-indekser, som specifikationen nægter at definere

Specifikationen kalder rækkefølgen for scheme-indekset host-defined og giver ingen tabel, hvilket betyder, at bytelayoutet alene ikke er nok til interoperabilitet med Excel. HotXLS bruger regnearkets temarækkefølge, hvilket er det, der round-tripper mod rigtige Excel-filer:

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

Tint og shade: MSOTINTSHADE-payloaden

fillColorExtMod-op er en MSOTINTSHADE-værdi, og den koder retning og mængde i én DWORD i stedet for som en signed fraction. Værdien $20000000 betyder uændret. En lightening tint er $02F4 shl 16 or amount shl 8 or $10 (MSOTINT); en darkening tint har samme form med $01F4 i high word (MSOSHADE). amount-byten går modsat intuitionen: $FF betyder uændret, og $00 betyder den fulde modifikation. HotXLS normaliserer det til én DrawingML-style double, hvor positiv værdi gør lysere og negativ værdi gør mørkere, med plus eller minus (255 - amount) / 255. Mappingen er eksakt for de værdier, Excel faktisk tilbyder i sin UI, hvilket er grunden til, at round-trip er tabsfri og ikke omtrent tabsfri: det velkendte "Lighter 40%" er amount 153, og (255 - 153) / 255 er 0.4 uden afrundingsfejl i nogen retning. En shade med amount 191 kommer tilbage som -64/255. Her er encoderen, begrænset til det lovlige område:

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

Sådan sætter og læser du en theme fill fra Delphi

På skrivesiden er en theme fill to ekstra felter på style-recorden pr. serie. TXLSChartSeriesStyleInfo fik HasFillTheme, FillThemeColor og FillThemeTint, og builderen udsender kun GelFrame, når både HasStyle og HasFillTheme er sat. Hvis du også sætter en eksplicit FillRgb, går den værdi ordret ind i OPT1-fillColor; hvis du ikke gør, fladgør HotXLS selv farven gennem en indbygget standard-Office-tematabel med tinten anvendt, så en serie, der kun har tema, stadig har en fornuftig flad farve for consumers, der ignorerer OPT2. Bemærk Default()-initialiseringen, som betyder noget, fordi TXLSChartSeriesInfo indeholder managed felter, og dens almindelige Boolean-medlemmer ellers er skrald fra stacken:

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

    Series[0] := Default(TXLSChartSeriesInfo);   // brug aldrig FillChar på denne 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, rød i den lave 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;        // ingen eksplicit RGB: flattenet
    Series[1].Style.FillThemeColor := 8;         // accent5

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

Læsning går tilbage gennem den samme chartmodel, som resten af HotXLS-chartinspektionen bruger. GetChartModel returnerer en ejet TXLSChartModel, som du frigiver, og hver TXLSChartSeries eksponerer HasFillTheme, FillThemeColor og FillThemeTint ved siden af FillRgb, der afkodes fra OPT1-fillColor, som har forrang for AreaFormat-farven for den serie. De samme tre værdier når også det kanoniske semantiske snapshot som SolidFillThemeSet, SolidFillThemeColor og SolidFillThemeTint, så en workbook-diff ser en temaændring som en temaændring og ikke som et uforklaret RGB-skred. Hvis du kommer fra XLSX-siden, er dette binærformatets modstykke til den styling, der beskrives i HotXLS-guiden til Excel-charts, images og drawings i 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;

Hvad lover en theme fill i binær XLS ikke?

Tre ærlige begrænsninger. For det første og vigtigst for alle, der auditerer denne kode: Ingen eksempel-fil i det lokale korpus indeholder overhovedet en GelFrame-record. De 11 forekomster af byteparret 66 10 i conditional-formatting-eksemplet ligger ved grænser, der ikke er recordgrænser, og et fuldt stream-record-dump finder nul hits. Bylayoutet, der beskrives her, blev udledt af specifikationen og derefter fastlåst på tre måder: gennem decode-symmetri på builderens output, gennem håndbyggede byte-tests, der sender en syntetisk $1066-payload direkte ind i decoderen, og gennem assertion af den præcise flattenede RGB. Det er en svagere evidensform end en optaget Excel-fil, og det er værd at sige i stedet for at antyde noget andet. For det andet bruger flattening for en fill, der kun har tema, en indbygget standard-Office-tematabel og ikke en temadel læst fra workbooken, fordi binær XLS ikke har en temadel i den betydning, som en pakket XLSX har — hvis du har brug for, at workbookens eget tema driver den flade farve, skal du selv levere FillRgb. For det tredje accepterer decoderen kun en GelFrame inde i en series-blok; den samme record kan optræde på chartområdet eller en akseramme, og at acceptere den dér ville lydløst tilskrive en baggrundsfill til en serie, så disse ignoreres. En fillColorExt uden flaget $08000000 behandles på samme måde som en almindelig extended color og sætter aldrig HasFillTheme. For workbooks, hvor chartet er forfattet i XLSX-verdenen og kun passerer igennem, er bevaringsstien i redigering af Excel-charts uden at miste ChartML den sikrere vej, og containeren, som disse records ligger i, er dækket i læsning af OLE2-compound-filer i Delphi uden COM IStorage

Temafarvede chart-fills, GelFrame-encoderen og -decoderen samt den fulde BIFF8-chart-substream-builder leveres i HotXLS Delphi-regnearkskomponenten til Delphi og C++Builder, som læser og skriver XLS, XLSX og ODS uden Excel installeret