HotXLS хранит метки времени свойств документов Excel в файле как UTC, а через API отдаёт их как локальное время: TXLSWorkbook.CreatedDate и LastSavedDate для .xls, TXLSXWorkbook.Created и Modified для .xlsx. Начиная с v2.384.48 оба движка конвертируют локальное время в UTC при записи и обратно при чтении, применяя правила перехода на летнее время, действующие на дату самой метки. До этого пришлось делать два исправления, и оба бага выжили по одной и той же неловкой причине: каждый автоматический round-trip проходил, а панель File > Info в Excel показывала не тот день или не тот час. Если вы читали наш обзор задания свойств документов Excel в Delphi, то это та часть, где даты перестают быть простыми значениями
Почему тест «сохранил и переоткрыл» прятал ошибку в один день?
Сам на себя round-trip прятал ошибку, потому что писатель и читатель делили одну и ту же неверную константу, и ошибка гасила сама себя. Дата в property set OLE — это FILETIME, 64-битный счёт тиков по 100 наносекунд с 1601-01-01 UTC ([MS-DTYP] §2.3.3), тогда как Delphi TDateTime считает дни с 1899-12-30 — тот же серийный ноль, что разобран в статье о серийных датах Excel в Delphi и системах 1900 и 1904. Разница между двумя эпохами — 109205 дней, что проверяется без календаря: 25569 (эпоха Unix как TDateTime) плюс 109205 даёт 134774 — эпоха Unix, посчитанная в днях FILETIME. Сборки HotXLS до v2.384.17 использовали 109206, так что каждая метка создания и сохранения записывалась на день позже и читалась на день раньше. Тестовый набор видел присвоенное им значение; Excel видел завтрашний день
const
// дни от эпохи FILETIME (1601-01-01) до эпохи TDateTime (1899-12-30)
// проверка: 25569 + 109205 = 134774, эпоха Unix в днях FILETIME
FileTimeDayBias = 109205;
function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
// Сначала округлите до целых миллисекунд, затем масштабируйте в тики по 100 нс.
// Прямое масштабирование Double в тики превращает 04:00 в 03:59:59.9999
Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
Комментарий про округление в том наброске — второй, меньший урок из того же кода. Умножение дробного TDateTime прямо на 864 000 000 000 тиков в сутки впускает ошибку двоичной плавающей точки в младшие разряды, и метка ровно в 04:00 возвращалась как 03:59:59.9999. HotXLS v2.384.48 округляет до целых миллисекунд перед масштабированием, так что значения на ровный час переживают путешествие нетронутыми. Тот же релиз добавил шаг с часовым поясом, который этот набросок нарочно опускает, потому что вход здесь уже UTC
Какие property ID SummaryInformation держат даты?
В property set \005SummaryInformation, определённом [MS-OLEPS], время создания живёт под property ID $0C (PIDSI_CREATE_DTM), время последнего сохранения — под $0D (PIDSI_LASTSAVE_DTM), а суммарное время редактирования — под $0A (PIDSI_EDITTIME). Старые сборки HotXLS писали метку сохранения в $0E, то есть PIDSI_PAGECOUNT, так что у Excel не было даты сохранения, зато была property счётчика страниц с меткой времени внутри. Начиная с v2.384.17 читатель уважает и ту legacy-раскладку: когда $0D отсутствует, а $0E несёт VT_FILETIME, значение берётся как время последнего сохранения. Каждое прочитанное PROPVARIANT теперь освобождается через PropVariantClear, потому что битый файл может спрятать строку под любым из этих ID. Если хотите увидеть те потоки своими глазами, пошаговый разбор чтения составных файлов OLE2 в Delphi без COM IStorage показывает, как до них добраться
PIDSI_EDITTIME — ловушка внутри ловушки. Свойство типизировано как VT_FILETIME, но держит длительность — сырой счёт прошедших тиков по 100 нс без всякой эпохи. Старый писатель обращался с ним как с датой: делил EditTimeMinutes на 1440 и прогонял результат через конверсию эпох, поэтому 125 минут редактирования попадали в файл примерно как 299 лет. Нынешний читатель узнаёт ту кодировку по размеру: настоящая сессия редактирования не длится три века, так что у любого значения от 109206 дней и больше отнимается legacy-смещение перед заполнением 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; // длительность, хранится как сырые тики
if Book.SaveAs('settlement.xls') <> 1 then
raise Exception.Create('Save failed');
finally
Book.Free;
end;
end;
Почему даты XLSX уезжали ровно на смещение часового пояса?
Даты XLSX уезжали на смещение зоны, потому что dcterms:created и dcterms:modified в docProps/core.xml — значения W3CDTF с меткой Z, что по модели core properties ECMA-376 Part 2 означает UTC, а HotXLS раньше ставил локальное время с той самой Z. Книга, созданная в 09:30 на машине в UTC+8, несла 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');
// На машине с Central European Time 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 не конвертирует при чтении меток времени?
W3CDTF-читатель HotXLS с v2.384.59 конвертирует каждую форму профиля с меткой зоны, и единственный случай, который он всё ещё оставляет в покое, — время без зоны. До того релиза парсер брал первые 19 символов и конвертировал из UTC, только если 20-й символ был Z, поэтому метка с дробными секундами (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, — вторая честная граница. Метка XLSX, записанная до v2.384.48, была локальным временем в костюме Z, и ничто в файле не отличит её от корректной, поэтому нынешний читатель сдвигает её на смещение зоны. Классические FILETIME-метки тех сборок получают тот же сдвиг, а дата создания, записанная до v2.384.17, вдобавок читается на день позже — лишний день старой константы тоже не детектируется; узнаваемую сигнатуру имеют только кодировка edit-time и размещение в $0E. Имейте в виду и то, что значение API локально для машины, читающей файл: сервис в UTC и десктоп в Токио отчитаются разными CreatedDate для одного файла, и оба будут правы
Как тестировать метки времени документов?
Тестируйте метки времени документов против того, что ваш собственный код не писал. Оба бага этой истории проходили проверку «сохранил — переоткрыл», потому что симметричная ошибка невидима для симметричного теста. Сверяйтесь с книгой, сохранённой Excel, или ассерьте сырые байты и XML-текст после сохранения, и гоняйте набор на машине с зоной, отличной от UTC, с тестовой датой по обе стороны перехода на летнее время. Build-агент, живущий в UTC, с удовольствием пропустит старый сломанный код
Метки времени документов малы, но именно по ним сортируются системы учёта, поисковые индексы и журналы аудита, а дата, уехавшая на день или на восемь часов, хуже отсутствующей, потому что её никто не заподозрит. Компонент электронных таблиц HotXLS для Delphi берёт на себя арифметику эпох, property ID и конверсию UTC для .xls и .xlsx, так что ваш код может присваивать обычные локальные TDateTime и оставить файловый формат библиотеке