Excelシリアル値をJSで日付に戻す — 60が無い理由と1904年基準
Excel から書き出した CSV や、ライブラリで読んだ xlsx のセルに 45000 のような5桁の数値が入っていることがある。これは日付が壊れたのではなく、Excel が内部で持っているシリアル値(通算日数)がそのまま出てきたものである。
シリアル値を日付に戻すコードは数行で書けそうに見える。実際、ネットでよく見かけるのは「1899-12-30 に日数を足す」一行だ。ところがこの一行は、1900年3月1日より前の日付で1日ずれるし、serial 60 という実在しない日付を黙って 1900-02-28 に変換してしまう。さらにブックが「1904年基準」だった場合は、すべての日付が4年と1日ずれる。
この記事では、ハシトシステムのExcelシリアル値 変換ツールが実際に使っている変換ロジックを元に、
- なぜ serial 60 が存在しないのか(1900年うるう年バグ)
- なぜ起点を 1899-12-30 と 1899-12-31 に分ける必要があるのか
- 1904年基準とは何で、ブックがどちらなのかをどう判定するか
を、Microsoft の公式ドキュメントと突き合わせながら、手元で実行したコードで確かめる。
前提:シリアル値とは何か
Excel は日付を「ある起点からの日数」という数値で持つ。Microsoft のサポート記事「Date systems in Excel」によると、Excel には次の2つの日付システムがある。
| 日付システム | 起点 | 既定になっている環境 |
|---|---|---|
| 1900年基準 | 1900年1月1日 = 1 | Windows 版 Excel、Excel for Mac 2011 以降 |
| 1904年基準 | 1904年1月1日 = 0 | それより前の Excel for Mac |
同じ記事には具体例が載っている。2011年7月5日は、1900年基準では 40729、1904年基準では 39267 になり、両者の差は常に 1,462 日(4年と1日)である。
小数部は時刻を表す。1日(24時間)に対する割合なので、0.5 は 12:00:00、0.25 は 06:00:00 になる。45658.5 なら「45658 日目の正午」である。
表示形式が「標準」や「数値」のセルに日付を入れたとき、あるいは CSV を別システムに流し込んだときに、この数値が表に出てくる。
1900年うるう年バグ:serial 60 は存在しない日付
グレゴリオ暦では、100で割り切れる年は400で割り切れない限りうるう年ではない。したがって 1900年はうるう年ではなく、1900年2月29日は存在しない。
ところが Excel は 1900 年をうるう年として扱う。Microsoft Learn の「Excel incorrectly assumes that the year 1900 is a leap year」がその経緯を説明している。要約すると次のとおりである。
- Lotus 1-2-3 が最初に 1900 年をうるう年として扱った
- Multiplan と Excel は、Lotus 1-2-3 と同じシリアル日付を使って互換性を保つために、同じ前提を採用した
- 今から直すと、既存のワークシートのほぼすべての日付が1日減ってしまうため、修正しない
- 直さないことで残る問題は、1900年3月1日より前の日付で WEEKDAY 関数が誤った値を返すことだけ
- 1900 年以外のうるう年判定(たとえば 2100 年はうるう年ではない)は正しく扱う
つまり 1900年基準のシリアル値は、次のように並んでいる。
| serial | Excel 上の日付 | 実在するか |
|---|---|---|
| 1 | 1900-01-01 | する |
| 59 | 1900-02-28 | する |
| 60 | 1900-02-29 | しない |
| 61 | 1900-03-01 | する |
serial 60 に「架空の1日」が挟まっているので、serial 61 以降は実際の日数より1つ大きい。
起点を2つに分ける理由
この「1つ大きい」を吸収する方法は、起点をずらすことである。
- serial 61 以降:1899-12-30 を 0 とした通算日として数えると、実際の暦と一致する
- serial 1〜59:架空の2月29日より前なので、1899-12-31 を 0 とした通算日として数えると一致する
- serial 60:対応する実在の日付が無いので、エラーにする
1899-12-30 という起点は Excel 独自のものではない。.NET の DateTime.FromOADate のドキュメントは、OLE Automation 日付を「1899年12月30日の午前0時を基準日とする日数」と定義している。1899-12-30 起点の数え方は、serial 61 以降の Excel と同じ結果になる。
よく見かける「1899-12-30 に一律で足す」実装は、この OLE Automation 日付の定義をそのまま使っている。serial 61 以降では正しいが、60 以下では Excel と合わない。実際に比べると次のようになる(後述のコードで実行した結果)。
| serial | Excel の日付 | 1899-12-30 に一律加算した結果 |
|---|---|---|
| 1 | 1900-01-01 | 1899-12-31 |
| 59 | 1900-02-28 | 1900-02-27 |
| 60 | (存在しない) | 1900-02-28 |
| 61 | 1900-03-01 | 1900-03-01 |
| 45000 | 2023-03-15 | 2023-03-15 |
業務データで 1900 年の日付が出てくることはまず無いので、多くの場合は一律加算でも問題にならない。ただし「1」や「60」のような小さな数値が、日付ではない列から誤って変換対象に入ったときに、エラーにならず、それらしい日付が出てしまう点は知っておいたほうがよい。
1904年基準:ブック単位の設定
1904年基準は、ブック(ファイル)単位の設定である。xlsx の中では xl/workbook.xml の workbookPr 要素に date1904 属性として書かれる(Open XML SDK の WorkbookProperties.Date1904 がこの属性に対応する)。ECMA-376 のスキーマでは、この属性の既定値は false、つまり属性が無ければ1900年基準である。
1904年基準は 1904-01-01 を 0 とするので、1900年うるう年バグの影響を受けない。起点は1つで済む。
注意が必要なのは、基準の違うブック同士で日付をコピーしたときである。セルに入っているのはシリアル値なので、数値がそのまま移り、表示される日付が 1,462 日ずれる。たとえば serial 45000 は、1900年基準なら 2023-03-15、1904年基準なら 2027-03-16 になる。
実際に試す
ここからは手元で動かす。確認した環境は macOS / Node.js v20.17.0 である。ブラウザだけで確かめたい場合は、Excelシリアル値 変換ツールに同じ値を貼り付ければ同じ結果が出る。
1. 変換関数を書く
excel-serial.mjs として保存する。ツールのロジックと同じく、すべて UTC で計算してタイムゾーンの影響を受けないようにしている。
1 | |
ポイントは3つある。
days < 60のときだけ起点を 1899-12-31 に、それ以外は 1899-12-30 にする- 日付からシリアル値へ戻す側では、まず 1899-12-30 起点で数え、61 未満になったら 1899-12-31 起点で数え直す。1900-02-28 は 1899-12-30 起点だと 60 になってしまうので、この数え直しで 59 に戻る
- 小数部は秒に丸めてから足す。浮動小数点の誤差で 11:59:59.999 のような値にならないようにするためである
2. 公式ドキュメントの値と突き合わせる
check.mjs として保存する。Microsoft のサポート記事の例(2011-07-05 = 40729 / 39267)と、境界の 59・60・61 を確かめ、最後に 1900年〜2100年の全日で往復変換が一致するかを数える。
1 | |
1 | |
実行結果は次のとおりである。
1 | |
読み取れることを整理する。
- 40729 / 39267 / 差 1,462 は、Microsoft のサポート記事に書かれている値と一致した
- serial 60 は変換せずにエラーになり、59 と 61 は実在する日付に正しく戻った
45658.999999は、小数部を秒に丸めた結果 86,400 秒になり、翌日の 00:00:00 になった。1日の最後の瞬間を表す値が繰り上がる挙動として、仕様上こうなることを知っておく- 1900年〜2100年の全日(1904年基準は1904年以降)、計 145,368 件で往復変換の不一致は 0 件だった
3. 一律加算の実装と比べる
冒頭の「1899-12-30 に一律で足す」実装が、どこでずれるかを確かめる。
1 | |
1 | |
1 | |
61 以降は一致し、60 以下が1日ずつ前にずれる。60 はエラーにならず、実在する 1900-02-28 として出てくる。
4. ローカル時刻で組むと日付がずれる
もう一つよくあるのが、new Date(年, 月, 日)(ローカル時刻)で組み立ててから toISOString()(UTC)で文字列にする書き方である。同じコードを、タイムゾーンだけ変えて実行する。
1 | |
1 | |
日本時間で動かすと、UTC に直した時点で日付が前日の 2023-03-14 になる。ロジックは同じでも、実行するサーバのタイムゾーン次第で結果が変わる。上の excel-serial.mjs が Date.UTC だけで計算し、取り出すときも getUTC* 系を使う理由はここにある。
5. xlsx が 1904年基準かどうかを調べる
xlsx は ZIP なので、xl/workbook.xml を取り出して date1904 属性を見れば判定できる。XML Schema の boolean は 1 と true のどちらでも書けるので、両方を拾う。
1 | |
動作確認は、workbookPr に date1904="1" を付けたものと付けないものの xl/workbook.xml を自分で ZIP に詰めた2つのファイルで行った(Excel で保存したファイルではない)。UnZip 6.00(macOS 同梱)での結果は次のとおりである。
1 | |
1904 と出たブックのシリアル値は、serialToDate(値, true) で変換する。基準を取り違えたときのずれは必ず 1,462 日なので、「4年と1日ずれている」日付を見つけたら、まずこの設定を疑うとよい。
まとめ
- Excel のシリアル値は、既定の1900年基準では 1900-01-01 を 1 とする通算日数で、小数部が時刻を表す
- Lotus 1-2-3 との互換性のため、Excel は 1900 年をうるう年として扱う。そのため serial 60 は存在しない 1900-02-29 を指し、61 以降は実際より1つ大きい
- 正しく変換するには、serial 61 以降は 1899-12-30 起点、1〜59 は 1899-12-31 起点、60 はエラーと分ける。1899-12-30 に一律で足す実装は、60 以下で1日ずれる
- 1904年基準はブック単位の設定(
workbookPrのdate1904)で、同じ日付のシリアル値は 1900年基準より常に 1,462 小さい - JavaScript で扱うときは、
Date.UTCとgetUTC*で統一しないと、実行環境のタイムゾーンで日付が1日ずれる
変換を1件ずつ確かめたいときや、コードを書く前に値の見当をつけたいときは、ブラウザ内で完結するExcelシリアル値 変換ツールが使える。複数行を貼り付けると一括で変換でき、1900年基準と1904年基準をボタンで切り替えられる。