技術文章

Delphi 中的 Excel 日期序號:1900 與 1904 及 numFmt

開啟試算表,按一下顯示 2026-06-19 的儲存格,公式列仍然顯示日期;從 Delphi 讀取同一個儲存格,您會得到數字 46192。這兩種檢視都正確,因為 Excel 從來沒有在那個儲存格裡存過日期。它存的是序號,也就是天數計數,再附上一個數字格式,告訴畫面把這個計數顯示成日曆日期。儲存格值裡沒有日期型別,只有一個數字和一條顯示規則,而顯示規則正是把日期和普通數值區分開來的唯一東西

這種分離就是試算表函式庫必須避開的每一種日期 bug 的根源。單靠序號,您無法知道是哪一天,因為它沒說第零天是哪一天。相同的數字,會因為活頁簿上一個旗標而代表相差四年的兩個日期。而一個應該讀回日期的數字,若沒有人檢查其格式並辨認日期樣式,就只會讀回成純數值。這就是 HotXLS 的日期模型,以及它為什麼必須這樣設計

日期儲存格就是數字加上格式

Excel 將日期存成自某個紀元起算的天數,而一天中的時間則放在小數部分。序號中的中午會帶著 .5。整數部分就是天數計數。儲存值本身沒有任何東西會把它標成時間;標記它的是儲存格的數字格式:ECMA-376 把這叫做 numFmt,而格式碼只要寫成日期或時間樣式,儲存格就會顯示成日期。把格式拿掉,同一個儲存格就只會顯示成數字,底層值從未改變

這就是為什麼讀取儲存格值時,您拿到的是一個可能是 varDate、也可能只是 DoubleVariant,而同一個儲存格上的數字格式正是判斷第三方想表達什麼的訊號。當 HotXLS 開啟 XLSX 檔案時,儲存格會把 ValueNumberFormatIndex 一起帶進 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 慣例為基準,所以把 Excel 序號映射成 TDateTime 的函式庫,在活頁簿被標記為 1904 時,兩個方向都必須偏移 1462 天。讀取 1904 序號時,先減去 1462,再把它當成 TDateTime;把 TDateTime 寫進 1904 活頁簿時,先從序號扣掉 1462,Excel 才會顯示您要的那一天。HotXLS 在序列化某個 Date1904 已設的活頁簿時,會在內部套用這個位移,所以您指定成 TDateTime 的值,在畫面上會往返成同一個日曆日

刻意保留的 1900 閏年怪癖

1900 系統裡有一個著名的小彎折。Excel 把 1900 視為閏年,並接受 1900 年 2 月 29 日為真實日期,序號 60。但 1900 年其實不是閏年,因為世紀年只有能被 400 整除時才是閏年,而 1900 不是。這個不存在的日子,是早期某個帶著這個 bug 的試算表留下來的刻意相容行為,之後一路保留,只為了讓數十年來的檔案都維持相同的序號算術

實際影響很小但確實存在:對於 1900 年 3 月 1 日之後的任何日期,序號都會比嚴格正確的日數計數高一個,因為那個不存在的 2 月 29 日吃掉了一個數字。試算表函式庫會重現這個怪癖,而不是把它修正掉,因為完全對齊 Excel 的算術才是這份工作。若把它修正,現代日期就會全部比 Excel 顯示的結果早一天,這比保留一個四萬天前的 off-by-one 還糟,因為商務使用裡根本不會碰到那個日子。1904 系統沒有對應的不存在日,這也是歷史上少數店家偏好它的原因之一

從 numFmt 偵測日期

當數字來自別人寫出的檔案時,格式就是它是日期的唯一證據。ECMA-376 指定了一組內建格式 ID,其意義由規範固定,而日期和時間格式佔據已知範圍。ID 14 到 22 是一般地區設定的日期與時間格式,也就是大家熟悉的 m/d/yyyyh:mm 及其相關格式。ID 45 到 47 是經過時間格式。另有兩段,27 到 36 和 50 到 58,是 CJK 日曆使用的地區設定專用日期與時間格式,定義於 ECMA-376 18.8.30。只要儲存格的數字格式 ID 落在這些範圍裡,它就是日期或時間儲存格

內建 ID 能涵蓋常見情況,但不能涵蓋自訂格式。當活頁簿定義了自己的格式碼,例如非標準順序或本地化的月份名稱,ID 就會高於內建範圍,並指向活頁簿的數字格式表。這種情況下,要辨識日期就得讀取格式碼字串,尋找日期 token。HotXLS 把這兩種檢查包成一個內部述詞 XlsxNumFmtIsDate,它會先對內建日期範圍立即回傳 true,否則再透過 XlsxFormatCodeIsDate 解析自訂格式碼。對外可見的,就是儲存格的 NumberFormat 字串和 NumberFormatIndex,同時給您解析後的格式碼與要測試的 ID

為什麼格式剖析器不能只掃 d 和 m

剖析格式碼來找日期 token,看起來很簡單,直到您想到數字格式裡還有什麼。只要單純搜尋拼出日期的字母,也就是代表日、月、年、時、秒的 dmyhs,就會在兩種完全不是日期 token 的結構上誤判

第一種是加引號的字串字面值。數字格式可以在雙引號中嵌入字面文字,所以像 #,##0 "MM" 這種財務格式,會把字元 M 和 M 附加到數字上,完全沒有時間意義。若掃描器把引號裡的字母算成月份 token,就會錯把這個貨幣格式標成日期。第二種是中括號區段。數字格式在方括號中可以放指令,例如色彩名稱 [Red]、比較條件 [>1000]、地區標籤,以及經過時間標記 [h][mm]。有些方括號內容含有日期字母,有些沒有,把括號裡的文字當成格式主體來處理,就會同時造成誤判與漏判

正確的剖析器會逐字元走過格式碼,追蹤自己是不是在引號字面值裡,以及目前在幾層方括號巢狀裡,並且還要遵守反斜線跳脫單一後續字元的規則。只有在任何字串字面值之外、也在任何方括號區段之外找到的未跳脫日期字母,才算真正的日期 token。這正是 XlsxFormatCodeIsDate 的掃描方式:引號會切換成抑制 token 偵測的字面值狀態,直到遇到結束引號;反斜線會跳過下一個字元;方括號深度計數器則會在 [...] 區段中關閉偵測。結果就是 #,##0 "MM" 會被正確讀成數字格式,而只有在引號外含有單一 md 的簡短自訂碼,仍會被正確辨識成日期

從第三方檔案讀出日期

上面所有內容最後都匯成同一個流程:把別的應用程式寫出的數字,轉回您能信任的日期。序號給您天數計數,活頁簿的 Date1904 旗標告訴您這個計數是從哪個紀元量起的,而儲存格的數字格式 ID 或自訂格式碼,則是第一時間那個數字被設計成日期的唯一證據。三個條件少一個,您就會得到一個看似合理但其實錯誤的答案,而不是明顯的錯誤

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,把多個值和它們的格式參照一起放進一個結構。以這種方式儲存的日期儲存格,因為被打包就不再是日期,所以同樣的格式 ID 檢查也必須深入多儲存格記錄內部,逐格套用,而 1904 偏移量依然支配它產生的每一個序號。只檢查獨立數字記錄、卻跳過打包記錄的讀取器,會在沒有任何警告的情況下,把日期欄變成整數欄

實務上將序號對應到 TDateTime

一旦格式檢查確認那是日期,而 Date1904 旗標也已知,轉換就只是機械步驟。HotXLS 已經以 varDate 傳回的值,就是您可以直接使用的 TDateTime。若來源在沒有可辨識日期格式的情況下寫入序號,值會以純 Double 進來,這時就把它當作 1900 軸上的天數計數,若是 1904 活頁簿,再先扣掉 1462 天的位移,讓紀元對齊。反過來,把 TDateTime 指派給儲存格時,會存成以 1900 為基準的序號;當活頁簿標記為 1904 時,HotXLS 也會在儲存時套用同樣的 1462 天位移,所以最後存出的檔案會顯示您想要的日期,而不是偏差四年的日期

生成活頁簿時,請刻意設定這個旗標。預設會讓 Date1904 維持 false,這和 Excel for Windows 一樣,而且幾乎總是您要的結果;只有在重現 Mac 來源活頁簿,或下游系統明確要求 1904 軸時,才把它設成 true。避免整整四年錯誤的唯一規則,就是一致:每本活頁簿只選一次紀元,在那個紀元下寫入所有日期,並根據檔案實際攜帶的旗標讀回每一個序號

日期只是儲存格真正內容這個更大故事中的一個欄位。和網格一起帶著走的鄰近中繼資料層,也就是標題、作者與時間戳記,在我們關於活頁簿中繼資料與文件屬性的文章裡有說明,裡面的 CreatedModified 值同樣以 TDateTime 儲存,而且也遵循未設定等於零的慣例。當日期是計算結果而不是儲存值時,我們關於公式引擎與自訂函式的文章裡的評估規則就會決定格式隨後要顯示的序號。兩者都建立在同一套日期模型上,而這套模型隨著 Delphi 和 C++Builder 的 HotXLS 試算表元件 一起提供,能在沒有 Excel 自動化的情況下讀寫 XLS 與 XLSX 日期