HotXLS записывает группы вторичной оси диаграммы BIFF8 через второй блок AxisParent, а не добавляет вторую chart group после осей. В классическом chart substream XLS каждая chart group — ChartFormat, record типа диаграммы и CrtLink — находится внутри собственного блока axis group, а каждая series привязывается к нему через SerToCrt. Если перепутать эту вложенность, второй chart group, к которому могла бы привязаться series, не появится, сколько бы axis records вы ни записали
Почему вторая chart group после осей ни к чему не привязывается?
Весь ответ в grammar, причём в одной строке ABNF. Правило CHARTFOMATS в [MS-XLS] 2.1.7.20.1 говорит AxesUsed 1*2AXISPARENT, а затем раскрывает AXISPARENT = AxisParent Begin Pos [AXES] 1*4CRT End. Если прочитать эти две продукции вместе, форма становится очевидной: chart groups — потомки axis group, а не её siblings. Chart substream с двумя парами осей и одной завершающей chart group — не dual-axis chart с особенностью layout, а chart с одной chart group и набором осиротевших axis records. Это важно, потому что SerToCrt ($1045), расположенная внутри блока SERIESFORMAT, содержит индекс chart group с нулевой базой, а не индекс оси. Запись crt = 1, когда существует только один блок CRT, указывает series на никогда не выданную chart group. Интуиция, которая здесь подводит, связана с именем record: AXESUSED ($1046) звучит так, будто считает оси, поэтому естественный следующий шаг — вывести больше осей. На самом деле он считает axis groups, и каждая axis group тянет за собой полную plot area и chart group
Как отметить series для secondary axis group
На стороне HotXLS всё сводится к одному Boolean. TXLSChartSeriesInfo содержит поле SecondaryAxis, и его установка у любой series в массиве, переданном в TXLSWorksheets.AddChartSheet, переводит весь builder в режим двух групп. Отдельного вызова «enable secondary axis» и параметра количества осей нет, потому что count выводится из данных: если хотя бы одной series нужна secondary group, chart нужны две
var
Wb: TXLSWorkbook;
Series: array [0..1] of TXLSChartSeriesInfo;
begin
Wb := TXLSWorkbook.Create;
try
Wb.Sheets.Add.Name := 'Data';
// ... заполнить A1:C12 категориями, revenue и margin ...
Series[0] := Default(TXLSChartSeriesInfo); // никогда не использовать FillChar для этой записи
Series[0].Name := 'Revenue';
Series[0].Categories := 'Data!$A$1:$A$12';
Series[0].Values := 'Data!$B$1:$B$12';
Series[1] := Default(TXLSChartSeriesInfo);
Series[1].Name := 'Margin';
Series[1].Categories := 'Data!$A$1:$A$12';
Series[1].Values := 'Data!$C$1:$C$12';
Series[1].SecondaryAxis := True; // AXESUSED становится равным 2
Wb.Sheets.AddChartSheet('Dual Axis', xlsChartTypeLine,
'Revenue vs Margin', '', '', Series);
Wb.SaveAs('dual-axis.xls', xlExcel97);
finally
Wb.Free;
end;
end;
Строка Default(TXLSChartSeriesInfo) — не украшение. TXLSChartSeriesInfo смешивает managed fields — имена WideString, dynamic arrays trendline и error-bar — с обычными членами Boolean, а Delphi гарантирует автоматическую очистку только managed fields. Оставьте SecondaryAxis неинициализированным, и он будет равен тому, что оказалось в стеке, а на практике один и тот же binary построит single-axis chart из console host и dual-axis chart под test runner. Диапазоны category и value, тем временем, разрешаются через EXTERNSHEET table workbook ещё до того, как builder их увидит — той же machinery индексов, которая описана в статье о классификации внешних ссылок BIFF SupBook и XTI в HotXLS, — поэтому диапазон с неизвестным sheet деградирует в пустой placeholder BRAI, а не ломает сборку
Что HotXLS выдаёт, когда series является secondary
Emitter меняет форму, а не только значение. Без secondary series AXESUSED содержит 1 (или 0 для pie и 3D pie, у которых вообще нет axis groups), после чего идёт один блок AxisParent. При наличии secondary AXESUSED содержит 2, и builder дважды выполняет блок, устанавливая iax — первое слово 18-байтного payload AxisParent ($1041) — сначала в 0, затем в 1. Каждый проход выдаёт Pos, category axis и value axis (Axis, $101D), marker PlotArea ($1035), default Frame, затем ChartFormat ($1014), record типа диаграммы, CrtLink ($1022) и два marker End для закрытия chart group и axis group. Secondary series затем привязывается через SerToCrt crt = 1, а primary сохраняет crt = 0. Один record намеренно не дублируется: legend выдаётся только в первой group, потому что Excel даёт chart одну legend независимо от количества axis groups. Ещё два свойства стоит обозначить прямо. Перестройка emitter так, чтобы chart group находилась внутри axis-parent block, не изменила вывод обычных charts — без secondary series substream побайтно идентичен предыдущей версии, поскольку параметризация AddAxisParent с iax = 0 — ровно старый code path. И builder по-прежнему выдаёт полную пару осей для каждой group, поэтому secondary group всегда приходит со своей category axis, даже если вам нужна только её value scale
Как chart inspection восстанавливает привязку axis group?
Чтение выполняется двумя проходами по списку records, и иначе нельзя, потому что AXESUSED приходит до описываемых им блоков. Первый проход ищет только $1046 и читает первое слово как количество axis groups. Это значение начинается с 1 и только увеличивается: HotXLS берёт максимум текущего count и объявленного, поэтому повреждённый или дублированный AXESUSED не сможет откатить chart, который уже показал наличие двух групп. Второй проход отслеживает текущую axis group, обновляя её на каждом AxisParent, и проставляет этот индекс каждой встретившейся Axis record, пока не появится следующий AxisParent
var
Model: TXLSChartModel;
i: Integer;
begin
// Sheets[1] — data worksheet, Sheets[2] — chart sheet
Model := Wb.Sheets[2]._Chart.GetChartModel;
try
if Model.AxisGroupCount = 2 then
Writeln('AXESUSED declares a secondary axis group');
for i := 0 to Model.AxisCount - 1 do
Writeln('axis ', i, ' group ', Model.GetAxis(i).AxisGroup);
for i := 0 to Model.SeriesCount - 1 do
Writeln('series ', i, ' chart group ', Model.GetSeries(i).ChartGroup);
finally
Model.Free;
end;
end;
Стоит назвать два ограничения. TXLSChartModel.AxisGroupCount сообщает то, что файл объявляет, а не число реально найденных блоков AxisParent; файл, который говорит 2 и содержит один блок, сообщит 2, и именно в AxisCount вы заметите расхождение. А TXLSChartAxis.AxisGroup — это positional stamp: он фиксирует, внутри какого блока была прочитана axis, и это единственное, что говорит формат. На стороне series декодирование SerToCrt разрешено только внутри Series block, потому что тот же record id встречается в контекстах, где это не series binding, а decoder без такой проверки с готовностью перезаписал бы не ту series
Как проверять secondary axes без настоящего Excel-файла
Для проверки не понадобился Excel-файл с secondary axis, и в этом полезная часть истории. Structural decoding — свойство последовательности records, поэтому синтезированная последовательность доказывает его не хуже captured sequence. Регрессия строит AXESUSED с payload 2, затем два блока AxisParent, каждый из которых оборачивает category axis и value axis, и проверяет, что модель возвращается с AxisGroupCount = 2, четырьмя осями со stamp 0, 0, 1, 1 и ожидаемыми типами осей во второй паре
// Структурная проверка полностью без участия Excel
Chart := TXLSCustomChart.Create(nil, $0600);
try
AddWordRecord($1046, [2]); // AXESUSED: две axis groups
AddAxisParentGroup(0); // AxisParent iax=0 + Begin + 2 Axis + End
AddAxisParentGroup(1); // AxisParent iax=1 + Begin + 2 Axis + End
Model := Chart.GetChartModel;
Assert.AreEqual(2, Model.AxisGroupCount);
Assert.AreEqual(4, Model.AxisCount);
Assert.AreEqual(0, Model.GetAxis(1).AxisGroup);
Assert.AreEqual(1, Model.GetAxis(2).AxisGroup);
finally
Model.Free;
Chart.Free;
end;
Если синтезируете records самостоятельно, полезны две практические заметки. TXLSCustomChart.AddData(RecID, Len, nil) разыменовывает payload, когда Len не равен нулю, поэтому marker Begin ($1033) и End ($1034) нужно добавлять с нулевой длиной, а не с nil blob и устаревшей длиной. И синтетическая последовательность доказывает decoder, но никогда не доказывает, что Excel примет ваш output — write side был построен по ABNF, а затем проверен round-trip через GetChartModel с assertions для stamps осей 0/0/1/1 и chart groups series 0/1, а побайтно идентичный no-secondary path служит safety net. Это та же консервативная позиция, на которой построена остальная поддержка charts, images и drawings в HotXLS для Delphi: декодировать то, что говорят records, и отказываться угадывать binary layout, который вы не прочитали в спецификации. После появления write side полный Delphi suite прошёл 1650 из 1650 на Win32 и Win64
Параметры сцены Chart3d и ловушка fAuto
Две соседние детали бьют по тем, кто выходит за пределы default chart. Первая — Chart3d ($103A, [MS-XLS] 2.4.46), плоский payload длиной 14 байт, выдаваемый внутри chart group для 3D-вариантов: anRot (rotation, от 0 до 360), anElev (elevation со знаком, от -90 до 90), pcDist (perspective distance, от 0 до 100, игнорируется без fPerspective), pcHeight и pcDepth (процент ширины chart, от 5 до 500), pcGap (от 0 до 500) и grbit, чьи биты — fPerspective $0001, fCluster $0002, fAutoscale $0004, f3DScaling $0010 и f2DWalls $0020. Спецификация добавляет ограничения, которые layout record сам за вас не проверит: на transposed bar chart anRot и anElev не должны превышать 44, а на pie chart anElev не должен быть отрицательным
Вторая — бит fAuto, и именно он создаёт сообщение «мои цвета проигнорированы». LineFormat ($1007), AreaFormat ($100A) и MarkerFormat ($1009) содержат fAuto в нулевом бите своего grbit, и при установленном бите Excel применяет automatic style, считая соседние явные RGB values, line style, shape marker и size marker декорацией. Любой emitter, записывающий custom series style, должен очистить bit 0; default emitters оставляют его установленным именно для выбора Excel palette. Если вы редактируете существующую workbook, а не строите новую, правила сохранения снова отличаются и разобраны в статье о редактировании Excel charts без потери сохранённого ChartML
Группы вторичной оси, привязка SerToCrt и показанная typed chart model входят в табличный компонент HotXLS для Delphi для Delphi и C++Builder, который читает и записывает BIFF8 charts без установленного Excel; на странице продукта есть полная справка по chart records и список перегрузок AddChartSheet