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

Документни timestamp-ове в Delphi: FILETIME, UTC и DST

HotXLS съхранява timestamp-овете на документните свойства на Excel като UTC вътре във файла и ги излага като локално време през API-я: TXLSWorkbook.CreatedDate и LastSavedDate за .xls, TXLSXWorkbook.Created и Modified за .xlsx. От v2.384.48 и двата engine-а конвертират локалното време към UTC при запис и обратно при четене, ползвайки правилата за лятното време, важащи за собствената дата на stamp-а. Пътят дотам отне два fix-а и двата бъга оцеляха по една и съща притеснителна причина: всеки автоматизиран round trip минаваше, докато панелът File > Info на Excel показваше грешен ден или грешен час. Ако сте чели нашия преглед на задаването на документни свойства на Excel в Delphi, тук е мястото, където датите спират да са прости стойности

Защо тест „запиши и отвори отново“ скри грешка от един ден?

Собственият round trip скри грешката, защото writer и reader споделяха същата грешна константа, така че грешката се анулираше сама. Дата в OLE property set е FILETIME, 64-битово броене на тактове по 100 наносекунди от 1601-01-01 UTC ([MS-DTYP] §2.3.3), докато Delphi TDateTime брои дни от 1899-12-30 — същият serial произход, разгледан в Excel date serials в Delphi и системите 1900 срещу 1904. Разстоянието между двете епохи е 109205 дни, което можете да проверите и без календар: 25569 (Unix епохата като TDateTime) плюс 109205 дава 134774, Unix епохата, преброена в FILETIME дни. HotXLS build-ове преди v2.384.17 ползваха 109206, така че всеки stamp на създаване и запис се записваше с един ден късно и се четеше с един ден рано. Тест suite-ът виждаше стойността, която сам е задал; 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 и как build-овете преди v2.384.17 записваха всеки CreatedDate stamp с един ден късно и го четяха с един ден рано със 109206
Скицата UtcDateTimeToFileTimeTicks държи отместването там, където грешна константа се анулира сама — симетричен тест запис-и-повторно-отваряне виждаше зададената от себе си стойност, докато панелът Info на Excel показваше утрешния ден

Коментарът за закръгляне в тази скица е вторият, по-малкият урок от същия код. Умножаването на дробен TDateTime директно по 864 000 000 000 такта на ден позволява на грешката от двоичната плаваща запетая да се промъкне в последните цифри и stamp в точно 04:00 се връщаше като 03:59:59.9999. HotXLS v2.384.48 закръгля до цели милисекунди преди мащабирането, така че стойностите на ровно часа оцеляват непокътнати. Същият release добави и стъпката за часовата зона, която тази скица нарочно пропуска, защото входът тук вече е UTC

Кои property ID-та в SummaryInformation пазят датите?

В \005SummaryInformation property set-а, дефиниран от [MS-OLEPS], времето на създаване стои под property ID $0C (PIDSI_CREATE_DTM), времето на последен запис под $0D (PIDSI_LASTSAVE_DTM), а общото време за редактиране под $0A (PIDSI_EDITTIME). По-старите HotXLS build-ове записваха stamp-а на последен запис в $0E, тоест PIDSI_PAGECOUNT, така че Excel нямаше дата на запис за показване, а имаше property за брой страници, в което стои timestamp. От v2.384.17 нататък reader-ът отчита и този legacy layout: когато $0D липсва, а $0E носи VT_FILETIME, стойността се приема за време на последен запис. Всяко прочитане на PROPVARIANT вече се освобождава с PropVariantClear, защото повреден файл може да е паркирал string под който и да е от тези ID-та. Ако искате да видите тези stream-ове със собствените си очи, разходката из четенето на OLE2 compound файлове в Delphi без COM IStorage показва как да стигнете до тях

PIDSI_EDITTIME е капанът вътре в капана. Свойството е типизирано VT_FILETIME, но пази продължителност — суров брой изминали тактове по 100 ns, без прибавена епоха. Старият writer го третираше като дата, делеше EditTimeMinutes на 1440 и прокарваше резултата през конверсията на епохата, така че 125 минути редактиране кацаха във файла като около 299 години. Текущият reader разпознава това кодиране по размер: никоя истинска сесия за редактиране не обхваща три века, така че всяка стойност от 109206 дни или повече получава приспаднато legacy отместването, преди EditTimeMinutes да се попълни

Карта на HotXLS за property set-а 005SummaryInformation, където PIDSI_CREATE_DTM на $0C пази времето на създаване, PIDSI_LASTSAVE_DTM на $0D — stamp-а на запис, PIDSI_EDITTIME на $0A — сурова продължителност вместо дата, а $0E PIDSI_PAGECOUNT е слотът, който по-старите build-ове ползваха погрешно за timestamp-ове
PIDSI_EDITTIME е капанът вътре в капана — типизирано VT_FILETIME, но пази изминали тактове без епоха, което веднъж превърна 125 минути редактиране в около 299 години, докато не се появи reader евристиката по размер
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. Classic engine-ът имаше същия дефект в FILETIME стойностите си, а custom date свойства, добавяни чрез TXLSXWorkbook.CustomProperties.AddDate (записвани като vt:filetime), го споделяха. От v2.384.48 и трите пътя конвертират преди запис и конвертират обратно при четене, щом stamp-ът носи Z, а от v2.384.59 страната на четене отчита и дробни секунди и изрични отмествания +hh:mm / -hh:mm

Самата конверсия е мястото, където наивният fix се проваля. LocalFileTimeToFileTime прилага отместването, което важи в момента, така че януарски stamp, конвертиран през юли, излиза с час грешка във всяка зона с лятно време. HotXLS вместо това вика TzSpecificLocalTimeToSystemTime и SystemTimeToTzSpecificLocalTime, които избират стандартно или лятно време според датата, която се конвертира, а незададена стойност нула минава недокосната, така че никога не се превръща в дата от 1899, отместена с няколко часа

Пътища за конверсия локал-към-UTC в HotXLS за януарски stamp 17:00 CET, записан през юли: LocalFileTimeToFileTime прилага днешното лятно отместване и каца с час грешка на 15:00Z, докато TzSpecificLocalTimeToSystemTime избира отместването от собствената дата на stamp-а и записва коректните 16:00Z
Отместването на зоната принадлежи на собствената дата на stamp-а, не на текущите правила на машината — един Windows API избира правилната страна на смяната на лятното време, другият тихо мести януарски stamp-ове, конвертирани през юли, с един час
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 при четене на timestamp-ове?

W3CDTF reader-ът на HotXLS конвертира всяка зонирана форма от профила от v2.384.59 нататък, а единственият случай, който още оставя настрана, е време без зона. Преди този release parser-ът взимаше първите 19 символа и конвертираше от UTC само когато символ 20 беше Z, така че stamp с дробни секунди (01:30:00.5Z) или изрично отместване (+08:00) се четеше като локално време без корекция и излизаше с грешка от отместването на зоната. От HotXLS 2.384.59 Created, Modified и custom свойствата с дата парсват дробни секунди с произволна дължина, Z и отмествания +hh:mm / -hh:mm, конвертират момента към UTC и после към локално време, а date-only stamp като 2026-07-01 четат като тази дата. Stamp с време, но без маркер за зона — което W3CDTF профилът не позволява, а ECMA-376 Part 2 няма правило за него — все още се чете непроменен като локално време, а stamp, който изобщо не се парсва, се връща като нула. Работни книги, минали през Excel, са наред; пакети, произведени от други генератори, които изхвърлят зоната, заслужават точкова проверка

Файловете, записани от по-стари HotXLS build-ове, са другата честна граница. XLSX stamp, записан преди v2.384.48, е било локално време, облякло Z, и нищо във файла не го различава от коректен, така че текущият reader го мести с отместването на зоната. Classic FILETIME stamp-ове от тези build-ове получават същото местене, а дата на създаване, записана преди v2.384.17, допълнително се чете с един ден късно, защото излишният ден на старата константа също не може да бъде засечен; само edit-time кодирането и разположението в $0E имат разпознаваема сигнатура. Имайте предвид също, че API стойността е локална за машината, която чете, така че service в UTC и desktop в Токио ще докладват различни CreatedDate за един и същ файл, и двете коректни

Как да тествате документните timestamp-ове?

Тествайте документните timestamp-ове срещу нещо, което вашият код не е писал. И двата бъга минаха през проверка запис-и-повторно-отваряне, защото симетрична грешка е невидима за симетричен тест. Сравнявайте с работна книга, записана от Excel, или assert-вайте суровите байтове и XML текста след запис, и пускайте suite-а на машина, настроена на зона различна от UTC, с тестова дата от двете страни на смяна на лятното време. Build agent, който върви в UTC, ще прегърне стария, счупен код с удоволствие

Документните timestamp-ове са дребни, но именно по тях сортират системите за записи, search индексите и audit trail-овете, а дата, грешаща с ден или с осем часа, е по-лоша от липсваща, защото никой не я поставя под съмнение. HotXLS Delphi spreadsheet компонента поема епохната аритметика, property ID-тата и UTC конверсията и за .xls, и за .xlsx, така че вашият код може да задава обикновени локални TDateTime стойности и да остави файловия формат на библиотеката