Технічна стаття

Мітки часу документа Excel у Delphi: FILETIME, UTC і DST

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 приховував помилку, бо writer і reader ділили ту саму неправильну константу, тож похибка сама себе скасовувала. Дата в наборі властивостей 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 ns.
  // Пряме масштабування 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

Які ID властивостей SummaryInformation тримають дати?

У наборі властивостей \005SummaryInformation, визначеному [MS-OLEPS], час створення живе під ID властивості $0C (PIDSI_CREATE_DTM), час останнього збереження — під $0D (PIDSI_LASTSAVE_DTM), а сумарний час редагування — під $0A (PIDSI_EDITTIME). Старіші збірки HotXLS писали мітку останнього збереження в $0E, тобто PIDSI_PAGECOUNT, тож Excel не мав дати збереження для показу, а властивість кількості сторінок тримала мітку часу. Від v2.384.17 читач також поважає той застарілий layout: коли $0D відсутній, а $0E несе VT_FILETIME, значення береться як час останнього збереження. Кожне прочитання PROPVARIANT тепер також вивільняється через PropVariantClear, бо пошкоджений файл може припаркувати рядок під будь-яким із цих ID. Якщо хочете побачити ті потоки власними очима, покроковий розбір читання складених файлів OLE2 у Delphi без COM IStorage показує, як до них дістатися

PIDSI_EDITTIME — це пастка всередині пастки. Властивість типізована як VT_FILETIME, але тримає тривалість — сире число минулих тіків по 100 ns без жодної епохи. Старий письменник трактував її як дату: ділив EditTimeMinutes на 1440 і проганяв результат через конверсію епохи, тож 125 хвилин редагування приземлялися у файл як приблизно 299 років. Поточний читач розпізнає те кодування за розміром: жодна реальна сесія редагування не тягнеться три століття, тож від будь-якого значення від 109206 днів і більше віднімається застарілий зсув перед заповненням EditTimeMinutes

Карта HotXLS набору властивостей 005SummaryInformation, де 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 — локальний час; файл зберігає FILETIME в UTC
    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, що за моделлю основних властивостей 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 agent, що працює під UTC, з радістю пропустить старий, зламаний код

Мітки часу документа — дрібниця, але саме за ними сортують системи записів, пошукові індекси та журнали аудиту, і дата з похибкою на день чи на вісім годин гірша за відсутню, бо ніхто її не оскаржує. Компонент електронних таблиць HotXLS для Delphi робить арифметику епох, ID властивостей і конверсію UTC для обох .xls і .xlsx, тож ваш код може призначати прості локальні значення TDateTime і лишити файловий формат бібліотеці