打开电子表格,单击显示 2026-06-19 的单元格,编辑栏仍然显示为日期。从 Delphi 中读取相同的单元格,你会得到数字 46192。这两种视图都是正确的,因为 Excel 从未在该单元格中存储日期。它存储了一个序列号(即天数计数),并附加了一个数字格式以告诉屏幕将计数渲染为日历日期。单元格值中没有日期类型。有一个数字和一个显示规则,而显示规则是区分日期与纯数量的唯一事物。
这种分离是电子表格库必须规避的每个日期错误的根源。单凭序列号无法说明它是哪一天,因为它没有说明第 0 天是哪一天。根据单个工作簿标志,同一个数字代表相隔四年的两个日期。并且,除非有东西检查其格式并识别出日期模式,否则本应读回为日期的数字将读回为纯数量。这就是 HotXLS 中构建日期模型的方式,以及为什么必须这样构建的原因。
日期单元格是数字加上格式
Excel 将日期存储为自纪元以来的天数,时间部分在小数部分。中午在序列中携带 .5。整数部分是天数计数。存储的值中没有任何内容将其标记为时间。标记它的是单元格的数字格式:ECMA-376 称之为 numFmt,其格式代码表示日期或时间模式的单元格显示为日期。去掉格式,同一个单元格就会显示数字;底层的值从未改变。
这就是为什么读取单元格值会给你一个可能是 varDate 或可能是普通 Double 的 Variant,以及为什么同一单元格上的数字格式是决定第三方真正意图的信号。当 HotXLS 打开 XLSX 文件时,单元格将其 Value 和 NumberFormatIndex 都携带进 TXLSXCell,而你参考格式索引以了解该数字是否为日期。
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 天的两个纪元
默认的日期系统(每个 Windows 工作簿使用的系统)从 1899 年的最后一天开始计算,因此序列 1 落在 1900 年的第一天。另一个系统追溯到早期的 Macintosh,并从 1904 年开始计算,因此它的序列 1 是四年零一天之后。工作簿在单个标志中记录了它使用哪个系统。在 OOXML 包中,该标志是工作簿部分上的 date1904;HotXLS 将其显现为工作簿的 Date1904 属性。
两个纪元之间的差距正好是 1462 天。那是四个日历年(三个 365 天和一个 366 天,总共 1461 天),再加上两个零天约定之间一天左右的偏移量。该数字是固定的,你可以记在脑海中。它的重要性在于它不是零。从 1904 工作簿中复制并根据 1900 规则解释的序列(或反过来),会导致每个日期相差 1462 天,这表现为相差四年多的错误日期,且很容易被误认为是损坏的数据。
因为 Delphi 原生的 TDateTime 锚定在 1900 约定上,所以每当工作簿标记为 1904 时,将 Excel 序列映射到 TDateTime 的库就必须在两个方向上都偏移 1462。读取 1904 序列时,在将其视为 TDateTime 之前减去 1462;将 TDateTime 写入 1904 工作簿时,从序列中减去 1462,以便 Excel 渲染你指定的日期。当 HotXLS 为设置了 Date1904 的工作簿序列化日期值时,会在内部应用此偏移,因此你分配的 TDateTime 值会在屏幕上往返还原为同一个日历日。
刻意设计的 1900 闰年奇特行为
在 1900 系统中有一个著名的缺陷。Excel 将 1900 年视为闰年,并接受 1900 年 2 月 29 日作为一个真实日期,序列为 60。1900 年并不是闰年,因为世纪年只有在能被 400 整除时才是闰年,而 1900 年不能。这个幻影日是继承自带有该错误早期电子表格的故意保留的兼容性行为,此后一直保留,以便序列算术在数十年文件之间保持一致。
实际的后果很小但存在:对于 1900 年 3 月 1 日或之后的任何日期,序列比严格正确的天数计数大 1,因为不存在的 2 月 29 日消耗了一个数字。电子表格库会重现这种奇特行为而不是修复它,因为完全匹配 Excel 的算术是全部工作。纠正它会导致每个现代日期与 Excel 显示的相差一天,这比携带一个商业用途中没有真实日期会触及的、有四万天历史的差一错误更糟糕。1904 系统没有等效的幻影日,这就是历史上少数机构更喜欢它的原因之一。
从 numFmt 中检测日期
当数字来自其他人编写的文件时,它的格式是它是日期的唯一证据。ECMA-376 分配了一组内置格式 ID,其含义由规范固定,并且日期和时间格式占用已知的范围。ID 14 到 22 是通用区域设置的日期和时间格式,即我们熟悉的 m/d/yyyy、h:mm 及其亲属。ID 45 到 47 是累计时间格式。另外两个波段(27 到 36 以及 50 到 58)是用于 CJK 日历的特定区域设置日期和时间格式,定义在 ECMA-376 18.8.30 中。数字格式 ID 落在这些范围内的单元格即是日期或时间单元格。
内置 ID 涵盖了常见情况,但不涵盖自定义情况。当工作簿定义了自己的格式代码(例如非标准顺序或本地化月份名称)时,ID 位于内置范围之上,并指向工作簿的数字格式表。对于这些情况,识别日期意味着读取格式代码字符串并寻找日期标记(token)。HotXLS 将这两项检查合并到一个内部谓词 XlsxNumFmtIsDate 中,该谓词对内置日期范围立即返回 True,否则通过 XlsxFormatCodeIsDate 解析自定义格式代码。其公开部分是单元格的 NumberFormat 字符串及其 NumberFormatIndex,为你提供了已解析的格式代码和要测试的 ID。
为什么格式解析器不能仅扫描 d 和 m
为了日期标记而解析格式代码看起来微不足道,直到你记起数字格式中还存在别的东西。单纯搜索拼写日期的字母(即天、月、年、小时和秒的 d、m、y、h 和 s),会在两个根本不是日期标记的结构上引发误判。
第一种是带引号的字符串字面量。数字格式可以在双引号中嵌入字面文本,因此诸如 #,##0 "MM" 的财务格式会在数字后追加字符 M 和 M,而没有任何时间意义。将引号内的字母计为月份标记的扫描器会错误地将该货币格式标记为日期。第二种是括号部分。数字格式在方括号中携带指令(例如颜色名称 [Red]、比较条件 [>1000]、区域设置标签,以及累计时间标记 [h] and [mm])。一些括号内容包含日期字母,一些则不包含,将括号文本与格式主体同等对待会导致误报和漏报。
正确的解析器会逐个字符地遍历格式代码,跟踪它是否在引用的字面量内部以及它在括号嵌套中的深度,并且它还尊重转义单个紧随其后字符的反斜杠。只有在任何字符串字面量之外且在任何括号部分之外找到的未转义日期字母才算作真正的日期标记。这正是 XlsxFormatCodeIsDate 的扫描方式:引号切换一个在字面量内的状态,该状态抑制标记检测直到结束引号,反斜杠跳过下一个字符,且括号深度计数器抑制在 [...] 运行中的检测。其回报是 #,##0 "MM" 被正确读取为数字格式,而引号外仅包含单个 m 或 d 的简短自定义代码仍然被正确识别为日期。
从第三方文件中读取日期
当数字来自其他人编写的文件时,它的格式是它是日期的唯一证据。序列号给你天数计数,工作簿的 Date1904 标志告诉你点是自哪个纪元开始测量的,而单元格的数字格式 ID 或自定义代码是该数字最初被当作日期的唯一证据。丢掉这三者中的任何一个,你都会得到一个合理的错误答案,而不是明显的错误。
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 侧有一个额外值得提及的陷阱。在较旧的 .xls 流中,一系列相邻的数值单元格可以打包到单个多单元格记录中(即 MULRK),该记录在一个结构中存储多个值及其格式引用。以这种方式存储的日期单元格并不因为被打包就不是日期,因此相同的格式 ID测试必须深入多单元格记录内并应用于每个单元格,且 1904 偏移仍然管理着它产生的每个序列。只检查独立数字记录并跳过打包记录的读取器会静默地将日期列转换为整数列。
在实践中将序列号映射到 TDateTime
一旦格式检查确认了日期且 Date1904 标志已知,转换就是机械的。HotXLS 已经返回为 varDate 的值是你可以直接使用的 TDateTime。作为纯 Double 到达的值(这发生在源在没有识别日期格式的情况下写入序列号时)通过将其读取为 1900 轴上的天数计数来转换,并且对于 1904 工作簿,首先减去 1462 天的偏移量以便纪元对齐。相反,将 TDateTime 分配给单元格会存储基于 1900 的序列号,当工作簿被标记为 1904 时,HotXLS 会在保存时应用相同的 1462 天偏移,以便保存的文件显示你指定的日期,而不是偏离四年的日期。
在生成工作簿时请刻意设置该标志。默认情况下 Date1904 为 False(这与 Windows 的 Excel 匹配,并且几乎总是你想要的);仅当你在重制 Mac 源的工作簿或者下游系统特别期望 1904 轴时才将其设置为 True。防止整类四年错误的唯一规则是一致性:每个工作簿选择一次纪元,在其下写入每个日期,并在文件实际携带的标志下读回每个序列号。
日期只是关于单元格真正保存内容的更广泛故事中的一列。相邻的元数据层(与网格一起流转的标题、作者和时间戳)已在我们关于工作簿元数据和文档属性的文章中介绍,其中相同的 Created and Modified 值作为 TDateTime 存储,并具有相同的“未设置等于零”的约定。当日期是计算结果而不是存储值时,我们关于公式引擎和自定义函数的文章中的评估规则决定了格式随后渲染的序列号。两者都在 Delphi 和 C++Builder 的 HotXLS Component 随附的相同日期模型上工作,该组件在没有 Excel 自动化的的情况下读取和写入 XLS and XLSX 日期。