Artigo Técnico

Gráficos waterfall e treemap do Excel em Delphi com HotXLS

A HotXLS escreve gráficos waterfall e treemap do Excel como parts cx:chartSpace autônomas — xl/charts/chartExN.xml carregando o content type application/vnd.ms-office.chartex+xml — e os ancora por um wrapper mc:AlternateContent para que o Excel 2016 em diante renderize o gráfico enquanto leitores antigos recebem uma shape de fallback. Escolha xlsxChartWaterfall ou xlsxChartTreemap e o pipeline de parts inteiro segue junto

O jeito como esse problema costuma se anunciar é um ticket de suporte. Um cliente manda uma pasta de trabalho que o time financeiro dele montou no Excel 2019, sua ferramenta de relatório abre, salva, e o gráfico waterfall sumiu. Sem exceção, sem aviso, nada no log — o gráfico simplesmente não está na saída. Se você for procurá-lo no caminho clássico de gráficos, vai procurar por muito tempo, porque o gráfico nunca esteve lá para começar

A âncora mc:AlternateContent que a HotXLS escreve para um gráfico chartEx: o ramo mc:Choice exige cx1 e guarda um xdr:graphicFrame cujo a:graphicData referencia o namespace chartex com um relacionamento cx:chart puro, enquanto mc:Fallback carrega um retângulo de aviso que leitores antigos desenham no lugar
A âncora mc:AlternateContent roteia um gráfico chartEx entre o Excel 2016 e leitores antigos: o ramo Choice exige cx1 e carrega o frame chartex, enquanto o Fallback guarda o retângulo de aviso que leitores antigos precisam desenhar

Por que os gráficos waterfall e treemap não ficam em c:chartSpace?

Porque a Microsoft não tinha onde colocá-los. O schema de gráficos DrawingML na ECMA-376 Parte 1 §21.2 enumera os elementos de gráfico dele — c:barChart, c:lineChart, c:pieChart e o resto — como uma choice fechada, então adicionar waterfall, treemap, sunburst e funnel no Excel 2016 teria significado quebrar um schema do qual todo leitor existente depende. Os tipos novos foram para um documento paralelo: uma part cx:chartSpace no namespace chartex de 2014, http://schemas.microsoft.com/office/drawing/2014/chartex, guardada ao lado dos gráficos clássicos mas estruturalmente sem relação com eles. O lado do drawing costura os dois mundos com mc:AlternateContent, o mecanismo de compatibilidade da ECMA-376 Parte 3: o ramo mc:Choice declara Requires="cx1" e guarda um xdr:graphicFrame cujo a:graphicData/@uri é o namespace chartex e cujo payload é uma referência <cx:chart r:id="rIdN"/> pura, enquanto mc:Fallback guarda um retângulo com o aviso que o próprio Excel escreve ali. Um leitor que não entende cx1 é obrigado pelo contrato da Parte 3 a seguir o fallback

O tipo de relacionamento é o outro sinal. Um gráfico clássico pende da part de drawing com o relacionamento OOXML padrão de gráfico; uma part chartEx usa http://schemas.microsoft.com/office/2014/relationships/chartEx no lugar, documentado em [MS-ODRAWXML]. A HotXLS expõe os dois mundos como constantes — XlsxRtChartEx para esse relacionamento, XlsxCtChartEx para o content type, XlsxNsMsChartEx e XlsxNsMsChartEx2015 para os dois namespaces que a âncora precisa — e o predicado XlsxChartIsChartEx é o que roteia um gráfico pelo caminho estendido no save. Gráficos combinados e XML de gráfico clássico preservado vão pelo outro, descrito no artigo sobre XML de gráfico preservado e gráficos combinados

Quatro regras de schema que decidem se o Excel abre o arquivo

O schema chartex é mais estrito que o clássico em quatro lugares específicos, e cada um deles produz o mesmo prompt de reparo inútil quando você erra. BuildChartExKnownXml e BuildChartExRelsXml na HotXLS assam os quatro, mas vale conhecê-los se você algum dia inspecionar o XML na mão

  • Ordem. cx:chartData tem que vir antes de cx:chart no documento. É o inverso da intuição que você constrói com gráficos clássicos, onde os caches vivem dentro da série dentro da plot area; numa part chartEx os dados ficam na frente num bloco próprio e o corpo do gráfico só os referencia por id
  • Cardinalidade. cx:plotArea pode conter exatamente um cx:plotAreaRegion. Nem zero, nem dois. Não existe história de gráfico combinado aqui — uma região, e as séries dentro dela
  • Colocação do tipo. O tipo de gráfico não é nome de elemento nenhum. É o atributo layoutId em cx:series, tomando um token da enumeração ST_SeriesLayout no schema chartex da Microsoft. XlsxChartExLayoutId mapeia a enum da HotXLS nesse token e retorna 'waterfall' ou 'treemap'; XlsxChartExLayoutIdToType roda o mapeamento ao contrário na leitura
  • O relacionamento que gráficos clássicos nunca precisam. cx:chartData guarda um elemento <cx:externalData r:id="rId1"/>, e esse relacionamento tem que existir em xl/charts/_rels/chartExN.xml.rels apontando de volta para a worksheet que é dona dos dados, porque o Excel o usa para resolver as fórmulas de range cx:f. Omita a part de rels e o gráfico abre vazio sem nada explicando o porquê

Como você adiciona um gráfico waterfall ou treemap em Delphi?

Do mesmo jeito que você adiciona qualquer outro gráfico na HotXLS — os tipos estendidos são membros de TXLSXChartType, então TXLSXWorksheet.AddChart os aceita direto e tudo a jusante se baseia no valor da enum. Não existe uma API chartEx separada para aprender

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

    // Retângulo de âncora em coordenadas de linha/coluna, igual a um gráfico clássico
    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;
O pipeline de save da HotXLS para gráficos estendidos do Excel: AddChart com xlsxChartWaterfall ou xlsxChartTreemap chega a XlsxChartIsChartEx, que roteia gráficos clássicos para um contador compartilhado e gráficos chartEx para um contador independente que escreve chartEx1.xml, o arquivo de rels dele apontando de volta para a worksheet, o override de content type chartex e o payload da âncora AlternateContent
Um modelo de gráfico e um predicado de roteamento alimentam dois contadores no save: gráficos clássicos compartilham chart1.xml e chart2.xml, enquanto o waterfall cai como chartEx1.xml porque o contador chartEx é independente do clássico

No save, a HotXLS vê XlsxChartIsChartEx retornar True, aloca um nome de part de um contador chartEx separado, e escreve xl/charts/chartEx1.xml, o arquivo de rels dele, o override de content type, e a âncora mc:AlternateContent produzida por XlsxChartExAnchorPayloadXml. O contador independente importa: uma pasta de trabalho com dois gráficos clássicos e um waterfall fica com chart1.xml, chart2.xml e chartEx1.xml, não chartEx3.xml. Treemaps então diferem de waterfalls em dois detalhes, os dois tratados pelo mesmo builder. A dimensão numérica muda de tipo — cx:numDim type="val" para um waterfall vira type="size" para um treemap, porque um retângulo de treemap codifica magnitude como área em vez de como valor plotado — e treemaps normalmente querem uma hierarquia de categorias, que o modelo expressa por TXLSXChartSeries.CategoriesMultiLevel. Ative essa flag e S.ResolveCategoryLevels lê o range da worksheet no momento da escrita, divide-o em um TXLSXChartDataCache por nível atrás de S.CategoryLevelCount e S.CategoryLevels[], e BuildChartExLevelXml emite um cx:lvl por nível dentro de um único cx:strDim, nível folha primeiro — a mesma ordenação física que a ECMA-376 Parte 1 §21.2 usa para c:multiLvlStrCache, então essa convenção sobrevive mesmo que nenhum markup sobreviva

Chart := Sheet.AddChart(xlsxChartTreemap, 'Revenue by Region',
  2, 8, 24, 16);
S := Chart.AddSeries('Revenue',
  'Sheet1!$A$2:$B$40',   // duas colunas: rótulo da folha, depois o grupo pai
  'Sheet1!$C$2:$C$40');
S.CategoriesMultiLevel := True;

A forma de cache que quebra toda suposição vinda de gráficos clássicos

Se você já escreveu um leitor de cache de gráfico clássico, três hábitos vão te trair numa part chartEx, e os três vêm de uma decisão de design: chartex guarda em atributos e no conteúdo de elementos o que DrawingML guarda em elementos filhos

  • Um ponto cacheado é <cx:pt idx="0">42</cx:pt>. O valor é o conteúdo do elemento. Não existe filho c:v para descer, então um parser que percorre procurando nós v não acha nada e reporta um cache vazio
  • ptCount e formatCode são atributos em cx:lvl. Num c:numCache clássico os dois são elementos filhos
  • Múltiplos níveis de hierarquia são múltiplos elementos cx:lvl dentro de um cx:strDim. Não existe um elemento de cache multinível separado do jeito que c:multiLvlStrCache é separado de c:strCache

BuildChartExLevelXml escreve essa forma e ParseChartExXml a lê de volta, passando todo valor por XlsxEscapeText na saída para que um rótulo de categoria contendo um ampersand ou um colchete angular sobreviva ao round trip. Vale notar o que o builder faz quando um nível numérico não carrega format code: ele escreve formatCode="General" em vez de descartar o atributo, porque aqui o atributo não é opcional do jeito que o equivalente clássico é

Formas de cache lado a lado em parts chartEx da HotXLS: um c:numCache clássico guarda ptCount e formatCode como elementos filhos com valores dentro de c:v, enquanto cx:numDim os guarda como atributos em cx:lvl com o valor como conteúdo de elemento, e CategoriesMultiLevel emite um cx:lvl por nível de categoria, folha primeiro, dentro de um único cx:strDim
Chartex guarda em atributos e no conteúdo de elementos o que DrawingML guarda em elementos filhos: o valor fica direto dentro de cx:pt, ptCount e formatCode viram atributos em cx:lvl, e uma hierarquia de categorias são vários elementos cx:lvl num cx:strDim

O prefixo de namespace que fez a leitura do tipo falhar

A primeira versão do parser chartEx da HotXLS escrevia arquivos perfeitamente bons e lia todos de volta como gráfico de colunas. A causa raiz era uma linha, e a lição generaliza para qualquer parser OOXML que você for escrever: TXMLReader.Name retorna o nome como escrito no documento, prefixo incluído. Numa part de gráfico clássico a maioria dos elementos chega sem prefixo, então SameText(Reader.Name, 'series') funciona e você para de pensar nisso. Numa part chartEx todo elemento é prefixado — o reader te entrega 'cx:series', a comparação falha silenciosamente em todos, e você não ganha exceção nem XML malformado, só uma lista de séries vazia e um tipo de gráfico que cai no default. A correção é comparar no local name, num lugar só em vez de em todo call site

// Reader.Name carrega o prefixo de namespace: 'cx:series', não '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 esse helper em todo teste de elemento e um helper AttrVal equivalente para atributos, porque cx:autoUpdate e companhia também são prefixados. Uma vez que os elementos de série estão sendo vistos de verdade, o tipo volta do primeiro layoutId encontrado, e cada cx:series é ligado ao bloco cx:data dele casando cx:dataId/@val com cx:data/@id. Gráficos abertos mas nunca modificados tomam o caminho antigo no lugar: os bytes originais são reproduzidos de FRawChartXml, então um arquivo de cliente intocado sai como entrou — a mesma disciplina de writer conservador que rege extensões de nível de planilha no artigo sobre a extensão de sparklines

O que a HotXLS deliberadamente não faz com esses gráficos

Dois limites valem ser ditos claramente, porque os dois são escolhas em vez de lacunas esperando preenchimento. Os renderizadores PDF e SVG embutidos não desenham séries waterfall nem treemap: os tipos não casam com nenhum ramo de drawing, então a renderização de série é pulada e o resto da planilha exporta normalmente — fingir um treemap plausível num PDF exportado seria pior que não desenhar nada, porque ele discordaria silenciosamente do que o Excel mostra para o mesmo arquivo. E xlsxChartWaterfall e xlsxChartTreemap são os únicos dois layouts estendidos que a HotXLS constrói a partir do modelo; ST_SeriesLayout também define sunburst, funnel, box-and-whisker e histogram, e uma pasta de trabalho contendo qualquer um deles ainda faz round trip pelo caminho de replay do XML cru, só que você não consegue criar um a partir de código Delphi hoje. A renderização dos tipos clássicos de gráfico está inalterada e é coberta no artigo sobre gráficos, imagens e drawings, e o pipeline chartEx acompanha ela no HotXLS Delphi Excel Component para Delphi e C++Builder