一份從 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 對日單位把數量直接加到序號上;對月與年單位,把序號轉成真實日期、呼叫 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 天」估算步距,再把原始步距進位到 10 的冪次的 1、2 或 5 倍。這個估算只決定刻度之間隔幾個單位。位置仍然出自 IncMonth 與 IncYear。粗略的月長可以讓軸密一點或疏一點,但每個刻度仍落在那兩個函式產生的日期上
活頁簿序號保持為軸座標
HotXLS 的日期軸以活頁簿序號為座標空間,因為 Delphi 的 TDateTime 沒有序號 60 的位置。在 1900 日期系統裡,那個序號是 1900 年 2 月 29 日——一個從未存在、Excel 為相容更早的試算表程式而保留的日子。TXLSChartDateAxisTransform.TrySerialToDateTime 以真實日曆唯一能做的方式處理它:60 以下的序號位移一天,序號 60 摺疊到 2 月 28 日。標一個點的標籤,這樣就夠了。對幾何卻是錯的:序號 59 與 60 變成同一個 TDateTime,2 月 28 日與 3 月 1 日只差一個單位,而 Excel 畫的是差兩個
三個序號同時展示問題的兩面。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 年初的資料,所以實務教訓是關於您自己的程式碼。要計算軸邊界或註解位置,就用序號算、在邊界上用 DateTimeToSerial 轉換——第一段程式碼就是這麼做的。在呼叫端把序號軸與 TDateTime 算術混用,一條標在 1900 年 3 月 1 日之前的註解,會落在離它的點一天遠的地方。第二個紀元更常出現:1904 活頁簿的序號 0 是 1904 年 1 月 1 日,Dates1904 參數為真時,轉換在兩個方向都套用固定的 1462 天位移。兩個紀元,以及儲存格數字格式與它們的互動,在我們談 Excel 日期序號、1904 系統與數字格式的文章中涵蓋
HotXLS 怎麼自動選擇日、月或年?
TXLSChartDateAxisTransform.DetectUnit 挑的是資料間距實際需要的最細日曆單位。它丟掉 NaN 與無限值,把其餘排序一次,跳過重複日期。任何相鄰間距短於一個日曆月,單位就是日;否則任何間距短於一個日曆年,單位就是月;否則是年。月份測試是 IncMonth(Previous, 1) > Current,而這個選擇對本文的資料正好關鍵:30 天門檻會把 1 月 31 日到 2 月 28 日的間距判成「不足一個月」,把月頻數列切成日。有效日期少於兩個時無間距可量,方法回傳日
ResolveUnits 把偵測單位與圖表宣告的合併。明確的基準單位勝出。缺失的主要或次要單位,在偵測單位比基準粗時繼承偵測值,否則退回基準。最後一條規則最容易被漏掉:有效的主要或次要單位比基準細時,基準調降以對齊。基準單位決定在任何刻度存在之前點怎麼分組,因為 Normalize 把值吸附到當日、當月一號或 1 月 1 日。月基準配日主要單位,會在刻度程式碼看到資料之前就把一整個月的點摺進一個槽位
自動主要刻度出現幾個,取決的不只是日期範圍。繪圖長度、座標軸字型的實測寬度、標籤數字格式、旋轉標籤的投影寬度都封頂計數,所以帶長 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;
這裡的基準單位刻意設成日。月底日期落在不同的日號上,月基準會把每個點吸附到當月一號,把 3 月 31 日的餘額搬到 3 月 1 日。確切日號有意義時,把基準釘在日,讓主要單位負責按月間隔
標籤與方向遵循同一套存在規則。軸的 numFmt 元素預設 sourceLinked 為 true,所以 TXLSXChartAxis 把格式代碼、source-linked 值與 NumberFormatSet 分開保存,空代碼永遠不會被誤當成缺席元素。ChartML 以 1/60000 度儲存文字旋轉、正值順時針;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 描述軸間距——一個純數字,沒有日、月、年的概念,所以存成 ODS 的 Excel 月頻軸帶不走它的單位。HotXLS 不發明反正會被其他 ODF 應用程式忽略的非標準屬性,而是不把單位寫進 ODS 檔。月間距對開檔的人要緊的話,出貨 XLSX
BIFF8 活頁簿把日期軸設定放在 AxcExt 記錄裡,九個固定的 16 位元字組。它的自動旗標遮罩欄位而不移除它們,所以標成「自動最小值」的記錄仍然帶著一個最小值。HotXLS 在讀取時保留被遮罩的最小值、最大值、間距、單位與交叉值並原樣寫回,在 XLS 物件模型裡保持未知的單位代碼,只在圖表轉成 XLSX 時映射有效的日期單位 0、1、2(日、月、年)
日期軸很少單獨旅行。當 Excel 活頁簿把日期軸折線與次要軸上的柱形數列湊成一對,我們談保留 ChartML 與組合圖的文章說明 HotXLS 在什麼都沒變時如何重播原始圖表 XML、有變時如何把型別化編輯合併進去;我們的圖表、圖片與繪圖總覽涵蓋上文用到的錨定與數列呼叫。HotXLS Delphi Component 頁面列出 Delphi 與 C++Builder 的完整圖表功能集,包括不必安裝 Excel 就能讀寫與渲染 XLS 與 XLSX 圖表