Artigo Técnico

Gráficos Waterfall e Treemap do Excel em Delphi com o HotXLS

O HotXLS escreve gráficos waterfall e treemap do Excel como partes cx:chartSpace autónomas — xl/charts/chartExN.xml com o content type application/vnd.ms-office.chartex+xml — e ancora-os através de um wrapper mc:AlternateContent para que o Excel 2016 e posteriores renderizem o gráfico enquanto leitores mais antigos recebem uma forma fallback. Escolha xlsxChartWaterfall ou xlsxChartTreemap e todo o pipeline de partes se segue

A forma como este problema costuma anunciar-se é um ticket de suporte. Um cliente envia um workbook que a equipa de finanças dele construiu no Excel 2019, a sua ferramenta de relatórios abre-o, grava-o, e o gráfico waterfall desapareceu. Sem exceção, sem aviso, nada no log — o gráfico simplesmente não está no output. Se for procurá-lo no code path clássico de gráficos, vai procurar durante muito tempo, porque o gráfico nunca lá esteve para começar

A âncora mc:AlternateContent que o HotXLS escreve para um gráfico chartEx: o ramo mc:Choice exige cx1 e contém um xdr:graphicFrame cujo a:graphicData referencia o namespace chartex com uma relação cx:chart nua, enquanto o mc:Fallback carrega um retângulo de aviso que os leitores mais antigos desenham em vez disso
A âncora mc:AlternateContent encaminha um gráfico chartEx entre o Excel 2016 e leitores mais antigos: o ramo Choice exige cx1 e carrega o frame chartex, enquanto o Fallback contém o retângulo de aviso que os leitores mais antigos têm de desenhar

Porque é que os gráficos waterfall e treemap não estão em c:chartSpace?

Porque a Microsoft não podia pô-los lá. O schema de gráficos DrawingML na parte 1 §21.2 da ECMA-376 enumera os seus elementos de gráfico — c:barChart, c:lineChart, c:pieChart e o resto — como uma choice fechada, pelo que adicionar waterfall, treemap, sunburst e funnel no Excel 2016 teria significado partir um schema do qual todos os leitores existentes dependem. Os novos tipos foram antes para um documento paralelo: uma parte 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 não relacionada com eles. O lado do drawing cose os dois mundos com mc:AlternateContent, o mecanismo de compatibilidade da parte 3 da ECMA-376: o ramo mc:Choice declara Requires="cx1" e contém um xdr:graphicFrame cujo a:graphicData/@uri é o namespace chartex e cuja payload é uma referência nua <cx:chart r:id="rIdN"/>, enquanto o mc:Fallback contém um retângulo com o aviso que o próprio Excel escreve lá. Um leitor que não perceba cx1 é obrigado pelo contrato da parte 3 a seguir o fallback

O tipo de relação é o outro indício. Um gráfico clássico pendura-se da parte de drawing com a relação de gráfico OOXML padrão; uma parte chartEx usa http://schemas.microsoft.com/office/2014/relationships/chartEx em vez disso, documentado em [MS-ODRAWXML]. O HotXLS expõe os dois mundos como constantes — XlsxRtChartEx para essa relação, XlsxCtChartEx para o content type, XlsxNsMsChartEx e XlsxNsMsChartEx2015 para os dois namespaces de que a âncora precisa — e o predicado XlsxChartIsChartEx é o que encaminha um gráfico para o path estendido no save. Gráficos de combinação e XML de gráfico clássico preservado vão pelo outro, descrito em o artigo sobre XML de gráfico preservado e gráficos de combinação

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

O schema chartex é mais estrito do que o clássico em quatro sítios específicos, e cada um deles produz o mesmo prompt de reparação inútil quando se engana. O BuildChartExKnownXml e o BuildChartExRelsXml no HotXLS trazem os quatro embutidos, mas vale a pena conhecê-los se alguma vez inspecionar o XML à mão

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

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

Da mesma forma que adiciona qualquer outro gráfico no HotXLS — os tipos estendidos são membros de TXLSXChartType, pelo que o TXLSXWorksheet.AddChart aceita-os diretamente e tudo a jusante depende do valor da enum. Não há 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 ancoragem em coordenadas de linha/coluna, como num 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 do HotXLS para gráficos Excel estendidos: AddChart com xlsxChartWaterfall ou xlsxChartTreemap chega ao XlsxChartIsChartEx, que encaminha gráficos clássicos para um contador partilhado e gráficos chartEx para um contador independente que escreve chartEx1.xml, o seu ficheiro de rels a apontar de volta para a worksheet, o content type override chartex e a payload da âncora AlternateContent
Um modelo de gráfico e um predicado de encaminhamento alimentam dois contadores no save: os gráficos clássicos partilham chart1.xml e chart2.xml, enquanto o waterfall aterra como chartEx1.xml porque o contador chartEx é independente do clássico

No save, o HotXLS vê o XlsxChartIsChartEx retornar True, aloca um nome de parte a partir de um contador chartEx separado, e escreve xl/charts/chartEx1.xml, o seu ficheiro de rels, o content type override e a âncora mc:AlternateContent produzida pelo XlsxChartExAnchorPayloadXml. O contador independente importa: um workbook com dois gráficos clássicos e um waterfall fica com chart1.xml, chart2.xml e chartEx1.xml, não chartEx3.xml. Os treemaps diferem depois dos waterfalls em dois detalhes, ambos tratados pelo mesmo builder. A dimensão numérica muda de tipo — cx:numDim type="val" para um waterfall torna-se type="size" para um treemap, porque um retângulo de treemap codifica magnitude como área em vez de valor plotado — e os treemaps querem normalmente uma hierarquia de categorias, que o modelo expressa através do TXLSXChartSeries.CategoriesMultiLevel. Ative essa flag e o S.ResolveCategoryLevels lê o range da worksheet na altura de escrever, divide-o num TXLSXChartDataCache por nível por trás do S.CategoryLevelCount e do S.CategoryLevels[], e o BuildChartExLevelXml emite um cx:lvl por nível dentro de um único cx:strDim, nível de folha primeiro — a mesma ordem física que a parte 1 §21.2 da ECMA-376 usa para c:multiLvlStrCache, pelo que a convenção passa de lá para cá mesmo que o markup não passe

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

A forma do cache que parte todos os pressupostos dos gráficos clássicos

Se já escreveu um leitor de cache de gráfico clássico, três hábitos vão traí-lo numa parte chartEx, e os três vêm de uma decisão de design: o chartex guarda em atributos e em conteúdo de elemento aquilo que o DrawingML guarda em elementos filhos

  • Um ponto em cache é <cx:pt idx="0">42</cx:pt>. O valor é o conteúdo do elemento. Não há filho c:v para onde descer, pelo que um parser que percorra à procura de nós v não encontra nada e reporta um cache vazio
  • O ptCount e o formatCode são atributos em cx:lvl. Num c:numCache clássico ambos são elementos filhos
  • Níveis de hierarquia múltiplos são múltiplos elementos cx:lvl dentro de um cx:strDim. Não há um elemento de cache multi-nível separado da forma como o c:multiLvlStrCache é separado do c:strCache

O BuildChartExLevelXml escreve essa forma e o ParseChartExXml lê-a de volta, passando cada valor pelo XlsxEscapeText à saída para que um label de categoria com um ampersand ou um colchete angular sobreviva ao round trip. Vale a pena notar o que o builder faz quando um nível numérico não traz format code: escreve formatCode="General" em vez de largar o atributo, porque aqui o atributo não é opcional da forma como o seu equivalente clássico é

Formas de cache lado a lado em partes chartEx do HotXLS: um c:numCache clássico guarda ptCount e formatCode como elementos filhos com valores dentro de c:v, enquanto o cx:numDim guarda-os como atributos em cx:lvl com o valor como conteúdo de elemento, e o CategoriesMultiLevel emite um cx:lvl por nível de categoria, folha primeiro, dentro de um único cx:strDim
O chartex guarda em atributos e conteúdo de elemento aquilo que o DrawingML guarda em elementos filhos: o valor fica diretamente dentro de cx:pt, o ptCount e o formatCode tornam-se 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 falhar a leitura do tipo

A primeira versão do parser chartEx do HotXLS escrevia ficheiros perfeitamente bons e lia todos de volta como um gráfico de colunas. A causa raiz era uma linha, e a lição generaliza para qualquer parser OOXML que venha a escrever: o TXMLReader.Name retorna o nome tal como está escrito no documento, prefixo incluído. Numa parte de gráfico clássico a maioria dos elementos chega sem prefixo, pelo que SameText(Reader.Name, 'series') funciona e deixa-se de pensar nisso. Numa parte chartEx todo o elemento tem prefixo — o reader entrega-lhe 'cx:series', a comparação falha silenciosamente em todos, e não há exceção nem XML malformado, apenas uma lista de séries vazia e um tipo de gráfico que cai no default. A correção é comparar no local name, num só sítio em vez de em cada call site

// Reader.Name traz o seu 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;

O ParseChartExXml usa esse helper para cada teste de elemento e um helper equivalente AttrVal para atributos, já que o cx:autoUpdate e companhia também têm prefixo. Assim que os elementos de série são realmente vistos, o tipo volta do primeiro layoutId encontrado, e cada cx:series é ligado ao seu bloco cx:data casando cx:dataId/@val com cx:data/@id. Gráficos abertos mas nunca modificados seguem antes o path mais antigo: os bytes originais são repostos a partir do FRawChartXml, pelo que um ficheiro de cliente intocado sai como entrou — a mesma disciplina de writer conservador que governa as extensões ao nível da worksheet em o artigo sobre a extensão sparkline

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

Vale a pena declarar dois limites sem rodeios, porque ambos são escolhas e não lacunas à espera de serem preenchidas. Os renderers PDF e SVG embutidos não desenham séries waterfall ou treemap: os tipos não casam com nenhum ramo de drawing, pelo que a renderização de séries é saltada e o resto da folha exporta normalmente — fingir um treemap de aspeto plausível num PDF exportado seria pior do que não desenhar nada, porque discordaria silenciosamente daquilo que o Excel mostra para o mesmo ficheiro. E o xlsxChartWaterfall e o xlsxChartTreemap são os únicos dois layouts estendidos que o HotXLS constrói a partir do modelo; o ST_SeriesLayout também define sunburst, funnel, box-and-whisker e histogram, e um workbook que contenha algum deles continua a fazer round trip pelo path de replay de XML cru, só não se pode criar um a partir de código Delphi hoje. A renderização dos tipos de gráfico clássicos está inalterada e é coberta em o artigo sobre gráficos, imagens e drawings, e o pipeline chartEx vem ao lado dela no HotXLS Delphi Excel Component para Delphi e C++Builder