Техническая статья

Временные метки документов Excel в Delphi: FILETIME и UTC

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;
Хронология HotXLS: эпоха FILETIME 1601-01-01, эпоха TDateTime 1899-12-30 и эпоха Unix 1970 года, показывающая смещение в 109205 дней за UtcDateTimeToFileTimeTicks и то, как сборки до v2.384.17 писали каждую метку CreatedDate на день позже и читали её на день раньше с 109206
Набросок UtcDateTimeToFileTimeTicks держит смещение там, где неверная константа гасит сама себя — симметричный тест «сохранил и переоткрыл» видел присвоенное значение, а панель Info в Excel показывала завтра

Комментарий про округление в том наброске — второй, меньший урок из того же кода. Умножение дробного 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

Карта property set 005SummaryInformation в HotXLS, где PIDSI_CREATE_DTM на $0C держит время создания, PIDSI_LASTSAVE_DTM на $0D — метку сохранения, PIDSI_EDITTIME на $0A — сырую длительность вместо даты, а $0E PIDSI_PAGECOUNT — слот, который старые сборки использовали под метки времени
PIDSI_EDITTIME — ловушка внутри ловушки: типизирован как VT_FILETIME, но держит прошедшие тики без эпохи, что однажды превратило 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;   // длительность, хранится как сырые тики
    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 года, сдвинутую на пару часов

Пути конверсии из локального времени в UTC в HotXLS для январской метки 17:00 CET, сохраняемой в июле: LocalFileTimeToFileTime применяет сегодняшнее летнее смещение и попадает на час мимо, в 15:00Z, а TzSpecificLocalTimeToSystemTime берёт смещение с даты самой метки и пишет корректные 16:00Z
Смещение зоны принадлежит дате самой метки, а не текущим правилам машины: один Windows API выбирает верную сторону перехода на летнее время, другой тихо двигает январские метки, сконвертированные в июле, на час
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 и оставить файловый формат библиотеке