Technical Article

DelphiにおけるExcelの日付シリアル値:1900年 vs 1904年とnumFmt

スプレッドシートを開き、2026-06-19と表示されているセルをクリックすると、数式バーは日付のままで読み取られます。しかし、同じセルをDelphiから読み取ると、46192という数値が取得されます。どちらの見え方も正しいです。なぜなら、Excelはそのセルに日付を格納したことはないからです。Excelはシリアル値、すなわち経過日数を格納し、その日数をカレンダー上の日付として画面にレンダリングする指示となる数値書式を添付しました。セルの値自体に日付型はありません。数値と表示ルールが存在し、表示ルールこそが、日付と単なる数値を区別する唯一のものです。

この分離こそが、スプレッドシートライブラリが回避しなければならないすべての日付バグの根本原因です。シリアル値だけでは、どの日を指しているかを判断できません。なぜなら、基準日(基準となる0日目)が何日であったかが分からないからです。同じ数値であっても、1つのワークブックフラグによって4年ずれた2つの日付を意味します。And、日付として読み取られるべき数値は、そのフォーマットを検査して日付パターンを認識しない限り、単なる数値として読み取られてしまいます。これが、HotXLSの日付モデルがどのように構築されているか、およびなぜそうあるべきかの理由です。

日付セルは数値とフォーマットの組み合わせ

Excelは日付を、基準日からの経過日数として格納し、時刻は小数部分に表されます。例えば正午のシリアル値は末尾が.5になります。整数部分は経過日数です。格納された値自体には、それが時間的なデータであることを示すマークはありません。それを示すのはセルの数値フォーマットです。ECMA-376はこれをnumFmtと呼び、フォーマットコードが日付または時刻のパターンを示すセルが日付として表示されます。フォーマットを剥ぎ取ると、同じセルには単なる数値が表示されます。背後にある値自体は決して変更されていません。

これが、セルの値を読み取ると、varDateまたは単純なDoubleのいずれかであるVariantが返される理由であり、同じセルの数値フォーマットこそが、サードパーティがどちらを意図したかを決定するシグナルとなる理由です。When HotXLS opens an XLSX file, a cell carries both its Value and its NumberFormatIndex into TXLSXCell, and the format index is what you consult to learn whether the number is a date.

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日ずれた2つの基準日(エポック)

Windowsのワークブックが使用するデフォルトの日付システムは、1899年の最後の日からカウントするため、シリアル値1は1900年の初日になります。もう一方のシステムは初期のMacintoshに由来し、1904年の開始からカウントするため、シリアル値1は4年と1日後になります。ワークブックは、どちらのシステムを使用しているかを1つのフラグに記録します。In an OOXML package that flag is date1904 on the workbook part; HotXLS surfaces it as the Date1904 property of the workbook.

2つの基準日(エポック)の間隔は正確に1462日です。これは4つの暦年(365日の年が3回、366日のうるう年が1回で、計1461日)に、2つの基準日規約間の約1日のオフセットを加えたものです。この数値は固定されており、暗記しておくことができます。重要なのは、これがゼロではないということです。A serial copied out of a 1904 workbook and interpreted under 1900 rules, or the reverse, lands every date 1462 days off, which presents as dates that are wrong by just over four years and is easy to mistake for corrupt data.

Delphi独自のTDateTimeは1900年の規約に固定されているため、Excelのシリアル値をTDateTimeにマッピングするライブラリは、ワークブックが1904に設定されている場合、常に双方向で1462日のオフセット処理を行う必要があります。1904年のシリアル値を読み取る際は、TDateTimeとして扱う前に1462を減算します。1904年のワークブックにTDateTimeを書き出す際は、Excelが意図した日をレンダリングするようにシリアル値から1462を減算します。HotXLSは、Date1904が設定されているワークブック向けに日付値をシリアル化する際、内部でこのシフト処理を適用するため、TDateTimeとして割り当てた値が、画面上でも同じカレンダー日付にラウンドトリップします。

意図的な1900年のうるう年の不具合

1900年システムには有名なバグが存在します。Excelは1900年をうるう年として扱い、1900年2月29日を実際の存在日付(シリアル値60)として受け入れます。実際には1900年はうるう年ではありません。なぜなら、世紀の年は400で割り切れる場合のみうるう年になり、1900年は割り切れないからです。この幻の日は、初期のスプレッドシートプログラムがそのバグを抱えて出荷されたことに起因する、意図的な互換性のための動作であり、数十年にわたるファイル間でシリアル値の計算を同一に保つために今日まで維持されています。

実質的な影響は小さいですが確かに存在します。1900年3月1日以降の任意の日付について、シリアル値は厳密に正しい日数カウントよりも1つ大きくなります。実在しない2月29日が数値を消費してしまったためです。A spreadsheet library reproduces the quirk rather than fixing it, because matching Excel's arithmetic exactly is the entire job. Correcting it would put every modern date one day off from what Excel shows, which is a worse outcome than carrying a forty-thousand-day-old off-by-one that no real date in business use ever touches. The 1904 system has no equivalent phantom day, which is one reason a few shops historically preferred it.

numFmtから日付を検出する

他人が作成したファイルから数値が渡されたとき、そのフォーマットこそが、それが日付データであることを示す唯一の証拠になります。ECMA-376は、仕様によって意味が固定された一連の組み込みフォーマットIDを割り当てており、日付および時刻フォーマットは既知の範囲を占有しています。ID 14から22は、一般的なロケールの日付および時刻フォーマット(おなじみのm/d/yyyyh:mmなど)です。ID 45から47は経過時間フォーマットです。さらに2つの範囲である27から36および50から58は、ECMA-376 18.8.30で定義されている、CJK(日中韓)カレンダーで使用されるロケール固有の日付および時刻フォーマットです。数値フォーマットIDがこれらの範囲のいずれかに該当するセルは、日付または時刻のセルです。

組み込みIDは一般的なケースをカバーしますが、カスタム書式はカバーしません。ワークブックが非標準の配置やローカライズされた月名などの独自の書式コードを定義している場合、そのIDは組み込み範囲を超え、ワークブックの数値書式テーブルを指します。これらに対して日付を認識するには、書式コードの文字列を読み取り、日付トークンを探す必要があります。HotXLSは、両方のチェックを1つの内部関数XlsxNumFmtIsDateに統合しています。この関数は、組み込みの日付範囲に対しては即座にtrueを返し、そうでない場合はXlsxFormatCodeIsDateを介してカスタム書式コードを解析します。これの公開側は、セルのNumberFormat文字列とNumberFormatIndexであり、解決された書式コードとテスト対象のIDの両方を提供します。

書式パーサーが単にdやmをスキャンするだけでは不十分な理由

日付トークンを求めて書式コードを解析することは些細なことのように見えますが、数値書式には他にも様々なデータが含まれていることを忘れてはなりません。日付を構成する文字、すなわち日、月、年、時、秒を表すdmyhsを素朴に検索するだけでは、日付トークンではない2つの構造に対して誤検知を引き起こします。

1つ目は、二重引用符で囲まれた文字列リテラルです。数値書式はダブルクォーテーションで囲まれたリテラルテキストを埋め込むことができるため、#,##0 "MM"のような財務書式は、時間的な意味を持たない文字「MM」を数値の末尾に追加します。引用符内の文字を月のトークンとしてカウントするスキャナーは、この通貨書式を誤って日付としてフラグ立てしてしまいます。2つ目はブラケット(角括弧)セクションです。数値書式は、[Red]のような色名、[>1000]のような比較条件、ロケールタグ、経過時間マーカーの[h][mm]などの角括弧付きディレクティブを保持します。角括弧内のコンテンツには日付を構成する文字を含むものと含まないものがあり、角括弧内のテキストを書式の本体と同様に扱うと、誤検知と見落としの両方の原因になります。

正しいパーサーは、書式コードを1文字ずつ走査し、それが引用符で囲まれたリテラルの内部にあるかどうか、およびブラケットのネストの深さを追跡し、後続の1文字をエスケープするバックスラッシュも認識します。文字列リテラルの外側、かつ角括弧セクションの外側で見つかった、エスケープされていない日付文字だけが本物の日付トークンとしてカウントされます。まさにこれがXlsxFormatCodeIsDateのスキャン方法です。引用符はインリテラル(リテラル内)状態を切り替え、閉じ引用符までトークン検出を抑制し、バックスラッシュは次の文字をスキップし、ブラケット深度カウンターは[...]内の検出を抑制します。利点は、#,##0 "MM"が数値書式として正しく読み取られる一方で、引用符の外側に単一のmdしか含まない簡潔なカスタムコードであっても、日付として正しく認識されることです。

サードパーティ製ファイルからの日付の読み取り

上記のすべてが1つのワークフローに集約されます。それは、別のアプリケーションが書き出した数値を、信頼できる日付に戻すことです。シリアル値は経過日数を提供し、ワークブックのDate1904フラグはカウントの基準日を指示し、セルの数値書式IDまたはカスタムコードは、その数値がそもそも日付として意図されたものであることを示す唯一の証拠になります。3つのうちのいずれか1つでも欠落すると、目に見えるエラーではなく、もっともらしく見える誤った日付が生成されてしまいます。

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側には、特筆すべき罠がもう1つあります。古い.xlsストリームでは、隣接する一連の数値セルが、単一のマルチセルレコードであるMULRKにパックされ、複数の値とその書式参照が1つの構造体に格納されることがあります。日付セルがその方法で格納された場合であっても、パックされているからといって日付であることに変わりはないため、同じ書式IDテストをマルチセルレコードの内部にまで適用し、各セルごとに判定を行う必要があります。また、1904年のオフセット処理も、生成されるすべてのシリアル値に対して有効です。独立した数値レコードのみを検査し、パックされたものをスキップするリーダーは、日付の列を静かに整数の列へと変換してしまいます。

実践におけるシリアル値のTDateTimeへのマッピング

書式チェックで日付が確認され、Date1904フラグが判明すれば、変換は機械的です。HotXLSがすでにvarDateとして返している値は、直接使用できるTDateTimeです。ソースが認識可能な日付書式なしにシリアル値を書き込んだために単純なDoubleとして届いた値は、1900年軸上の経過日数として読み取ることで変換し、1904年のワークブックの場合は、基準日(エポック)を合わせるために最初に1462日のオフセットを減算します。逆方向において、セルにTDateTimeを割り当てると1900年ベースのシリアル値が格納され、HotXLSはワークブックに1904年のフラグが立っている場合、保存時に同じ1462日のシフト処理を適用します。これにより、保存されたファイルは4年ずれた日付ではなく、意図した日付を表示します。

ワークブックを生成する際は、意図的にこのフラグを設定してください。デフォルトではDate1904はfalseに設定されており、これはExcel for Windowsと一致し、ほぼすべての用途で望ましい設定です。Mac起源のワークブックを再現する場合や、下流のシステムが特に1904年軸を期待している場合にのみ、trueに設定してください。4年のズレを引き起こす一連のエラーを防ぐ唯一のルールは「一貫性」です。すなわち、ワークブックごとに基準日(エポック)を1回だけ決定し、すべての日付をその基準日の下で書き込み、すべてのシリアル値をファイルが実際に保持しているフラグの下で読み返すことです。

日付は、セルが実際に保持しているものに関する広範なストーリーの一部にすぎません。隣接するメタデータレイヤ、すなわちグリッドと並行して保持されるタイトル、著者、およびタイムスタンプについては、ワークブックのメタデータとドキュメントプロパティに関する記事でカバーされており、そこでは同じCreatedModifiedの値が、同様の「未設定=ゼロ」の規約に従ってTDateTimeとして格納されます。日付が格納された値ではなく計算結果である場合、数式エンジンとカスタム関数に関する記事の評価ルールが、フォーマットが描画するシリアル値を決定します。どちらも、Excelオートメーションを使用せずにXLSおよびXLSXの日付を読み書きする、DelphiおよびC++Builder向けのHotXLS Componentで提供されているのと同じ日付モデルをベースに動作します。