HotXLS 在文件里以 UTC 存储 Excel 文档属性时间戳,再通过 API 以本地时间暴露出来:.xls 用 TXLSWorkbook.CreatedDate 和 LastSavedDate,.xlsx 用 TXLSXWorkbook.Created 和 Modified。从 v2.384.48 起,两个引擎都在写入时把本地时间换算成 UTC、读取时换算回来,并且用的是时间戳所在日期当天的夏令时规则。走到这一步经历了两次修复,而两个 bug 能存活至今都是同一个尴尬的原因:所有自动化往返测试全部通过,Excel 的 File > Info 面板却显示错误的日期或小时。如果你读过我们关于在 Delphi 中设置 Excel 文档属性的总览,本文就是日期不再是简单值的那部分
为什么保存再重开的测试藏住了一天的误差?
自身往返之所以藏住误差,是因为写入器和读取器共享同一个错误常数,错误自己抵消了自己。OLE 属性集里的日期是一个 FILETIME——自 1601-01-01 UTC 起 100 纳秒 tick 的 64 位计数([MS-DTYP] §2.3.3);而 Delphi 的 TDateTime 从 1899-12-30 起按天数计数,也就是Delphi 中的 Excel 日期序列号与 1900/1904 系统讲过的同一个序列号起点。两个纪元之间相差 109205 天,不用查日历也能验证:25569(Unix 纪元的 TDateTime 值)加 109205 得 134774,即按 FILETIME 天数计的 Unix 纪元。v2.384.17 之前的 HotXLS 用的是 109206,于是每条创建和保存时间戳写出去都晚一天、读回来都早一天。测试套件看到的是自己赋的值;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;
那段取整注释是同一份代码里的第二个小教训。把带小数的 TDateTime 直接乘上每天 864,000,000,000 个 tick,会让二进制浮点误差漏进最低几位,恰好 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 年的姿态落进文件。现在的读取端按数值大小识别这种编码:真实的编辑时长不可能横跨三个世纪,所以任何 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 起,三条路径都在写入前换算、读取时只要时间戳带 Z 就换算回来;从 v2.384.59 起,读取端还支持小数秒和显式的 +hh:mm / -hh:mm 偏移
换算本身才是天真的修法翻车的地方。LocalFileTimeToFileTime 用的是当前生效的偏移,于是一月份的时间戳在七月换算,在任何有夏令时的时区都会差出一个小时。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>
// (七月为 UTC+2),而 ApprovedOn 写成 16:00Z(一月为 UTC+1)
finally
Book.Free;
end;
end;
读取时间戳时 HotXLS 不换算什么?
从 v2.384.59 起,HotXLS 的 W3CDTF 读取端换算该 profile 里每一种带时区标记的形式,唯一仍不碰的是不带时区的时间。那个版本之前,解析器取前 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 profile 不允许、ECMA-376 Part 2 也没给规则——依旧按本地时间原样读取;完全解析不了的时间戳则返回零。经过 Excel 之手的工作簿都没问题;由丢弃时区的其他生成器产出的包,值得抽查几眼
老版本 HotXLS 写出的文件是另一条诚实的边界。v2.384.48 之前写出的 XLSX 时间戳是披着 Z 的本地时间,文件里没有任何东西能把它和正确的戳区分开,所以现在的读取端会按当前时区偏移挪它。那些构建写出的经典 FILETIME 时间戳同样吃这一挪,而 v2.384.17 之前写出的创建日期还会额外晚一天读回,因为旧常数多出的那一天同样无法检测;只有编辑时长编码和 $0E 的位置有可识别的签名。还要记住:API 值相对的是执行读取那台机器的本地时区,跑在 UTC 的服务与东京的桌面机对同一份文件会报出不同的 CreatedDate,而且都对
文档时间戳该怎么测?
测文档时间戳要对照你的代码没有写过的东西。这两个 bug 都通过了保存再重开的检查,因为对称的错误对对称的测试不可见。拿 Excel 保存的工作簿做参照,或者在保存后断言原始字节和 XML 文本,并把测试套件跑在一台设成非 UTC 时区的机器上,测试日期分别落在一次夏令时切换的两侧。跑在 UTC 上的构建代理会高高兴兴地放过旧的坏代码
文档时间戳很小,但记录系统、搜索索引和审计追踪都是按它们排序的,一个差一天或差八小时的日期比缺失更糟,因为没人会怀疑它。HotXLS Delphi 电子表格组件替你处理纪元算术、属性 ID 和 .xls 与 .xlsx 两种格式的 UTC 换算,你的代码只管赋普通的本地 TDateTime 值,文件格式的事交给库