技术文章

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

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 做检查,一旦刻度不再递增或越过轴最大值,生成立即停止

HotXLS 图表日期轴在 2026 年 1 月 31 日锚点之后放置月刻度的两种方式示意图:从上一个收拢值推进每个刻度会钉死在 28 号,而 BuildTicks 从轴最小值计算 AddUnits(MinValue, xcduMonths, Step 乘 i),刻度落在 2 月 28 日、3 月 31 日和 4 月 30 日
收拢会丢信息,所以 TXLSChartDateAxisTransform 从不从上一个刻度出发,每个位置都从轴最小值重算,月末收拢到真实日历
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 日期轴为什么保持工作簿 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 和无穷值,剩下的排一次序(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,会在刻度代码看到它们之前就把一个月的点塌缩进一个槽。刻度密度同样有界,受绘图长度、轴字体的实测宽度、标签数字格式和旋转标签的投影约束,每个后端都从同一个共享的、有界的刻度数组画标签、刻度线和网格线,与主刻度重合的次刻度只画一次

HotXLS 如何自动选日期轴单位示意图:DetectUnit 丢掉 NaN 和无穷值、排序一次、用 IncMonth 量日历间隔而不是 30 天阈值,1 月 31 日、2 月 28 日、3 月 31 日的序列因此被识别为月度,ResolveUnits 尊重显式 base 并在 major unit 更细时把 base 降下来
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 自动化