Technický článek

Časová razítka dokumentu Excel v Delphi: FILETIME, UTC a DST

HotXLS ukládá časová razítka vlastností dokumentu Excel uvnitř souboru jako UTC a přes API je vystavuje jako lokální čas: TXLSWorkbook.CreatedDate a LastSavedDate pro .xls, TXLSXWorkbook.Created a Modified pro .xlsx. Od v2.384.48 oba engine konvertují lokální čas při zápisu na UTC a při čtení zpět, a to podle pravidel letního času platných na datu samotného razítka. Cesta k tomu vedla přes dvě opravy a oba bugy přežily ze stejného trapného důvodu: každý automatizovaný round trip prošel, zatímco panel Excelu File > Info ukazoval špatný den nebo špatnou hodinu. Pokud jste četli náš přehled Nastavení metadat a vlastností dokumentu Excelu v Delphi s HotXLS, tady je místo, kde data přestávají být jednoduché hodnoty

Proč test uložit-a-znovu-otevřít schoval chybu o den?

Vlastní round trip chybu schoval, protože writer a reader sdíleli stejnou špatnou konstantu, takže se chyba sama zrušila. Datum v OLE property setu je FILETIME, 64bitový počet tiků po 100 nanosekundách od 1601-01-01 UTC ([MS-DTYP] §2.3.3), zatímco Delphi TDateTime počítá dny od 1899-12-30, od téhož sériového počátku, o kterém pojednává Datumové serialy Excelu v Delphi: 1900 vs 1904 a numFmt. Mezera mezi oběma epochami je 109205 dní, což ověříte bez kalendáře: 25569 (Unixová epocha jako TDateTime) plus 109205 dá 134774, Unixovou epochu počítanou v dnech FILETIME. HotXLS buildy před v2.384.17 používaly 109206, takže každé razítko vytvoření i uložení se zapisovalo o den později a četlo o den dřív. Test suite viděl hodnotu, kterou přiřadil; Excel viděl zítřek

const
  // dny od epochy FILETIME (1601-01-01) k epoše TDateTime (1899-12-30)
  // kontrola: 25569 + 109205 = 134774, Unixová epocha v dnech FILETIME
  FileTimeDayBias = 109205;

function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
  // Nejdřív zaokrouhlit na celé milisekundy, pak škálovat na tiky po 100 ns.
  // Přímé škálování Double na tiky z 04:00 udělá 03:59:59.9999
  Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
Časová osa HotXLS s epochou FILETIME 1601-01-01, epochou TDateTime 1899-12-30 a Unixovou epochou 1970, ukazující bias 109205 dní za UtcDateTimeToFileTimeTicks a to, jak buildy před v2.384.17 zapisovaly každé razítko CreatedDate o den později a četly ho s 109206 o den dřív
Skica UtcDateTimeToFileTimeTicks drží bias tam, kde se špatná konstanta sama zruší — symetrický test ulož-a-znovu-otevři viděl přiřazenou hodnotu, zatímco Info panel Excelu ukazoval zítřek

Zaokrouhlovací komentář v téhle skice je druhá, menší lekce ze stejného kódu. Přímé vynásobení zlomkového TDateTime hodnotou 864 000 000 000 tiků na den nechá do spodních číslic prosáknout chybu binárního floating-pointu a razítko přesně 04:00 se vrátilo jako 03:59:59.9999. HotXLS v2.384.48 zaokrouhluje na celé milisekundy dřív, než škáluje, takže hodnoty na celou hodinu přežijí cestu nedotčené. Týž release přidal krok s časovým pásmem, který ta skica záměrně vynechává, protože vstup je tady už v UTC

Která property ID SummaryInformation drží data?

V property setu \005SummaryInformation definovaném [MS-OLEPS] žije čas vytvoření pod property ID $0C (PIDSI_CREATE_DTM), čas posledního uložení pod $0D (PIDSI_LASTSAVE_DTM) a celkový čas editace pod $0A (PIDSI_EDITTIME). Starší buildy HotXLS zapisovaly razítko uložení do $0E, což je PIDSI_PAGECOUNT, takže Excel neměl co ukázat jako datum uložení a property s počtem stran držela časové razítko. Od v2.384.17 reader tohle legacy rozložení také respektuje: když $0D chybí a $0E nese VT_FILETIME, bere se hodnota jako čas posledního uložení. Každé přečtené PROPVARIANT se teď navíc uvolňuje přes PropVariantClear, protože poškozený soubor může pod kterýmkoli z těchto ID zaparkovat string. Pokud chcete tyhle streamy vidět na vlastní oči, průvodce Čtení souborů OLE2 Compound File v Delphi bez COM IStorage ukazuje, jak k nim dojít

PIDSI_EDITTIME je pastka uvnitř pastky. Property je typovaná jako VT_FILETIME, ale drží dobu trvání, syrový počet uplynulých tiků po 100 ns bez přičtené epochy. Starý writer s ní zacházel jako s datem, dělil EditTimeMinutes 1440 a tlačil výsledek skrz konverzi epochy, takže 125 minut editace dopadlo do souboru jako zhruba 299 let. Současný reader pozná tohle kódování podle velikosti: žádná editační relace netrvá tři staletí, takže každé hodnotě 109206 dní a víc se před vyplněním EditTimeMinutes odečte legacy offset

Mapa property setu 005SummaryInformation v HotXLS, kde PIDSI_CREATE_DTM na $0C drží čas vytvoření, PIDSI_LASTSAVE_DTM na $0D razítko uložení, PIDSI_EDITTIME na $0A syrovou dobu trvání místo data a $0E PIDSI_PAGECOUNT slot, který starší buildy zneužívaly pro časová razítka
PIDSI_EDITTIME je pastka uvnitř pastky — typované VT_FILETIME, ale drží uplynulé tiky bez epochy, což jednou změnilo 125 minut editace na zhruba 299 let, než přišla heuristika readeru založená na velikosti
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Title := 'Q3 settlement';
    // Hodnoty API jsou lokální čas; soubor ukládá UTC FILETIMEy
    Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
    Book.LastSavedDate := Now;     // HotXLS zapisuje, co přiřadíte, samo si Now nevkládá
    Book.RevisionNumber := 7;
    Book.EditTimeMinutes := 125;   // doba trvání, uložená jako syrové tiky
    if Book.SaveAs('settlement.xls') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Book.Free;
  end;
end;

Proč byla data XLSX vedle přesně o offset časového pásma?

Data v XLSX byla vedle o offset pásma, protože dcterms:created a dcterms:modified v docProps/core.xml jsou hodnoty W3CDTF označené Z, což v modelu core properties ECMA-376 Part 2 znamená UTC, a HotXLS tam dřív razítkoval lokální čas s touhle Z přilepenou. Sešit vytvořený v 09:30 na stroji v UTC+8 nesl 09:30:00Z a Excel na tomže stroji z něj udělal 17:30. Classic engine měl stejnou vadu ve svých hodnotách FILETIME a vlastní datové properties přidávané přes TXLSXWorkbook.CustomProperties.AddDate (zapisované jako vt:filetime) ji sdílely taky. Od v2.384.48 všechny tři cesty konvertují před zápisem a při čtení zpět vždy, když razítko nese Z, a od v2.384.59 čtecí strana navíc zvládá zlomkové sekundy a explicitní offsety +hh:mm / -hh:mm

Samotná konverze je místo, kde naivní oprava selže. LocalFileTimeToFileTime aplikuje offset, který platí právě teď, takže lednové razítko konvertované v červenci vyjde o hodinu vedle v každém pásmu s letním časem. HotXLS místo toho volá TzSpecificLocalTimeToSystemTime a SystemTimeToTzSpecificLocalTime, které vybírají standardní nebo letní čas podle konvertovaného data, a nenastavená nulová hodnota projde bez doteku, takže se nikdy nestane datem z roku 1899 posunutým o pár hodin

Konverzní cesty lokální čas na UTC v HotXLS pro lednové razítko 17:00 CET uložené v červenci: LocalFileTimeToFileTime aplikuje dnešní letní offset a dopadne o hodinu vedle na 15:00Z, zatímco TzSpecificLocalTimeToSystemTime vybere offset z data samotného razítka a zapíše správné 16:00Z
Offset pásma patří datu razítka, ne aktuálním pravidlům stroje — jedno Windows API vybere správnou stranu změny letního času, druhé potichu posune lednová razítka konvertovaná v červenci 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 nastaveném na Central European Time teď core.xml drží
    // <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
    // (UTC+2 v červenci), zatímco ApprovedOn se zapíše jako 16:00Z (UTC+1 v lednu)
  finally
    Book.Free;
  end;
end;

Co HotXLS při čtení razítek nekonvertuje?

W3CDTF reader HotXLS konvertuje každou zónou označenou formu profilu od v2.384.59 a jediný případ, který pořád nechává na pokoji, je čas bez zóny. Před tímto relesem bral parser prvních 19 znaků a konvertoval z UTC jen tehdy, když dvacátý znak byl Z, takže razítko se zlomkovými sekundami (01:30:00.5Z) nebo explicitním offsetem (+08:00) se četlo jako lokální čas bez korekce a končilo vedle o offset pásma. Od HotXLS 2.384.59 parsují Created, Modified i vlastní properties s datem zlomkové sekundy libovolné délky, Z a offsety +hh:mm / -hh:mm, konvertují okamžik na UTC a pak na lokální čas a datumové razítko jako 2026-07-01 čtou jako ono datum. Razítko s časem, ale bez značky zóny, které profil W3CDTF neumožňuje a ECMA-376 Part 2 na něj nedává pravidlo, se pořád čte jako lokální čas beze změny a razítko, které se neparsuje vůbec, se vrací jako nula. Sešity, které prošly Excelem, jsou v pořádku; balíčky od jiných generátorů, které zónu upustí, si zaslouží spot check

Soubory zapsané staršími buildy HotXLS jsou druhá upřímná hranice. XLSX razítko zapsané před v2.384.48 byl lokální čas v převleku za Z a nic v souboru ho neodliší od správného, takže současný reader ho posune o offset pásma. Classic FILETIME razítka z těchhle buildů dostanou tentýž posun a datum vytvoření zapsané před v2.384.17 se navíc čte o den později, protože ten extra den staré konstanty taky nejde detekovat; rozpoznatelný podpis má jen kódování edit-času a umístění do $0E. Mějte dál na paměti, že hodnota API je lokální pro stroj, který čte, takže služba běžící v UTC a desktop v Tokiu nahlásí pro týž soubor různé hodnoty CreatedDate, obě správné

Jak testovat časová razítka dokumentu?

Testujte časová razítka dokumentu proti něčemu, co váš vlastní kód nezapsal. Oba tyhle bugy prošly kontrolou ulož-a-znovu-otevři, protože symetrickou chybu symetrický test nevidí. Porovnávejte se sešitem uloženým Excelem, nebo assertujte syrové byty a XML text po uložení a pusťte suite na stroji nastaveném na ne-UTC pásmo s testovacím datem na obou stranách změny letního času. Build agent běžící v UTC starý rozbitý kód ochotně pustí zeleně

Časová razítka dokumentu jsou malá, ale přesně podle nich řadí records systémy, vyhledávací indexy a auditní stopy a datum, které je vedle o den nebo o osm hodin, je horší než chybějící, protože si ho nikdo nezpochybní. Komponenta HotXLS pro Excel v Delphi odvede epochovou aritmetiku, property ID i UTC konverzi pro .xls i .xlsx, takže váš kód může přiřazovat obyčejné lokální hodnoty TDateTime a nechat formát souboru na knihovně