技術記事

DelphiのExcelドキュメントタイムスタンプ:FILETIMEとUTCとDST

HotXLSはExcelのドキュメントプロパティのタイムスタンプを、ファイル内ではUTCで保存し、APIからはローカル時刻として露出します。.xlsならTXLSWorkbook.CreatedDateとLastSavedDate、.xlsxならTXLSXWorkbook.CreatedとModifiedです。v2.384.48以降、両エンジンとも書き込み時にローカル時刻をUTCへ、読み取り時にUTCをローカル時刻へ変換します。その変換には、スタンプ自身の日付に適用されるサマータイム規則を使います。ここに辿り着くまで修正は2回あり、どちらのバグも同じ気まずい理由で生き延びていました。自動化されたラウンドトリップは全部通るのに、Excelのファイル > 情報ペインだけが日や時刻を間違えて表示する。DelphiでExcelのドキュメントプロパティを設定するの概説を読んだことがあるなら、ここから先は日付が単なる値でなくなるところです

保存して開き直すテストが1日ずれを隠せた理由

自己ラウンドトリップが誤差を隠したのは、ライターとリーダーが同じ間違った定数を共有していたからです。誤りは自分自身で打ち消し合いました。OLEプロパティセットの日付はFILETIME、つまり1601-01-01 UTCからの100ナノ秒tickの64ビットカウントです([MS-DTYP] §2.3.3)。一方DelphiのTDateTimeは1899-12-30からの日数を数えます。このシリアルの原点はDelphiにおけるExcelの日付シリアルと1900/1904システムで扱ったのと同じものです。2つのエポックの差は109205日で、カレンダーなしで検算できます。25569(TDateTimeとしてのUnixエポック)に109205を足すと134774、FILETIME日数で数えたUnixエポックになります。v2.384.17より前のHotXLSビルドは109206を使っていたため、作成・保存のスタンプはすべて1日遅く書かれ、1日早く読まれていました。テストスイートが目にしたのは代入した値で、Excelが見たのは明日でした

const
  // FILETIMEのエポック(1601-01-01)からTDateTimeのエポック(1899-12-30)までの日数
  // 検算:25569 + 109205 = 134774、FILETIME日数で数えたUnixエポック
  FileTimeDayBias = 109205;

function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
  // まずミリ秒単位に丸めてから100ナノ秒tickへ拡大する
  // Doubleを直接tickへ拡大すると04:00が03:59:59.9999になる
  Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
FILETIMEのエポック1601-01-01、TDateTimeのエポック1899-12-30、1970年のUnixエポックを並べたHotXLSのタイムライン図。UtcDateTimeToFileTimeTicksの背後にある109205日のバイアスと、v2.384.17より前のビルドが109206でCreatedDateスタンプを1日遅く書いて1日早く読んでいた様子を示します
UtcDateTimeToFileTimeTicksのスケッチは、間違った定数が自己打消しする位置にバイアスを置いています。対称な保存・開き直しテストは代入値を見て満足し、Excelの情報ペインだけが明日を表示していました

スケッチの丸めコメントは、同じコードからの2つ目の、小ぶりの教訓です。小数部を持つTDateTimeを1日864,000,000,000 tickで直接乗じると、2進浮動小数点の誤差が下位の桁へ漏れ出し、ちょうど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のどれにでも文字列を置けるからです。ストリームを自分の目で確かめたいなら、COM IStorageなしでDelphiのOLE2複合ファイルを読むのウォークスルーがアクセス方法を示しています

PIDSI_EDITTIMEは罠の中の罠です。このプロパティはVT_FILETIME型ですが、中身は期間、つまりエポックを加算しない経過100ナノ秒tickの生の数です。旧ライターはこれを日付のように扱い、EditTimeMinutesを1440で割ってエポック変換に通していたため、125分の編集が約299年としてファイルに着地しました。現在のリーダーはその符号化をサイズで見分けます。現実の編集セッションが3世紀に及ぶことはないので、109206日以上の値にはレガシーオフセットを差し引いてからEditTimeMinutesを埋めます

005SummaryInformationプロパティセットのHotXLSマップ図。$0CのPIDSI_CREATE_DTMが作成時刻を、$0DのPIDSI_LASTSAVE_DTMが保存スタンプを保持し、$0AのPIDSI_EDITTIMEは日付ではなく生の期間、そして$0EのPIDSI_PAGECOUNTは古いビルドがタイムスタンプに誤用したスロットです
PIDSI_EDITTIMEは罠の中の罠です。VT_FILETIME型なのにエポックなしの経過tickを保持し、サイズベースのリーダーヒューリスティックが来るまで、125分の編集時間を約299年に変えていました
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;   // 期間であり、生のtickとして保存される
    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へ変換しました。クラシックエンジンもFILETIME値にまったく同じ欠陥を抱えており、TXLSXWorkbook.CustomProperties.AddDate(vt:filetimeとして書かれる)で追加したカスタム日付プロパティもこれを共有していました。v2.384.48以降、3つの経路すべてが書き込み前に変換し、スタンプがZを運ぶときは読み取り時に逆変換します。さらにv2.384.59以降、読み取り側は小数秒と明示的な+hh:mm / -hh:mmオフセットも扱います

変換そのものは、素朴な修正が挫折する場所です。LocalFileTimeToFileTimeは現在有効なオフセットを適用するので、1月のスタンプを7月に変換すると、サマータイムのあるゾーンでは1時間ずれます。HotXLSは代わりにTzSpecificLocalTimeToSystemTimeとSystemTimeToTzSpecificLocalTimeを呼びます。これらは変換対象の日付から標準時かサマータイムかを選びます。未設定のゼロ値は素通しさせるので、数時間ずれた1899年の日付に化けることもありません

7月に保存した1月の17:00 CETスタンプに対するHotXLSのローカルからUTCへの変換経路の図。LocalFileTimeToFileTimeは今日のサマータイムオフセットを適用して1時間ずれた15:00Zに着地しますが、TzSpecificLocalTimeToSystemTimeはスタンプ自身の日付からオフセットを選び、正しい16:00Zを書き込みます
ゾーンオフセットはスタンプ自身の日付に属し、マシンの現在の規則には属しません。あるWindows APIはサマータイム切替の正しい側を選び、もう片方は7月に変換した1月のスタンプを黙って1時間ずらします
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>
    // (7月はUTC+2)。一方ApprovedOnは16:00Zとして書かれる(1月は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ビルドが書いたファイルは、もう1つの正直な境界です。v2.384.48より前に書かれたXLSXスタンプはZを着たローカル時刻であり、正しいものとファイル内で区別がつかないので、現在のリーダーはゾーンオフセットぶんずらします。同じビルドのクラシックFILETIMEスタンプも同じシフトを受け、v2.384.17より前に書かれた作成日はさらに1日遅く読み戻されます。旧定数の余分な1日も検出できないからです。認識可能な署名を持つのは、編集時間の符号化と$0Eの置き場所だけです。もう1つ覚えておきたいのは、APIの値が読み取りを行うマシンのローカルだという点です。UTCで動くサービスと東京のデスクトップは、同じファイルに対して違うCreatedDateを報告します。どちらも正しいのです

ドキュメントのタイムスタンプはどうテストすべきか

ドキュメントのタイムスタンプは、自分のコードが書いていないものと突き合わせてテストします。2つのバグはどちらも保存後に開き直すチェックを通過しました。対称な誤りは対称なテストには見えないからです。Excelが保存したワークブックと比較するか、保存後の生バイトとXMLテキストをアサートし、非UTCゾーンに設定したマシンで、サマータイム切替の両側にテスト日を置いてスイートを回しましょう。UTCで動くビルドエージェントは、古い壊れたコードを喜んで通過させてくれます

ドキュメントのタイムスタンプは小さな存在ですが、レコード管理システム、検索インデックス、監査証跡がソートの基準にするものです。1日ぶんや8時間ぶんずれた日付は、欠けている日付より始末が悪いのです。誰も疑わないのですから。HotXLS Delphiスプレッドシートコンポーネントは、.xlsと.xlsxの両方についてエポック計算、プロパティID、UTC変換を処理します。あなたのコードは素のローカルTDateTimeを代入して、ファイル形式をライブラリに任せればいいのです