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

Редактирование диаграмм Excel без потери ChartML в HotXLS

Часть диаграммы, сохранённая свежей сборкой Excel, редко останавливается на элементах, которые определяет схема диаграмм ECMA-376. Chart space обычно открывается блоком mc:AlternateContent, предлагающим c14:style новым читателям и простой c:style старым, каждый ряд несёт c:extLst с c16:uniqueId, а подписи данных "Value From Cells" живут в ещё одном расширении. Программа, разбирающая лишь то, что моделирует, и перегенерирующая часть при сохранении, ничего из этого не пишет обратно. Поменяйте одно слово в заголовке, откройте файл в Excel заново — и подписи, тянувшие текст из диапазона ячеек, исчезли

Исправление — перестать трактовать сохранение как пересборку. Держите байты, с которыми диаграмма пришла, пишите их назад неизменными, когда ничего смоделированного не сдвинулось, а когда сдвинулось — вмержите новые элементы в исходное дерево, чтобы незнакомые части остались там, куда их положил Excel

Воспроизведение исходного ChartML, когда ничего не изменилось

HotXLS делает ровно это при TXLSXWorkbook.PreserveUnsupportedParts = True, что является значением по умолчанию у свежесозданной книги. При импорте каждая диаграмма хранит свои исходные UTF-8 байты вместе с длиной и 64-битным отпечатком содержимого, которое представляет типизированная модель. При сохранении HotXLS перегенерирует смоделированную часть, сравнивает длину и отпечаток, и если оба совпали — пишет сохранённые байты дословно: без переупорядочивания элементов, без дрейфа пробелов и без второй UTF-16 копии каждой нетронутой диаграммы в памяти

Поскольку сравнение идёт по содержимому, присвоение свойству его же значения ничего не меняет. Сеттеры TXLSXChart выходят рано при равном значении, поэтому цикл, «освежающий» заголовок каждой диаграммы из строки базы данных, всё равно проигрывает каждую диаграмму, чей заголовок уже был верен. Форматирование линий у планок ошибок и линий тренда отслеживается собственным флагом изменения, и его смена всегда считается правкой

Диаграмма сохранения ChartML в HotXLS в Delphi: неизменённые диаграммы проигрывают исходные байты, отредактированные вливают типизированные узлы модели в сохранённый XML, тогда как пересборка только из модели теряет ветки extLst и AlternateContent
HotXLS хранит исходные байты ChartML при импорте и переигрывает их нетронутыми или вливает типизированные правки в сохранённое дерево, когда модель изменилась

TXLSXChart.HasPreservedXml говорит, каким путём идёт диаграмма, ещё до всякой правки. Он сообщает True, когда к диаграмме прикреплён исходный ChartML, — это предусловие и воспроизведения, и слияния

var
  Workbook: TXLSXWorkbook;
  Chart: TXLSXChart;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // True по умолчанию; задайте явно, чтобы намерение пережило рефакторинг.
    // Успеть до Open, потому что Open строит состояние
    Workbook.PreserveUnsupportedParts := True;
    Workbook.Open('quarterly-review.xlsx');

    Chart := Workbook.Sheets[1].Charts[0];   // Sheets[] с единицы, Charts[] с нуля
    if not Chart.HasPreservedXml then
      Log('chart 0 has no original ChartML attached');   // Log — ваша собственная процедура

    Chart.Title := Chart.Title;                  // равное значение: всё равно воспроизводится
    Workbook.Sheets[1].Charts[1].Title :=
      'Revenue by region, Q3 2026';              // эта диаграмма идёт путём слияния
    Workbook.SaveAs('quarterly-review-out.xlsx');
  finally
    Workbook.Free;
  end;
end;

Что переживает слияние, когда вы правите одну диаграмму?

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

Удалите ряд — и слияние не должно воскрешать его из сохранённого XML только потому, что оригинал всё ещё держит c:ser на том месте. Всё, чем владеет модель, берётся из модели, даже когда ответ модели — «исчез». То же на уровне атрибутов: когда вы снимаете жирность, курсив, подчёркивание, зачёркивание или размер шрифта у известного текстового стиля, слияние читает отсутствующий атрибут как сброс и не копирует b или sz обратно из исходного defRPr. Неизвестные атрибуты того же элемента сохраняют место

Заголовки следуют тому же правилу. При импорте HotXLS хранит нативные богатые прогоны c:title со своими шрифтами, размерами и языковыми тегами на прогон и проигрывает их, пока текст не тронут. Присвойте новый Title — и богатое содержимое отбрасывается; заголовок переписывается одним прогоном, стилизованным из TitleTextStyle. Вливать старые прогоны в новый текст значило бы гадать, к какому прогону принадлежат новые слова, а догадка здесь выдаёт заголовки, которых никто не набирал

Порядок схемы сохраняется там, где Office привередлив. extLst остаётся последним ребёнком родителя, как требует схема. Блоки markup-compatibility получают собственное обращение: читатель пропускает mc:Choice и читает mc:Fallback, и когда тот fallback оборачивает известную модели группу построения, слияние пишет новое типизированное содержимое обратно в поддерево fallback. Наивное слияние выдало бы правленую группу и оставило старую копию внутри ветки fallback, и сохранённая диаграмма носила бы ту же группу дважды

Отключение одной диаграммы через ClearPreservedXml

TXLSXChart.ClearPreservedXml сбрасывает исходный XML и отпечаток одной диаграммы, и следующее сохранение перестраивает её чисто из типизированной модели. Это решение на уровне диаграммы. PreserveUnsupportedParts по-прежнему управляет тем, строится ли состояние сохранения при импорте, и покрывает к тому же прочие неподдерживаемые части пакета, а не только диаграммы, — почему выключать его ради одной чистой диаграммы — неправильный рычаг

Очищайте диаграмму, когда отбросить — и есть смысл операции: например, диаграмма-шаблон всё ещё носит форматирование ушедшей корпоративной темы. Не очищайте как рефлекс отладки. Диаграмма, неверно рендерящаяся после правки, обычно вопрос моделирования, а очистка сохранённого XML выбрасывает единственную копию того, что Excel написал изначально

Один случай требует сначала проверки. Если планка ошибок или линия тренда использует форматирование линии, которое типизированная модель не может представить, её свойство LineAppearanceSupported равно False; подпись линии тренда с форматированием вне модели сообщает то же через LabelAppearanceSupported. HotXLS не подменит ни то, ни другое более простой версией молча: как только у диаграммы нет сохранённого XML для отката, сохранение останавливается с EArgumentException, называющим непредставимую линию или подпись. Проверить обе до очистки — один цикл

// Ваш собственный помощник, не часть HotXLS: True, когда каждая линия плана
// ошибок, линия тренда и подпись тренда записываются из типизированной модели одной
function ChartRebuildable(Chart: TXLSXChart): Boolean;
var
  I, T: Integer;
  S: TXLSXChartSeries;
begin
  Result := False;
  for I := 0 to Chart.SeriesCount - 1 do    // покрывает каждую группу построения
  begin
    S := Chart.Series[I];
    if (S.ErrBarX <> nil) and not S.ErrBarX.LineAppearanceSupported then Exit;
    if (S.ErrBarY <> nil) and not S.ErrBarY.LineAppearanceSupported then Exit;
    for T := 0 to S.TrendlineCount - 1 do
    begin
      if not S.Trendlines[T].LineAppearanceSupported then Exit;
      if not S.Trendlines[T].LabelAppearanceSupported then Exit;
    end;
  end;
  Result := True;
end;

// ...
Chart := Workbook.Sheets[1].Charts.FindByName('Old theme chart');
if (Chart <> nil) and ChartRebuildable(Chart) then
  Chart.ClearPreservedXml      // следующее сохранение перестроит эту диаграмму из модели
else
  Log('kept original ChartML for this chart');

Комбинированные диаграммы с AddPlotGroup и второй осью

TXLSXChart.AddPlotGroup дописывает упорядоченную группу построения со своим семейством диаграмм и своим назначением первичной или вторичной оси. Это покрывает диаграмму, о которой просит каждая финансовая команда: выручка столбцами по левой оси, маржа в процентах линией по правой. Каждая диаграмма выставляет ValueAxis, CategoryAxis, SecondaryValueAxis, SecondaryCategoryAxis и SeriesAxis с типизированными настройками позиции, идентификаторов осей, пересечения, единиц дат, интервалов меток, формата чисел и видимости

Плоский API однотипных диаграмм на месте и отображается на группы. ChartType и AddSeries адресуют первую группу построения; SeriesCount и Series[] идут по рядам всех групп по порядку — почему помощник выше видит и линейный ряд. Порядок важен в одном месте. TXLSXCharts.Add создаёт диаграмму вовсе без группы построения, и что из AddSeries или AddPlotGroup выполнится первым — то и создаст группу один. Вызовите первым AddPlotGroup(xlsxChartLine, xlsxAxisSecondary) на свежей диаграмме — линия станет группой один, тип диаграммы переключится на линейный, и ряд выручки, добавленный следом, попадёт в неё. Явное создание обеих групп снимает неоднозначность, поэтому пример так и делает

var
  Chart: TXLSXChart;
  Columns, Line: TXLSXChartPlotGroup;
begin
  // Закреплена над строками 1..15, столбцами 5..12; групп построения ещё нет
  Chart := Sheet.Charts.Add(xlsxChartColumn, 'Revenue and margin',
    1, 5, 15, 12);
  Columns := Chart.AddPlotGroup(xlsxChartColumn);              // группа 1, первичная
  Columns.AddSeries('Revenue', 'Data!$A$2:$A$13', 'Data!$B$2:$B$13');

  Line := Chart.AddPlotGroup(xlsxChartLine, xlsxAxisSecondary); // группа 2
  Line.AddSeries('Margin %', 'Data!$A$2:$A$13', 'Data!$C$2:$C$13');
  Line.ZOrder := 1;                     // рисовать линию перед столбцами

  Chart.SecondaryValueAxis.NumberFormat := '0%';
  Chart.SecondaryValueAxis.NumberFormatSourceLinked := False;
  Chart.SecondaryValueAxis.MinValue := 0;
  Chart.SecondaryValueAxis.MaxValue := 0.5;

  // Область построения долями от диаграммы: x, y, ширина, высота одним вызовом
  Chart.PlotAreaLayout.SetPositionAndSize(0.08, 0.12, 0.80, 0.72);
end;
Диаграмма комбинированной диаграммы HotXLS в Delphi: столбцы выручки из AddSeries на главной оси значений и линия маржи из AddPlotGroup на вторичной оси значений, тогда как прежний плоский API достаёт только до группы один
Каждый вызов AddPlotGroup дописывает упорядоченную группу со своим семейством диаграмм и назначением главной или вторичной оси, тогда как ChartType и AddSeries по-прежнему адресуют первую группу

ZOrder на группе построения управляет слоями: писатель диаграмм сортирует группы по нему перед выдачей блоков c:barChart и c:lineChart, меньшие значения сзади. Поскольку каждая упорядоченная группа смоделирована, открытие и пересохранение комбинированной диаграммы держит каждое семейство вместе с его вторичной, датной и рядной осями. Ссылки формул диаграмм и кэшированные значения рядов переживают копии книг и циклы открыть/сохранить, а привязки диаграмм сопоставляются с их частями по идентификатору связи, поэтому пакет, чьи записи связей переставил другой инструмент, всё равно ставит каждую диаграмму на её позицию листа

Почему Office игнорирует ручную компоновку?

Потому что координаты пришли неполными. Office требует x, y, w и h полной группой, раз хоть одна присутствует в c:manualLayout, и иначе игнорирует весь элемент. Схема помечает все четыре необязательными, поэтому файл только с x и y валиден и всё равно ничего не делает в Excel

TXLSXChartLayout.SetPositionAndSize — тот вызов. Он ставит все четыре значения, помечает четыре элемента режима присутствующими и включает компоновку одним шагом. Отдельные свойства X, Y, Width и Height ставят по одному значению, и HotXLS пишет ровно присутствующие элементы — так же, как хранит присутствие каждого из девяти необязательных детей при чтении файла. Это держит нетронутую компоновку верной источнику. Это же значит, что компоновка из двух присваиваний свойств будет записана с двумя координатами, и Office её пропустит. Тот же класс TXLSXChartLayout стоит за PlotAreaLayout, TitleLayout, LegendLayout и TitleLayout каждой оси

Диаграмма правила ручной компоновки HotXLS в Delphi: x, y, w и h должны прийти полной группой, иначе Office игнорирует весь элемент manualLayout, с режимами edge и factor и layoutTarget, ограниченным областью построения
Office применяет ручную раскладку, только когда x, y, w и h приходят полной группой — вот почему SetPositionAndSize пишет все четыре одним вызовом

Два дефолта следуют за Excel, а не за всем, что разрешает спецификация. Новая компоновка использует edge для XMode и YMode и factor для WidthMode и HeightMode; стандарт разрешает прочие комбинации, но Excel сохраняет позиции как края, размеры как факторы и конвертирует всё остальное при сохранении. А layoutTarget (Target, внутренняя или внешняя) пишется для области построения, тогда как компоновки заголовка, подписи оси и легенды обходятся без неё: заметки реализации Microsoft к схеме диаграмм не допускают её под теми элементами

Как это встаёт в цикл книги

Сохранение диаграмм — один случай более широкого правила: библиотека, правящая документы, собранные где-то ещё, должна менять то, о чём её попросили, и оставлять остальное как нашла. HotXLS применяет ту же логику к темам, цепочкам вычислений и расширениям листов — беспотерянные циклы для темы, extLst и calcChain — и к проектам VBA и внешним связям в сохранении VBA и внешних связей. Создание диаграмм, размещение изображений и рисование фигур с нуля — в статье диаграммы, изображения и рисунки в HotXLS

HotXLS читает, правит и пишет диаграммы XLSX из нативного кода Delphi и C++Builder без установленного на машине Excel, так что точность диаграмм решает библиотека, а не сервер автоматизации. Справочник API диаграмм и пробная сборка — на странице компонента HotXLS для электронных таблиц Delphi