Articolo tecnico

Grafici waterfall e treemap di Excel in Delphi con HotXLS

HotXLS scrive i grafici waterfall e treemap di Excel come parti cx:chartSpace autonome — xl/charts/chartExN.xml con content type application/vnd.ms-office.chartex+xml — e le aggancia tramite un wrapper mc:AlternateContent così Excel 2016 e successivi renderizza il grafico mentre i lettori più vecchi ricevono una shape di fallback. Scegli xlsxChartWaterfall o xlsxChartTreemap e tutta la pipeline delle parti segue

Il modo in cui questo problema di solito si annuncia è un ticket di supporto. Un cliente manda una cartella di lavoro creata dal suo team finance in Excel 2019, il tuo tool di report la apre, la salva, e il grafico waterfall è sparito. Nessuna eccezione, nessun warning, nulla nel log: il grafico semplicemente non è nell'output. Se lo cerchi nel percorso di codice dei grafici classici, lo cerchi a lungo, perché il grafico non c'è mai stato dall'inizio

L'ancora mc:AlternateContent che HotXLS scrive per un grafico chartEx: il ramo mc:Choice richiede cx1 e contiene un xdr:graphicFrame il cui a:graphicData fa riferimento al namespace chartex con una relazione cx:chart nuda, mentre mc:Fallback porta un rettangolo di avviso che i lettori più vecchi disegnano al suo posto
L'ancora mc:AlternateContent instrada un grafico chartEx tra Excel 2016 e i lettori più vecchi: il ramo Choice richiede cx1 e porta il frame chartex, mentre il Fallback contiene il rettangolo di avviso che i lettori più vecchi devono disegnare

Perché i grafici waterfall e treemap non stanno in c:chartSpace?

Perché Microsoft non poteva metterceli. Lo schema dei grafici DrawingML in ECMA-376 Part 1 §21.2 enumera i suoi elementi di grafico — c:barChart, c:lineChart, c:pieChart e il resto — come una scelta chiusa, quindi aggiungere waterfall, treemap, sunburst e funnel in Excel 2016 avrebbe significato rompere uno schema da cui dipende ogni lettore esistente. I nuovi tipi sono finiti invece in un documento parallelo: una parte cx:chartSpace nel namespace chartex del 2014, http://schemas.microsoft.com/office/drawing/2014/chartex, conservata accanto ai grafici classici ma strutturalmente estranea a loro. Il lato del disegno ricuce i due mondi con mc:AlternateContent, il meccanismo di compatibilità di ECMA-376 Part 3: il ramo mc:Choice dichiara Requires="cx1" e contiene un xdr:graphicFrame il cui a:graphicData/@uri è il namespace chartex e il cui payload è un riferimento <cx:chart r:id="rIdN"/> nudo, mentre mc:Fallback contiene un rettangolo con l'avviso che Excel stesso scrive lì. Un lettore che non capisce cx1 è tenuto dal contratto della Part 3 a prendere il fallback

Il tipo di relazione è l'altro indizio rivelatore. Un grafico classico pende dalla parte di disegno con la relazione OOXML standard; una parte chartEx usa invece http://schemas.microsoft.com/office/2014/relationships/chartEx, documentato in [MS-ODRAWXML]. HotXLS espone entrambi i mondi come costanti — XlsxRtChartEx per quella relazione, XlsxCtChartEx per il content type, XlsxNsMsChartEx e XlsxNsMsChartEx2015 per i due namespace di cui l'ancora ha bisogno — e il predicato XlsxChartIsChartEx è ciò che instrada un grafico sul percorso esteso al salvataggio. I grafici combinati e l'XML dei grafici classici preservato vanno sull'altro percorso, descritto in l'articolo sull'XML di grafico preservato e i grafici combinati

Quattro regole dello schema che decidono se Excel apre il file

Lo schema chartex è più severo di quello classico in quattro punti specifici, e ognuno di loro produce lo stesso inutile avviso di riparazione quando sbagli. BuildChartExKnownXml e BuildChartExRelsXml in HotXLS li incorporano tutti e quattro, ma vale la pena conoscerli se ispezioni mai l'XML a mano

  • Ordine. cx:chartData deve venire prima di cx:chart nel documento. È l'opposto dell'intuizione che ti costruisci dai grafici classici, dove le cache stanno dentro le serie dentro l'area del tracciato; in una parte chartEx i dati stanno davanti in un blocco tutto loro e il corpo del grafico li riferisce solo per id
  • Cardinalità. cx:plotArea può contenere esattamente un cx:plotAreaRegion. Non zero, non due. Qui non c'è storia di grafico combinato: una regione, e le serie dentro
  • Posizione del tipo. Il tipo di grafico non è affatto un nome di elemento. È l'attributo layoutId su cx:series, che prende un token dall'enumerazione ST_SeriesLayout dello schema chartex di Microsoft. XlsxChartExLayoutId mappa l'enum HotXLS su quel token e restituisce 'waterfall' o 'treemap'; XlsxChartExLayoutIdToType esegue la mappatura al contrario in lettura
  • La relazione che i grafici classici non servono mai. cx:chartData contiene un elemento <cx:externalData r:id="rId1"/>, e quella relazione deve esistere in xl/charts/_rels/chartExN.xml.rels puntando di nuovo al foglio di lavoro che possiede i dati, perché Excel la usa per risolvere le formule di intervallo cx:f. Tralascia la parte rels e il grafico si apre vuoto senza nulla che spieghi il perché

Come aggiungi un grafico waterfall o treemap in Delphi?

Nello stesso modo in cui aggiungi qualsiasi altro grafico in HotXLS: i tipi estesi sono membri di TXLSXChartType, quindi TXLSXWorksheet.AddChart li accetta direttamente e tutto a valle si basa sul valore dell'enum. Non c'è un'API chartEx separata da imparare

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

    // Rettangolo di ancoraggio in coordinate riga/colonna, come un grafico classico
    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;
La pipeline di salvataggio HotXLS per i grafici Excel estesi: AddChart con xlsxChartWaterfall o xlsxChartTreemap arriva a XlsxChartIsChartEx, che instrada i grafici classici a un contatore condiviso e i grafici chartEx a un contatore indipendente che scrive chartEx1.xml, il suo file rels che punta di nuovo al foglio di lavoro, l'override del content type chartex e il payload dell'ancora AlternateContent
Un solo modello di grafico e un solo predicato di instradamento alimentano due contatori al salvataggio: i grafici classici condividono chart1.xml e chart2.xml, mentre il waterfall finisce come chartEx1.xml perché il contatore chartEx è indipendente da quello classico

Al salvataggio, HotXLS vede XlsxChartIsChartEx restituire True, assegna un nome di parte da un contatore chartEx separato, e scrive xl/charts/chartEx1.xml, il suo file rels, l'override del content type e l'ancora mc:AlternateContent prodotta da XlsxChartExAnchorPayloadXml. Il contatore indipendente conta: una cartella di lavoro con due grafici classici e un waterfall ottiene chart1.xml, chart2.xml e chartEx1.xml, non chartEx3.xml. I treemap poi differiscono dai waterfall in due dettagli, entrambi gestiti dallo stesso builder. La dimensione numerica cambia tipo: cx:numDim type="val" per un waterfall diventa type="size" per un treemap, perché un rettangolo treemap codifica la grandezza come area invece che come valore tracciato, e i treemap di solito vogliono una gerarchia di categorie, che il modello esprime tramite TXLSXChartSeries.CategoriesMultiLevel. Imposta quel flag e S.ResolveCategoryLevels legge l'intervallo dal foglio di lavoro al momento della scrittura, lo divide in un TXLSXChartDataCache per livello dietro S.CategoryLevelCount e S.CategoryLevels[], e BuildChartExLevelXml emette un cx:lvl per livello dentro un singolo cx:strDim, livello foglia per primo, lo stesso ordinamento fisico che ECMA-376 Part 1 §21.2 usa per c:multiLvlStrCache, quindi quella convenzione passa oltre anche se nessuno dei markup lo fa

Chart := Sheet.AddChart(xlsxChartTreemap, 'Revenue by Region',
  2, 8, 24, 16);
S := Chart.AddSeries('Revenue',
  'Sheet1!$A$2:$B$40',   // due colonne: etichetta della foglia, poi gruppo padre
  'Sheet1!$C$2:$C$40');
S.CategoriesMultiLevel := True;

La forma delle cache che infrange ogni assunzione dei grafici classici

Se hai scritto un reader di cache per grafici classici, tre abitudini ti tradiranno su una parte chartEx, e tutte e tre vengono da una sola decisione di design: chartex memorizza in attributi e contenuto di elemento ciò che DrawingML memorizza in elementi figli

  • Un punto in cache è <cx:pt idx="0">42</cx:pt>. Il valore è il contenuto dell'elemento. Non c'è un figlio c:v in cui scendere, quindi un parser che cammina cercando nodi v non trova nulla e riporta una cache vuota
  • ptCount e formatCode sono attributi su cx:lvl. In una c:numCache classica entrambi sono elementi figli
  • Più livelli di gerarchia sono più elementi cx:lvl dentro un solo cx:strDim. Non esiste un elemento di cache multilivello separato come c:multiLvlStrCache è separato da c:strCache

BuildChartExLevelXml scrive quella forma e ParseChartExXml la rilegge, facendo passare ogni valore da XlsxEscapeText in uscita così un'etichetta di categoria con una e commerciale o una parentesi angolare sopravvive al round trip. Vale la pena notare cosa fa il builder quando un livello numerico non porta un format code: scrive formatCode="General" invece di omettere l'attributo, perché qui l'attributo non è opzionale come lo è la sua controparte classica

Forme di cache affiancate nelle parti chartEx di HotXLS: una c:numCache classica memorizza ptCount e formatCode come elementi figli con i valori dentro c:v, mentre cx:numDim li memorizza come attributi su cx:lvl con il valore come contenuto di elemento, e CategoriesMultiLevel emette un cx:lvl per livello di categoria, foglia per prima, dentro un singolo cx:strDim
Chartex memorizza in attributi e contenuto di elemento ciò che DrawingML memorizza in elementi figli: il valore sta direttamente dentro cx:pt, ptCount e formatCode diventano attributi su cx:lvl, e una gerarchia di categorie è diversi elementi cx:lvl in un cx:strDim

Il prefisso di namespace che faceva fallire la rilettura del tipo

La prima versione del parser chartEx di HotXLS scriveva file perfettamente validi e ne rileggeva ognuno come un grafico a colonne. La causa principale era una riga, e la lezione si generalizza a qualsiasi parser OOXML tu scriverai mai: TXMLReader.Name restituisce il nome come scritto nel documento, prefisso incluso. In una parte di grafico classica la maggior parte degli elementi arriva senza prefisso, quindi SameText(Reader.Name, 'series') funziona e smetti di pensarci. In una parte chartEx ogni elemento è prefissato: il reader ti consegna 'cx:series', il confronto fallisce in silenzio su tutti, e non ottieni né eccezioni né XML malformato, solo una lista di serie vuota e un tipo di grafico che ripiega sul default. Il fix è confrontare sul local name, in un punto solo invece che in ogni chiamata

// Reader.Name porta il suo prefisso di namespace: 'cx:series', non '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 usa quell'helper per ogni test sugli elementi e un helper equivalente AttrVal per gli attributi, visto che anche cx:autoUpdate e compagni sono prefissati. Una volta che gli elementi di serie vengono davvero visti, il tipo torna dal primo layoutId incontrato, e ogni cx:series viene collegata al suo blocco cx:data facendo combaciare cx:dataId/@val con cx:data/@id. I grafici aperti ma mai modificati prendono invece il percorso più vecchio: i byte originali vengono riprodotti da FRawChartXml, quindi un file del cliente non toccato esce com'era entrato, la stessa disciplina di scrittore conservativo che governa le estensioni a livello di foglio in l'articolo sulle estensioni sparkline

Cosa HotXLS non fa deliberatamente con questi grafici

Due confini meritano di essere dichiarati chiaramente, perché entrambi sono scelte e non lacune in attesa di essere colmate. I renderer PDF e SVG integrati non disegnano le serie waterfall o treemap: i tipi non corrispondono a nessun ramo di disegno, quindi il rendering delle serie viene saltato e il resto del foglio viene esportato normalmente, e fingere un treemap dall'aspetto plausibile in un PDF esportato sarebbe peggio che non disegnare nulla, perché sarebbe in silenzioso disaccordo con ciò che Excel mostra per lo stesso file. E xlsxChartWaterfall e xlsxChartTreemap sono gli unici due layout estesi che HotXLS costruisce dal modello; ST_SeriesLayout definisce anche sunburst, funnel, box-and-whisker e histogram, e una cartella di lavoro che ne contiene uno qualsiasi fa comunque il round trip attraverso il percorso di replay dell'XML grezzo, solo che oggi non puoi crearne uno dal codice Delphi. Il rendering dei tipi di grafico classici è invariato e coperto in l'articolo su grafici, immagini e disegni, e la pipeline chartEx viaggia accanto ad esso nella HotXLS Delphi Excel Component per Delphi e C++Builder