Technischer Artikel

Excel-Waterfall- und Treemap-Diagramme in Delphi mit HotXLS

HotXLS schreibt Excel-Waterfall- und Treemap-Diagramme als eigenständige cx:chartSpace-Parts, xl/charts/chartExN.xml mit dem Content-Type application/vnd.ms-office.chartex+xml, und verankert sie über einen mc:AlternateContent-Wrapper, sodass Excel 2016 und neuer das Diagramm rendert, während ältere Reader eine Fallback-Form bekommen. Wählen Sie xlsxChartWaterfall oder xlsxChartTreemap, und die ganze Part-Pipeline folgt

So wie sich dieses Problem üblicherweise ankündigt, ist ein Support-Ticket. Ein Kunde schickt eine Arbeitsmappe, die seine Finanzabteilung in Excel 2019 gebaut hat, Ihr Reporting-Tool öffnet sie, speichert sie, und das Waterfall-Diagramm ist weg. Keine Exception, keine Warnung, nichts im Log, das Diagramm ist einfach nicht im Output. Wenn Sie es im klassischen Chart-Codepfad suchen, suchen Sie lange, denn das Diagramm war von Anfang an nie da

Der mc:AlternateContent-Anker, den HotXLS für ein chartEx-Diagramm schreibt: der mc:Choice-Zweig verlangt cx1 und hält ein xdr:graphicFrame, dessen a:graphicData den chartex-Namespace mit einer nackten cx:chart-Relation referenziert, während mc:Fallback ein Hinweisrechteck trägt, das ältere Reader stattdessen zeichnen
Der mc:AlternateContent-Anker lenkt ein chartEx-Diagramm zwischen Excel 2016 und älteren Readern: der Choice-Zweig verlangt cx1 und trägt den chartex-Frame, während der Fallback das Hinweisrechteck hält, das ältere Reader zeichnen müssen

Warum stehen Waterfall- und Treemap-Diagramme nicht in c:chartSpace?

Weil Microsoft sie dort nicht unterbringen konnte. Das DrawingML-Chart-Schema in ECMA-376 Part 1 §21.2 zählt seine Diagrammelemente, c:barChart, c:lineChart, c:pieChart und den Rest, als geschlossene Choice auf, also hätte das Hinzufügen von Waterfall, Treemap, Sunburst und Funnel in Excel 2016 ein Schema gebrochen, auf das jeder bestehende Reader angewiesen ist. Die neuen Typen kamen stattdessen in ein paralleles Dokument: ein cx:chartSpace-Part im chartex-Namespace von 2014, http://schemas.microsoft.com/office/drawing/2014/chartex, neben den klassischen Diagrammen gespeichert, aber strukturell unverwandt. Die Zeichnungsseite näht die zwei Welten mit mc:AlternateContent zusammen, dem Kompatibilitätsmechanismus aus ECMA-376 Part 3: Der mc:Choice-Zweig deklariert Requires="cx1" und hält ein xdr:graphicFrame, dessen a:graphicData/@uri der chartex-Namespace ist und dessen Payload eine nackte <cx:chart r:id="rIdN"/>-Referenz ist, während mc:Fallback ein Rechteck mit dem Hinweis hält, den Excel selbst dort hinschreibt. Ein Reader, der cx1 nicht versteht, ist laut Part-3-Vertrag verpflichtet, den Fallback zu nehmen

Der Relationstyp ist der andere Hinweisgeber. Ein klassisches Diagramm hängt mit der Standard-OOXML-Chart-Relation am Drawing-Part; ein chartEx-Part verwendet stattdessen http://schemas.microsoft.com/office/2014/relationships/chartEx, dokumentiert in [MS-ODRAWXML]. HotXLS legt beide Welten als Konstanten offen, XlsxRtChartEx für diese Relation, XlsxCtChartEx für den Content-Type, XlsxNsMsChartEx und XlsxNsMsChartEx2015 für die beiden Namespaces, die der Anker braucht, und das Prädikat XlsxChartIsChartEx lenkt ein Diagramm beim Speichern den erweiterten Pfad entlang. Kombinationsdiagramme und konserviertes klassisches Chart-XML gehen den anderen Weg, beschrieben in dem Artikel über konserviertes Chart-XML und Kombinationsdiagramme

Vier Schema-Regeln, die entscheiden, ob Excel die Datei öffnet

Das chartex-Schema ist strenger als das klassische an vier konkreten Stellen, und jede von ihnen erzeugt dieselbe nutzlose Reparatur-Aufforderung, wenn Sie es falsch machen. BuildChartExKnownXml und BuildChartExRelsXml in HotXLS backen alle vier ein, aber sie lohnen das Wissen, falls Sie je das XML von Hand inspizieren

  • Reihenfolge. cx:chartData muss im Dokument vor cx:chart kommen. Das ist die Umkehrung der Intuition, die Sie aus klassischen Diagrammen aufbauen, wo die Caches in den Serien in der Plot-Area leben; in einem chartEx-Part sitzen die Daten vorn in einem eigenen Block, und der Diagrammkörper referenziert sie nur per id
  • Kardinalität. cx:plotArea darf genau ein cx:plotAreaRegion enthalten. Nicht null, nicht zwei. Hier gibt es keine Kombinationsdiagramm-Geschichte, eine Region, und die Serien darin
  • Typ-Platzierung. Der Diagrammtyp ist überhaupt kein Elementname. Es ist das layoutId-Attribut an cx:series, das ein Token aus der ST_SeriesLayout-Enumeration im Microsoft-chartex-Schema nimmt. XlsxChartExLayoutId mappt die HotXLS-Enum auf dieses Token und gibt 'waterfall' oder 'treemap' zurück; XlsxChartExLayoutIdToType läuft das Mapping beim Lesen rückwärts
  • Die Relation, die klassische Diagramme nie brauchen. cx:chartData hält ein <cx:externalData r:id="rId1"/>-Element, und diese Relation muss in xl/charts/_rels/chartExN.xml.rels existieren und zurück auf das Arbeitsblatt zeigen, dem die Daten gehören, denn Excel nutzt sie, um die cx:f-Bereichsformeln aufzulösen. Lassen Sie den Rels-Part weg, und das Diagramm öffnet leer, ohne dass irgendetwas erklärt, warum

Wie fügen Sie in Delphi ein Waterfall- oder Treemap-Diagramm hinzu?

Genau so, wie Sie in HotXLS jedes andere Diagramm hinzufügen, die erweiterten Typen sind Mitglieder von TXLSXChartType, also nimmt TXLSXWorksheet.AddChart sie direkt, und alles flussabwärts hängt am Enum-Wert. Es gibt keine separate chartEx-API zum Lernen

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

    // Ankerrechteck in Zeilen-/Spaltenkoordinaten, wie bei einem klassischen Diagramm
    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;
Die HotXLS-Save-Pipeline für erweiterte Excel-Diagramme: AddChart mit xlsxChartWaterfall oder xlsxChartTreemap erreicht XlsxChartIsChartEx, das klassische Diagramme an einen gemeinsamen Zähler und chartEx-Diagramme an einen unabhängigen Zähler lenkt, der chartEx1.xml, seine Rels-Datei zurück auf das Arbeitsblatt, den chartex-Content-Type-Override und die AlternateContent-Anker-Payload schreibt
Ein Diagrammmodell und ein Routing-Prädikat speisen zwei Zähler beim Speichern: klassische Diagramme teilen sich chart1.xml und chart2.xml, während das Waterfall als chartEx1.xml landet, weil der chartEx-Zähler vom klassischen unabhängig ist

Beim Speichern sieht HotXLS XlsxChartIsChartEx True zurückgeben, vergibt einen Part-Namen aus einem separaten chartEx-Zähler und schreibt xl/charts/chartEx1.xml, seine Rels-Datei, den Content-Type-Override und den mc:AlternateContent-Anker, den XlsxChartExAnchorPayloadXml erzeugt. Der unabhängige Zähler zählt: Eine Arbeitsmappe mit zwei klassischen Diagrammen und einem Waterfall bekommt chart1.xml, chart2.xml und chartEx1.xml, nicht chartEx3.xml. Treemaps unterscheiden sich dann in zwei Details von Waterfalls, beide vom selben Builder behandelt. Die numerische Dimension wechselt den Typ, cx:numDim type="val" bei einem Waterfall wird type="size" bei einem Treemap, denn ein Treemap-Rechteck kodiert Größe als Fläche statt als geplotteten Wert, und Treemaps wollen meist eine Kategoriehierarchie, die das Modell über TXLSXChartSeries.CategoriesMultiLevel ausdrückt. Setzen Sie das Flag, und S.ResolveCategoryLevels liest den Bereich beim Schreiben aus dem Arbeitsblatt, splittet ihn in einen TXLSXChartDataCache pro Ebene hinter S.CategoryLevelCount und S.CategoryLevels[], und BuildChartExLevelXml emittiert ein cx:lvl pro Ebene innerhalb eines einzigen cx:strDim, Blattebene zuerst, dieselbe physische Ordnung, die ECMA-376 Part 1 §21.2 für c:multiLvlStrCache verwendet, also zieht diese Konvention mit, obwohl nichts vom Markup übernommen wird

Chart := Sheet.AddChart(xlsxChartTreemap, 'Revenue by Region',
  2, 8, 24, 16);
S := Chart.AddSeries('Revenue',
  'Sheet1!$A$2:$B$40',   // zwei Spalten: Blatt-Label, dann Elterngruppe
  'Sheet1!$C$2:$C$40');
S.CategoriesMultiLevel := True;

Die Cache-Form, die jede Annahme aus klassischen Diagrammen bricht

Wenn Sie einen klassischen Chart-Cache-Reader geschrieben haben, verraten Sie drei Gewohnheiten an einem chartEx-Part, und alle drei kommen aus einer Design-Entscheidung: chartex speichert in Attributen und Elementinhalt, was DrawingML in Kindelementen speichert

  • Ein gecachter Punkt ist <cx:pt idx="0">42</cx:pt>. Der Wert ist der Elementinhalt. Es gibt kein c:v-Kind, in das man absteigen könnte, also findet ein Parser, der nach v-Knoten sucht, nichts und meldet einen leeren Cache
  • ptCount und formatCode sind Attribute an cx:lvl. In einem klassischen c:numCache sind beide Kindelemente
  • Mehrere Hierarchieebenen sind mehrere cx:lvl-Elemente innerhalb eines cx:strDim. Es gibt kein separates Multi-Level-Cache-Element, so wie c:multiLvlStrCache von c:strCache getrennt ist

BuildChartExLevelXml schreibt diese Form und ParseChartExXml liest sie zurück, wobei jeder Wert beim Rausgehen durch XlsxEscapeText läuft, damit ein Kategorielabel mit einem Ampersand oder einem spitzen Klammerpaar den Round Trip überlebt. Erwähnenswert ist, was der Builder tut, wenn eine numerische Ebene kein Format-Code trägt: Er schreibt formatCode="General", statt das Attribut wegzulassen, denn hier ist das Attribut nicht optional, so wie es sein klassisches Pendant ist

Cache-Formen im Seitenvergleich in HotXLS-chartEx-Parts: ein klassisches c:numCache speichert ptCount und formatCode als Kindelemente mit Werten in c:v, während cx:numDim sie als Attribute an cx:lvl speichert mit dem Wert als Elementinhalt, und CategoriesMultiLevel emittiert ein cx:lvl pro Kategorieebene, Blatt zuerst, innerhalb eines einzigen cx:strDim
Chartex speichert in Attributen und Elementinhalt, was DrawingML in Kindelementen speichert: der Wert sitzt direkt in cx:pt, ptCount und formatCode werden Attribute an cx:lvl, und eine Kategoriehierarchie sind mehrere cx:lvl-Elemente in einem cx:strDim

Das Namespace-Präfix, das das Typ-Rücklesen scheitern ließ

Die erste Version des HotXLS-chartEx-Parsers schrieb einwandfreie Dateien und las jede einzelne als Säulendiagramm zurück. Die Wurzel war eine Zeile, und die Lektion verallgemeinert auf jeden OOXML-Parser, den Sie je schreiben werden: TXMLReader.Name gibt den Namen so zurück, wie er im Dokument steht, Präfix inklusive. In einem klassischen Chart-Part kommen die meisten Elemente ohne Präfix an, also funktioniert SameText(Reader.Name, 'series'), und Sie hören auf, darüber nachzudenken. In einem chartEx-Part ist jedes Element präfixiert, der Reader gibt Ihnen 'cx:series', der Vergleich schlägt still auf allen fehl, und Sie bekommen keine Exception und kein kaputtes XML, nur eine leere Serienliste und einen Diagrammtyp, der auf den Default zurückfällt. Der Fix ist, auf den lokalen Namen zu vergleichen, an einer Stelle statt an jedem Aufrufort

// Reader.Name trägt sein Namespace-Präfix: 'cx:series', nicht '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 verwendet diesen Helfer für jeden Elementtest und einen gleichwertigen AttrVal-Helfer für Attribute, denn cx:autoUpdate und Co. sind auch präfixiert. Sobald die Serienelemente tatsächlich gesehen werden, kommt der Typ vom ersten layoutId, auf das man stößt, und jedes cx:series wird mit seinem cx:data-Block verdrahtet, indem cx:dataId/@val gegen cx:data/@id gematcht wird. Geöffnete, aber nie veränderte Diagramme nehmen stattdessen den älteren Pfad: Die Originalbytes laufen aus FRawChartXml wieder durch, also kommt eine unberührte Kundendatei so wieder heraus, wie sie hineinging, dieselbe konservative Writer-Disziplin, die Blattebene-Erweiterungen in dem Sparkline-Erweiterungsartikel regelt

Was HotXLS mit diesen Diagrammen bewusst nicht tut

Zwei Grenzen lohnen die klare Ansage, denn beide sind Entscheidungen statt Lücken, die auf Füllung warten. Die eingebauten PDF- und SVG-Renderer zeichnen keine Waterfall- oder Treemap-Serien: Die Typen matchen keinen Zeichenzweig, also wird die Serien-Renderung übersprungen und der Rest des Blatts exportiert normal, ein plausibel aussehender Treemap in einem exportierten PDF wäre schlimmer als nichts zu zeichnen, denn er würde still vom Excel-Bild desselben Files abweichen. Und xlsxChartWaterfall und xlsxChartTreemap sind die einzigen zwei erweiterten Layouts, die HotXLS aus dem Modell baut; ST_SeriesLayout definiert auch Sunburst, Funnel, Box-and-Whisker und Histogramm, und eine Arbeitsmappe mit einem davon round-tript trotzdem über den Raw-XML-Replay-Pfad, nur können Sie heute keinen aus Delphi-Code erzeugen. Die Renderung der klassischen Diagrammtypen ist unverändert und in dem Artikel über Diagramme, Bilder und Drawings behandelt, und die chartEx-Pipeline fährt neben ihr im HotXLS Delphi Excel Component für Delphi und C++Builder