Techninis straipsnis

Excel dokumentų laiko žymos Delphi: FILETIME, UTC ir DST

HotXLS Excel dokumentų savybių laiko žymas faile saugo kaip UTC, o per API atiduoda jas kaip vietinį laiką: TXLSWorkbook.CreatedDate ir LastSavedDate .xls formatui, TXLSXWorkbook.Created ir Modified .xlsx formatui. Nuo v2.384.48 abu varikliai rašydami vietinį laiką paverčia UTC, o skaitodami — atgal, taikydami vasaros laiko taisykles, galiojančias pačios žymos datai. Iki ten nuėjus prireikė dviejų pataisymų, ir abu bugai išgyveno dėl tos pačios gėdingos priežasties: kiekvienas automatizuotas round trip pereidavo, o Excel File > Info panelė rodė neteisingą dieną ar neteisingą valandą. Jei skaitėte mūsų apžvalgą kaip nustatyti Excel dokumento savybes Delphi programoje, tai ta vieta, kur datos nustoja būti paprastomis reikšmėmis

Kodėl išsaugojimo ir atvėrimo testas paslėpdavo vienos dienos klaidą?

Savas round trip klaidą slėpė todėl, kad rašytojas ir skaitytojas dalijosi ta pačia neteisinga konstanta, tad klaida panaikindavo pati save. OLE savybių rinkinio data yra FILETIME — 64 bitų 100 nanosekundžių tiktų skaičius nuo 1601-01-01 UTC ([MS-DTYP] §2.3.3), o Delphi TDateTime skaičiuoja dienas nuo 1899-12-30, tos pačios serijinės kilmės, aprašytos Excel datų serijose Delphi ir 1900 bei 1904 sistemose. Tarp dviejų epochų yra 109205 dienos, ką galite patikrinti be kalendoriaus: 25569 (Unix epocha kaip TDateTime) plus 109205 duoda 134774, Unix epochą, suskaičiuotą FILETIME dienomis. HotXLS versijos iki v2.384.17 naudojo 109206, todėl kiekviena sukūrimo ir įrašymo žyma būdavo parašoma diena vėliau ir skaitoma diena anksčiau. Testų rinkinys matė pačią jam priskirtą reikšmę; Excel matydavo rytojų

const
  // dienos nuo FILETIME epochos (1601-01-01) iki TDateTime epochos (1899-12-30)
  // patikra: 25569 + 109205 = 134774, Unix epocha FILETIME dienomis
  FileTimeDayBias = 109205;

function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
  // Pirmiausia apvalykite iki sveikų milisekundžių, tada perverskite į 100 ns tiktus.
  // Double perversus tiesiai į tiktus 04:00 virsta 03:59:59.9999
  Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
HotXLS laiko juosta su FILETIME epocha 1601-01-01, TDateTime epocha 1899-12-30 ir 1970 metų Unix epocha, rodanti 109205 dienų poslinkį už UtcDateTimeToFileTimeTicks ir kaip versijos iki v2.384.17 kiekvieną CreatedDate žymą rašė diena vėliau ir skaitė diena anksčiau su 109206
UtcDateTimeToFileTimeTicks eskize poslinkis paliktas ten, kur neteisinga konstanta panaikina pati save — simetriškas išsaugojimo ir atvėrimo testas matė pačią priskirtą reikšmę, o Excel Info panelė rodė rytojų

Apvalinimo komentaras tame eskize — antra, mažesnė pamoka iš to paties kodo. Trupmeninį TDateTime padauginus tiesiai iš 864 000 000 000 tiktų per dieną, dvejetainės slankiojo kablelio klaida susprogsdavo į žemiausius skaitmenis, ir tiksliai 04:00 žyma sugrįzdavo kaip 03:59:59.9999. HotXLS v2.384.48 prieš masteliokeitimą apvalina iki sveikų milisekundžių, tad tikslaus laiko reikšmės išgyvena kelionę nesugadintos. Tas pats leidimas pridėjo laiko zonos žingsnį, kurį šis eskizas sąmoningai palieka nuošaly, nes čia įvestis jau yra UTC

Kurie SummaryInformation property ID saugo datas?

\005SummaryInformation savybių rinkinyje, apibrėžtame [MS-OLEPS], sukūrimo laikas gyvena po property ID $0C (PIDSI_CREATE_DTM), paskutinio įrašymo laikas — po $0D (PIDSI_LASTSAVE_DTM), o bendras redagavimo laikas — po $0A (PIDSI_EDITTIME). Senesnės HotXLS versijos paskutinio įrašymo žymą rašydavo į $0E, tai yra PIDSI_PAGECOUNT, tad Excel neturėdavo ką parodyti kaip įrašymo datą, o puslapių skaičiaus savybė laikydavo laiko žymą. Nuo v2.384.17 skaitytojas gerbia ir tą paveldėtą išdėstymą: kai $0D nėra, o $0E neša VT_FILETIME, reikšmė paimama kaip paskutinio įrašymo laikas. Kiekvienas perskaitytas PROPVARIANT dabar ir atlaisvinamas su PropVariantClear, nes sugadintas failas po bet kurio iš tų ID gali padėti string reikšmę. Jei norite tuos srautus pamatyti savo akimis, aprašyme kaip skaityti OLE2 compound failus Delphi programoje be COM IStorage parodoma, kaip prie jų prieiti

PIDSI_EDITTIME yra spąstai spąstuose. Savybė tipuota VT_FILETIME, bet laiko trukmę — žalią praėjusių 100 ns tiktų skaičių be jokios pridėtos epochos. Senas rašytojas ją elgdavo kaip datą: EditTimeMinutes padalydavo iš 1440 ir rezultatą praleisdavo per epochos konversiją, tad 125 redagavimo minutės faile atsidurdavo kaip apie 299 metai. Dabartinis skaitytojas tą koduotę atpažįsta pagal dydį: jokia tikra redagavimo sesija nenutęsia trijų amžių, tad bet kokia 109206 dienų ar daugiau reikšmė prieš užpildant EditTimeMinutes atima paveldėtąjį poslinkį

HotXLS 005SummaryInformation savybių rinkinio žemėlapis, kur PIDSI_CREATE_DTM ties $0C laiko sukūrimo laiką, PIDSI_LASTSAVE_DTM ties $0D — įrašymo žymą, PIDSI_EDITTIME ties $0A — žalią trukmę vietoj datos, o $0E PIDSI_PAGECOUNT — vietą, kurią senesnės versijos klaidingai naudojo laiko žymoms
PIDSI_EDITTIME yra spąstai spąstuose — tipuotas VT_FILETIME, bet laiko praėjusius tiktus be epochos, kas kadaise 125 redagavimo minutes pavertė maždaug 299 metais, kol atsirado pagal dydį sprendžianti skaitytojo heuristika
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Title := 'Q3 settlement';
    // API reikšmės yra vietinis laikas; faile saugomi UTC FILETIME
    Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
    Book.LastSavedDate := Now;     // HotXLS rašo tai, ką priskiriate, pats Now neužregistruoja
    Book.RevisionNumber := 7;
    Book.EditTimeMinutes := 125;   // trukmė, saugoma kaip žali tiktai
    if Book.SaveAs('settlement.xls') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Book.Free;
  end;
end;

Kodėl XLSX datos klydo lygiai per laiko zonos poslinkį?

XLSX datos klydo per zonos poslinkį, nes dcterms:created ir dcterms:modified docProps/core.xml yra W3CDTF reikšmės, pažymėtos Z, kas pagal ECMA-376 Part 2 core properties modelį reiškia UTC, o HotXLS anksčiau žymėdavo vietinį laiką su ta Z prie galo. Darbaknygė, sukurta 09:30 mašinoje UTC+8 zonoje, nešdavo 09:30:00Z, ir Excel toje pačioje mašinoje ją paversdavo 17:30. Klasikinis variklis turėjo identišką ydą savo FILETIME reikšmėse, ir savos datos savybės, pridėtos per TXLSXWorkbook.CustomProperties.AddDate (rašomas kaip vt:filetime), dalijosi ja taip pat. Nuo v2.384.48 visi trys keliai rašydami konvertuoja, o skaitodami konvertuoja atgal, kai tik žyma neša Z, o nuo v2.384.59 skaitanti pusė taip pat gerbia trupmenines sekundes ir aiškius +hh:mm / -hh:mm poslinkius

Pati konversija — ten, kur naivus pataisymas klysta. LocalFileTimeToFileTime taiko poslinkį, galiojantį dabar, tad sausio žyma, konvertuota liepą, išeina per valandą tikslu bet kurioje zonoje su vasaros laiku. HotXLS vietoj to kviečia TzSpecificLocalTimeToSystemTime ir SystemTimeToTzSpecificLocalTime, kurios standartinį ar vasaros laiką renkasi pagal konvertuojamą datą, o nenustatyta nulinė reikšmė praeina neliečiamas, kad niekada nevirstų 1899 metų data, pasislinkusia per keletą valandų

HotXLS vietinio laiko į UTC konversijos keliai sausio 17:00 CET žymai, išsaugotai liepą: LocalFileTimeToFileTime taiko šiandieninį vasaros laiko poslinkį ir atsiduria per valandą šalia ties 15:00Z, o TzSpecificLocalTimeToSystemTime poslinkį renkasi iš pačios žymos datos ir parašo teisingą 16:00Z
Zonos poslinkis priklauso pačios žymos datai, o ne mašinos dabartinėms taisyklėms — vienas Windows API renkasi teisingą vasaros laiko perėjimo pusę, kitas tyliai paslenkia liepą konvertuojamas sausio žymas per valandą
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');
    // Mašinoje, nustatytoje į Central European Time, core.xml dabar laiko
    // <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
    // (UTC+2 liepą), o ApprovedOn rašomas kaip 16:00Z (UTC+1 sausį)
  finally
    Book.Free;
  end;
end;

Ką HotXLS nekonvertuoja skaitant laiko žymas?

HotXLS W3CDTF skaitytojas nuo v2.384.59 konvertuoja kiekvieną profilio formą su zonos žyma, ir vienintelis atvejis, kurį vis dar palieka ramybėje, — laikas be zonos. Iki to leidimo parseris imdavo pirmuosius 19 simbolių ir iš UTC konvertuodavo tik tada, kai dvidešimtas simbolis buvo Z, tad žyma su trupmeninėmis sekundėmis (01:30:00.5Z) ar aiškiu poslinkiu (+08:00) būdavo skaitoma kaip vietinis laikas be jokio pataikymo ir galiausiai klydavo per zonos poslinkį. Nuo HotXLS 2.384.59 Created, Modified ir datos reikšmės custom savybės išskaido bet kokio ilgio trupmenines sekundes, Z bei +hh:mm / -hh:mm poslinkius, akimirksnį paverčia UTC ir paskui vietiniu laiku, o tik datą turinčią žymą, tokią kaip 2026-07-01, skaito kaip tą datą. Žyma su laiku, bet be zonos ženklo, kurios W3CDTF profilis neleidžia ir už kurią ECMA-376 Part 2 neduoda jokios taisyklės, vis dar skaitoma kaip nepakeistas vietinis laikas, o žyma, kuri visai neišsiskaido, sugrįžta kaip nulis. Per Excel praėję darbaknygės yra geros; kitų generatorių pagaminti paketai, išmetantys zoną, nusipelno vietinės patikros

Senesnių HotXLS versijų parašyti failai — kita sąžininga riba. XLSX žyma, parašyta iki v2.384.48, buvo vietinis laikas, apsirengęs Z, ir niekas faile jos neatskiria nuo teisingos, tad dabartinis skaitytojas paslenka ją per zonos poslinkį. Klasikinės FILETIME žymos iš tų versijų gauna tą patį poslinkį, o sukūrimo data, parašyta iki v2.384.17, papildomai skaitoma diena vėliau, nes senosios konstantos papildoma diena taip pat neaptinkama; tik edit-time koduotė ir $0E vietos pasirinkimas turi atpažįstamą parašą. Turėkite omenyje ir tai, kad API reikšmė yra vietinė skaitančiai mašinai, tad servisas, veikiantis UTC zonoje, ir stalinius kompiuteris Tokijo zonoje tam pačiam failui parodys skirtingas CreatedDate reikšmes, abi teisėtas

Kaip turėtumėte testuoti dokumentų laiko žymas?

Testuokite dokumentų laiko žymas prieš kažką, ko jūsų paties kodas nerašė. Abu šie bugai praėjo išsaugojimo ir atvėrimo patikrą, nes simetriška klaida simetriškam testui nematoma. Palyginkite su Excel išsaugota darbaknyge, arba tikrinkite žalius baitus ir XML tekstą po išsaugojimo, ir paleiskite rinkinį mašinoje, nustatytoje ne UTC zonoje, su testavimo data kiekvienoje vasaros laiko perėjimo pusėje. UTC zonoje veikiantis build agentas seną, sulaužytą kodą mielai praleis

Dokumentų laiko žymos yra smulkmena, bet būtent pagal jas rikiuojasi įrašų sistemos, paieškos indeksai ir audito pėdsakai, o data, klydstanti per dieną ar per aštuonias valandas, yra blogesnė už nesamą, nes niekas dėl jos nesukelia abejonės. HotXLS Delphi skaičiuoklės komponentas epochų aritmetiką, property ID ir UTC konversiją atlieka tiek .xls, tiek .xlsx formatams, tad jūsų kodas gali priskirti paprastas vietines TDateTime reikšmes ir failo formatą palikti bibliotekai