Technisch artikel

Excel-chartthemakleuren in Delphi: HotXLS GelFrame

Een chartserie die met een letterlijke RGB-waarde is gevuld, volgt het workbooktheme niet. Verander je het theme, dan houdt de serie de oude kleur. HotXLS handelt dit in binair XLS af met chartseriesvullingen in themakleur: een GelFrame-record, 4198 of $1066, dat direct na de AreaFormat binnen het seriesblok wordt geschreven en een OfficeArt-scheme-index plus tint bevat. Excel rendert de serie daarna zoals het een themavulling rendert die het zelf heeft geschreven

Waar komt het recordnummer van GelFrame vandaan?

Het recordnummer van GelFrame is 4198 ($1066), en je vindt het niet in de eigen specificatiesectie van het record. [MS-XLS] 2.4.131 beschrijft wat een GelFrame bevat, maar vermeldt anders dan de meeste recordsecties niet de waarde van rt. Ook de ABNF van de chartsubstream helpt niet: die geeft alleen de productie GELFRAME = 1*2GelFrame *Continue, die het record benoemt maar niet nummert. Het nummer staat in de tabel met recordnummerenumeraties, meerdere pagina's verwijderd van de sectie die de payload documenteert. Die productie verdient een tweede blik voor iedereen die een reader schrijft: ze staat één of twee GelFrame-records toe, elk optioneel gevolgd door Continue-records, dus een parser die per productie precies één record verwacht, handelt een bestand dat hij niet zelf heeft geschreven verkeerd af. HotXLS emit precies één GelFrame per serie met theme, wat Excel voor een eenvoudige vaste themavulling produceert, en zijn decoder behandelt het record als een self-contained payload in plaats van een vast aantal aan te nemen

In de GelFrame-payload: twee OfficeArt-propertytabellen

De GelFrame-payload bestaat uit twee OfficeArt-propertytabellen achter elkaar: een OfficeArtFOPT (OPT1 genoemd), gevolgd door een OfficeArtTertiaryFOPT (OPT2). Elke tabel is een propertycount van twee bytes, gevolgd door zoveel FOPTE-entries van zes bytes, en elke entry bestaat uit een opid van twee bytes plus een op van vier bytes. Bit 15 van opid is fComplex: wanneer die bit is ingesteld, is de op-waarde een bytelengte en volgt een variabele staart na de vaste entries. Een decoder die die staarten negeert, desynchroniseert en leest voor alles na de eerste complexe property onzin-opids

De themavulling wordt uitgedrukt door drie properties die over beide tabellen zijn verdeeld, plus één die het type vulling declareert. HotXLS schrijft vier properties in 28 bytes zonder complexe staarten:

  • fillType $0180 in OPT1, ingesteld op 1 (msofillSolid)
  • fillColor $0181 in OPT1, de afgevlakte RGB die een oudere of theme-onbewuste consumer tekent
  • fillColorExt $019E in OPT2, de basisthemakleur
  • fillColorExtMod $01A0 in OPT2, de tint of shade die op die basis wordt toegepast

Die splitsing is bewust onderdeel van het formaat en geen implementatietoeval: [MS-ODRAW] 2.2.2 beschrijft de themadriehoek als een vlakke kleur plus een basiskleur plus een wijziging, zodat een consumer die themes begrijpt de vulling opnieuw berekent en een consumer die dat niet doet toch iets redelijks schildert. De omringende opids volgen hetzelfde patroon en dragen dezelfde nummering in de oude en de huidige edities van [MS-ODRAW], handig wanneer je twee revisies naast elkaar leest: fillOpacity $0182, fillBackColor $0183, fillShadeType $019C, fillBackColorExt $01A2 en fillBackColorExtMod $01A4

Waarom staat de scheme-index in de rode byte?

Omdat een OfficeArtCOLORREF door byte-offset en niet door numerieke waarde wordt gedefinieerd: rood op byte 0, groen op byte 1, blauw op byte 2 en flags op byte 3. Lees je die structuur als een little-endian DWORD, wat elke FOPTE-op is, dan wordt rood de minst significante byte. Het uitgewerkte voorbeeld van lineColor in [MS-ODRAW] bevestigt dat. fSchemeIndex, de flagsbit E, heeft dus de numerieke waarde $08000000, en de scheme-index zelf komt in de rode byte met de vereiste dat groen en blauw nul zijn. Accent1 is daarom de op-waarde $08000004 en niet $00000004, laat staan $04000000

De volgorde van de theme-index die de specificatie weigert te definiëren

De specificatie noemt de volgorde van de scheme-index host-defined en geeft geen tabel, wat betekent dat de bytelayout alleen niet genoeg is voor interop met Excel. HotXLS gebruikt de spreadsheetthemevolgorde, die round-trips met echte Excel-bestanden:

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

Tint en shade: de MSOTINTSHADE-payload

De fillColorExtMod-op is een MSOTINTSHADE-waarde en codeert richting en hoeveelheid in één DWORD in plaats van als signed fraction. De waarde $20000000 betekent ongewijzigd. Een lichter makende tint is $02F4 shl 16 or amount shl 8 or $10 (MSOTINT); een donkerder makende tint heeft dezelfde vorm met $01F4 in het high word (MSOSHADE). De byte amount loopt tegengesteld aan de intuïtie: $FF betekent onveranderd en $00 betekent de volledige wijziging. HotXLS normaliseert dit naar één DrawingML-achtige double waarbij positief lichter maakt en negatief donkerder, met plus of min (255 - amount) / 255. De mapping is exact voor de waarden die Excel werkelijk in zijn UI aanbiedt, waardoor de round-trip lossless en niet ongeveer lossless is: het bekende "Lighter 40%" is amount 153 en (255 - 153) / 255 is 0,4 zonder afrondingsfout in beide richtingen. Een shade met amount 191 komt terug als -64/255. Dit is de encoder, begrensd op het toegestane bereik:

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

Een themavulling instellen en lezen vanuit Delphi

Aan de write-kant bestaat een themavulling uit twee extra velden op het styles-record per serie. TXLSChartSeriesStyleInfo kreeg HasFillTheme, FillThemeColor en FillThemeTint, en de builder emit het GelFrame alleen wanneer zowel HasStyle als HasFillTheme is ingesteld. Stel je ook een expliciete FillRgb in, dan gaat die waarde letterlijk in de OPT1-fillColor; doe je dat niet, dan vlakt HotXLS de kleur zelf af via een ingebouwde default Office-themetabel met de tint toegepast, zodat een serie die alleen een theme heeft nog steeds een verstandige vlakke kleur bezit voor consumers die OPT2 negeren. Let op de Default()-initialisatie, die ertoe doet omdat TXLSChartSeriesInfo managed fields bevat en de gewone Boolean-members anders stackgarbage zijn:

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

    Series[0] := Default(TXLSChartSeriesInfo);   // initialiseer dit record nooit met 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, rood in de lage byte
    Series[0].Style.HasFillTheme := True;
    Series[0].Style.FillThemeColor := 4;         // accent1
    Series[0].Style.FillThemeTint := 0.4;        // lichter 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;        // geen expliciete RGB: afgevlakt
    Series[1].Style.FillThemeColor := 8;         // accent5

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

Teruglezen loopt via hetzelfde chartmodel dat de rest van de HotXLS-chartinspectie gebruikt. GetChartModel retourneert een owned TXLSChartModel die je vrijgeeft, en elke TXLSChartSeries stelt HasFillTheme, FillThemeColor en FillThemeTint beschikbaar naast de FillRgb die uit de OPT1-fillColor is gedecodeerd en voor die serie voorrang heeft op de AreaFormat-kleur. Dezelfde drie waarden bereiken ook de canonical semantic snapshot als SolidFillThemeSet, SolidFillThemeColor en SolidFillThemeTint, zodat een workbookdiff een themewijziging als themewijziging ziet in plaats van als onverklaarde RGB-drift. Kom je van de XLSX-kant, dan is dit de binaire tegenhanger van de styling die wordt beschreven in de HotXLS-handleiding voor Excel-charts, afbeeldingen en drawings in 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, de OPT1 fillColor
    end;
  finally
    Model.Free;
  end;
finally
  Wb.Free;
end;

Wat belooft een themavulling in binair XLS niet?

Drie eerlijke grenzen. Ten eerste, en het belangrijkst voor iedereen die deze code auditeert: geen enkel voorbeeldbestand in het lokale corpus bevat überhaupt een GelFrame-record. De elf voorkomens van het bytepaar 66 10 in het conditional-formattingvoorbeeld zitten op posities die geen recordgrenzen zijn, en een dump van de volledige stream vindt nul hits. De hier beschreven bitlayout is afgeleid uit de specificatie en daarna op drie manieren vastgezet: via decodesymmetrie op de builderoutput, via met de hand gebouwde bytetests die een synthetische $1066-payload rechtstreeks aan de decoder voeren en via een assertion op de exacte afgevlakte RGB. Dat is zwakker bewijs dan een vastgelegd Excel-bestand, en dat moet je zeggen in plaats van het tegendeel te suggereren. Ten tweede gebruikt het afvlakken voor een vulling die alleen een theme heeft een ingebouwde default Office-themetabel en geen themapart die uit de workbook is gelezen, omdat binair XLS geen themapart heeft in de zin waarin een verpakt XLSX dat heeft — als je het eigen theme van de workbook de vlakke kleur wilt laten bepalen, lever dan zelf FillRgb aan. Ten derde accepteert de decoder alleen een GelFrame binnen een seriesblok; hetzelfde record kan op het chartgebied of een axisframe voorkomen, en het daar accepteren zou stilletjes een achtergrondvulling aan een serie toeschrijven, dus die gevallen worden genegeerd. Een fillColorExt zonder de flag $08000000 wordt eveneens behandeld als gewone extended color en zet nooit HasFillTheme. Voor workbooks waarin de chart in de XLSX-wereld is gemaakt en alleen wordt doorgegeven, is het preservationpad in Excel-charts bewerken zonder ChartML te verliezen de veiligere route, en de container waarin deze records zitten wordt behandeld in OLE2-compoundbestanden in Delphi lezen zonder COM IStorage

Chartvullingen met themakleur, de GelFrame-encoder en -decoder en de volledige BIFF8-chartsubstreambuilder worden geleverd in de HotXLS Delphi spreadsheet component voor Delphi en C++Builder, die XLS, XLSX en ODS leest en schrijft zonder dat Excel geïnstalleerd is