技术文章

HotXLS 图表日期轴:日历准确的刻度(Delphi)

一张从 2026 年 1 月 31 日开始的月末余额图,4 月的刻度应该落在 4 月 30 日。用日历月从上一个刻度推进每一个,标签出来是 2026-01-31、2026-02-28、2026-03-28、2026-04-28——后面每个刻度都钉死在 28 号。改成按固定 30 天推进,2 月的刻度落到 3 月 2 日,12 月的刻度落到 12 月 27 日。能同时躲开两个陷阱的规则说起来简单:每个刻度都从轴最小值计算,并把月末收拢到真实日历

在季末结账图上,第一种错误是危险的,因为画面看起来仍然是对的。线条在该涨的地方涨,标签间距均匀,3 月的余额停在季末前三天。发现它的是拿图对账本核对的财务负责人,而到那时图表通常已经贴进董事会材料

为什么累积出来的月刻度会漂移?

月刻度漂移,是因为收拢丢掉了信息。1 月 31 日一旦被收拢成 2 月 28 日,「序列想要的是 31 号」这个事实就没了,从收拢值出发的任何一步都继承这份损失。HotXLS 里,lxChart 单元的 TXLSChartDateAxisTransform.BuildTicks 从不从上一个刻度出发:刻度 i 总是 AddUnits(MinValue, UnitKind, Step * i, ...),于是 3 月是 1 月 31 日之后的两个月,出来就是 3 月 31 日。AddUnits 对日单位把计数直接加到 serial 上;对月和年单位,它先把 serial 转成真实日期,调 IncMonth 或 IncYear,再转回去

这个循环每一边都有界。不管调用方传多大的上限,结果数组都不超过 4096 个条目;Step * i 在相乘发生之前先对照 MaxInt 检查;刻度一旦不再递增或越过轴最大值,生成立刻停止。横跨几百万年的恶意工作簿产出一个短数组然后返回

HotXLS 图表日期轴在 2026 年 1 月 31 日锚点之后放置月刻度的两种方式示意图:从上一个收拢值推进每个刻度会钉死在 28 号,而 BuildTicks 从轴最小值计算 AddUnits(MinValue, xcduMonths, Step 乘 i),刻度落在 2 月 28 日、3 月 31 日和 4 月 30 日
收拢会丢信息,所以 TXLSChartDateAxisTransform 从不从上一个刻度出发,每个位置都从轴最小值重算,月末收拢到真实日历

transform 是公开的,所以任何图表存在之前你就能自己跑这套算术。下面的控制台片段为上面的序列构建一整年的月刻度,并打印图表将要使用的标签

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 继续充当轴坐标

HotXLS 日期轴保持工作簿 serial 作坐标空间,因为 Delphi 的 TDateTime 没有给 serial 60 留槽位。在 1900 日期系统里那个 serial 是 1900 年 2 月 29 日——一个从未存在过的日子,Excel 为兼容更老的电子表格程序把它保留至今。TXLSChartDateAxisTransform.TrySerialToDateTime 按真实日历唯一可行的方式处理它:60 以下的 serial 平移一天,serial 60 折到 2 月 28 日。给单个点打标签这没问题;对几何这是错的——serial 59 和 60 变成同一个 TDateTime,2 月 28 日和 3 月 1 日相距一个单位,而 Excel 画的是相距两个

三个 serial 把问题的两面都摆出来。FormatValue 对幻影槽位特判,打印的和 Excel 打印的完全一致;经 TDateTime 的往返则把它弄丢

var
  S: Integer;
  D: TDateTime;
begin
  for S := 59 to 61 do
  begin
    TXLSChartDateAxisTransform.TrySerialToDateTime(S, False, D);
    Writeln(S, '  axis label: ',
      TXLSChartDateAxisTransform.FormatValue(S, False),
      '  TDateTime: ', FormatDateTime('yyyy-mm-dd', D));
  end;
  // 59  axis label: 1900-02-28  TDateTime: 1900-02-28
  // 60  axis label: 1900-02-29  TDateTime: 1900-02-28
  // 61  axis label: 1900-03-01  TDateTime: 1900-03-01
end;

几乎没人画 1900 年代初的数据,所以实际的教训是关于你自己的代码。要算轴边界或注释位置,就在 serial 里算,在边界上用 DateTimeToSerial 转换——第一段代码正是这么做的。调用方把基于 serial 的轴和 TDateTime 算术混着用,一条日期在 1900 年 3 月 1 日之前的注释就会落在离它的点一个单位的地方。第二个纪元更常碰到:1904 工作簿里 serial 0 是 1904 年 1 月 1 日,Dates1904 参数为 true 时 transform 在两个方向都套用固定的 1462 天偏移。两个纪元以及单元格数字格式如何与之互动,见我们讲 Excel 日期 serial、1904 系统与数字格式的文章

HotXLS 日期轴为什么保持工作簿 serial 而不转成 TDateTime 示意图:serial 60 是幻影 1900 年 2 月 29 日,TrySerialToDateTime 把 60 以下的 serial 平移、把 60 折到 2 月 28 日,两侧的日期因此比 Excel 画的近一个单位,而 FormatValue 把 serial 60 标为 1900-02-29
折叠给单个点打标签没问题,对几何是致命的,因为 1900 年 3 月之前画的东西相对之后的东西整体偏移;1904 工作簿改用固定的 1462 天偏移

HotXLS 怎么自动选日、月还是年?

TXLSChartDateAxisTransform.DetectUnit 选的是数据里的间隔真正需要的最细日历单位。它丢掉 NaN 和无穷值,把其余的排一次序,跳过重复日期。任何相邻间隔短于一个日历月,单位就是日;否则任何间隔短于一个日历年,就是月;再否则是年。月的判据是 IncMonth(Previous, 1) > Current,这个选择恰恰对本文的数据要紧:30 天阈值会把 1 月 31 日到 2 月 28 日的间隔称为「不足一个月」,把一个月度序列切成日。有效日期少于两个时没有间隔可量,方法返回日

ResolveUnits 把检测出的单位与图表声明的合并。显式的 base unit 说了算。缺失的 major 或 minor unit 在检测单位比 base 粗时继承检测单位,否则退回 base。最后一条规则最容易被漏:生效的 major 或 minor unit 比 base 更细时,base 降下来与之匹配。base unit 决定任何刻度存在之前点如何分组,因为 Normalize 把值吸附到当天的日、当月的一号或 1 月 1 日。日级 major unit 下配月级 base,会在刻度代码看到它们之前把一整个月的点折叠进一个槽

HotXLS 如何自动选日期轴单位示意图:DetectUnit 丢掉 NaN 和无穷值、排序一次、用 IncMonth 量日历间隔而不是 30 天阈值,1 月 31 日、2 月 28 日、3 月 31 日的序列因此被识别为月度,ResolveUnits 尊重显式 base 并在 major unit 更细时把 base 降下来
Normalize 在任何刻度存在之前把每个值吸附到当天的日、当月一号或 1 月 1 日,因为日级 major unit 下配月级 base 会把一个月的点塌缩进一个槽

自动主刻度出多少个,不只取决于日期范围。绘图长度、轴字体的实测宽度、标签数字格式、旋转标签的投影宽度都在封顶计数,所以配长 mmmm yyyy 标签的窄图,刻度比配 mm/yy 的宽图少。标签、刻度线和网格线全部从同一根刻度数组绘制——HTML、SVG 和 PDF 输出因此保持一致——落在主刻度上的次刻度只画一次

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。赋值置标志,标志设为 False 让元素回到省略态,XLSX 写入器只在标志置位时写出元素。auto 容易误读:它管的是类别与日期之间的自动判别,与自动刻度单位毫无关系

uses
  SysUtils, DateUtils, lxHandleX;

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));
      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;             // 度
    // AutoDateAxis 不动:不写出 c:auto 元素

    Book.SaveAs('month-end-balance.xlsx');
  finally
    Book.Free;
  end;
end;

这里的 base unit 特意选日。月末日期落在不同的日号上,月级 base 会把每个点吸附到当月一号,把 3 月 31 日的余额挪到 3 月 1 日。确切的日号有意义时,把 base 钉在日;月度间隔交给 major unit 去做

标签与方向遵循同样的在场规则。轴的 numFmt 元素 sourceLinked 默认为 true,所以 TXLSXChartAxis 把格式代码、source-linked 值和 NumberFormatSet 分开保存,空代码永远不会被误当成缺席的元素。ChartML 以一度的六万分之一存文字旋转、正值顺时针;TextStyle.Rotation 收普通度数,设为 -45 的标签在 SVG 和 PDF 输出里朝同一边斜。反转的(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[1].Charts[0].CategoryAxis;  // Sheets[] 从 1 起算,Charts[] 从 0 起算
    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;

ODS 与老式 XLS 文件里的日历单位

日历模型能伸到每种文件格式表达得了的地方。ODF 1.2 用 chart:interval-major 描述轴间隔——一个没有任何日月年概念的普通数字——所以 Excel 的月度轴存成 ODS 带不走它的单位。HotXLS 宁可把单位留在 ODS 文件之外,也不发明一个反正会被其他 ODF 应用无视的非标准属性。月度间隔对打开文件的人要紧的话,交付 XLSX

BIFF8 工作簿把日期轴设置放在 AxcExt 记录里——九个固定的 16 位字。它的 automatic 标志只掩码字段而不删除字段,标着「automatic minimum」的记录仍带着最小值。HotXLS 读取时保留被掩码的最小值、最大值、间隔、单位和交叉值并原样写回,未知单位代码在 XLS 对象模型里原样保留,图表转 XLSX 时只映射有效的日期单位 0、1、2(日、月、年)

日期轴很少独行。Excel 工作簿把日期轴折线与次轴上的柱形序列配对时,我们讲 ChartML 保留与组合图表的文章解释了 HotXLS 如何在什么都没变时重放原始图表 XML、有变化时把类型化编辑合并进去;我们讲图表、图片与绘制的概览覆盖上面用到的锚定与序列调用。HotXLS Delphi Component 页面列出 Delphi 和 C++Builder 的完整图表功能集,包括无需安装 Excel 的 XLS 与 XLSX 图表读取、写出与渲染