開啟試算表,按一下顯示 2026-06-19 的儲存格,資料編輯列仍然顯示日期;從 Delphi 讀取相同的儲存格,您會得到數字 46192;兩種檢視都是正確的,因為 Excel 從未在該儲存格中儲存日期;它儲存了一個序號(日期的計數),並附加了一個數字格式,告訴螢幕將計數轉譯為日曆日期;儲存格值中沒有日期型態;只有一個數字和一個顯示規則,而顯示規則是區分日期與普通數量的唯一因素
這種分離是試算表函式庫必須規避的每個日期錯誤的根源;單憑序號並不能說明是哪一天,因為它沒有說明第零天是哪一天;相同的數字根據單個活頁簿旗標代表相隔四年的兩個日期;且除非有東西檢查其格式並識別日期模式,否則應該讀回為日期的數字將讀回為純數量;這就是 HotXLS 中建置日期模型的方式,以及為什麼它必須如此建置的原因
日期儲存格是數字加上格式
Excel 將日期儲存為自紀元以來的日數,並在小數部分中表示一天中的時間;序號中的中午攜帶 .5;整數部分是日數計數;儲存的值中沒有任何內容將其標記為時間;標記它的是儲存格的數字格式:ECMA-376 將此稱為 numFmt,且格式代碼說明日期或時間模式的儲存格會顯示為日期;剝除格式,相同的儲存格會顯示數字,底層值從未改變
這就是為什麼讀取儲存格值會給您一個可能是 varDate 或可能是純 Double 的 Variant,以及為什麼相同儲存格上的數字格式是決定第三方意圖的訊號;當 HotXLS 開啟 XLSX 檔案時,儲存格會將其 Value 和 NumberFormatIndex 兩者都攜帶到 TXLSXCell 中,而格式索引是您諮詢以了解數字是否為日期的依據
var
Book: TXLSXWorkbook;
Cell: TXLSXCell;
begin
Book := TXLSXWorkbook.Create;
try
if Book.Open('timesheet.xlsx') <> 1 then
raise Exception.Create('Cannot open workbook');
Cell := Book.Sheets[0].Cells[1, 1]; // row 1, col 1 (1-based)
// Value may arrive as varDate or as a plain numeric serial;
// the format index is the signal that tells them apart.
Writeln('raw value : ', VarToStr(Cell.Value));
Writeln('numFmt idx: ', Cell.NumberFormatIndex);
Writeln('format : ', Cell.NumberFormat);
finally
Book.Free;
end;
end;
相隔 1462 天的兩個紀元
預設的日期系統(每個 Windows 活頁簿使用的系統)從 1899 年的最末尾開始計算,因此序號 1 落在 1900 年的第一天;另一個系統追溯到早期的 Macintosh,並從 1904 年的開始進行計算,因此其序號 1 是四年零一天之後;活頁簿在一個旗標中記錄它使用哪個系統;在 OOXML 套件中,該旗標是活頁簿組件上的 date1904;HotXLS 將其呈現為活頁簿的 Date1904 屬性
兩個紀元之間的差距正好是 1462 天;那是四個日曆年(三個 365 天,一個 366 天,共計 1461 天),加上兩個第零天慣例之間的一天多位移量;該數字是固定的,您可以記在腦海中;它的重要性在於它不是零;從 1904 活頁簿複製出來並在 1900 規則下解釋的序號(反之亦然),會使每個日期偏移 1462 天,這呈現為偏差四年多的日期,且很容易被誤認為損壞的資料
因為 Delphi 自身的 TDateTime 錨定在 1900 慣例上,當活頁簿被標記為 1904 時,將 Excel 序號對應到 TDateTime 的函式庫必須在兩個方向上都偏移 1462;讀取 1904 序號,在將其視為 TDateTime 之前減去 1462;將 TDateTime 寫入 1904 活頁簿,從序號中減去 1462,以便 Excel 轉譯您意圖的日期;當 HotXLS 為設定了 Date1904 的活頁簿序列化日期值時,會在內部套用此偏移,因此您分配為 TDateTime 的值在螢幕上會來回轉換為相同的日曆天
刻意設計的 1900 閏年奇特之處
1900 系統中有一個著名的皺褶;Excel 將 1900 年視為閏年,並接受 1900 年 2 月 29 日為真實日期(序號 60);1900 年並不是閏年,因為世紀年只有在能被 400 整除時才是閏年,而 1900 年不能;這個幻影天是繼承自帶有該錯誤之早期試算表的刻意相容性行為,自此保留下來,以便序號算術在數十年的檔案中保持一致
實際的後果很小但確實存在:對於 1900 年 3 月 1 日或之後的任何日期,序號都比嚴格正確的日數計算高出一個,因為不存在的 2 月 29 日消耗了一個數字;試算表函式庫會重現該奇特之處而不是修正它,因為完全匹配 Excel 的算術是全部的工作;修正它會使每個現代日期與 Excel 顯示的日期偏差一天,這比攜帶一個商業用途中沒有真實日期會碰到的四萬天前的偏差一(Off-by-one)結果更糟;1904 系統沒有對等的幻影天,這是歷史上少數商家偏好它的原因之一
偵測日期從 numFmt
當數字來自其他人寫入的檔案時,其格式是它是日期的唯一證據;ECMA-376 分配了一組內建的格式識別碼,其含義由規範固定,且日期和時間格式佔用已知的範圍;識別碼 14 到 22 是通用區域設定的日期和時間格式,即熟悉的 m/d/yyyy、h:mm 及其相關格式;識別碼 45 到 47 是經過時間格式;另外兩個頻段(27 到 36 以及 50 到 58)是用於 CJK 日曆的區域設定特定日期和時間格式(定義於 ECMA-376 18.8.30);數字格式識別碼落在這些範圍內的儲存格即為日期或時間儲存格
內建識別碼涵蓋了常見情況,但不涵蓋自訂情況;當活頁簿定義其自身的格式代碼(例如非標準順序或本地化的月份名稱)時,識別碼會高於內建範圍,並指向活頁簿的數字格式表;對於這些情況,識別日期意味著讀取格式代碼字串並尋找日期 Token(記號);HotXLS 將這兩種檢查折疊到一個內部述詞 XlsxNumFmtIsDate 中,該述詞針對內建日期範圍立即傳回 true,否則透過 XlsxFormatCodeIsDate 解析自訂格式代碼;其公用側是儲存格的 NumberFormat 字串及其 NumberFormatIndex,為您提供解析後的格式代碼以及要測試的識別碼
為什麼格式解析器不能僅掃描 d 和 m
解析格式代碼以尋找日期 Token 看起來很簡單,直到您記住數字格式中還存在什麼;對拼寫日期的字母(代表日、月、年、時、秒的 d、m、y、h、s)進行簡單搜尋,會對完全不是日期 Token 的兩個結構產生誤判
第一種是引用的字串字面量;數字格式可以在雙引號中嵌入字面量文字,因此像 #,##0 "MM" 這樣的財務格式會在沒有任何時間含義的情況下,將字元 M 和 M 附加到數字上;將引號內的字母計為月份 Token 的掃描器會錯誤地將該貨幣格式標記為日期;第二種是中括號區段;數字格式在中括號內攜帶指令,例如色彩名稱 [Red]、比較條件 [>1000]、區域設定標籤,以及經過時間標記 [h] 和 [mm];某些中括號內容包含日期字母,而某些則不包含,將括號內文字與格式主體同等對待會導致誤判與遺漏
正確的解析器會逐字元遍歷格式代碼,追蹤它是否位於引用的字面量內部,以及它在中括號巢狀結構中的深度,並且它還遵守引用單個後續字元的反斜線逸出字元;只有在任何字串字面量之外且在中括號區段之外找到的未逸出日期字母,才算作真正的日期 Token;這正是 XlsxFormatCodeIsDate 掃描的方式:引號會翻轉隱藏 Token 偵測的字面量內部狀態,直到出現結束引號為止,反斜線會跳過下一個字元,且中括號深度計數器會抑制 [...] 執行期間的偵測;回報是 #,##0 "MM" 被正確讀取為數字格式,而僅在引號外包含單個 m 或 d 的簡短自訂代碼仍被正確識別為日期
讀取第三方檔案中的日期
上述所有內容都收斂到一個工作流程中:將其他應用程式寫入的數字轉換回您可以信任的日期;序號給您日數計數,活頁簿的 Date1904 旗標告訴您該計數是從哪個紀元開始測量的,而儲存格的數字格式識別碼或自訂代碼是最初該數字旨在作為日期的唯一證據;丟棄這三者中的任何一個,您都會得到一個看似合理但錯誤的答案,而不是明顯的錯誤
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
Cell: TXLSXCell;
r: Integer;
begin
Book := TXLSXWorkbook.Create;
try
if Book.Open('vendor-export.xlsx') <> 1 then
raise Exception.Create('Cannot open export');
// The 1904 flag is workbook-wide: read it once, apply it to
// every serial the workbook hands back.
if Book.Date1904 then
Writeln('workbook uses the 1904 date system')
else
Writeln('workbook uses the 1900 date system');
Sheet := Book.Sheets[0];
for r := 1 to 10 do
begin
Cell := Sheet.Cells[r, 1];
// A date is only a date when its format says so; the same numeric
// value with a plain format is just a quantity.
Writeln(Format('row %d value=%s numFmt=%d code="%s"',
[r, VarToStr(Cell.Value), Cell.NumberFormatIndex, Cell.NumberFormat]));
end;
finally
Book.Free;
end;
end;
舊版 BIFF 端有一個額外值得提及的陷阱;在較舊的 .xls 串流中,相鄰數值儲存格的連續執行可以打包到單個多儲存格記錄 MULRK 中,該記錄在一個結構中儲存多個值及其格式參照;以這種方式儲存的日期儲存格並不會因為被打包而不再是日期,因此相同的格式識別碼測試必須深入多儲存格記錄內部並套用到每個儲存格,且 1904 偏移量仍引導它產生的每個序號;僅檢查獨立數字記錄並跳過打包記錄的讀取器,會靜默地將日期欄位轉換為整數欄位
在實務中將序號對應到 TDateTime
一旦格式檢查確認了日期且 Date1904 旗標已知,轉換就是機械性的;HotXLS 已作為 varDate 傳回的值是您可以直接使用的 TDateTime;當來源在沒有被識別的日期格式下寫入序號時,會以純 Double 到達,轉換方式是將其讀取為 1900 軸上的天數計數,對於 1904 活頁簿,先減去 1462 天的偏移量以便紀元對齊;往另一個方向,將 TDateTime 分配給儲存格會儲存基於 1900 的序號,當活頁簿被標記為 1904 時,HotXLS 在儲存時套用相同的 1462 天位移,因此儲存的檔案顯示您意圖的日期,而不是偏差四年的日期
產生活頁簿時,請刻意設定該旗標;預設情況下 Date1904 為 false,這與 Excel for Windows 一致且幾乎總是您想要的;只有當您重現 Mac 來源的活頁簿或下游系統特別預期 1904 軸時,才將其設定為 true;防止整類四年誤差的唯一規則是一致性:每個活頁簿選擇一次紀元、在其下寫入每個日期,並在檔案實際攜帶的旗標下讀回每個序號
日期是關於儲存格真正保存什麼的更廣泛故事中的一個部分;相鄰的指示資料層(與網格一起使用的標題、作者和時間戳記)在我們關於活頁簿指示資料和檔案屬性的文章中有所介紹,其中相同的 Created and Modified 值儲存為 TDateTime(帶有相同的未設定等於零慣例);當日期是計算結果而不是儲存值時,我們關於公式引擎和自訂函數的文章中的評估規則會決定格式隨後轉譯的序號;兩者都在與隨 Delphi 和 C++Builder 的 HotXLS 試算表元件一起出貨的相同日期模型上運作,該元件在沒有 Excel 自動化的情況下讀取 and 寫入 XLS 和 XLSX 日期