HotXLS 渲染 Excel 图表日期轴用的是日历算术,不是固定的天数。lxChart 单元里的 TXLSChartDateAxisTransform 类把每个坐标都保持为工作簿 serial,每个月或年刻度都直接从轴最小值通过 IncMonth 和 IncYear 算出,并把月末收拢到真实日历上,于是锚在 1 月 31 日的序列拿到的刻度是 2 月 28 日、3 月 31 日和 4 月 30 日,而不是一路滑走。同一个 transform 还会自动挑日、月或年单位,并被 HTML、SVG 和分页 PDF 图表渲染器共享
它防住的这个 bug,画过月末结算图的人都眼熟。把一个月当 30 天的渲染器到第一年年底会早漂五天。更聪明一点、用日历月、但每个刻度从上一个刻度推进的渲染器,坏得更安静:1 月 31 日变成 2 月 28 日,下一步落在 3 月 28 日,之后每个刻度都钉死在 28 号上。图表看着挺像样,标签全是错的,直到财务问 3 月的余额为什么画在季末前三天,才发现不对
为什么累积出来的月刻度会漂移?
月刻度漂移是因为收拢丢掉了信息。1 月 31 日一旦被收拢成 2 月 28 日,「序列想要的是 31 号」这个事实就没了,从收拢值出发的任何一步都继承这个损失。TXLSChartDateAxisTransform.BuildTicks 从不从上一个刻度出发。刻度 i 总是按 AddUnits(MinValue, UnitKind, Step * i, ...) 计算,即从轴最小值量起的 min + 单位 × 索引。AddUnits 对日单位直接把计数加到 serial 上,对月和年单位则先把 serial 转成真实日期,调 IncMonth 或 IncYear,再转回去。这个循环在每一边都有界:结果数组无论调用方要多少都封顶 4096 个条目,Step * i 在相乘之前先对 MaxInt 做检查,一旦刻度不再递增或越过轴最大值,生成立即停止
uses
SysUtils, lxChart;
var
Ticks: TXLSChartDateValues;
MinSerial, MaxSerial: Double;
I, Count: Integer;
begin
// 1900 日期系统(Dates1904 = False),月末锚定的序列
MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
EncodeDate(2026, 1, 31), False);
MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
EncodeDate(2026, 12, 31), False);
// 一个月步长,目标 12 个刻度,绝不超过 64
Count := TXLSChartDateAxisTransform.BuildTicks(MinSerial, MaxSerial,
1, xcduMonths, False, 12, 64, Ticks);
for I := 0 to Count - 1 do
Writeln(TXLSChartDateAxisTransform.FormatValue(Ticks[I], False));
// 2026-01-31, 2026-02-28, 2026-03-31, 2026-04-30 ... 2026-12-31
end;
BuildTicks 里有一处明摆着的近似。请求的步长为零时,方法按「一月 30 天、一年 365 天」估一个步长,然后把原始步长取整到 1、2 或 5 乘以 10 的幂。这个估计只决定刻度之间隔多少个单位。刻度位置本身仍然来自 IncMonth 和 IncYear,所以近似只会改变刻度密度,永远不会把一个刻度挪出日历
为什么保持工作簿 serial 而不转成 TDateTime?
HotXLS 的日期轴保持工作簿 serial 作为坐标空间,因为 Delphi 的 TDateTime 没有给 serial 60 留位置——那个幻影 1900 年 2 月 29 日,是 1900 日期系统作为兼容怪癖继承下来的。TrySerialToDateTime 把 60 以下的 serial 平移一天,把 serial 60 折到 2 月 28 日上,给单个点打标签没问题,对几何却是致命的:先把每个点都转成 TDateTime,幻影日两侧的两天就会比 Excel 画的近一个单位,于是 1900 年 3 月之前画的全部东西相对之后的东西整体偏移。TXLSChartDateAxisTransform.FormatValue 对同一个槽位做了特判,把它标成 1900-02-29,与 Excel 完全一致。在 1904 工作簿里,serial 0 是 1904 年 1 月 1 日,transform 在两个方向上都套用固定的 1462 天偏移;两个纪元的完整故事见我们讲 Excel 日期 serial、1904 系统与数字格式的文章
HotXLS 怎么自动选日、月还是年?
TXLSChartDateAxisTransform.DetectUnit 选的是数据里的间隔真正需要的最细日历单位。方法先丢掉 NaN 和无穷值,剩下的排一次序(O(n log n)),跳过重复日期。只要有任何相邻间隔短于一个日历月,单位就是日;否则只要有任何间隔短于一个日历年,单位就是月;否则是年。判断用的是 IncMonth(Previous, 1) > Current,不是 30 天阈值,所以 1 月 31 日、2 月 28 日、3 月 31 日这样的月末序列会被正确识别成月度数据。不同的有效日期少于两个时没有间隔可量,方法退回日单位
ResolveUnits 然后把检测出的单位与图表声明的东西合并。显式的 base unit 得到尊重,缺失的 major 或 minor unit 在检测单位比 base 粗时继承检测单位。最后一步最容易被漏掉:如果生效的 major 或 minor unit 比 base unit 更细,base 会被降下来与之匹配。base unit 是数据点在任何刻度存在之前归一化的目标,Normalize 把值吸附到当天的日、当月的一号或 1 月 1 日,所以日级 major unit 下配月级 base,会在刻度代码看到它们之前就把一个月的点塌缩进一个槽。刻度密度同样有界,受绘图长度、轴字体的实测宽度、标签数字格式和旋转标签的投影约束,每个后端都从同一个共享的、有界的刻度数组画标签、刻度线和网格线,与主刻度重合的次刻度只画一次
dateAx 元素存什么,省略又意味着什么?
ChartML 里日期轴上的每个日历设置都是可选的,省略一个不等于写入它的默认值。ECMA-376 Part 1 §21.2(DrawingML Charts)的 CT_DateAx 类型允许 baseTimeUnit、majorTimeUnit、minorTimeUnit 和 auto 各自出现或不出现,省略 baseTimeUnit 是让 Excel 自己决定,与显式写 days 不同。因此 TXLSXChartAxis 把每个值与一个在场标志一起存储:BaseTimeUnit 配 BaseTimeUnitSet、MajorTimeUnit 配 MajorTimeUnitSet、MinorTimeUnit 配 MinorTimeUnitSet、AutoDateAxis 配 AutoDateAxisSet。赋值会置上标志,清掉标志让元素回到省略态,XLSX 写入器只在标志置位时才写出元素。注意 auto 指的是类别与日期之间的自动判别,不是自动刻度单位
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
Chart: TXLSXChart;
Axis: TXLSXChartAxis;
M: Integer;
begin
Book := TXLSXWorkbook.Create;
try
Sheet := Book.Sheets.Add('Close');
Sheet.Cells[1, 1].Value := 'Month end';
Sheet.Cells[1, 2].Value := 'Balance';
for M := 1 to 12 do
begin
Sheet.Cells[M + 1, 1].Value :=
EncodeDate(2026, M, DaysInAMonth(2026, M)); // DateUtils
Sheet.Cells[M + 1, 2].Value := 1000 + M * 75;
end;
Chart := Sheet.AddLineChart('Month-end balance',
'Close!$A$2:$A$13', 'Close!$B$2:$B$13', 15, 1, 32, 9);
Axis := Chart.CategoryAxis;
Axis.Kind := xlsxAxisDate; // 写出 c:dateAx
Axis.BaseTimeUnit := xlsxChartTimeDays; // 同时置上 BaseTimeUnitSet
Axis.MajorTimeUnit := xlsxChartTimeMonths;
Axis.MajorUnit := 1; // > 0 标记 majorUnit 已设置
Axis.NumberFormat := 'mmm yyyy';
Axis.NumberFormatSourceLinked := False;
Axis.TextStyle.Rotation := -45; // 度;按 1/60000 存储
// AutoDateAxis 保持不动:不写出 c:auto 元素
Book.SaveAs('month-end-balance.xlsx');
finally
Book.Free;
end;
end;
标签与方向遵循同样的在场感知规则。轴的 numFmt 位于标题之后、刻度线之前,其 sourceLinked 属性默认为 true,TXLSXChartAxis 把格式代码、source-linked 值和 NumberFormatSet 分开存,所以空代码永远不会被误当成缺席的元素。文字旋转按 1/60000 度存储,正值表示顺时针;TextStyle.Rotation 接受普通度数,SVG 输出按原样使用这个角度,而分页后端的正角度是逆时针的,它在唯一一个共享边界上翻转符号。反转的 maxMin 轴把主次刻度、网格线、数据点、趋势线和误差线一起镜像,绝不会只镜像标签。当你打开别处写出的工作簿,这些标志会告诉你作者真正指定了什么
var
Book: TXLSXWorkbook;
Axis: TXLSXChartAxis;
begin
Book := TXLSXWorkbook.Create;
try
if Book.Open('forecast.xlsx') <> 1 then
raise Exception.Create('Cannot open workbook');
Axis := Book.Sheets[0].Charts[0].CategoryAxis;
if Axis.Kind = xlsxAxisDate then
begin
if Axis.BaseTimeUnitSet then
Writeln('baseTimeUnit = ', Ord(Axis.BaseTimeUnit))
else
Writeln('baseTimeUnit omitted: Excel chooses at render time');
if Axis.AutoDateAxisSet then
Writeln('auto = ', Axis.AutoDateAxis);
end;
finally
Book.Free; // 再保存一次,省略的元素仍是省略态
end;
end;
日历模型在哪里止步?
日历模型止步于无法表达日历单位的格式,HotXLS 也不假装能跨过去。ODF 1.2 提供的 chart:interval-major 是个普通数字,没有日、月、年的概念,所以 Excel 的月度轴存成 ODS 带不走它的单位,HotXLS 也不会发明一个非标准属性去假装有。在老的 BIFF8 一侧,AxcExt 记录取九个固定的 16 位字,它的 automatic 标志只是掩码字段而不是删掉字段;HotXLS 在读取和写回时保留被掩码的最小值、最大值、间隔、单位和交叉值,未知单位代码在 Classic 模型里原样保留,只有有效的日期单位 0、1 和 2(日、月、年)映射到 XLSX
日期轴只是图表模型的一层,这个模型还得在不是它创建的文件里活下来。当一个 Excel 工作簿把日期轴折线与次轴上的柱形序列组合在一起,我们讲 ChartML 保留与组合图表的文章展示了 HotXLS 如何在什么都没变时逐字节重放原始图表 XML、有变化时把类型化编辑合并进去,我们讲图表、图片与绘制的概览则覆盖上面用到的锚定与序列 API。这一切都随面向 Delphi 和 C++Builder 的 HotXLS Delphi Component 发布,它读写并渲染 XLS 和 XLSX 图表,不需要 Excel 自动化