Technický článek

Vodopádové a treemap grafy Excelu v Delphi s HotXLS

HotXLS zapisuje vodopádové a treemap grafy Excelu jako samostatné části cx:chartSpace — xl/charts/chartExN.xml s content type application/vnd.ms-office.chartex+xml — a kotví je přes obal mc:AlternateContent, takže Excel 2016 a novější graf vykreslí, zatímco starší čtečky dostanou fallback tvar. Zvolte xlsxChartWaterfall nebo xlsxChartTreemap a celá pipeline částí běží sama

Takhle se tenhle problém obvykle ohlásí — support ticketem. Zákazník pošle workbook, který jeho finanční tým postavil v Excelu 2019, váš reportovací nástroj ho otevře, uloží a vodopádový graf je pryč. Žádná výjimka, žádné varování, nic v logu — graf prostě není ve výstupu. Když ho půjdete hledat do klasické cesty chart kódu, budete hledat dlouho, protože tam nikdy nebyl

Kotva mc:AlternateContent, kterou HotXLS zapisuje pro chartEx graf: větev mc:Choice vyžaduje cx1 a drží xdr:graphicFrame, jehož a:graphicData odkazuje na namespace chartex s holým vztahem cx:chart, zatímco mc:Fallback nese obdélník s upozorněním, který místo grafu vykreslí starší čtečky
Kotva mc:AlternateContent přepíná chartEx graf mezi Excelem 2016 a staršími čtečkami: větev Choice vyžaduje cx1 a nese chartex frame, zatímco Fallback drží obdélník s upozorněním, který musí starší čtečky vykreslit

Proč vodopádové a treemap grafy nejsou v c:chartSpace?

Protože je tam Microsoft nemohl dát. Schéma DrawingML chart v ECMA-376 Part 1 §21.2 vyjmenovává prvky grafu — c:barChart, c:lineChart, c:pieChart a ostatní — jako uzavřenou volbu, takže přidat v Excelu 2016 waterfall, treemap, sunburst a funnel by znamenalo rozbít schéma, na kterém závisí každá existující čtečka. Nové typy šly místo toho do paralelního dokumentu: část cx:chartSpace v namespace chartex z roku 2014, http://schemas.microsoft.com/office/drawing/2014/chartex, uložená vedle klasických grafů, ale strukturálně s nimi nesouvisející. Kreslicí strana sešívá oba světy přes mc:AlternateContent, kompatibilní mechanismus z ECMA-376 Part 3: větev mc:Choice deklaruje Requires="cx1" a drží xdr:graphicFrame, jehož a:graphicData/@uri je namespace chartex a jehož payload je holý odkaz <cx:chart r:id="rIdN"/>, zatímco mc:Fallback drží obdélník s upozorněním, které tam Excel sám píše. Čtečka, která nerozumí cx1, je kontraktem Part 3 povinna vzít fallback

Typ vztahu je druhé prozrazení. Klasický graf visí na kreslicí části se standardním OOXML chart vztahem; část chartEx místo toho používá http://schemas.microsoft.com/office/2014/relationships/chartEx, dokumentovaný v [MS-ODRAWXML]. HotXLS vystavuje oba světy jako konstanty — XlsxRtChartEx pro ten vztah, XlsxCtChartEx pro content type, XlsxNsMsChartEx a XlsxNsMsChartEx2015 pro dva namespace, které kotva potřebuje — a predikát XlsxChartIsChartEx je to, co při uložení nasměruje graf do rozšířené cesty. Kombinační grafy a zachovaná klasická chart XML jdou tou druhou, popsanou v článku o zachované chart XML a kombinačních grafech

Čtyři pravidla schématu, která rozhodují, jestli Excel soubor otevře

Schéma chartex je na čtyřech konkrétních místech přísnější než klasické a každé z nich při chybě vyprodukuje stejnou nepoužitelnou výzvu k opravě. BuildChartExKnownXml a BuildChartExRelsXml v HotXLS mají všechny čtyři zapracované, ale stojí za to je znát, pokud XML někdy zkoumáte ručně

  • Pořadí. cx:chartData musí být v dokumentu před cx:chart. To je opak intuice, kterou si stavíte z klasických grafů, kde cache žijí uvnitř série uvnitř plot area; v části chartEx sedí data vpředu ve vlastním bloku a tělo grafu se na ně jen odkazuje id
  • Kardinalita. cx:plotArea může obsahovat přesně jeden cx:plotAreaRegion. Ne nula, ne dva. Žádný příběh kombinačního grafu tady není — jedna oblast a série uvnitř ní
  • Umístění typu. Typ grafu vůbec není jméno elementu. Je to atribut layoutId na cx:series, přijímající token z enumerace ST_SeriesLayout v Microsoft chartex schématu. XlsxChartExLayoutId mapuje HotXLS enum na ten token a vrací 'waterfall' nebo 'treemap'; XlsxChartExLayoutIdToType spouští mapování pozpátku při čtení
  • Vztah, který klasické grafy nikdy nepotřebují. cx:chartData drží element <cx:externalData r:id="rId1"/> a ten vztah musí existovat v xl/charts/_rels/chartExN.xml.rels a ukazovat zpět na worksheet, který data vlastní, protože Excel ho používá k rozlišení range formulí cx:f. Vynechejte rels část a graf se otevře prázdný a nic nevysvětlí proč

Jak přidáte vodopádový nebo treemap graf v Delphi?

Stejně jako přidáváte jakýkoli jiný graf v HotXLS — rozšířené typy jsou členy TXLSXChartType, takže je TXLSXWorksheet.AddChart přijme přímo a všechno níže se odvíjí od hodnoty enumu. Žádné samostatné chartEx API se učit nemusíte

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

    // Kotva v řádkových a sloupcových souřadnicích, stejně jako klasický graf
    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;
Save pipeline HotXLS pro rozšířené grafy Excelu: AddChart s xlsxChartWaterfall nebo xlsxChartTreemap dorazí k XlsxChartIsChartEx, který nasměruje klasické grafy na sdílený čítač a chartEx grafy na nezávislý čítač zapisující chartEx1.xml, jeho rels soubor ukazující zpět na worksheet, chartex content type override a payload kotvy AlternateContent
Jeden model grafu a jeden routing predikát živí při uložení dva čítače: klasické grafy sdílejí chart1.xml a chart2.xml, zatímco waterfall přistane jako chartEx1.xml, protože chartEx čítač je na klasickém nezávislý

Při uložení HotXLS vidí XlsxChartIsChartEx vracet True, alokuje jméno části z odděleného chartEx čítače a zapíše xl/charts/chartEx1.xml, jeho rels soubor, content-type override a kotvu mc:AlternateContent produkovanou funkcí XlsxChartExAnchorPayloadXml. Ten nezávislý čítač je důležitý: workbook se dvěma klasickými grafy a jedním waterfall dostane chart1.xml, chart2.xml a chartEx1.xml, ne chartEx3.xml. Treemapy se pak od waterfall liší ve dvou detailech, oba obslouží stejný builder. Číselná dimenze mění typ — cx:numDim type="val" pro waterfall se pro treemap mění na type="size", protože obdélník treemapu kóduje velikost jako plochu, ne jako vynesenou hodnotu — a treemapy obvykle chtějí hierarchii kategorií, kterou model vyjadřuje přes TXLSXChartSeries.CategoriesMultiLevel. Nastavte ten příznak a S.ResolveCategoryLevels při zápisu přečte range z worksheetu, rozdělí ho na jeden TXLSXChartDataCache na úroveň za S.CategoryLevelCount a S.CategoryLevels[], a BuildChartExLevelXml emituje jeden cx:lvl na úroveň uvnitř jediného cx:strDim, nejdřív úroveň listu — stejné fyzické pořadí, jaké ECMA-376 Part 1 §21.2 používá pro c:multiLvlStrCache, takže se konvence přenáší, i když se žádný markup nepřenáší

Chart := Sheet.AddChart(xlsxChartTreemap, 'Revenue by Region',
  2, 8, 24, 16);
S := Chart.AddSeries('Revenue',
  'Sheet1!$A$2:$B$40',   // dva sloupce: popisek listu, pak nadřazená skupina
  'Sheet1!$C$2:$C$40');
S.CategoriesMultiLevel := True;

Tvar cache, který rozbije každý předpoklad z klasických grafů

Pokud jste psali čtečku cache klasických grafů, tři návyky vás na části chartEx zradí a všechny tři pramení z jednoho rozhodnutí: chartex ukládá do atributů a obsahu elementu to, co DrawingML ukládá do child elementů

  • Cache bod je <cx:pt idx="0">42</cx:pt>. Hodnota je obsah elementu. Není tu žádný child c:v, do kterého by se sestoupilo, takže parser chodící za uzly v nic nenajde a ohlásí prázdnou cache
  • ptCount a formatCode jsou atributy na cx:lvl. V klasickém c:numCache jsou oba child elementy
  • Více úrovní hierarchie je více elementů cx:lvl uvnitř jednoho cx:strDim. Neexistuje žádný samostatný element multi-level cache tak, jak je c:multiLvlStrCache oddělený od c:strCache

BuildChartExLevelXml zapisuje ten tvar a ParseChartExXml ho čte zpět, přičemž každou hodnotu na cestě ven prohání přes XlsxEscapeText, takže popisek kategorie obsahující ampersand nebo špičatou závorku přežije round trip. Stojí za zmínku, co builder dělá, když číselná úroveň nemá format code: zapíše formatCode="General" místo vynechání atributu, protože tady atribut není volitelný tak, jak je jeho klasický protějšek

Vedle sebe tvary cache v chartEx částech HotXLS: klasické c:numCache ukládá ptCount a formatCode jako child elementy s hodnotami uvnitř c:v, zatímco cx:numDim je ukládá jako atributy na cx:lvl s hodnotou jako obsahem elementu, a CategoriesMultiLevel emituje jeden cx:lvl na úroveň kategorie, nejdřív list, uvnitř jediného cx:strDim
Chartex ukládá do atributů a obsahu elementu to, co DrawingML ukládá do child elementů: hodnota sedí přímo uvnitř cx:pt, ptCount a formatCode se stávají atributy na cx:lvl a hierarchie kategorií je několik elementů cx:lvl v jednom cx:strDim

Namespace prefix, který rozbil zpětné čtení typu

První verze chartEx parseru HotXLS psala naprosto správné soubory a každý z nich přečetla zpět jako sloupcový graf. Kořenová příčina byl jeden řádek a poučení zobecňuje na každý OOXML parser, který kdy napíšete: TXMLReader.Name vrací jméno tak, jak je zapsané v dokumentu, včetně prefixu. V klasické chart části přichází většina elementů bez prefixu, takže SameText(Reader.Name, 'series') funguje a přestanete o tom přemýšlet. V chartEx části má prefix každý element — čtečka vám předá 'cx:series', porovnání tiše selže na všech a nedostanete žádnou výjimku ani špatně utvořené XML, jen prázdný seznam sérií a typ grafu padající na default. Oprava spočívá v porovnání lokálního jména, na jednom místě, ne na každém call site

// Reader.Name nese svůj namespace prefix: 'cx:series', ne '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 používá ten helper pro každý test elementu a ekvivalentní helper AttrVal pro atributy, protože i cx:autoUpdate a spol. mají prefix. Jakmile se elementy sérií skutečně vidí, typ se vrátí z prvního nalezeného layoutId a každá cx:series se propojí se svým blokem cx:data spárováním cx:dataId/@val s cx:data/@id. Grafy otevřené, ale nikdy nezměněné jdou místo toho starší cestou: původní bajty se přehrají z FRawChartXml, takže nedotčený soubor zákazníka vyjde ven tak, jak vešel — stejná disciplína konzervativního zapisovače, která řídí rozšíření na úrovni sheetu v článku o sparkline rozšíření

Co HotXLS s těmito grafy záměrně nedělá

Dvě hranice stojí za to říct jasně, protože obě jsou volby, ne mezery čekající na zaplnění. Vestavěné PDF a SVG renderery nekreslí vodopádové ani treemap série: typy neodpovídají žádné kreslicí větvi, takže vykreslení série se přeskočí a zbytek sheetu se exportuje normálně — předstírat věrohodně vypadající treemap v exportovaném PDF by bylo horší než nekreslit nic, protože by to tiše nesouhlasilo s tím, co Excel ukazuje pro stejný soubor. A xlsxChartWaterfall a xlsxChartTreemap jsou jediné dva rozšířené layouty, které HotXLS staví z modelu; ST_SeriesLayout definuje taky sunburst, funnel, box-and-whisker a histogram, a workbook obsahující kterýkoli z nich pořád projde round trip cestou přehrávání raw XML, jen ho dnes z Delphi kódu nenapíšete. Vykreslování klasických typů grafů se nemění a je pokryté v článku o grafech, obrázcích a kresbách a chartEx pipeline se dodává vedle něj v HotXLS Delphi Excel Component pro Delphi a C++Builder