Artykuł techniczny

Wykresy wodospadowe i treemap w Excelu w Delphi z HotXLS

HotXLS zapisuje wykresy wodospadowe i treemap w Excelu jako samodzielne części cx:chartSpace — xl/charts/chartExN.xml z typem treści application/vnd.ms-office.chartex+xml — i kotwiczy je przez wrapper mc:AlternateContent, żeby Excel 2016 i nowszy renderował wykres, a starsze czytniki dostały kształt zastępczy. Wybierasz xlsxChartWaterfall albo xlsxChartTreemap i cały pipeline części jedzie za tym

Ten problem zwykle ogłasza się ticketem wsparcia. Klient przysyła skoroszyt, który ich zespół finansowy zbudował w Excelu 2019, twoje narzędzie raportowe go otwiera, zapisuje, a wykres wodospadowy znika. Żadnego wyjątku, żadnego ostrzeżenia, nic w logu — wykres po prostu nie ma go w wyjściu. Jeśli pójdziesz go szukać w klasycznej ścieżce kodowej wykresów, będziesz szukał długo, bo wykres nigdy tam nie był

Kotwica mc:AlternateContent, którą HotXLS zapisuje dla wykresu chartEx: gałąź mc:Choice wymaga cx1 i trzyma xdr:graphicFrame, którego a:graphicData wskazuje przestrzeń nazw chartex z gołą relacją cx:chart, a mc:Fallback niesie prostokąt informacyjny, który starsze czytniki rysują w zamian
Kotwica mc:AlternateContent rozdziela wykres chartEx między Excel 2016 a starsze czytniki: gałąź Choice wymaga cx1 i niesie ramkę chartex, a Fallback trzyma prostokąt informacyjny, który starsze czytniki muszą narysować

Dlaczego wykresów wodospadowych i treemap nie ma w c:chartSpace?

Bo Microsoft nie mógł ich tam dodać. Schemat wykresów DrawingML w ECMA-376 Part 1 §21.2 wylicza swoje elementy wykresów — c:barChart, c:lineChart, c:pieChart i resztę — jako zamknięty wybór, więc dodanie wodospadu, treemap, sunburst i funnel w Excelu 2016 oznaczałoby złamanie schematu, na którym polega każdy istniejący czytnik. Nowe typy trafiły zamiast tego do równoległego dokumentu: części cx:chartSpace w przestrzeni nazw chartex z 2014, http://schemas.microsoft.com/office/drawing/2014/chartex, składowanej obok klasycznych wykresów, ale strukturalnie z nimi niepowiązanej. Warstwa rysowania zszywa oba światy przez mc:AlternateContent, mechanizm kompatybilności z ECMA-376 Part 3: gałąź mc:Choice deklaruje Requires="cx1" i trzyma xdr:graphicFrame, którego a:graphicData/@uri to przestrzeń nazw chartex, a payload to gołe odwołanie <cx:chart r:id="rIdN"/>, podczas gdy mc:Fallback trzyma prostokąt z informacją, którą Excel sam tam zapisuje. Czytnik, który nie rozumie cx1, jest zobowiązany kontraktem Part 3 do wzięcia fallbacku

Typ relacji to druga wskazówka. Klasyczny wykres wisi na części rysunkowej ze standardową relacją wykresu OOXML; część chartEx używa zamiast tego http://schemas.microsoft.com/office/2014/relationships/chartEx, udokumentowanego w [MS-ODRAWXML]. HotXLS wystawia oba światy jako stałe — XlsxRtChartEx dla tej relacji, XlsxCtChartEx dla typu treści, XlsxNsMsChartEx i XlsxNsMsChartEx2015 dla dwóch przestrzeni nazw, których potrzebuje kotwica — a predykat XlsxChartIsChartEx kieruje wykres w rozszerzoną ścieżkę przy zapisie. Wykresy kombinowane i zachowany klasyczny XML wykresów idą tą drugą, opisaną w artykule o zachowanym XML wykresów i wykresach kombinowanych

Cztery reguły schematu, które decydują, czy Excel otworzy plik

Schemat chartex jest ostrzejszy niż klasyczny w czterech konkretnych miejscach i każde z nich daje ten sam niepomocny komunikat o naprawie, gdy coś zepsujesz. BuildChartExKnownXml i BuildChartExRelsXml w HotXLS mają wszystkie cztery wpisane w kod, ale warto je znać, jeśli kiedykolwiek oglądasz XML ręcznie

  • Kolejność. cx:chartData musi być w dokumencie przed cx:chart. To odwrotność intuicji, którą budujesz na klasycznych wykresach, gdzie cache mieszkają w serii wewnątrz obszaru kreślenia; w części chartEx dane siedzą z przodu we własnym bloku, a ciało wykresu odwołuje się do nich tylko po id
  • Liczność. cx:plotArea może zawierać dokładnie jeden cx:plotAreaRegion. Nie zero, nie dwa. Nie ma tu historii wykresu kombinowanego — jeden region i serie w środku
  • Umiejscowienie typu. Typ wykresu w ogóle nie jest nazwą elementu. To atrybut layoutId na cx:series, przyjmujący token z wyliczenia ST_SeriesLayout w schemacie chartex Microsoftu. XlsxChartExLayoutId mapuje enum HotXLS na ten token i zwraca 'waterfall' lub 'treemap'; XlsxChartExLayoutIdToType odpala mapowanie wstecz przy czytaniu
  • Relacja, której klasyczne wykresy nigdy nie potrzebują. cx:chartData trzyma element <cx:externalData r:id="rId1"/> i ta relacja musi istnieć w xl/charts/_rels/chartExN.xml.rels, wskazując z powrotem na arkusz, który jest właścicielem danych, bo Excel używa jej do rozwiązania formuł zakresów cx:f. Pomiń część rels, a wykres otworzy się pusty bez niczego, co tłumaczyłoby dlaczego

Jak dodać wykres wodospadowy albo treemap w Delphi?

Tak samo, jak dodajesz każdy inny wykres w HotXLS — typy rozszerzone są członkami TXLSXChartType, więc TXLSXWorksheet.AddChart przyjmuje je bezpośrednio, a wszystko niżej w pipeline kluczuje po wartości enuma. Nie ma osobnego API chartEx do nauki

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Chart: TXLSXChart;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('cashflow.xlsx');
    Sheet := Book.Sheets[0];

    // Prostokąt kotwiczący we współrzędnych wiersz/kolumna, jak w klasycznym wykresie
    Chart := Sheet.AddChart(xlsxChartWaterfall, 'Q3 Cash Flow',
      2, 5, 20, 12);
    Chart.AddSeries('Movement', 'Sheet1!$A$2:$A$9', 'Sheet1!$B$2:$B$9');

    Book.SaveAs('cashflow-waterfall.xlsx');
  finally
    Book.Free;
  end;
end;
Pipeline zapisu HotXLS dla rozszerzonych wykresów Excela: AddChart z xlsxChartWaterfall albo xlsxChartTreemap trafia do XlsxChartIsChartEx, który kieruje klasyczne wykresy do współdzielonego licznika, a wykresy chartEx do niezależnego licznika zapisującego chartEx1.xml, plik rels wskazujący z powrotem na arkusz, nadpisanie typu treści chartex i payload kotwicy AlternateContent
Jeden model wykresu i jeden predykat routingu zasilają przy zapisie dwa liczniki: klasyczne wykresy dzielą chart1.xml i chart2.xml, a wodospad ląduje jako chartEx1.xml, bo licznik chartEx jest niezależny od klasycznego

Przy zapisie HotXLS widzi, że XlsxChartIsChartEx zwraca True, przydziela nazwę części z osobnego licznika chartEx i zapisuje xl/charts/chartEx1.xml, jego plik rels, nadpisanie typu treści i kotwicę mc:AlternateContent wyprodukowaną przez XlsxChartExAnchorPayloadXml. Niezależny licznik ma znaczenie: skoroszyt z dwoma klasycznymi wykresami i jednym wodospadem dostaje chart1.xml, chart2.xml i chartEx1.xml, nie chartEx3.xml. Treemapy różnią się potem od wodospadów dwoma szczegółami, oba obsługiwane przez ten sam builder. Wymiar numeryczny zmienia typ — cx:numDim type="val" dla wodospadu staje się type="size" dla treemap, bo prostokąt treemap koduje wielkość jako powierzchnię, nie jako wykreśloną wartość — a treemapy zwykle chcą hierarchii kategorii, którą model wyraża przez TXLSXChartSeries.CategoriesMultiLevel. Ustaw ten flag i S.ResolveCategoryLevels czyta zakres z arkusza w czasie zapisu, dzieli go na jeden TXLSXChartDataCache na poziom za S.CategoryLevelCount i S.CategoryLevels[], a BuildChartExLevelXml emituje jeden cx:lvl na poziom wewnątrz jednego cx:strDim, najpierw poziom liścia — ta sama fizyczna kolejność, której ECMA-376 Part 1 §21.2 używa dla c:multiLvlStrCache, więc ta konwencja przenosi się dalej, nawet jeśli żadna część znaczników się nie przenosi

Chart := Sheet.AddChart(xlsxChartTreemap, 'Revenue by Region',
  2, 8, 24, 16);
S := Chart.AddSeries('Revenue',
  'Sheet1!$A$2:$B$40',   // dwie kolumny: etykieta liścia, potem grupa nadrzędna
  'Sheet1!$C$2:$C$40');
S.CategoriesMultiLevel := True;

Kształt cache, który łamie każde założenie z klasycznych wykresów

Jeśli pisałeś czytnik cache klasycznych wykresów, trzy nawyki zdradzą cię na części chartEx, a wszystkie trzy biorą się z jednej decyzji projektowej: chartex przechowuje w atrybutach i treści elementów to, co DrawingML przechowuje w elementach potomnych

  • Zacache'owany punkt to <cx:pt idx="0">42</cx:pt>. Wartością jest treść elementu. Nie ma elementu potomnego c:v, do którego można zejść, więc parser chodzący w poszukiwaniu węzłów v niczego nie znajduje i raportuje pusty cache
  • ptCount i formatCode to atrybuty na cx:lvl. W klasycznym c:numCache oba są elementami potomnymi
  • Wiele poziomów hierarchii to wiele elementów cx:lvl wewnątrz jednego cx:strDim. Nie ma osobnego elementu cache wielopoziomowego w taki sposób, w jaki c:multiLvlStrCache jest oddzielone od c:strCache

BuildChartExLevelXml zapisuje ten kształt, a ParseChartExXml czyta go z powrotem, przepuszczając każdą wartość przez XlsxEscapeText przy wyjściu, żeby etykieta kategorii z ampersandem albo nawiasem ostrym przeżyła round trip. Warto odnotować, co builder robi, gdy poziom numeryczny nie niesie kodu formatu: zapisuje formatCode="General" zamiast upuszczać atrybut, bo tu atrybut nie jest opcjonalny tak, jak jego klasyczny odpowiednik

Kształty cache obok siebie w częściach chartEx HotXLS: klasyczny c:numCache przechowuje ptCount i formatCode jako elementy potomne z wartościami wewnątrz c:v, a cx:numDim trzyma je jako atrybuty na cx:lvl z wartością w treści elementu, a CategoriesMultiLevel emituje jeden cx:lvl na poziom kategorii, najpierw liść, wewnątrz jednego cx:strDim
Chartex przechowuje w atrybutach i treści elementów to, co DrawingML trzyma w elementach potomnych: wartość siedzi bezpośrednio w cx:pt, ptCount i formatCode stają się atrybutami na cx:lvl, a hierarchia kategorii to kilka elementów cx:lvl w jednym cx:strDim

Prefiks przestrzeni nazw, który psuł odczyt typu

Pierwsza wersja parsera chartEx w HotXLS zapisywała całkiem dobre pliki i czytała każdy z nich z powrotem jako wykres kolumnowy. Sednem była jedna linia, a lekcja uogólnia się na każdy parser OOXML, jaki kiedykolwiek napiszesz: TXMLReader.Name zwraca nazwę tak, jak jest zapisana w dokumencie, z prefiksem. W klasycznej części wykresu większość elementów przychodzi bez prefiksu, więc SameText(Reader.Name, 'series') działa i przestajesz o tym myśleć. W części chartEx każdy element ma prefiks — czytnik podaje ci 'cx:series', porównanie po cichu zawodzi na wszystkich, i nie dostajesz żadnego wyjątku ani zepsutego XML, tylko pustą listę serii i typ wykresu spadający do domyślnego. Poprawka to porównywanie po nazwie lokalnej, w jednym miejscu zamiast przy każdym wywołaniu

// Reader.Name zawiera prefiks przestrzeni nazw: 'cx:series', a nie 'series'
function IsEl(const LocalName: string): Boolean;
var
  NodeName: string;
begin
  NodeName := String(Reader.Name);
  Result := SameText(NodeName, LocalName) or
            EndsText(':' + LocalName, NodeName);
end;

ParseChartExXml używa tego helpera do każdego testu elementu i równoważnego helpera AttrVal do atrybutów, bo cx:autoUpdate i spółka też mają prefiksy. Gdy elementy serii są w końcu widziane, typ wraca z pierwszego napotkanego layoutId, a każdy cx:series jest podpinany do swojego bloku cx:data przez dopasowanie cx:dataId/@val do cx:data/@id. Wykresy otwarte, ale nigdy niezmodyfikowane, idą starszą ścieżką: oryginalne bajty wracają z FRawChartXml, więc nietknięty plik klienta wychodzi tak, jak wszedł — ta sama dyscyplina konserwatywnego zapisu, która rządzi rozszerzeniami na poziomie arkusza w artykule o rozszerzeniu sparklines

Czego HotXLS celowo nie robi z tymi wykresami

Dwie granice warto nazwać wprost, bo obie to wybory, nie luki czekające na uzupełnienie. Wbudowane renderery PDF i SVG nie rysują serii wodospadowych ani treemap: te typy nie pasują do żadnej gałęzi rysowania, więc renderowanie serii jest pomijane, a reszta arkusza eksportuje się normalnie — sfałszowanie wiarygodnie wyglądającego treemap w eksportowanym PDF byłoby gorsze niż narysowanie niczego, bo po cichu zaprzeczałoby temu, co Excel pokazuje dla tego samego pliku. A xlsxChartWaterfall i xlsxChartTreemap to jedyne dwa rozszerzone układy, które HotXLS buduje z modelu; ST_SeriesLayout definiuje też sunburst, funnel, box-and-whisker i histogram, a skoroszyt zawierający którekolwiek z nich wciąż przechodzi round trip ścieżką replayu surowego XML — po prostu nie dasz rady dziś utworzyć takiego z kodu Delphi. Renderowanie klasycznych typów wykresów jest niezmienione i opisane w artykule o wykresach, obrazach i rysunkach, a pipeline chartEx jedzie razem z nim w HotXLS Delphi Excel Component dla Delphi i C++Builder