Teknisk artikel

Excel-dokumenttidsstempler i Delphi: FILETIME, UTC og DST

HotXLS gemmer Excel-dokumentegenskabernes tidsstempler som UTC inde i filen og eksponerer dem som lokal tid gennem API'en: TXLSWorkbook.CreatedDate og LastSavedDate for .xls, TXLSXWorkbook.Created og Modified for .xlsx. Siden v2.384.48 konverterer begge motorer lokal tid til UTC ved skrivning og tilbage ved læsning, med de regler for sommertid, der gælder på stempellets egen dato. Vejen dertil krævede to fixes, og begge bugs havde overlevet af samme pinlige grund: enhver automatiseret round trip bestod, mens Excels File > Info-pane viste den forkerte dag eller den forkerte time. Har du læst vores overview af setting Excel document properties in Delphi, er dette det sted, hvor datoerne holder op med at være simple værdier

Hvorfor skjulte en gem-og-genåbn-test en fejl på én dag?

En selv-round trip skjulte fejlen, fordi writer og reader delte samme forkerte konstant, så sejligheden annullerede sig selv. En OLE property set-dato er en FILETIME, en 64-bit tælling af 100-nanosekund-ticks siden 1601-01-01 UTC ([MS-DTYP] §2.3.3), mens en Delphi-TDateTime tæller dage fra 1899-12-30, samme serial-origin som dækket i Excel date serials i Delphi og 1900- vs 1904-systemerne. Mellemrummet mellem de to epoker er 109205 dage, hvilket du kan tjekke uden en kalender: 25569 (Unix-epoken som TDateTime) plus 109205 giver 134774, Unix-epoken talt i FILETIME-dage. HotXLS-builds før v2.384.17 brugte 109206, så hvert oprettelses- og gemmestempel blev skrevet én dag for sent og læst én dag for tidligt. Test-suitten så den værdi, den selv havde tildelt; Excel så i morgen

const
  // dage fra FILETIME-epoken (1601-01-01) til TDateTime-epoken (1899-12-30)
  // tjek: 25569 + 109205 = 134774, Unix-epoken i FILETIME-dage
  FileTimeDayBias = 109205;

function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
  // Rund først til hele millisekunder, skalér derefter til 100 ns ticks.
  // Skalering af Double direkte til ticks gør 04:00 til 03:59:59.9999
  Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
HotXLS tidslinje for FILETIME-epoken 1601-01-01, TDateTime-epoken 1899-12-30 og Unix-epoken 1970, som viser den 109205-dages bias bag UtcDateTimeToFileTimeTicks, og hvordan builds før v2.384.17 skrev hvert CreatedDate-stempel én dag for sent og læste det én dag for tidligt med 109206
UtcDateTimeToFileTimeTicks-skitsen holder bias dér, hvor en forkert konstant annullerer sig selv — en symmetrisk gem-og-genåbn-test så den tildelte værdi, mens Excel Info-pane viste i morgen

Afrundingskommentaren i den skitse er den anden, mindre lærestreg fra samme kode. Multiplicerer man en fractionel TDateTime direkte med 864.000.000.000 ticks pr. dag, lader man binær floating point-fejl lække ind i de nederste cifre, og et stempel på præcis 04:00 kom tilbage som 03:59:59.9999. HotXLS v2.384.48 runder til hele millisekunder før skalering, så hele-timers-værdier overlever turen intakte. Samme release tilføjede det tidszonetrin, som skitsen med vilje udelader, for inputtet her er allerede UTC

Hvilke SummaryInformation property-id'er holder datoerne?

I det af [MS-OLEPS] definerede \005SummaryInformation property set bor oprettelsestidspunktet under property ID $0C (PIDSI_CREATE_DTM), sidst-gemt-tidspunktet under $0D (PIDSI_LASTSAVE_DTM) og den samlede redigeringstid under $0A (PIDSI_EDITTIME). Ældre HotXLS-builds skrev gemmestemplet til $0E, som er PIDSI_PAGECOUNT, så Excel havde ingen gemmedato at vise og en sideantalsegenskab med et tidsstempel. Siden v2.384.17 respekterer readeren også det gamle layout: mangler $0D, og bærer $0E en VT_FILETIME, tages værdien som sidst-gemt-tidspunkt. Enhver læst PROPVARIANT frigives nu også med PropVariantClear, for en misdannet fil kan parkere en streng under vilkårlige af disse id'er. Vil du se disse streams med egne øjne, viser gennemgangen i reading OLE2 compound files in Delphi without COM IStorage, hvordan du når dem

PIDSI_EDITTIME er fælden inde i fælden. Egenskaben er typet VT_FILETIME men holder en varighed, det rå antal forløbne 100 ns-ticks uden nogen epoke lagt til. Den gamle writer behandlede den som en dato, dividerede EditTimeMinutes med 1440 og pressede resultatet gennem epokekonverteringen, så 125 minutters redigering landede i filen som omtrent 299 år. Den nuværende reader genkender den encoding på størrelsen: ingen reel redigeringssession strækker sig over tre århundreder, så enhver værdi på 109206 dage eller derover får det gamle offset trukket fra, før EditTimeMinutes udfyldes

HotXLS-kort over 005SummaryInformation property settet, hvor PIDSI_CREATE_DTM på $0C holder oprettelsestidspunktet, PIDSI_LASTSAVE_DTM på $0D gemmestemplet, PIDSI_EDITTIME på $0A en rå varighed frem for en dato, og $0E PIDSI_PAGECOUNT den plads, ældre builds misbrugte til tidsstempler
PIDSI_EDITTIME er fælden inde i fælden — typet VT_FILETIME men med forløbne ticks uden epoke, som engang gjorde 125 redigeringsminutter til omtrent 299 år, indtil en størrelsesbaseret reader-heuristik kom til
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Title := 'Q3 settlement';
    // API-værdier er lokal tid; filen gemmer UTC-FILETIMEs
    Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
    Book.LastSavedDate := Now;     // HotXLS skriver, hvad du tildeler, den stampler ikke Now
    Book.RevisionNumber := 7;
    Book.EditTimeMinutes := 125;   // en varighed, gemt som rå ticks
    if Book.SaveAs('settlement.xls') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Book.Free;
  end;
end;

Hvorfor afveg XLSX-datoer med præcis tidszoneforskellen?

XLSX-datoer afveg med zoneforskellen, fordi dcterms:created og dcterms:modified i docProps/core.xml er W3CDTF-værdier mærket med Z, hvilket betyder UTC under core properties-modellen i ECMA-376 Part 2, og HotXLS stamplede tidligere lokal tid med det Z på. En arbejdsbog oprettet kl. 09:30 på en maskine i UTC+8 bar 09:30:00Z, og Excel på samme maskine konverterede den til 17:30. Classic-motoren havde den identiske fejl i sine FILETIME-værdier, og selvvalgte date-egenskaber tilføjet gennem TXLSXWorkbook.CustomProperties.AddDate (skrevet som vt:filetime) delte den også. Siden v2.384.48 konverterer alle tre stier før skrivning og tilbage ved læsning, hver gang stempellet bærer et Z, og siden v2.384.59 respekterer læsesiden også fractionelle sekunder og eksplicitte +hh:mm- / -hh:mm-offsets

Konverteringen selv er der, hvor en naiv fix tager fejl. LocalFileTimeToFileTime anvender det offset, der gælder lige nu, så et januarstempel konverteret i juli lander en time forkert i enhver zone med sommertid. HotXLS kalder i stedet TzSpecificLocalTimeToSystemTime og SystemTimeToTzSpecificLocalTime, som vælger normaltid eller sommertid ud fra den dato, der konverteres, og en usat værdi på nul passerer uændret igennem, så den aldrig bliver til en 1899-dato forskudt et par timer

HotXLS konverteringsstier fra lokal tid til UTC for et januarstempel 17:00 CET gemt i juli: LocalFileTimeToFileTime anvender dagens sommertidsoffset og lander en time forkert på 15:00Z, mens TzSpecificLocalTimeToSystemTime vælger offsettet fra stempellets egen dato og skriver de korrekte 16:00Z
Zoneoffsettet tilhører stempellets egen dato, ikke maskinens aktuelle regler — én Windows API vælger den rigtige side af en sommertidsændring, den anden forskubber stille januarstempler konverteret i juli med en time
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');
    // På en maskine sat til Central European Time holder core.xml nu
    // <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
    // (UTC+2 i juli), mens ApprovedOn skrives som 16:00Z (UTC+1 i januar)
  finally
    Book.Free;
  end;
end;

Hvad konverterer HotXLS ikke ved læsning af tidsstempler?

HotXLS' W3CDTF-reader konverterer alle zonemærkede former i profilen siden v2.384.59, og det eneste tilfælde, den stadig lader i fred, er et klokkeslæt uden zone. Før den release tog parseren de første 19 tegn og konverterede fra UTC kun, når tegn 20 var Z, så et stempel med fractionelle sekunder (01:30:00.5Z) eller et eksplicit offset (+08:00) blev læst som lokal tid uden justering og endte forkert med netop zoneforskellen. Siden HotXLS 2.384.59 parser Created, Modified og date-værdierede selvvalgte egenskaber fractionelle sekunder af enhver længde, Z og +hh:mm- / -hh:mm-offsets, konverterer tidspunktet til UTC og derefter til lokal tid og læser et dato-only-stempel som 2026-07-01 som netop den dato. Et stempel med klokkeslæt men intet zonemærke, som W3CDTF-profilen ikke tillader, og ECMA-376 Part 2 ikke giver en regel for, læses stadig uændret som lokal tid, og et stempel, der slet ikke kan parses, kommer tilbage som nul. Arbejdsbøger, der har været igennem Excel, er fine; pakker fra andre generatorer, der dropper zonen, fortjener en spot-tjek

Filer skrevet af ældre HotXLS-builds er den anden ærlige grænse. Et XLSX-stempel skrevet før v2.384.48 var lokal tid i et Z-kostum, og intet i filen skelner det fra et korrekt, så den nuværende reader forskubber det med zoneforskellen. Classic-FILETIME-stempler fra de builds tager samme forskud, og en oprettelsesdato skrevet før v2.384.17 læses derudover én dag for sent, for den ekstra dag i den gamle konstant kan heller ikke detekteres; kun edit-time-encodingen og $0E-placeringen har en genkendelig signatur. Husk også, at API-værdien er lokal til maskinen, der læser, så en service i UTC og en desktop i Tokyo rapporterer forskellige CreatedDate-værdier for samme fil, begge korrekte

Hvordan bør du teste dokumenttidsstempler?

Test dokumenttidsstempler mod noget, din egen kode ikke har skrevet. Begge disse bugs bestod en gem-og-genåbn-kontrol, for en symmetrisk fejl er usynlig for en symmetrisk test. Sammenlign med en arbejdsbog gemt af Excel, eller assert de rå bytes og XML-teksten efter gemning, og kør suiten på en maskine sat til en ikke-UTC-zone med en testdato på hver side af en sommertidsændring. En build-agent i UTC vil gladelig lade den gamle, brudte kode bestå

Dokumenttidsstempler er små, men de er det, records-systemer, søgeindekser og audit trails sorterer efter, og en dato, der afviger en dag eller otte timer, er værre end en manglende, fordi ingen sætter den i tvivl. HotXLS Delphi spreadsheet component håndterer epokearitmetikken, property-id'erne og UTC-konverteringen for både .xls og .xlsx, så din kode kan tildele almindelige lokale TDateTime-værdier og lade filformatet ligge til biblioteket