Техническая статья

Waterfall и treemap-диаграммы Excel в Delphi с HotXLS

HotXLS пишет Excel-диаграммы waterfall и treemap как отдельные части cx:chartSpace — xl/charts/chartExN.xml с content type application/vnd.ms-office.chartex+xml — и якорит их через обёртку mc:AlternateContent, чтобы Excel 2016 и новее рендерили диаграмму, а старые читатели получали fallback-фигуру. Выберите xlsxChartWaterfall или xlsxChartTreemap — и весь пайплайн частей поедет сам

Обычно эта проблема объявляет о себе тикетом в поддержку. Клиент присылает книгу, которую их финкоманда собрала в Excel 2019, ваш отчётный инструмент открывает её, сохраняет — и waterfall-диаграмма исчезла. Ни исключения, ни предупреждения, ничего в логе: диаграммы просто нет в выводе. Если пойдёте искать её в классическом кодовом пути диаграмм, искать придётся долго, потому что диаграммы там никогда и не было

Якорь mc:AlternateContent, который HotXLS пишет для chartEx-диаграммы: ветка mc:Choice требует cx1 и держит xdr:graphicFrame, чьи a:graphicData ссылаются на namespace chartex с голой связью cx:chart, а mc:Fallback несёт прямоугольник-уведомление, который вместо диаграммы рисуют старые читатели
Якорь mc:AlternateContent разводит chartEx-диаграмму между Excel 2016 и старыми читателями: ветка Choice требует cx1 и несёт chartex-фрейм, а Fallback держит прямоугольник-уведомление, который обязаны рисовать старые читатели

Почему waterfall и treemap-диаграмм нет в c:chartSpace?

Потому что Microsoft не могла положить их туда. Схема диаграмм DrawingML в ECMA-376 Part 1 §21.2 перечисляет свои элементы диаграмм — c:barChart, c:lineChart, c:pieChart и остальные — как закрытый choice, так что добавление waterfall, treemap, sunburst и funnel в Excel 2016 означало бы слом схемы, на которую завязан каждый существующий читатель. Новые типы ушли в параллельный документ: часть cx:chartSpace в namespace chartex 2014 года, http://schemas.microsoft.com/office/drawing/2014/chartex, лежит рядом с классическими диаграммами, но структурно с ними не связана. Рисовальная сторона сшивает два мира через mc:AlternateContent — механизм совместимости из ECMA-376 Part 3: ветка mc:Choice объявляет Requires="cx1" и держит xdr:graphicFrame, чей a:graphicData/@uri — это namespace chartex, а полезная нагрузка — голая ссылка <cx:chart r:id="rIdN"/>, тогда как mc:Fallback держит прямоугольник с уведомлением, которое туда пишет сам Excel. Читатель, не понимающий cx1, по контракту Part 3 обязан взять fallback

Второй маркер — тип связи. Классическая диаграмма висит на рисовальной части со стандартной OOXML chart-связью; chartEx-часть использует вместо неё http://schemas.microsoft.com/office/2014/relationships/chartEx, задокументированный в [MS-ODRAWXML]. HotXLS выставляет оба мира константами — XlsxRtChartEx для этой связи, XlsxCtChartEx для content type, XlsxNsMsChartEx и XlsxNsMsChartEx2015 для двух namespace, которые нужны якорю, — а предикат XlsxChartIsChartEx решает, что диаграмма на сохранении уйдёт в расширенный путь. Комбинированные диаграммы и сохранённый классический chart XML идут другим, описанным в статье про сохранённый chart XML и комбинированные диаграммы

Четыре правила схемы, которые решают, откроет ли Excel файл

Схема chartex строже классической в четырёх конкретных местах, и каждое из них при ошибке даёт один и тот же бесполезный запрос на восстановление. BuildChartExKnownXml и BuildChartExRelsXml в HotXLS зашивают все четыре, но их стоит знать, если вы когда-нибудь будете смотреть XML руками

  • Порядок. cx:chartData обязан стоять в документе раньше cx:chart. Это противоречит интуиции, выработанной на классических диаграммах, где кэши живут внутри серии внутри области графика; в chartEx-части данные лежат впереди своим отдельным блоком, а тело диаграммы только ссылается на них по id
  • Кардинальность. cx:plotArea может содержать ровно один cx:plotAreaRegion. Не ноль и не два. Никаких комбинированных диаграмм здесь нет — одна область, и серии внутри неё
  • Размещение типа. Тип диаграммы вообще не имя элемента. Это атрибут layoutId на cx:series, принимающий токен из перечисления ST_SeriesLayout в схеме chartex от Microsoft. XlsxChartExLayoutId отображает перечисление HotXLS на этот токен и возвращает 'waterfall' или 'treemap'; XlsxChartExLayoutIdToType гонит отображение в обратную сторону на чтении
  • Связь, которая классическим диаграммам не нужна. cx:chartData держит элемент <cx:externalData r:id="rId1"/>, и эта связь обязана существовать в xl/charts/_rels/chartExN.xml.rels, указывая обратно на лист-владельца данных, потому что Excel использует её для разрешения формул диапазонов cx:f. Пропустите rels-часть — диаграмма откроется пустой, и ничто не объяснит почему

Как добавить waterfall или treemap-диаграмму в Delphi?

Точно так же, как любую другую диаграмму в HotXLS: расширенные типы — члены TXLSXChartType, так что TXLSXWorksheet.AddChart принимает их напрямую, и всё ниже по потоку завязано на значение перечисления. Никакого отдельного chartEx API учить не надо

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

    // Прямоугольник якоря в координатах строк/колонок, как у классической диаграммы
    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;
Пайплайн сохранения HotXLS для расширенных Excel-диаграмм: AddChart с xlsxChartWaterfall или xlsxChartTreemap доходит до XlsxChartIsChartEx, который маршрутизирует классические диаграммы на общий счётчик, а chartEx-диаграммы — на независимый, пишущий chartEx1.xml, его rels-файл со ссылкой обратно на лист, override content type chartex и полезную нагрузку якоря AlternateContent
Одна модель диаграммы и один маршрутизирующий предикат питают два счётчика на сохранении: классические диаграммы делят chart1.xml и chart2.xml, а waterfall ложится как chartEx1.xml, потому что chartEx-счётчик не зависит от классического

На сохранении HotXLS видит, что XlsxChartIsChartEx вернул True, выделяет имя части из отдельного chartEx-счётчика и пишет xl/charts/chartEx1.xml, его rels-файл, override content type и якорь mc:AlternateContent, который производит XlsxChartExAnchorPayloadXml. Независимый счётчик важен: книга с двумя классическими диаграммами и одним waterfall получает chart1.xml, chart2.xml и chartEx1.xml, а не chartEx3.xml. Дальше treemap отличается от waterfall в двух деталях, обе обрабатывает тот же билдер. Числовое измерение меняет тип — cx:numDim type="val" у waterfall становится type="size" у treemap, потому что прямоугольник treemap кодирует величину площадью, а не отложенным значением, — а treemap обычно хочет иерархию категорий, которую модель выражает через TXLSXChartSeries.CategoriesMultiLevel. Поставьте этот флаг — и S.ResolveCategoryLevels прочитает диапазон из листа в момент записи, разобьёт его на один TXLSXChartDataCache на уровень за S.CategoryLevelCount и S.CategoryLevels[], а BuildChartExLevelXml выпустит по одному cx:lvl на уровень внутри единого cx:strDim, листовой уровень первым, — тот же физический порядок, что ECMA-376 Part 1 §21.2 использует для c:multiLvlStrCache, так что эта конвенция переносится, хотя разметка — никакая

Chart := Sheet.AddChart(xlsxChartTreemap, 'Revenue by Region',
  2, 8, 24, 16);
S := Chart.AddSeries('Revenue',
  'Sheet1!$A$2:$B$40',   // две колонки: подпись листа, затем родительская группа
  'Sheet1!$C$2:$C$40');
S.CategoriesMultiLevel := True;

Форма кэша, которая ломает все допущения классических диаграмм

Если вы писали читатель кэша классических диаграмм, три привычки подведут вас на chartEx-части, и все три вытекают из одного дизайн-решения: chartex хранит в атрибутах и содержимом элементов то, что DrawingML хранит в дочерних элементах

  • Кэшированная точка — это <cx:pt idx="0">42</cx:pt>. Значение — содержимое элемента. Никакого дочернего c:v, в который можно спуститься, нет, так что парсер, идущий в поисках узлов v, не находит ничего и сообщает о пустом кэше
  • ptCount и formatCode — атрибуты на cx:lvl. В классическом c:numCache оба — дочерние элементы
  • Несколько уровней иерархии — это несколько элементов cx:lvl внутри одного cx:strDim. Отдельного многоуровневого элемента кэша нет — в отличие от того, как c:multiLvlStrCache отделён от c:strCache

BuildChartExLevelXml пишет эту форму, а ParseChartExXml читает её обратно, прогоняя каждое значение через XlsxEscapeText на выходе, чтобы подпись категории с амперсандом или угловой скобкой пережила round trip. Стоит отметить, что делает билдер, когда числовой уровень не несёт format code: он пишет formatCode="General" вместо того, чтобы выбросить атрибут, потому что здесь атрибут не опционален, как его классический аналог

Формы кэша бок о бок в chartEx-частях HotXLS: классический c:numCache хранит ptCount и formatCode дочерними элементами со значениями внутри c:v, тогда как cx:numDim хранит их атрибутами на cx:lvl со значением как содержимым элемента, а CategoriesMultiLevel выпускает по одному cx:lvl на уровень категории, лист первым, внутри единого cx:strDim
Chartex хранит в атрибутах и содержимом элементов то, что DrawingML хранит в дочерних элементах: значение сидит прямо внутри cx:pt, ptCount и formatCode становятся атрибутами на cx:lvl, а иерархия категорий — это несколько элементов cx:lvl в одном cx:strDim

Namespace-префикс, из-за которого ломалось чтение типа

Первая версия chartEx-парсера HotXLS писала совершенно нормальные файлы и читала каждый из них обратно как столбчатую диаграмму. Корень — одна строка, и урок обобщается на любой OOXML-парсер, который вы когда-нибудь напишете: TXMLReader.Name возвращает имя как написано в документе, включая префикс. В классической chart-части большинство элементов приходит без префикса, так что SameText(Reader.Name, 'series') работает, и вы перестаёте об этом думать. В chartEx-части префикс есть у каждого элемента: ридер отдаёт вам 'cx:series', сравнение молча не срабатывает на всех, и вы получаете ни исключения, ни битого XML — просто пустой список серий и тип диаграммы, откатывающийся к значению по умолчанию. Фикс — сравнивать по локальному имени, в одном месте, а не на каждом месте вызова

// Reader.Name несёт namespace-префикс: 'cx:series', а не '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 использует этот хелпер для каждой проверки элемента и эквивалентный хелпер AttrVal для атрибутов, потому что cx:autoUpdate и его товарищи тоже с префиксами. Как только элементы серий реально начинают видеться, тип возвращается из первого встреченного layoutId, а каждая cx:series привязывается к своему блоку cx:data сопоставлением cx:dataId/@val с cx:data/@id. Диаграммы, открытые, но не изменённые, идут старшим путём: исходные байты воспроизводятся из FRawChartXml, так что нетронутый файл клиента выходит таким же, каким вошёл, — та же дисциплина консервативного писателя, что управляет расширениями уровня листа в статье про расширение sparkline

Чего HotXLS намеренно не делает с этими диаграммами

Две границы стоит назвать прямо, потому что обе — выборы, а не дыры, ждущие заполнения. Встроенные рендереры PDF и SVG не рисуют серии waterfall и treemap: типы не совпадают ни с одной веткой отрисовки, так что рендер серий пропускается, а остальное листа экспортируется как обычно, — подделать правдоподобный treemap в экспортированном PDF было бы хуже, чем не рисовать ничего, потому что он молча разошёлся бы с тем, что Excel показывает для того же файла. И xlsxChartWaterfall с xlsxChartTreemap — единственные два расширенных layout, которые HotXLS строит из модели; ST_SeriesLayout определяет ещё sunburst, funnel, box-and-whisker и histogram, и книга с любым из них по-прежнему переживает round trip через путь воспроизведения сырого XML, просто сочинить такой из Delphi-кода сегодня нельзя. Рендер классических типов диаграмм не менялся и описан в статье про диаграммы, изображения и рисунки, а chartEx-пайплайн едет рядом в HotXLS Delphi Excel Component для Delphi и C++Builder