HotXLS записує BIFF8 chart secondary axis groups, видаючи другий AxisParent block, а не додаючи другий chart group після axes. У Classic XLS chart substream кожна chart group — ChartFormat, chart type record, CrtLink — живе всередині власного axis-group block, і кожна series прив’язується до одного через SerToCrt. Переплутаєте цю nesting — і для series не буде другої chart group, до якої можна прив’язатися, скільки б axis records ви не emit-или
Чому друга chart group після axes ні до чого не прив’язується?
Відповідь у grammar, і це один рядок ABNF. Правило CHARTFOMATS у [MS-XLS] 2.1.7.20.1 каже AxesUsed 1*2AXISPARENT, а потім розписує AXISPARENT = AxisParent Begin Pos [AXES] 1*4CRT End. Прочитайте ці дві productions разом — і shape стає очевидною: chart groups є children axis group, а не її siblings. Chart substream із двома axis pairs і одним trailing chart group — це не dual-axis chart із layout quirk, а chart з однією chart group і набором orphan axis records. Це важливо, бо SerToCrt ($1045), що лежить усередині SERIESFORMAT block, містить zero-based chart group index, а не axis index. Записати crt = 1, коли існує лише один CRT block, означає спрямувати series до chart group, якої ніколи не emit-или. Людей збиває з пантелику ім’я record: AXESUSED ($1046) звучить так, ніби він рахує axes, тому природний наступний крок — emit-ити більше axes. Насправді він рахує axis groups, і кожна axis group тягне за собою повну plot area та chart group
Позначення series для secondary axis group
На стороні HotXLS це зводиться до одного Boolean. TXLSChartSeriesInfo має field SecondaryAxis, і його встановлення в будь-якій series array, яку ви передаєте до TXLSWorksheets.AddChartSheet, перемикає весь builder у two-group mode. Окремого call "enable secondary axis" і параметра axis-count немає, бо 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 categories, revenue та margin ...
Series[0] := Default(TXLSChartSeriesInfo); // ніколи не робити FillChar цього record
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) — не decoration. TXLSChartSeriesInfo змішує managed fields (names WideString, dynamic trendline та error-bar arrays) зі звичайними Boolean members, а Delphi гарантує очищення лише managed fields. Залиште SecondaryAxis uninitialized — і він матиме те, що лежало в stack, а на практиці той самий binary створить single-axis chart із console host і dual-axis chart під test runner. Тим часом category та value ranges розв’язуються через workbook EXTERNSHEET table ще до того, як їх побачить builder — та сама index machinery описана в матеріалі про класифікацію BIFF SupBook та XTI external links у HotXLS, — тому range з unknown sheet деградує до empty placeholder BRAI, а не ламає build
Що HotXLS emit-ить, коли series є secondary
Emitter змінює shape, а не лише value. Без secondary series AXESUSED містить 1 (або 0 для pie і 3D pie, які взагалі не мають axis groups), після чого йде один AxisParent block. З нею AXESUSED містить 2, і builder запускає block двічі, із iax — першим word 18-byte AxisParent ($1041) payload — спочатку 0, потім 1. Кожен pass emit-ить Pos, category axis і value axis (Axis, $101D), marker PlotArea ($1035), default Frame, потім ChartFormat ($1014), chart type record, CrtLink ($1022) і два End markers, які закривають chart group та axis group. Secondary series тоді прив’язується через SerToCrt crt = 1, а primary зберігає crt = 0. Один record навмисно не дублюється: legend emit-иться лише в першій group, бо Excel дає chart одну legend незалежно від кількості axis groups. Варто прямо назвати ще дві властивості. Перебудова emitter так, щоб chart group була всередині axis-parent block, не змінила output для звичайних charts — без secondary series substream byte-identical до попередньої version, бо parametrizing AddAxisParent через iax = 0 — це рівно старий code path. І builder усе ще emit-ить повну pair axes per group, тому secondary group завжди приходить із власною category axis, навіть коли вам потрібен лише її value scale
Як chart inspection відновлює axis-group binding?
Reading відбувається у два passes над record list, і це необхідно, бо AXESUSED приходить до blocks, які описує. Перший pass шукає лише $1046 і читає його first word як axis-group count. Це value починається з 1 і лише збільшується: HotXLS бере maximum між current count та declared one, тому malformed або duplicated AXESUSED не може погіршити chart, який уже виявився таким, що оголошує дві groups. Другий pass стежить за current axis group, оновлюючи його на кожному AxisParent, і ставить цей index на кожен 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;
Варто назвати два limits. TXLSChartModel.AxisGroupCount повідомляє те, що file declares, а не кількість фактично знайдених AxisParent blocks; file, який каже 2, але містить один block, повідомить 2, і саме в AxisCount ви це побачите. А TXLSChartAxis.AxisGroup — positional stamp: він записує, всередині якого block прочитано axis, і це єдине, що повідомляє format. На стороні series decoding SerToCrt gated тим, що reader перебуває всередині Series block, бо той самий record id з’являється в contexts, де він не є series binding, і ungated decoder охоче перезаписав би неправильну series
Перевірка secondary axes без справжнього Excel file
Для цієї verification не знадобився Excel file із secondary axis, і саме це найкорисніша частина історії. Structural decoding є властивістю record sequence, тому synthesized sequence доводить її не гірше за captured one. Regression будує AXESUSED з payload 2, потім два AxisParent blocks, кожен із category axis і value axis, і перевіряє, що model повертається з AxisGroupCount = 2, чотирма axes, stamped 0, 0, 1, 1, і очікуваними axis types у другій pair
// Structural verification, взагалі без Excel file
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;
Дві практичні нотатки, якщо ви synthesize-ите records самі. TXLSCustomChart.AddData(RecID, Len, nil) dereference-ить payload, коли Len ненульовий, тому markers Begin ($1033) і End ($1034) треба додавати з length zero, а не з nil blob і stale length. І synthetic sequence доводить лише decoder, а не прийняття вашого output Excel — write side було структуровано за ABNF, а потім перевірено round-tripping через GetChartModel, із assertions per-axis stamps 0/0/1/1 та per-series chart groups 0/1, а byte-identical no-secondary path став safety net. Це та сама conservative posture, на якій побудована решта HotXLS chart, image та drawing support для Delphi: decode те, що кажуть records, і відмовляйся вгадувати binary layout, якого не прочитав у spec. Повний Delphi suite після landing write side пройшов 1650 із 1650 на Win32 і Win64
Chart3d scene parameters і пастка fAuto
Дві сусідні details дошкуляють тим, хто виходить за межі default chart. Перша — Chart3d ($103A, [MS-XLS] 2.4.46), плоский 14-byte payload, який emit-иться всередині chart group для 3D variants: anRot (rotation, 0–360), anElev (elevation, signed, -90–90), pcDist (perspective distance, 0–100, ігнорується, якщо не встановлено fPerspective), pcHeight і pcDepth (percent chart width, 5–500), pcGap (0–500), а також grbit, чиї bits — fPerspective $0001, fCluster $0002, fAutoscale $0004, f3DScaling $0010 і f2DWalls $0020. Spec додає constraints, які record layout за вас не enforce-ить: на transposed bar chart anRot і anElev не мають перевищувати 44, а на pie chart anElev не може бути negative
Друга — bit fAuto, і саме він породжує bug report "my colors were ignored". LineFormat ($1007), AreaFormat ($100A) і MarkerFormat ($1009) усі мають fAuto у bit 0 свого grbit, і коли цей bit встановлений, Excel застосовує automatic style і трактує explicit RGB values, line style, marker shape та marker size поруч із ним як decoration. Будь-який emitter, що записує custom series style, має очистити bit 0; default emitters залишають його set, щоб Excel вибрав palette. Якщо ви редагуєте existing workbook, а не будуєте новий, preservation rules знову відрізняються і описані в редагуванні Excel charts без втрати preserved ChartML
Secondary axis groups, SerToCrt binding і typed chart model, показані тут, постачаються у HotXLS Delphi spreadsheet component для Delphi та C++Builder, який читає й записує BIFF8 charts без установленого Excel; product page містить повну reference chart records і список overloads AddChartSheet