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

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 реферира chartex namespace-а с гола 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 част в chartex namespace-а от 2014, http://schemas.microsoft.com/office/drawing/2014/chartex, съхранявана до класическите диаграми, но структурно несвързана с тях. Рисуващата страна зашива двата свята заедно с mc:AlternateContent, механизма за съвместимост от ECMA-376 Part 3: клонът mc:Choice декларира Requires="cx1" и държи xdr:graphicFrame, чийто a:graphicData/@uri е chartex namespace-ът и чието съдържание е гола <cx:chart r:id="rIdN"/> референция, докато mc:Fallback държи правоъгълник с уведомлението, което самият Excel записва там. Рийдър, който не разбира cx1, е задължен от договора на Part 3 да вземе fallback-а

Типът на релацията е другата улика. Класическата диаграма виси от drawing частта със стандартната 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 е по-строга от класическата на четири конкретни места, и всяко от тях дава същия безполезен repair prompt, когато го сбъркаш. BuildChartExKnownXml и BuildChartExRelsXml в HotXLS вграждат и четирите, но си заслужава да ги знаеш, ако някога инспектираш XML-а на ръка

  • Ред. cx:chartData трябва да е преди cx:chart в документа. Това е обратното на интуицията, която изграждаш от класическите диаграми, където кешовете живеят вътре в серията вътре в plot area-та; в chartEx част данните стоят отпред в собствен блок и тялото на диаграмата само ги реферира по id
  • Кардиналност. cx:plotArea може да съдържа точно един cx:plotAreaRegion. Не нула, не два. Тук няма история с комбинирани диаграми — един регион, и сериите вътре в него
  • Поставяне на типа. Типът на диаграмата изобщо не е име на елемент. Той е атрибутът layoutId върху cx:series, приемащ токен от енумерацията ST_SeriesLayout в Microsoft chartex схемата. 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-а на chartex content type-а и payload-а на 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-ите по два детайла, и двата обработени от същия builder. Числовото измерение сменя типа — 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, така че тази конвенция се пренася, макар нищо от markup-а да не се пренася

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. Няма отделен multi-level кеш елемент, както c:multiLvlStrCache е отделен от c:strCache

BuildChartExLevelXml записва тази форма, а ParseChartExXml я чете обратно, прокарвайки всяка стойност през XlsxEscapeText на излизане, така че етикет на категория, съдържащ амперсанд или ъглова скоба, оцелява round trip-а. Струва си да се отбележи какво прави builder-ът, когато числово ниво носи без 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, просто празен списък със серии и тип на диаграма, който пада до default-а. Поправката е да сравняваш по локалното име, на едно място, а не на всяко място на извикване

// 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