Articolo tecnico

Timestamp dei documenti Excel in Delphi: FILETIME, UTC e DST

HotXLS memorizza i timestamp delle proprietà dei documenti Excel come UTC dentro il file e li espone come ora locale tramite l'API: TXLSWorkbook.CreatedDate e LastSavedDate per .xls, TXLSXWorkbook.Created e Modified per .xlsx. Dalla v2.384.48 entrambi gli engine convertono l'ora locale in UTC in scrittura e viceversa in lettura, usando le regole dell'ora legale valide sulla data della marcatura stessa. Arrivarci è costato due correzioni, ed entrambi i bug erano sopravvissuti per la stessa imbarazzante ragione: ogni round trip automatizzato passava, mentre il riquadro File > Info di Excel mostrava il giorno sbagliato o l'ora sbagliata. Se avete letto la nostra panoramica su impostare le proprietà dei documenti Excel in Delphi, questo è il punto in cui le date smettono di essere semplici valori

Perché un test salva-e-riapri nascondeva un errore di un giorno?

Un round trip su se stesso nascondeva l'errore perché writer e reader condividevano la stessa costante sbagliata, così lo sbaglio si cancellava da solo. Una data di un property set OLE è un FILETIME, un conteggio a 64 bit di tick da 100 nanosecondi dal 1601-01-01 UTC ([MS-DTYP] §2.3.3), mentre un TDateTime Delphi conta i giorni dal 1899-12-30, la stessa origine seriale trattata in seriali di date Excel in Delphi e i sistemi 1900 vs 1904. Il divario tra le due epoche è di 109205 giorni, verificabile senza calendario: 25569 (l'epoca Unix come TDateTime) più 109205 dà 134774, l'epoca Unix contata in giorni FILETIME. Le build HotXLS prima della v2.384.17 usavano 109206, quindi ogni marcatura di creazione e salvataggio veniva scritta un giorno tardi e letta un giorno presto. La suite di test vedeva il valore che aveva assegnato; Excel vedeva domani

const
  // giorni dall'epoca FILETIME (1601-01-01) all'epoca TDateTime (1899-12-30)
  // verifica: 25569 + 109205 = 134774, l'epoca Unix in giorni FILETIME
  FileTimeDayBias = 109205;

function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
  // Arrotondate prima a millisecondi interi, poi scalate a tick da 100 ns.
  // Scalare il Double direttamente a tick trasforma 04:00 in 03:59:59.9999
  Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
Cronologia HotXLS dell'epoca FILETIME 1601-01-01, dell'epoca TDateTime 1899-12-30 e dell'epoca Unix 1970, con il bias di 109205 giorni dietro UtcDateTimeToFileTimeTicks e come le build prima della v2.384.17 scrivevano ogni marcatura CreatedDate un giorno tardi e la rileggevano un giorno presto con 109206
Lo schema di UtcDateTimeToFileTimeTicks tiene il bias dove una costante sbagliata si cancella da sola — un test simmetrico salva-e-riapri vedeva il valore assegnato mentre il riquadro Info di Excel mostrava domani

Il commento sull'arrotondamento in quello schema è la seconda, più piccola lezione dello stesso codice. Moltiplicare un TDateTime frazionario direttamente per 864.000.000.000 tick al giorno lascia che l'errore del floating point binario si infili nelle cifre finali, e una marcatura di 04:00 esatte tornava come 03:59:59.9999. HotXLS v2.384.48 arrotonda a millisecondi interi prima di scalare, così i valori in punto sopravvivono al viaggio intatti. La stessa release ha aggiunto il passaggio del fuso orario che questo schema omette deliberatamente, perché l'input qui è già UTC

Quali ID di proprietà SummaryInformation contengono le date?

Nel property set \005SummaryInformation definito da [MS-OLEPS], l'ora di creazione sta sotto l'ID di proprietà $0C (PIDSI_CREATE_DTM), l'ultimo salvataggio sotto $0D (PIDSI_LASTSAVE_DTM), e il tempo totale di editing sotto $0A (PIDSI_EDITTIME). Le build HotXLS più vecchie scrivevano la marcatura di salvataggio a $0E, che è PIDSI_PAGECOUNT, quindi Excel non aveva una data di salvataggio da mostrare e una proprietà conteggio pagine con dentro un timestamp. Dalla v2.384.17 il reader onora anche quel layout legacy: quando $0D è assente e $0E porta un VT_FILETIME, il valore è preso come ora di ultimo salvataggio. Ogni PROPVARIANT letto ormai viene anche rilasciato con PropVariantClear, perché un file malformato può parcheggiare una stringa sotto uno qualsiasi di questi ID. Se volete vedere quegli stream con i vostri occhi, la guida su leggere file compound OLE2 in Delphi senza COM IStorage mostra come arrivarci

PIDSI_EDITTIME è la trappola dentro la trappola. La proprietà è tipizzata VT_FILETIME ma contiene una durata, il numero grezzo di tick da 100 ns trascorsi senza epoca aggiunta. Il vecchio writer la trattava come una data, dividendo EditTimeMinutes per 1440 e spingendo il risultato nella conversione d'epoca, così 125 minuti di editing finivano nel file come circa 299 anni. Il reader attuale riconosce quella codifica dalla dimensione: nessuna sessione di editing reale copre tre secoli, quindi qualsiasi valore di 109206 giorni o più ha l'offset legacy sottratto prima che EditTimeMinutes venga riempito

Mappa HotXLS del property set 005SummaryInformation dove PIDSI_CREATE_DTM a $0C contiene l'ora di creazione, PIDSI_LASTSAVE_DTM a $0D la marcatura di salvataggio, PIDSI_EDITTIME a $0A una durata grezza anziché una data, e $0E PIDSI_PAGECOUNT lo slot che le build più vecchie usavano per sbaglio per i timestamp
PIDSI_EDITTIME è la trappola dentro la trappola — tipizzato VT_FILETIME ma con tick trascorsi senza epoca, cosa che un tempo trasformava 125 minuti di editing in circa 299 anni finché non è arrivata un'euristica di lettura basata sulla dimensione
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Title := 'Q3 settlement';
    // i valori API sono ora locale; il file memorizza FILETIME UTC
    Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
    Book.LastSavedDate := Now;     // HotXLS scrive ciò che assegnate, non timbra Now da sé
    Book.RevisionNumber := 7;
    Book.EditTimeMinutes := 125;   // una durata, memorizzata come tick grezzi
    if Book.SaveAs('settlement.xls') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Book.Free;
  end;
end;

Perché le date XLSX erano sbagliate di esattamente l'offset del fuso?

Le date XLSX erano sbagliate dell'offset del fuso perché dcterms:created e dcterms:modified in docProps/core.xml sono valori W3CDTF taggati con Z, che significa UTC secondo il modello delle core properties di ECMA-376 Part 2, e HotXLS un tempo timbrava l'ora locale con quella Z attaccata. Un workbook creato alle 09:30 su una macchina in UTC+8 portava 09:30:00Z, ed Excel sulla stessa macchina lo convertiva in 17:30. L'engine classico aveva lo stesso identico difetto nei suoi valori FILETIME, e le proprietà data personalizzate aggiunte tramite TXLSXWorkbook.CustomProperties.AddDate (scritte come vt:filetime) lo condividevano anch'esse. Dalla v2.384.48 tutti e tre i percorsi convertono prima di scrivere e riconvertono in lettura ogni volta che la marcatura porta una Z, e dalla v2.384.59 il lato lettura onora anche i secondi frazionari e gli offset espliciti +hh:mm / -hh:mm

La conversione in sé è dove una correzione ingenua va storta. LocalFileTimeToFileTime applica l'offset in vigore adesso, quindi una marcatura di gennaio convertita a luglio esce con un'ora di scarto in qualsiasi fuso con ora legale. HotXLS chiama invece TzSpecificLocalTimeToSystemTime e SystemTimeToTzSpecificLocalTime, che scelgono ora solare o legale dalla data che si sta convertendo, e un valore non impostato di zero passa indenne, così non diventa mai una data 1899 spostata di qualche ora

Percorsi di conversione da locale a UTC di HotXLS per una marcatura 17:00 CET di gennaio salvata a luglio: LocalFileTimeToFileTime applica l'offset di ora legale di oggi e atterra con un'ora di scarto a 15:00Z, mentre TzSpecificLocalTimeToSystemTime sceglie l'offset dalla data della marcatura stessa e scrive il corretto 16:00Z
L'offset del fuso appartiene alla data della marcatura, non alle regole attuali della macchina — una API Windows sceglie il lato giusto di un cambio di ora legale, l'altra sposta in silenzio di un'ora le marcature di gennaio convertite a luglio
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');
    // Su una macchina impostata su Central European Time, core.xml ora contiene
    // <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
    // (UTC+2 a luglio), mentre ApprovedOn viene scritto come 16:00Z (UTC+1 a gennaio)
  finally
    Book.Free;
  end;
end;

Che cosa HotXLS non converte leggendo i timestamp?

Il reader W3CDTF di HotXLS converte ogni forma con tag di fuso del profilo dalla v2.384.59, e l'unico caso che lascia ancora in pace è un'ora senza fuso. Prima di quella release il parser prendeva i primi 19 caratteri e convertiva da UTC solo quando il carattere 20 era Z, quindi una marcatura con secondi frazionari (01:30:00.5Z) o un offset esplicito (+08:00) veniva letta come ora locale senza correzioni e finiva fuori dell'offset del fuso. Da HotXLS 2.384.59, Created, Modified e le proprietà personalizzate con valore data analizzano secondi frazionari di qualsiasi lunghezza, Z e offset +hh:mm / -hh:mm, convertono l'istante in UTC e poi in ora locale, e leggono una marcatura solo-data come 2026-07-01 come quella data. Una marcatura con ora ma senza indicatore di fuso, che il profilo W3CDTF non consente e per cui ECMA-376 Part 2 non dà regole, resta letta come ora locale invariata, e una marcatura che non si analizza affatto torna come zero. I workbook che passano per Excel stanno bene; i pacchetti prodotti da altri generatori che buttano via il fuso meritano un controllo a campione

I file scritti da build HotXLS più vecchie sono l'altro confine onesto. Una marcatura XLSX scritta prima della v2.384.48 era ora locale con una Z addosso, e niente nel file la distingue da una corretta, quindi il reader attuale la sposta dell'offset del fuso. Le marcature FILETIME classiche di quelle build prendono lo stesso spostamento, e una data di creazione scritta prima della v2.384.17 si rilegge in più un giorno tardi, perché il giorno extra della vecchia costante non è rilevabile nemmeno lui; solo la codifica dell'edit time e la posizione a $0E hanno una firma riconoscibile. Tenete presente anche che il valore API è locale alla macchina che legge, quindi un servizio in UTC e un desktop a Tokyo riporteranno valori CreatedDate diversi per lo stesso file, entrambi corretti

Come si devono testare i timestamp dei documenti?

Testate i timestamp dei documenti contro qualcosa che il vostro codice non ha scritto. Entrambi questi bug hanno passato un controllo salva-poi-riapri, perché uno sbaglio simmetrico è invisibile a un test simmetrico. Confrontate con un workbook salvato da Excel, oppure fate assert sui byte grezzi e sul testo XML dopo il salvataggio, e fate girare la suite su una macchina impostata su un fuso non UTC con una data di test su ciascun lato di un cambio di ora legale. Un build agent che gira in UTC passerà allegramente il vecchio codice rotto

I timestamp dei documenti sono piccoli, ma sono ciò per cui i sistemi di record, gli indici di ricerca e le tracce di audit ordinano, e una data sbagliata di un giorno o di otto ore è peggiore di una mancante perché nessuno la mette in discussione. Il componente foglio di calcolo HotXLS per Delphi gestisce l'aritmetica delle epoche, gli ID di proprietà e la conversione UTC per .xls e .xlsx, così il vostro codice può assegnare semplici valori TDateTime locali e lasciare il formato file alla libreria