Artículo técnico

Gráficos Waterfall y Treemap de Excel en Delphi con HotXLS

HotXLS escribe los gráficos waterfall y treemap de Excel como partes cx:chartSpace independientes — xl/charts/chartExN.xml con el content type application/vnd.ms-office.chartex+xml — y los ancla a través de un wrapper mc:AlternateContent para que Excel 2016 y posteriores rendericen el gráfico mientras los lectores antiguos reciben una forma de respaldo. Elige xlsxChartWaterfall o xlsxChartTreemap y toda la pipeline de partes sigue sola

La forma en que este problema suele anunciarse es un ticket de soporte. Un cliente envía un workbook que su equipo de finanzas montó en Excel 2019, tu herramienta de informes lo abre, lo guarda, y el gráfico waterfall ha desaparecido. Sin excepción, sin aviso, nada en el log — el gráfico simplemente no está en la salida. Si vas a buscarlo por el code path clásico de gráficos, buscarás mucho rato, porque el gráfico nunca estuvo ahí desde el principio

El ancla mc:AlternateContent que HotXLS escribe para un gráfico chartEx: la rama mc:Choice exige cx1 y contiene un xdr:graphicFrame cuyo a:graphicData referencia el namespace chartex con una relación cx:chart desnuda, mientras que mc:Fallback lleva un rectángulo de aviso que los lectores antiguos dibujan en su lugar
El ancla mc:AlternateContent encamina un gráfico chartEx entre Excel 2016 y los lectores antiguos: la rama Choice exige cx1 y lleva el frame chartex, mientras que Fallback contiene el rectángulo de aviso que los lectores antiguos deben dibujar

¿Por qué los gráficos waterfall y treemap no están en c:chartSpace?

Porque Microsoft no podía meterlos ahí. El esquema de gráficos DrawingML de ECMA-376 Parte 1 §21.2 enumera sus elementos de gráfico — c:barChart, c:lineChart, c:pieChart y el resto — como una choice cerrada, así que añadir waterfall, treemap, sunburst y funnel en Excel 2016 habría significado romper un esquema del que depende todo lector existente. Los tipos nuevos fueron a un documento paralelo: una parte cx:chartSpace en el namespace chartex de 2014, http://schemas.microsoft.com/office/drawing/2014/chartex, guardada junto a los gráficos clásicos pero estructuralmente ajena a ellos. El lado del dibujo cose los dos mundos con mc:AlternateContent, el mecanismo de compatibilidad de la Parte 3 de ECMA-376: la rama mc:Choice declara Requires="cx1" y contiene un xdr:graphicFrame cuyo a:graphicData/@uri es el namespace chartex y cuyo payload es una referencia desnuda <cx:chart r:id="rIdN"/>, mientras que mc:Fallback contiene un rectángulo con el aviso que el propio Excel escribe ahí. Un lector que no entienda cx1 está obligado por el contrato de la Parte 3 a tomar el fallback

El tipo de relación es la otra pista. Un gráfico clásico cuelga de la parte de dibujo con la relación OOXML estándar de gráficos; una parte chartEx usa http://schemas.microsoft.com/office/2014/relationships/chartEx en su lugar, documentada en [MS-ODRAWXML]. HotXLS expone los dos mundos como constantes — XlsxRtChartEx para esa relación, XlsxCtChartEx para el content type, XlsxNsMsChartEx y XlsxNsMsChartEx2015 para los dos namespaces que necesita el ancla — y el predicado XlsxChartIsChartEx es lo que encamina un gráfico por el path extendido al guardar. Los combination charts y el XML clásico de gráficos preservado van por el otro, descrito en el artículo sobre XML de gráficos preservado y combination charts

Cuatro reglas del esquema que deciden si Excel abre el archivo

El esquema chartex es más estricto que el clásico en cuatro puntos concretos, y todos producen el mismo aviso de reparación poco útil cuando te equivocas. BuildChartExKnownXml y BuildChartExRelsXml en HotXLS ya incorporan las cuatro, pero merece la pena conocerlas si alguna vez inspeccionas el XML a mano

  • Orden. cx:chartData debe ir antes que cx:chart en el documento. Es lo contrario de la intuición que construyes con los gráficos clásicos, donde los caches viven dentro de la serie dentro del plot area; en una parte chartEx los datos van delante en su propio bloque y el cuerpo del gráfico solo los referencia por id
  • Cardinalidad. cx:plotArea puede contener exactamente un cx:plotAreaRegion. Ni cero ni dos. Aquí no hay historia de combination chart — una región, y las series dentro
  • Ubicación del tipo. El tipo de gráfico no es en absoluto un nombre de elemento. Es el atributo layoutId en cx:series, que toma un token de la enumeración ST_SeriesLayout del esquema chartex de Microsoft. XlsxChartExLayoutId mapea el enum de HotXLS a ese token y devuelve 'waterfall' o 'treemap'; XlsxChartExLayoutIdToType corre el mapeo hacia atrás al leer
  • La relación que los gráficos clásicos nunca necesitan. cx:chartData contiene un elemento <cx:externalData r:id="rId1"/>, y esa relación tiene que existir en xl/charts/_rels/chartExN.xml.rels apuntando de vuelta a la worksheet que posee los datos, porque Excel la usa para resolver las fórmulas de rango cx:f. Omite la parte rels y el gráfico se abre vacío sin nada que explique por qué

¿Cómo añades un gráfico waterfall o treemap en Delphi?

Igual que añades cualquier otro gráfico en HotXLS — los tipos extendidos son miembros de TXLSXChartType, así que TXLSXWorksheet.AddChart los acepta directamente y todo lo que viene después depende del valor del enum. No hay una API chartEx aparte que aprender

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

    // Rectángulo de anclaje en coordenadas de fila/columna, igual que un gráfico clásico
    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 de guardado de HotXLS para los gráficos extendidos de Excel: AddChart con xlsxChartWaterfall o xlsxChartTreemap llega a XlsxChartIsChartEx, que encamina los gráficos clásicos a un contador compartido y los chartEx a un contador independiente que escribe chartEx1.xml, su archivo de rels apuntando de vuelta a la worksheet, el override del content type chartex y el payload del ancla AlternateContent
Un solo modelo de gráfico y un solo predicado de enrutado alimentan dos contadores al guardar: los gráficos clásicos comparten chart1.xml y chart2.xml, mientras que el waterfall aterriza como chartEx1.xml porque el contador chartEx es independiente del clásico

Al guardar, HotXLS ve que XlsxChartIsChartEx devuelve True, asigna un nombre de parte de un contador chartEx separado, y escribe xl/charts/chartEx1.xml, su archivo de rels, el override del content type y el ancla mc:AlternateContent que produce XlsxChartExAnchorPayloadXml. El contador independiente importa: un workbook con dos gráficos clásicos y un waterfall obtiene chart1.xml, chart2.xml y chartEx1.xml, no chartEx3.xml. Los treemaps se diferencian de los waterfalls en dos detalles, ambos manejados por el mismo builder. La dimensión numérica cambia de tipo — cx:numDim type="val" para un waterfall se convierte en type="size" para un treemap, porque un rectángulo treemap codifica la magnitud como área y no como valor representado — y los treemaps normalmente quieren una jerarquía de categorías, que el modelo expresa con TXLSXChartSeries.CategoriesMultiLevel. Activa ese flag y S.ResolveCategoryLevels lee el rango de la worksheet en el momento de escribir, lo divide en un TXLSXChartDataCache por nivel detrás de S.CategoryLevelCount y S.CategoryLevels[], y BuildChartExLevelXml emite un cx:lvl por nivel dentro de un solo cx:strDim, con el nivel hoja primero — el mismo orden físico que ECMA-376 Parte 1 §21.2 usa para c:multiLvlStrCache, así que esa convención se conserva aunque no lo haga ningún markup

Chart := Sheet.AddChart(xlsxChartTreemap, 'Revenue by Region',
  2, 8, 24, 16);
S := Chart.AddSeries('Revenue',
  'Sheet1!$A$2:$B$40',   // dos columnas: etiqueta de hoja y luego el grupo padre
  'Sheet1!$C$2:$C$40');
S.CategoriesMultiLevel := True;

La forma del cache que rompe todas las suposiciones de los gráficos clásicos

Si has escrito un lector de caches de gráficos clásicos, tres hábitos te van a traicionar con una parte chartEx, y los tres vienen de una única decisión de diseño: chartex guarda en atributos y contenido de elemento lo que DrawingML guarda en elementos hijo

  • Un punto cacheado es <cx:pt idx="0">42</cx:pt>. El valor es el contenido del elemento. No hay un hijo c:v al que bajar, así que un parser que recorra buscando nodos v no encuentra nada y reporta un cache vacío
  • ptCount y formatCode son atributos sobre cx:lvl. En un c:numCache clásico ambos son elementos hijo
  • Varios niveles de jerarquía son varios elementos cx:lvl dentro de un solo cx:strDim. No hay un elemento de cache multi-nivel separado como c:multiLvlStrCache lo está de c:strCache

BuildChartExLevelXml escribe esa forma y ParseChartExXml la lee de vuelta, pasando cada valor por XlsxEscapeText a la salida para que una etiqueta de categoría con un ampersand o un corchete angular sobreviva al round trip. Merece la pena anotar qué hace el builder cuando un nivel numérico no lleva format code: escribe formatCode="General" en vez de descartar el atributo, porque aquí el atributo no es opcional como lo es su contrapartida clásica

Formas de cache lado a lado en las partes chartEx de HotXLS: un c:numCache clásico guarda ptCount y formatCode como elementos hijo con los valores dentro de c:v, mientras que cx:numDim los guarda como atributos sobre cx:lvl con el valor como contenido del elemento, y CategoriesMultiLevel emite un cx:lvl por nivel de categoría, hoja primero, dentro de un solo cx:strDim
Chartex guarda en atributos y contenido de elemento lo que DrawingML guarda en elementos hijo: el valor va directamente dentro de cx:pt, ptCount y formatCode se vuelven atributos sobre cx:lvl, y una jerarquía de categorías son varios elementos cx:lvl en un solo cx:strDim

El prefijo de namespace que hacía fallar la lectura del tipo

La primera versión del parser chartEx de HotXLS escribía archivos perfectamente válidos y los leía todos de vuelta como un gráfico de columnas. La causa raíz era una línea, y la lección se generaliza a cualquier parser OOXML que vayas a escribir: TXMLReader.Name devuelve el nombre tal como está escrito en el documento, prefijo incluido. En una parte de gráfico clásica la mayoría de los elementos llegan sin prefijo, así que SameText(Reader.Name, 'series') funciona y dejas de pensar en ello. En una parte chartEx todo elemento está prefijado — el reader te entrega 'cx:series', la comparación falla en silencio con todos, y no obtienes ni excepción ni XML malformado, solo una lista de series vacía y un tipo de gráfico que cae al default. El arreglo es comparar por el nombre local, en un solo sitio y no en cada punto de llamada

// Reader.Name lleva su prefijo de namespace: 'cx:series', no '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 ese helper para cada test de elemento y un helper equivalente AttrVal para los atributos, porque cx:autoUpdate y compañía también van prefijados. Una vez que los elementos de serie se ven de verdad, el tipo vuelve del primer layoutId encontrado, y cada cx:series se conecta a su bloque cx:data casando cx:dataId/@val contra cx:data/@id. Los gráficos abiertos pero nunca modificados toman el path antiguo: los bytes originales se reproducen desde FRawChartXml, así que un archivo de cliente intacto sale como entró — la misma disciplina de writer conservador que gobierna las extensiones a nivel de hoja en el artículo de la extensión de sparklines

Lo que HotXLS deliberadamente no hace con estos gráficos

Hay dos límites que merece la pena decir con claridad, porque ambos son decisiones y no huecos esperando a llenarse. Los renderers PDF y SVG integrados no dibujan series waterfall ni treemap: los tipos no casan con ninguna rama de dibujo, así que el renderizado de series se salta y el resto de la hoja se exporta con normalidad — fingir un treemap con aspecto plausible en un PDF exportado sería peor que no dibujar nada, porque discreparía en silencio de lo que Excel muestra para el mismo archivo. Y xlsxChartWaterfall y xlsxChartTreemap son los únicos dos layouts extendidos que HotXLS construye desde el modelo; ST_SeriesLayout también define sunburst, funnel, box-and-whisker e histogram, y un workbook que contenga cualquiera de ellos sigue haciendo round trip por el path de replay de XML crudo, solo que no puedes crearlo desde código Delphi a día de hoy. El renderizado de los tipos de gráfico clásicos no cambia y está cubierto en el artículo sobre gráficos, imágenes y dibujos, y la pipeline chartEx viaja con él en el HotXLS Delphi Excel Component para Delphi y C++Builder