Odborný článok

Časové pečiatky dokumentov v Delphi: FILETIME, UTC a DST

HotXLS ukladá časové pečiatky vlastností dokumentu Excel vnútri súboru ako UTC a cez API ich vystavuje ako lokálny čas: TXLSWorkbook.CreatedDate a LastSavedDate pre .xls, TXLSXWorkbook.Created a Modified pre .xlsx. Od v2.384.48 obe enginey konvertujú lokálny čas na UTC pri zápise a späť pri čítaní, s pravidlami letného času, ktoré platia na dátume samotnej pečiatky. Cesta k tomu viedla cez dve opravy a oba bugy prežili z rovnakého nepríjemného dôvodu: každý automatizovaný round trip prešiel, zatiaľ čo panel Excelu File > Info ukazoval zlý deň alebo zlú hodinu. Ak ste čítali náš prehľad o nastavovaní vlastností dokumentu Excel v Delphi, toto je časť, kde dátumy prestávajú byť jednoduché hodnoty

Prečo test ulož-a-otvor skryl chybu o jeden deň?

Sebakruhový round trip chybu skryl, lebo writer a reader zdieľali tú istú zlú konštantu, takže sa chyba sama zrušila. Dátum v OLE property sete je FILETIME, 64-bitový počet tickov po 100 nanosekundách od 1601-01-01 UTC ([MS-DTYP] §2.3.3), zatiaľ čo Delphi TDateTime počíta dni od 1899-12-30, toho istého serial počiatku, aký popisuje Excel date serialy v Delphi a systémy 1900 vs 1904. Medzera medzi epochami je 109205 dní, čo overíte bez kalendára: 25569 (Unix epocha ako TDateTime) plus 109205 dá 134774, Unix epochu počítanú v dňoch FILETIME. Buildy HotXLS pred v2.384.17 používali 109206, takže každá pečiatka vytvorenia aj uloženia sa zapisovala o deň neskôr a čítala o deň skôr. Test suite videl hodnotu, ktorú sám priradil; Excel videl zajtra

const
  // dni od epochy FILETIME (1601-01-01) po epochu TDateTime (1899-12-30)
  // kontrola: 25569 + 109205 = 134774, Unix epocha v dňoch FILETIME
  FileTimeDayBias = 109205;

function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
  // Najprv zaokrúhlite na celé milisekundy, potom škálujte na ticky po 100 ns.
  // Priame škálovanie Double na ticky zmení 04:00 na 03:59:59.9999
  Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
Časová os HotXLS s epochou FILETIME 1601-01-01, epochou TDateTime 1899-12-30 a Unix epochou 1970, ukazujúca bias 109205 dní za UtcDateTimeToFileTimeTicks a to, ako buildy pred v2.384.17 zapisovali každú pečiatku CreatedDate o deň neskôr a čítali ju o deň skôr s 109206
Náčrt UtcDateTimeToFileTimeTicks drží bias tam, kde sa zlá konštanta sama zruší — symetrický test ulož-a-otvor videl hodnotu, ktorú sám priradil, kým Info panel Excelu ukazoval zajtra

Komentár o zaokrúhľovaní v tom náčrte je druhá, menšia lekcia z toho istého kódu. Násobenie zlomkového TDateTime priamo 864 000 000 000 tickmi za deň nechá presiaknuť chybu binárneho floating pointu do spodných číslic a pečiatka presne 04:00 sa vrátila ako 03:59:59.9999. HotXLS v2.384.48 zaokrúhľuje na celé milisekundy pred škálovaním, takže hodnoty na celú hodinu prežijú cestu nedotknuté. To isté vydanie pridalo krok s časovým pásmom, ktorý tento náčrt zámierne vynecháva, pretože vstup je tu už UTC

Ktoré property ID v SummaryInformation nesú dátumy?

V property sete \005SummaryInformation definovanom [MS-OLEPS] žije čas vytvorenia pod property ID $0C (PIDSI_CREATE_DTM), čas posledného uloženia pod $0D (PIDSI_LASTSAVE_DTM) a celkový čas úprav pod $0A (PIDSI_EDITTIME). Staršie buildy HotXLS zapisovali pečiatku uloženia do $0E, čo je PIDSI_PAGECOUNT, takže Excel nemal čo ukázať ako dátum uloženia a vlastnosť počtu stránok nosila časovú pečiatku. Od v2.384.17 reader toto legacy rozloženie tiež rešpektuje: keď $0D chýba a $0E nesie VT_FILETIME, hodnota sa berie ako čas posledného uloženia. Každé prečítané PROPVARIANT sa teraz uvoľňuje cez PropVariantClear, lebo deformovaný súbor dokáže zaparkovať reťazec pod ktorýmkoľvek z týchto ID. Ak chcete tie streamy vidieť na vlastné oči, návod na čítanie OLE2 compound súborov v Delphi bez COM IStorage ukazuje, ako sa k nim dostať

PIDSI_EDITTIME je pasca vnútri pasce. Vlastnosť je typovaná ako VT_FILETIME, ale nesie trvanie, surový počet uplynulých tickov po 100 ns bez prirátanej epochy. Starý writer sa naňho pozeral ako na dátum, delil EditTimeMinutes číslom 1440 a výsledok ťahal cez konverziu epochy, takže 125 minút úprav pristalo v súbore ako zhruba 299 rokov. Súčasný reader rozpozná toto kódovanie podľa veľkosti: žiadna reálna editačná session netrvá tri storočia, takže od akejkoľvek hodnoty 109206 dní a viac sa pred vyplnením EditTimeMinutes odráta legacy offset

Mapa property setu 005SummaryInformation v HotXLS, kde PIDSI_CREATE_DTM na $0C nesie čas vytvorenia, PIDSI_LASTSAVE_DTM na $0D pečiatku uloženia, PIDSI_EDITTIME na $0A surové trvanie namiesto dátumu a $0E PIDSI_PAGECOUNT pozíciu, ktorú staršie buildy zneužívali pre pečiatky
PIDSI_EDITTIME je pasca vnútri pasce — typovaný VT_FILETIME, ale nesúci uplynulé ticky bez epochy, čo raz zmenilo 125 minút úprav na zhruba 299 rokov, kým nepricválala reader heuristika podľa veľkosti
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Title := 'Q3 settlement';
    // hodnoty API sú lokálny čas; súbor ukladá UTC FILETIMEy
    Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
    Book.LastSavedDate := Now;     // HotXLS zapisuje, čo priradíte, sám Now nepečiatkuje
    Book.RevisionNumber := 7;
    Book.EditTimeMinutes := 125;   // trvanie, uložené ako surové ticky
    if Book.SaveAs('settlement.xls') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Book.Free;
  end;
end;

Prečo boli dátumy v XLSX posunuté presne o offset časového pásma?

Dátumy v XLSX boli posunuté o offset pásma, pretože dcterms:created a dcterms:modified v docProps/core.xml sú hodnoty W3CDTF označené Z, čo v modeli core properties ECMA-376 Part 2 znamená UTC, a HotXLS pečiatkoval lokálny čas s týmto Z pripojeným. Workbook vytvorený o 09:30 na stroji v UTC+8 nosil 09:30:00Z a Excel na tom istom stroji ho prekonvertoval na 17:30. Klasický engine mal identickú chybu vo svojich hodnotách FILETIME a vlastné dátumové vlastnosti pridané cez TXLSXWorkbook.CustomProperties.AddDate (zapisované ako vt:filetime) ju zdieľali tiež. Od v2.384.48 všetky tri cesty konvertujú pred zápisom a pri čítaní späť vždy, keď pečiatka nesie Z, a od v2.384.59 čítacia strana rešpektuje aj zlomky sekúnd a explicitné offsety +hh:mm / -hh:mm

Samotná konverzia je miesto, kde naivná oprava zlyhá. LocalFileTimeToFileTime aplikuje offset platný práve teraz, takže januárová pečiatka konvertovaná v júli vyjde o hodinu zle v ľubovoľnom pásme s letným časom. HotXLS namiesto toho volá TzSpecificLocalTimeToSystemTime a SystemTimeToTzSpecificLocalTime, ktoré vyberajú štandardný alebo letný čas podľa konvertovaného dátumu, a nenastavená nulová hodnota prejde bez zmeny, aby sa nikdy nezmenila na dátum 1899 posunutý o pár hodín

Konverzné cesty HotXLS z lokálneho času na UTC pre januárovú pečiatku 17:00 CET uloženú v júli: LocalFileTimeToFileTime aplikuje dnešný letný offset a dopadne o hodinu zle na 15:00Z, zatiaľ čo TzSpecificLocalTimeToSystemTime vyberie offset z dátumu samotnej pečiatky a zapíše správnych 16:00Z
Offset pásma patrí dátumu samotnej pečiatky, nie aktuálnym pravidlám stroja — jedno Windows API vyberie správnu stranu zmeny letného času, druhé poticho posunie januárske pečiatky konvertované v júli o hodinu
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');
    // Na stroji nastavenom na Central European Time teraz core.xml obsahuje
    // <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
    // (UTC+2 v júli), zatiaľ čo ApprovedOn sa zapisuje ako 16:00Z (UTC+1 v januári)
  finally
    Book.Free;
  end;
end;

Čo HotXLS pri čítaní pečiatok nekonvertuje?

W3CDTF reader v HotXLS konvertuje od v2.384.59 každú zónovú formu profilu a jediný prípad, ktorý naďalej necháva na pokoji, je čas bez pásma. Pred týmto vydaním parser vzal prvých 19 znakov a konvertoval z UTC len vtedy, keď dvadsiaty znak bol Z, takže pečiatka so zlomkami sekúnd (01:30:00.5Z) alebo s explicitným offsetom (+08:00) sa čítala ako lokálny čas bez úpravy a skončila posunutá o offset pásma. Od HotXLS 2.384.59 Created, Modified a vlastné vlastnosti s hodnotou dátumu parsujú zlomky sekúnd ľubovoľnej dĺžky, Z a offsety +hh:mm / -hh:mm, konvertujú okamih na UTC a potom na lokálny čas a pečiatku iba s dátumom, ako 2026-07-01, čítajú ako ten dátum. Pečiatka s časom, ale bez zónového markeru, ktorú W3CDTF profil nepripúšťa a ECMA-376 Part 2 na ňu nemá pravidlo, sa stále číta ako nezmenený lokálny čas a pečiatka, ktorá sa neparsuje vôbec, sa vráti ako nula. Workbooky, ktoré prešli cez Excel, sú v poriadku; balíky od iných generátorov, ktoré pásmo zahodia, si zaslúžia spot check

Súbory zapísané staršími buildmi HotXLS sú druhá úprimná hranica. XLSX pečiatka zapísaná pred v2.384.48 bol lokálny čas v oblečení Z a nič v súbore ho neodlíši od správnej, takže súčasný reader ho posunie o offset pásma. Klasické pečiatky FILETIME z týchto buildov dostanú ten istý posun a dátum vytvorenia zapísaný pred v2.384.17 sa navyše načíta o deň neskôr, lebo extra deň starej konštanty tiež nemožno detegovať; rozpoznateľný podpis má len kódovanie edit-time a umiestnenie v $0E. Majte na pamäti aj to, že hodnota API je lokálna pre stroj, ktorý číta, takže služba bežiaca v UTC a desktop v Tokiu ohlásia pre ten istý súbor rôzne hodnoty CreatedDate, obe správne

Ako testovať časové pečiatky dokumentov?

Časové pečiatky dokumentov testujte proti niečomu, čo váš vlastný kód nezapisoval. Oba tieto bugy prešli kontrolou ulož-a-otvor, lebo symetrická chyba je pre symetrický test neviditeľná. Porovnávajte s workbookom uloženým Excelom, alebo assertujte surové bajty a XML text po uložení a pustite suite na stroji nastavenom na ne-UTC pásmo s testovacím dátumom na každej strane zmeny letného času. Build agent bežiaci v UTC starý, pokazený kód ochotne odsúhlasí

Časové pečiatky dokumentov sú malé, ale podľa nich zoraďujú záznamové systémy, search indexy a audit trail a dátum posunutý o deň alebo osem hodín je horší ako chýbajúci, lebo si ho nikto nespochybní. HotXLS Delphi spreadsheet component rieši aritmetiku epoch, property ID aj UTC konverziu pre .xls aj .xlsx, takže váš kód môže priraďovať obyčajné lokálne hodnoty TDateTime a formát súboru nechať knižnici