Teknisk artikel

Excel waterfall- og treemap-diagrammer i Delphi med HotXLS

HotXLS skriver Excel-waterfall- og treemap-diagrammer som selvstændige cx:chartSpace-parts — xl/charts/chartExN.xml der bærer content typen application/vnd.ms-office.chartex+xml — og forankrer dem gennem en mc:AlternateContent-indpakning, så Excel 2016 og nyere renderer diagrammet mens ældre læsere får en fallback-form. Vælg xlsxChartWaterfall eller xlsxChartTreemap, og hele part-pipelinen følger med

Sådan et problem melder sig som regel som en support-ticket. En kunde sender en workbook deres finansafdeling byggede i Excel 2019, dit rapporteringsværktøj åbner den, gemmer den, og waterfall-diagrammet er væk. Ingen exception, ingen advarsel, intet i loggen — diagrammet er bare ikke i outputtet. Går du på jagt efter det i den klassiske chart-kodesti, leder du længe, for diagrammet var der aldrig til at begynde med

Det mc:AlternateContent-anker HotXLS skriver for et chartEx-diagram: mc:Choice-grenen kræver cx1 og holder en xdr:graphicFrame hvis a:graphicData refererer chartex-navnerummet med en bar cx:chart-relation, mens mc:Fallback bærer et informationsrektangel som ældre læsere tegner i stedet
Mc:AlternateContent-ankeret ruter et chartEx-diagram mellem Excel 2016 og ældre læsere: Choice-grenen kræver cx1 og bærer chartex-framen, mens Fallback holder informationsrektanglet ældre læsere skal tegne

Hvorfor ligger waterfall- og treemap-diagrammer ikke i c:chartSpace?

Fordi Microsoft ikke kunne lægge dem der. DrawingML-chart-schemaet i ECMA-376 Part 1 §21.2 oplister sine chart-elementer — c:barChart, c:lineChart, c:pieChart og resten — som et lukket valg, så at tilføje waterfall, treemap, sunburst og funnel i Excel 2016 ville have betydet at bryde et schema enhver eksisterende læser er afhængig af. De nye typer kom i stedet ind i et parallelt dokument: en cx:chartSpace-part i 2014-chartex-navnerummet, http://schemas.microsoft.com/office/drawing/2014/chartex, gemt ved siden af de klassiske diagrammer men strukturelt uden relation til dem. Drawing-siden syr de to verdener sammen med mc:AlternateContent, kompatibilitetsmekanismen fra ECMA-376 Part 3: mc:Choice-grenen erklærer Requires="cx1" og holder en xdr:graphicFrame hvis a:graphicData/@uri er chartex-navnerummet og hvis payload er en bar <cx:chart r:id="rIdN"/>-reference, mens mc:Fallback holder et rektangel med den besked Excel selv skriver der. En læser der ikke forstår cx1, er ifølge Part 3-kontrakten forpligtet til at tage fallback'en

Relationshiptypen er det andet afslørende tegn. Et klassisk diagram hænger på drawing-parten med den standard OOXML-chart-relationship; en chartEx-part bruger i stedet http://schemas.microsoft.com/office/2014/relationships/chartEx, dokumenteret i [MS-ODRAWXML]. HotXLS eksponerer begge verdener som konstanter — XlsxRtChartEx for den relationship, XlsxCtChartEx for content typen, XlsxNsMsChartEx og XlsxNsMsChartEx2015 for de to navnerum ankeret har brug for — og prædikatet XlsxChartIsChartEx er det der ruter et diagram ned ad den udvidede sti ved gem. Kombinationsdiagrammer og bevaret klassisk chart-XML går ned ad den anden, beskrevet i artiklen om bevaret chart-XML og kombinationsdiagrammer

Fire schema-regler der afgør om Excel åbner filen

Chartex-schemaet er strengere end det klassiske fire bestemte steder, og hvert af dem giver den samme uhjælpsomme reparation-prompt når du får det galt. BuildChartExKnownXml og BuildChartExRelsXml i HotXLS bager alle fire ind, men de er værd at kende hvis du nogensinde inspicerer XML'en i hånden

  • Rækkefølge. cx:chartData skal komme før cx:chart i dokumentet. Det er det modsatte af den intuition du bygger fra klassiske diagrammer, hvor cachene bor inde i serien inde i plot-området; i en chartEx-part sidder dataene forrest i deres egen blok, og chart-kroppen refererer kun til dem via id
  • Kardinalitet. cx:plotArea må indeholde præcis én cx:plotAreaRegion. Ikke nul, ikke to. Der er ingen kombinationsdiagram-historie her — én region, og serien inden i den
  • Type-placering. Chart-typen er slet ikke et element-navn. Det er layoutId-attributten på cx:series, der tager en token fra ST_SeriesLayout-enumerationen i Microsofts chartex-schema. XlsxChartExLayoutId mapper HotXLS-enumen til den token og returnerer 'waterfall' eller 'treemap'; XlsxChartExLayoutIdToType kører mappingen baglæns ved læsning
  • Den relationship klassiske diagrammer aldrig behøver. cx:chartData holder et <cx:externalData r:id="rId1"/>-element, og den relationship skal findes i xl/charts/_rels/chartExN.xml.rels og pege tilbage på worksheet'en der ejer dataene, for Excel bruger den til at opløse cx:f-områdeformlerne. Udelader du rels-parten, åbner diagrammet tomt uden noget der forklarer hvorfor

Hvordan tilføjer du et waterfall- eller treemap-diagram i Delphi?

På samme måde som du tilføjer ethvert andet diagram i HotXLS — de udvidede typer er medlemmer af TXLSXChartType, så TXLSXWorksheet.AddChart tager dem direkte, og alt nedstrøms nøgler på enum-værdien. Der er ingen separat chartEx-API at lære

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

    // Forankringsrektangel i række/kolonne-koordinater, samme som et klassisk diagram
    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;
HotXLS-gem-pipelinen for udvidede Excel-diagrammer: AddChart med xlsxChartWaterfall eller xlsxChartTreemap når XlsxChartIsChartEx, som ruter klassiske diagrammer til en fælles tæller og chartEx-diagrammer til en uafhængig tæller der skriver chartEx1.xml, dens rels-fil der peger tilbage på worksheet'en, chartex-content-type-overriden og AlternateContent-ankerets payload
Én chart-model og ét routing-prædikat fodrer to tællere ved gem: klassiske diagrammer deler chart1.xml og chart2.xml, mens waterfall'en lander som chartEx1.xml fordi chartEx-tælleren er uafhængig af den klassiske

Ved gem ser HotXLS XlsxChartIsChartEx returnere True, allokerer et part-navn fra en separat chartEx-tæller og skriver xl/charts/chartEx1.xml, dens rels-fil, content-type-overriden og det mc:AlternateContent-anker som XlsxChartExAnchorPayloadXml producerer. Den uafhængige tæller betyder noget: en workbook med to klassiske diagrammer og ét waterfall får chart1.xml, chart2.xml og chartEx1.xml, ikke chartEx3.xml. Treemaps adskiller sig så fra waterfalls i to detaljer, begge håndteret af den samme builder. Den numeriske dimension skifter type — cx:numDim type="val" for et waterfall bliver til type="size" for et treemap, for et treemap-rektangel koder størrelse som areal frem for som en plottet værdi — og treemaps vil som regel have en kategori-hierarki, som modellen udtrykker gennem TXLSXChartSeries.CategoriesMultiLevel. Sætter du det flag, læser S.ResolveCategoryLevels området ud af worksheet'en ved skrivetidspunktet, splitter det i én TXLSXChartDataCache per niveau bag S.CategoryLevelCount og S.CategoryLevels[], og BuildChartExLevelXml emitter ét cx:lvl per niveau inde i én enkelt cx:strDim, blad-niveauet først — samme fysiske rækkefølge som ECMA-376 Part 1 §21.2 bruger for c:multiLvlStrCache, så den konvention følger med selvom intet af markup'et gør

Chart := Sheet.AddChart(xlsxChartTreemap, 'Revenue by Region',
  2, 8, 24, 16);
S := Chart.AddSeries('Revenue',
  'Sheet1!$A$2:$B$40',   // to kolonner: blad-label, derefter overordnet gruppe
  'Sheet1!$C$2:$C$40');
S.CategoriesMultiLevel := True;

Cache-formen der bryder hver antagelse fra klassiske diagrammer

Har du skrevet en klassisk chart-cache-læser, vil tre vaner forråde dig på en chartEx-part, og alle tre kommer fra én designbeslutning: chartex gemmer i attributter og element-indhold det DrawingML gemmer i child-elementer

  • Et cachet punkt er <cx:pt idx="0">42</cx:pt>. Værdien er element-indholdet. Der er ingen c:v-child at dykke ned i, så en parser der vandrer og leder efter v-noder finder intet og rapporterer en tom cache
  • ptCount og formatCode er attributter på cx:lvl. I en klassisk c:numCache er begge child-elementer
  • Flere hierarki-niveauer er flere cx:lvl-elementer inde i én cx:strDim. Der er ikke noget separat multi-level-cache-element på den måde c:multiLvlStrCache er adskilt fra c:strCache

BuildChartExLevelXml skriver den form, og ParseChartExXml læser den tilbage, og kører hver værdi gennem XlsxEscapeText på vej ud, så et kategori-label med en ampersand eller en vinklet parentes overlever round trip'en. Værd at bemærke hvad builder'en gør når et numerisk niveau ikke har nogen format code: den skriver formatCode="General" i stedet for at droppe attributten, for her er attributten ikke valgfri på den måde dens klassiske modpart er

Cache-former side om side i HotXLS-chartEx-parts: en klassisk c:numCache gemmer ptCount og formatCode som child-elementer med værdier inde i c:v, mens cx:numDim gemmer dem som attributter på cx:lvl med værdien som element-indhold, og CategoriesMultiLevel emitter ét cx:lvl per kategori-niveau, blad først, inde i én enkelt cx:strDim
Chartex gemmer i attributter og element-indhold det DrawingML gemmer i child-elementer: værdien sidder direkte inde i cx:pt, ptCount og formatCode bliver attributter på cx:lvl, og et kategori-hierarki er flere cx:lvl-elementer i én cx:strDim

Navnerums-præfikset der fik type-læsningen til at fejle

Første version af HotXLS-chartEx-parseren skrev helt fine filer og læste hver eneste af dem tilbage som et kolonnediagram. Roden var én linje, og lektionen generaliserer til enhver OOXML-parser du nogensinde kommer til at skrive: TXMLReader.Name returnerer navnet som skrevet i dokumentet, præfiks inkluderet. I en klassisk chart-part kommer de fleste elementer uden præfiks, så SameText(Reader.Name, 'series') virker, og du holder op med at tænke over det. I en chartEx-part er hvert element præfikseret — læseren giver dig 'cx:series', sammenligningen fejler tavst på alle sammen, og du får hverken exception eller misdannet XML, bare en tom serie-liste og en chart-type der falder tilbage til default. Rettelsen er at sammenligne på det lokale navn, ét sted frem for ved hvert kaldested

// Reader.Name bærer sit navnerums-præfiks: 'cx:series', ikke '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 bruger den hjælper til hver element-test og en tilsvarende AttrVal-hjælper til attributter, for cx:autoUpdate og co. er også præfikserede. Når serie-elementerne faktisk bliver set, kommer typen tilbage fra det første layoutId der mødes, og hver cx:series kobles til sin cx:data-blok ved at matche cx:dataId/@val mod cx:data/@id. Diagrammer der er åbnet men aldrig ændret, tager i stedet den ældre sti: de originale bytes afspilles fra FRawChartXml, så en urørt kundefil kommer ud som den gik ind — samme konservative-skriver-disciplin der styrer sheet-niveau-udvidelser i artiklen om sparkline-udvidelser

Hvad HotXLS bevidst ikke gør med disse diagrammer

To grænser er værd at sige klart og tydeligt, for begge er valg snarere end huller der venter på at blive fyldt. De indbyggede PDF- og SVG-renderere tegner ikke waterfall- eller treemap-serier: typerne matcher ingen tegne-gren, så serie-renderingen springes over, og resten af sheet'et eksporteres normalt — at fake et plausibelt udseende treemap i en eksporteret PDF ville være værre end at tegne intet, for det ville tavst være uenig med hvad Excel viser for samme fil. Og xlsxChartWaterfall og xlsxChartTreemap er de eneste to udvidede layouts HotXLS bygger fra modellen; ST_SeriesLayout definerer også sunburst, funnel, box-and-whisker og histogram, og en workbook der indeholder nogen af dem, round-tripper stadig gennem raw-XML-afspilningsstien — du kan bare ikke forfatte én fra Delphi-kode i dag. Renderingen af de klassiske chart-typer er uændret og dækket i artiklen om diagrammer, billeder og drawings, og chartEx-pipelinen leveres ved siden af i HotXLS Delphi Excel Component til Delphi og C++Builder