HotXLS 把 Excel 文件屬性的時間戳以 UTC 存進檔案,API 則以本地時間暴露它們:.xls 用 TXLSWorkbook.CreatedDate 與 LastSavedDate,.xlsx 用 TXLSXWorkbook.Created 與 Modified。從 v2.384.48 起,兩個引擎寫入時把本地時間轉成 UTC、讀取時轉回來,用的都是時間戳自己那天生效的日光節約規則。走到這一步花了兩次修正,而且兩個 bug 都因為同一個難堪的原因活了下來:每次自動化 round trip 都通過,只有 Excel 的 File > Info 面板顯示錯的日期或錯的時刻。如果您讀過我們的在 Delphi 中設定 Excel 文件屬性總覽,本文就是日期不再只是單純數值的那一部分
為什麼存檔再重開的測試藏住了一天之差?
自我 round trip 會藏住錯誤,是因為寫入端和讀取端共用同一個錯的常數,錯誤自己抵消了自己。OLE 屬性集的日期是 FILETIME,從 1601-01-01 UTC 起算的 64 位元 100 奈秒刻度計數([MS-DTYP] §2.3.3);Delphi 的 TDateTime 則從 1899-12-30 起算天數,跟Delphi 的 Excel 日期序號與 1900 對 1904 系統裡談的是同一個序號起點。兩個紀元相差 109205 天,這個數字不用查曆就能驗證:25569(以 TDateTime 表示的 Unix 紀元)加上 109205 得 134774,正是以 FILETIME 天數計的 Unix 紀元。v2.384.17 之前的 HotXLS 用的是 109206,所以每個建立與儲存時間戳寫入時晚一天、讀回時早一天。測試套件看到的是它自己指派的值;Excel 看到的是明天
const
// 從 FILETIME 紀元(1601-01-01)到 TDateTime 紀元(1899-12-30)的天數
// 驗算:25569 + 109205 = 134774,以 FILETIME 天數計的 Unix 紀元
FileTimeDayBias = 109205;
function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
// 先取整到毫秒,再換算成 100 奈秒刻度。
// 把 Double 直接乘成刻度,04:00 會變成 03:59:59.9999
Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
示意裡那條捨入註解,是同一段程式碼帶來的第二個、小一點的教訓。把帶小數的 TDateTime 直接乘上每天 864,000,000,000 個刻度,會讓二進位浮點誤差滲進最低位,正好 04:00 的時間戳回來變成 03:59:59.9999。HotXLS v2.384.48 先取整到整毫秒再換算,整點值得以完整撐過這趟旅程。同一版還加上了這個示意刻意省略的時區步驟,因為這裡的輸入已經是 UTC
哪些 SummaryInformation 屬性 ID 放著日期?
在 [MS-OLEPS] 定義的 \005SummaryInformation 屬性集裡,建立時間在屬性 ID $0C(PIDSI_CREATE_DTM),最後儲存時間在 $0D(PIDSI_LASTSAVE_DTM),總編輯時間在 $0A(PIDSI_EDITTIME)。更舊的 HotXLS 把最後儲存時間戳寫到 $0E,也就是 PIDSI_PAGECOUNT,於是 Excel 沒有儲存日期可顯示,倒是有一個裝著時間戳的頁數屬性。從 v2.384.17 起,讀取端也認得這種舊版布局:$0D 不存在而 $0E 帶著 VT_FILETIME 時,該值就當作最後儲存時間。現在每個 PROPVARIANT 讀取也都用 PropVariantClear 釋放,因為格式錯亂的檔案可以在任何一個 ID 底下塞一個字串。想親眼看看這些串流,在 Delphi 中不靠 COM IStorage 讀取 OLE2 複合檔案的實作展示了怎麼摸到它們
PIDSI_EDITTIME 是陷阱裡的陷阱。這個屬性型別是 VT_FILETIME,裝的卻是一段時長——已流逝的 100 奈秒刻度原始數量,不加任何紀元。舊寫入端把它當日期處理,把 EditTimeMinutes 除以 1440 再送進紀元轉換,125 分鐘的編輯進了檔案就變成約 299 年。現在的讀取端靠大小認出這種編碼:真實的編輯過程不會橫跨三個世紀,所以 109206 天以上的值,會在填入 EditTimeMinutes 之前先扣掉舊版偏移
var
Book: TXLSWorkbook;
begin
Book := TXLSWorkbook.Create;
try
Book.Title := 'Q3 settlement';
// API 值是本地時間;檔案裡存的是 UTC FILETIME
Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
Book.LastSavedDate := Now; // HotXLS 寫入您指派的值,不會自動蓋 Now 時間戳
Book.RevisionNumber := 7;
Book.EditTimeMinutes := 125; // 一段時長,以原始刻度儲存
if Book.SaveAs('settlement.xls') <> 1 then
raise Exception.Create('Save failed');
finally
Book.Free;
end;
end;
為什麼 XLSX 的日期剛好差了一個時區偏移?
XLSX 的日期差了時區偏移,是因為 docProps/core.xml 裡的 dcterms:created 與 dcterms:modified 是帶 Z 標記的 W3CDTF 值,在 ECMA-376 Part 2 的核心屬性模型下那代表 UTC,而 HotXLS 過去把本地時間打上那個 Z 就送出去。在 UTC+8 的機器上 09:30 建立的活頁簿,帶著 09:30:00Z,同一台機器上的 Excel 把它換成 17:30。classic 引擎的 FILETIME 值有一模一樣的毛病,透過 TXLSXWorkbook.CustomProperties.AddDate(寫成 vt:filetime)加入的自訂日期屬性也一起中鏢。從 v2.384.48 起,三條路徑都先轉換再寫入,時間戳帶 Z 時讀取也轉回來;從 v2.384.59 起,讀取端還認得小數秒與明確的 +hh:mm / -hh:mm 偏移
轉換本身就是天真修法出錯的地方。LocalFileTimeToFileTime 套用的是此刻生效的偏移,於是一月的時間戳拿到七月來轉,在有日光節約的時區就會差一小時。HotXLS 改呼叫 TzSpecificLocalTimeToSystemTime 與 SystemTimeToTzSpecificLocalTime,由被轉換的日期自己決定用標準時間還是日光節約時間;未設定的零值則原樣通過,不會變成一個被挪了幾小時的 1899 年日期
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
if Book.Open('report-template.xlsx') <> 1 then
raise Exception.Create('Template not available');
Book.Created := EncodeDate(2026, 7, 1) + EncodeTime(9, 30, 0, 0);
Book.Modified := Now;
Book.CustomProperties.AddDate('ApprovedOn',
EncodeDate(2026, 1, 20) + EncodeTime(17, 0, 0, 0));
Book.SaveAs('report.xlsx');
// 在設為中歐時間的機器上,core.xml 現在是
// <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
// (七月是 UTC+2),而 ApprovedOn 寫成 16:00Z(一月是 UTC+1)
finally
Book.Free;
end;
end;
讀取時間戳時,HotXLS 什麼情況不轉換?
HotXLS 的 W3CDTF 讀取器從 v2.384.59 起轉換設定檔允許的每種帶時區形式,唯一仍原樣放過的是不帶時區的時間。在那個版本之前,解析器只取前 19 個字元,而且只有第 20 個字元是 Z 才從 UTC 轉換,所以帶小數秒(01:30:00.5Z)或明確偏移(+08:00)的時間戳被當成本地時間讀入、不做任何調整,正好差一個時區偏移。從 HotXLS 2.384.59 起,Created、Modified 與日期型自訂屬性能解析任意長度的小數秒、Z 與 +hh:mm / -hh:mm 偏移,把時刻轉成 UTC 再轉成本地時間;2026-07-01 這種只到日期的時間戳,就照那個日期讀。有時間但沒有時區標記的時間戳——W3CDTF 設定檔不允許、ECMA-376 Part 2 也不給規則——仍然照本地時間原樣讀入,完全解析不開的時間戳則回傳零。經過 Excel 的活頁簿沒問題;別家產生器產出、丟掉時區的套件,值得抽查
更舊的 HotXLS 建置寫出的檔案是另一條誠實的邊界。v2.384.48 之前寫下的 XLSX 時間戳是戴著 Z 的本地時間,檔案裡沒有任何東西能把它與正確值區分開,所以現在的讀取端會按時區偏移挪它。那些建置寫的傳統 FILETIME 時間戳同樣吃這個挪移,而 v2.384.17 之前寫下的建立日期還會多晚一天讀回,因為舊常數多出來的那一天也無從偵測;只有編輯時長的編碼與 $0E 的位置留下了可辨識的指紋。也請記住:API 值是執行讀取那台機器的本地時間,所以跑在 UTC 的服務和在東京的桌面,對同一份檔案回報的 CreatedDate 會不同,而且兩個都對
文件時間戳該怎麼測?
測文件時間戳,要比對的不是您自己的程式碼寫出來的東西。這兩個 bug 都通過了存檔再重開的檢查,因為對稱的錯誤在對稱的測試裡是隱形的。拿 Excel 存的活頁簿來比對,或在存檔後斷言原始位元組與 XML 文字,並把測試套件跑在時區不是 UTC 的機器上,測試日期橫跨日光節約切換的兩側。跑在 UTC 的建置代理程式,會愉快地放行那個又舊又壞的版本
文件時間戳不起眼,但記錄系統、搜尋索引與稽核軌跡排序靠的就是它,而差一天或差八小時的日期比缺漏更糟,因為沒有人會起疑。HotXLS Delphi 試算表元件替 .xls 與 .xlsx 處理紀元換算、屬性 ID 與 UTC 轉換,您的程式碼只要指派普通的本地 TDateTime 值,檔案格式就交給函式庫