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;
スケッチの丸めコメントは、同じコードからの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を埋めます
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年の日付に化けることもありません
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を代入して、ファイル形式をライブラリに任せればいいのです