Technisch artikel

Excel Waterfall- en Treemap-grafieken in Delphi met HotXLS

HotXLS schrijft Excel waterfall- en treemap-grafieken als zelfstandige cx:chartSpace-parts — xl/charts/chartExN.xml met het content type application/vnd.ms-office.chartex+xml — en anker ze via een mc:AlternateContent-wrapper zodat Excel 2016 en later de grafiek renderen terwijl oudere readers een fallback-vorm krijgen. Kies xlsxChartWaterfall of xlsxChartTreemap en de hele part-pipeline volgt vanzelf

De manier waarop dit probleem zich meestal aankondigt is een supportticket. Een klant stuurt een workbook dat zijn financiële team in Excel 2019 heeft gebouwd, jouw reporting-tool opent het, slaat het op, en de waterfall-grafiek is weg. Geen exceptie, geen waarschuwing, niets in de log — de grafiek zit gewoon niet in de output. Ga je ernaar zoeken in het klassieke chart-codepad, dan zoek je lang, want de grafiek is er nooit geweest

Het mc:AlternateContent-anchor dat HotXLS voor een chartEx-grafiek schrijft: de mc:Choice-branch vereist cx1 en bevat een xdr:graphicFrame waarvan de a:graphicData de chartex-namespace refereert met een kale cx:chart-relatie, terwijl mc:Fallback een notice-rechthoek draagt die oudere readers in plaats daarvan tekenen
Het mc:AlternateContent-anchor routeert een chartEx-grafiek tussen Excel 2016 en oudere readers: de Choice-branch vereist cx1 en draagt het chartex-frame, terwijl de Fallback de notice-rechthoek houdt die oudere readers moeten tekenen

Waarom zitten waterfall- en treemap-grafieken niet in c:chartSpace?

Omdat Microsoft ze er niet kwijt kon. Het DrawingML-chartschema in ECMA-376 Part 1 §21.2 somt zijn chart-elementen op — c:barChart, c:lineChart, c:pieChart en de rest — als een gesloten choice, dus waterfall, treemap, sunburst en funnel toevoegen in Excel 2016 zou betekenen dat een schema breekt waar elke bestaande reader van afhangt. De nieuwe types gingen in plaats daarvan naar een parallel document: een cx:chartSpace-part in de chartex-namespace van 2014, http://schemas.microsoft.com/office/drawing/2014/chartex, opgeslagen naast de klassieke grafieken maar structureel niet aan hen gerelateerd. De drawing-kant naait de twee werelden aan elkaar met mc:AlternateContent, het compatibiliteitsmechanisme uit ECMA-376 Part 3: de mc:Choice-branch declareert Requires="cx1" en bevat een xdr:graphicFrame waarvan a:graphicData/@uri de chartex-namespace is en de payload een kale <cx:chart r:id="rIdN"/>-referentie, terwijl mc:Fallback een rechthoek houdt met de notice die Excel er zelf in schrijft. Een reader die cx1 niet begrijpt is volgens het Part 3-contract verplicht de fallback te nemen

Het relationship-type is de andere weggever. Een klassieke grafiek hangt aan de drawing-part met de standaard OOXML-chartrelationship; een chartEx-part gebruikt in plaats daarvan http://schemas.microsoft.com/office/2014/relationships/chartEx, gedocumenteerd in [MS-ODRAWXML]. HotXLS stelt beide werelden als constanten beschikbaar — XlsxRtChartEx voor die relationship, XlsxCtChartEx voor het content type, XlsxNsMsChartEx en XlsxNsMsChartEx2015 voor de twee namespaces die het anchor nodig heeft — en het predicaat XlsxChartIsChartEx is wat een grafiek bij het opslaan het verlengde pad in stuurt. Combinatiegrafieken en bewaarde klassieke chart-XML gaan het andere pad in, beschreven in het artikel over bewaarde chart-XML en combinatiegrafieken

Vier schemaregels die bepalen of Excel het bestand opent

Het chartex-schema is op vier specifieke plekken strenger dan het klassieke, en elk ervan levert dezelfde onbehulpzame repair-prompt op wanneer je het fout doet. BuildChartExKnownXml en BuildChartExRelsXml in HotXLS hebben alle vier ingebakken, maar het is de moeite waard ze te kennen als je ooit de XML met de hand inspecteert

  • Volgorde. cx:chartData moet in het document vóór cx:chart komen. Dat is het omgekeerde van de intuïtie die je van klassieke grafieken opbouwt, waar de caches in de series in de plot area leven; in een chartEx-part zit de data vooraan in een eigen blok en refereert de chart-body er alleen naar via id
  • Cardinaliteit. cx:plotArea mag exact één cx:plotAreaRegion bevatten. Niet nul, niet twee. Hier is geen combinatiegrafiek-verhaal — één region, en de series erin
  • Typeplaatsing. Het chart-type is helemaal geen elementnaam. Het is het layoutId-attribuut op cx:series, met een token uit de ST_SeriesLayout-enumeratie in het Microsoft-chartex-schema. XlsxChartExLayoutId mapt de HotXLS-enum op dat token en geeft 'waterfall' of 'treemap' terug; XlsxChartExLayoutIdToType draait de mapping bij het lezen om
  • De relationship die klassieke grafieken nooit nodig hebben. cx:chartData bevat een <cx:externalData r:id="rId1"/>-element, en die relationship moet bestaan in xl/charts/_rels/chartExN.xml.rels, wijzend terug naar de worksheet die eigenaar is van de data, want Excel gebruikt hem om de cx:f-bereikformules op te lossen. Laat je het rels-part weg, dan opent de grafiek leeg zonder dat iets uitlegt waarom

Hoe voeg je een waterfall- of treemap-grafiek toe in Delphi?

Op dezelfde manier als elke andere grafiek in HotXLS — de uitgebreide types zijn leden van TXLSXChartType, dus TXLSXWorksheet.AddChart accepteert ze direct en alles stroomafwaarts hangt aan de enum-waarde. Er is geen aparte chartEx-API om te leren

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

    // Anchor-rechthoek in rij/kolom-coördinaten, net als een klassieke grafiek
    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;
De HotXLS-save-pipeline voor uitgebreide Excel-grafieken: AddChart met xlsxChartWaterfall of xlsxChartTreemap bereikt XlsxChartIsChartEx, dat klassieke grafieken naar een gedeelde teller routeert en chartEx-grafieken naar een onafhankelijke teller die chartEx1.xml schrijft, zijn rels-bestand dat terugwijst naar de worksheet, de chartex content type override en de AlternateContent-anchor-payload
Eén chartmodel en één routing-predicaat voeden twee tellers bij het opslaan: klassieke grafieken delen chart1.xml en chart2.xml, terwijl de waterfall als chartEx1.xml landt omdat de chartEx-teller onafhankelijk is van de klassieke

Bij het opslaan ziet HotXLS XlsxChartIsChartEx True teruggeven, kent een part-naam toe uit een afzonderlijke chartEx-teller, en schrijft xl/charts/chartEx1.xml, zijn rels-bestand, de content-type override en het mc:AlternateContent-anchor dat XlsxChartExAnchorPayloadXml produceert. Die onafhankelijke teller doet ertoe: een workbook met twee klassieke grafieken en één waterfall krijgt chart1.xml, chart2.xml en chartEx1.xml, niet chartEx3.xml. Treemaps verschillen daarna op twee details van waterfalls, beide afgehandeld door dezelfde builder. De numerieke dimensie verandert van type — cx:numDim type="val" voor een waterfall wordt type="size" voor een treemap, want een treemap-rechthoek encodeert grootte als oppervlakte in plaats van als een uitgezette waarde — en treemaps willen meestal een categorie-hiërarchie, die het model via TXLSXChartSeries.CategoriesMultiLevel uitdrukt. Zet die flag en S.ResolveCategoryLevels leest het bereik bij het schrijven uit de worksheet, splitst het in één TXLSXChartDataCache per level achter S.CategoryLevelCount en S.CategoryLevels[], en BuildChartExLevelXml emitteert één cx:lvl per level in één enkele cx:strDim, leaf level eerst — dezelfde fysieke ordening die ECMA-376 Part 1 §21.2 voor c:multiLvlStrCache gebruikt, dus die conventie gaat over ook al doet geen van de markup dat

Chart := Sheet.AddChart(xlsxChartTreemap, 'Revenue by Region',
  2, 8, 24, 16);
S := Chart.AddSeries('Revenue',
  'Sheet1!$A$2:$B$40',   // twee kolommen: leaf-label, dan parent-groep
  'Sheet1!$C$2:$C$40');
S.CategoriesMultiLevel := True;

De cache-vorm die elke aanname van klassieke grafieken breekt

Als je een klassieke chart-cache-reader hebt geschreven, verraden drie gewoonten je op een chartEx-part, en alle drie komen uit één ontwerpbeslissing: chartex slaat in attributen en element content op wat DrawingML in kind-elementen opslaat

  • Een gecachte point is <cx:pt idx="0">42</cx:pt>. De waarde is de element content. Er is geen c:v-kind om in af te dalen, dus een parser die op zoek naar v-nodes wandelt vindt niets en meldt een lege cache
  • ptCount en formatCode zijn attributen op cx:lvl. In een klassieke c:numCache zijn beide kind-elementen
  • Meerdere hiërarchie-levels zijn meerdere cx:lvl-elementen in één cx:strDim. Er is geen apart multi-level-cache-element zoals c:multiLvlStrCache apart is van c:strCache

BuildChartExLevelXml schrijft die vorm en ParseChartExXml leest hem terug, waarbij elke waarde er via XlsxEscapeText uit gaat zodat een categorie-label met een ampersand of een angle bracket de round trip overleeft. Het is de moeite waard te noteren wat de builder doet wanneer een numeriek level geen format code draagt: hij schrijft formatCode="General" in plaats van het attribuut weg te laten, want hier is het attribuut niet optioneel zoals zijn klassieke tegenhanger

Naast-elkaar cache-vormen in HotXLS-chartEx-parts: een klassieke c:numCache slaat ptCount en formatCode als kind-elementen op met waarden in c:v, terwijl cx:numDim ze als attributen op cx:lvl opslaat met de waarde als element content, en CategoriesMultiLevel emitteert één cx:lvl per categorie-level, leaf eerst, in één enkele cx:strDim
Chartex slaat in attributen en element content op wat DrawingML in kind-elementen opslaat: de waarde zit direct in cx:pt, ptCount en formatCode worden attributen op cx:lvl, en een categorie-hiërarchie is meerdere cx:lvl-elementen in één cx:strDim

De namespace-prefix die de type-readback liet falen

De eerste versie van de HotXLS-chartEx-parser schreef prima bestanden en las ze allemaal terug als een kolomgrafiek. De root cause was één regel, en de les generaliseert naar elke OOXML-parser die je ooit zult schrijven: TXMLReader.Name geeft de naam terug zoals hij in het document staat, prefix inbegrepen. In een klassiek chart-part arriveren de meeste elementen zonder prefix, dus SameText(Reader.Name, 'series') werkt en je denkt er niet meer over na. In een chartEx-part is elk element geprefixt — de reader geeft je 'cx:series', de vergelijking faalt stilletjes op allemaal, en je krijgt geen exceptie en geen malformed XML, alleen een lege series-lijst en een chart-type dat terugvalt op de default. De fix is vergelijken op de local name, op één plek in plaats van op elke call site

// Reader.Name draagt zijn namespace-prefix: 'cx:series', niet '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 gebruikt die helper voor elke elementtest en een gelijkwaardige AttrVal-helper voor attributen, want cx:autoUpdate en vrienden zijn ook geprefixt. Zodra de series-elementen daadwerkelijk worden gezien, komt het type terug van de eerste layoutId die wordt tegengekomen, en elke cx:series wordt aan zijn cx:data-blok gekoppeld door cx:dataId/@val tegen cx:data/@id te matchen. Grafieken die zijn geopend maar nooit gewijzigd nemen in plaats daarvan het oudere pad: de originele bytes replayen vanuit FRawChartXml, dus een onaangeraakt klantbestand komt eruit zoals het erin ging — dezelfde conservative-writer-discipline die sheet-level-extensies regeert in het sparkline-extensie-artikel

Wat HotXLS met deze grafieken bewust niet doet

Twee grenzen zijn het waard om ronduit te benoemen, want beide zijn keuzes en geen gaten die nog gevuld moeten worden. De ingebouwde PDF- en SVG-renderers tekenen geen waterfall- of treemap-series: de types matchen geen enkele drawing-branch, dus series-rendering wordt overgeslagen en de rest van de sheet exporteert normaal — een plausibel uitziende treemap in een geëxporteerde PDF faken zou erger zijn dan niets tekenen, want hij zou stilletjes afwijken van wat Excel voor hetzelfde bestand toont. En xlsxChartWaterfall en xlsxChartTreemap zijn de enige twee uitgebreide layouts die HotXLS vanuit het model bouwt; ST_SeriesLayout definieert ook sunburst, funnel, box-and-whisker en histogram, en een workbook dat er een bevat round-tript nog steeds via het raw-XML-replay-pad, je kunt er alleen vandaag nog geen één vanuit Delphi-code aanmaken. Het renderen van de klassieke chart-types is onveranderd en behandeld in het artikel over grafieken, afbeeldingen en drawings, en de chartEx-pipeline vaart mee in de HotXLS Delphi Excel Component voor Delphi en C++Builder