Article technique

Horodatages Excel en Delphi : FILETIME, UTC et DST

HotXLS stocke les horodatages de propriétés de documents Excel en UTC dans le fichier et les expose en heure locale via l'API : TXLSWorkbook.CreatedDate et LastSavedDate pour .xls, TXLSXWorkbook.Created et Modified pour .xlsx. Depuis la v2.384.48, les deux moteurs convertissent l'heure locale en UTC à l'écriture et en sens inverse à la lecture, en utilisant les règles d'heure d'été qui s'appliquent à la date propre de l'horodatage. Y arriver a pris deux corrections, et les deux bogues ont survécu pour la même raison embarrassante : chaque aller-retour automatisé passait, tandis que le volet Fichier > Informations d'Excel affichait le mauvais jour ou la mauvaise heure. Si vous avez lu notre panorama de la définition des propriétés de documents Excel en Delphi, voici la partie où les dates cessent d'être des valeurs simples

Pourquoi un test de sauvegarde-réouverture cachait-il une erreur d'un jour ?

Un aller-retour sur soi-même cachait l'erreur parce que le générateur et le lecteur partageaient la même constante fausse, si bien que l'erreur s'annulait elle-même. Une date d'un jeu de propriétés OLE est une FILETIME, un comptage 64 bits de graduations de 100 nanosecondes depuis le 1601-01-01 UTC ([MS-DTYP] §2.3.3), tandis qu'un TDateTime Delphi compte les jours depuis le 1899-12-30, la même origine sérielle que couvre les numéros de série de dates Excel en Delphi et les systèmes 1900 vs 1904. L'écart entre les deux époques est de 109205 jours, ce que vous pouvez vérifier sans calendrier : 25569 (l'époque Unix en TDateTime) plus 109205 donne 134774, l'époque Unix comptée en jours FILETIME. Les builds HotXLS d'avant la v2.384.17 utilisaient 109206, si bien que chaque horodatage de création et de sauvegarde était écrit un jour trop tard et lu un jour trop tôt. La suite de tests voyait la valeur qu'elle avait assignée ; Excel voyait demain

const
  // jours de l'époque FILETIME (1601-01-01) à l'époque TDateTime (1899-12-30)
  // vérification : 25569 + 109205 = 134774, l'époque Unix en jours FILETIME
  FileTimeDayBias = 109205;

function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
  // Arrondir d'abord en millisecondes entières, puis passer aux pas de 100 ns.
  // Passer le Double directement aux pas transforme 04:00 en 03:59:59.9999
  Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
Chronologie HotXLS de l'époque FILETIME 1601-01-01, de l'époque TDateTime 1899-12-30 et de l'époque Unix 1970, montrant le biais de 109205 jours derrière UtcDateTimeToFileTimeTicks et comment les builds d'avant la v2.384.17 écrivaient chaque horodatage CreatedDate un jour trop tard et le lisaient un jour trop tôt avec 109206
Le croquis UtcDateTimeToFileTimeTicks garde le biais là où une constante fausse s'annule elle-même — un test symétrique de sauvegarde-réouverture voyait la valeur qu'il avait assignée tandis que le volet Informations d'Excel affichait demain

Le commentaire d'arrondi de ce croquis est la seconde, plus petite leçon du même code. Multiplier directement un TDateTime fractionnaire par 864 000 000 000 pas par jour laisse l'erreur du binaire flottant fuir dans les chiffres du bas, et un horodatage à exactement 04:00 revenait en 03:59:59.9999. HotXLS v2.384.48 arrondit en millisecondes entières avant la mise à l'échelle, si bien que les valeurs à heure ronde survivent au voyage intactes. La même version a ajouté l'étape de fuseau horaire que ce croquis laisse délibérément de côté, parce que l'entrée ici est déjà en UTC

Quels ID de propriétés SummaryInformation portent les dates ?

Dans le jeu de propriétés \005SummaryInformation défini par [MS-OLEPS], l'heure de création vit sous l'ID de propriété $0C (PIDSI_CREATE_DTM), l'heure de dernière sauvegarde sous $0D (PIDSI_LASTSAVE_DTM), et le temps d'édition total sous $0A (PIDSI_EDITTIME). Les builds HotXLS plus anciens écrivaient l'horodatage de dernière sauvegarde dans $0E, qui est PIDSI_PAGECOUNT, si bien qu'Excel n'avait aucune date de sauvegarde à afficher et une propriété de comptage de pages portant un horodatage. Depuis la v2.384.17, le lecteur honore aussi cette disposition historique : quand $0D est absent et que $0E porte un VT_FILETIME, la valeur est prise comme heure de dernière sauvegarde. Chaque lecture de PROPVARIANT est désormais aussi libérée avec PropVariantClear, parce qu'un fichier mal formé peut garer une chaîne sous n'importe lequel de ces IDs. Si vous voulez voir ces flux de vos propres yeux, le pas-à-pas sur la lecture de fichiers composés OLE2 en Delphi sans COM IStorage montre comment les atteindre

PIDSI_EDITTIME est le piège dans le piège. La propriété est typée VT_FILETIME mais porte une durée, le nombre brut de pas de 100 ns écoulés sans époque ajoutée. L'ancien générateur la traitait comme une date, divisant EditTimeMinutes par 1440 et poussant le résultat dans la conversion d'époque, si bien que 125 minutes d'édition atterrissaient dans le fichier en gros 299 ans. Le lecteur actuel reconnaît ce codage à sa taille : aucune vraie session d'édition ne s'étend sur trois siècles, donc toute valeur de 109206 jours ou plus se voit retrancher l'offset historique avant que EditTimeMinutes ne soit rempli

Carte HotXLS du jeu de propriétés 005SummaryInformation où PIDSI_CREATE_DTM en $0C porte l'heure de création, PIDSI_LASTSAVE_DTM en $0D l'horodatage de sauvegarde, PIDSI_EDITTIME en $0A une durée brute plutôt qu'une date, et $0E PIDSI_PAGECOUNT l'emplacement que les anciens builds utilisaient à tort pour les horodatages
PIDSI_EDITTIME est le piège dans le piège — typé VT_FILETIME mais portant des pas écoulés sans époque, ce qui a un temps transformé 125 minutes d'édition en gros 299 ans jusqu'à ce qu'arrive une heuristique de lecture basée sur la taille
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Title := 'Q3 settlement';
    // les valeurs de l'API sont en heure locale ; le fichier stocke des FILETIME UTC
    Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
    Book.LastSavedDate := Now;     // HotXLS écrit ce que vous assignez, il ne tamponne pas Now
    Book.RevisionNumber := 7;
    Book.EditTimeMinutes := 125;   // une durée, stockée en pas bruts
    if Book.SaveAs('settlement.xls') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Book.Free;
  end;
end;

Pourquoi les dates XLSX étaient-elles décalées d'exactement l'offset de fuseau ?

Les dates XLSX étaient décalées de l'offset de fuseau parce que dcterms:created et dcterms:modified dans docProps/core.xml sont des valeurs W3CDTF marquées d'un Z, qui signifie UTC sous le modèle de propriétés principales de ECMA-376 Partie 2, et HotXLS tamponnait l'heure locale avec ce Z attaché. Un classeur créé à 09:30 sur une machine en UTC+8 portait 09:30:00Z, et Excel sur cette même machine le convertissait en 17:30. Le moteur classique avait le même défaut dans ses valeurs FILETIME, et les propriétés de dates personnalisées ajoutées via TXLSXWorkbook.CustomProperties.AddDate (écrites comme vt:filetime) le partageaient aussi. Depuis la v2.384.48, les trois chemins convertissent avant d'écrire et reconvertissent à la lecture chaque fois que l'horodatage porte un Z, et depuis la v2.384.59, le côté lecture honore aussi les secondes fractionnaires et les offsets explicites +hh:mm / -hh:mm

La conversion elle-même est là où une correction naïve tourne mal. LocalFileTimeToFileTime applique l'offset en vigueur à l'instant présent, si bien qu'un horodatage de janvier converti en juillet ressort avec une heure d'écart dans toute zone à heure d'été. HotXLS appelle TzSpecificLocalTimeToSystemTime et SystemTimeToTzSpecificLocalTime à la place, qui choisissent heure standard ou heure d'été d'après la date convertie, et une valeur non fixée de zéro passe intacte pour ne jamais se transformer en date de 1899 décalée de quelques heures

Chemins de conversion local vers UTC de HotXLS pour un horodatage de janvier 17:00 CET sauvegardé en juillet : LocalFileTimeToFileTime applique l'offset d'été d'aujourd'hui et atterrit avec une heure d'écart à 15:00Z, tandis que TzSpecificLocalTimeToSystemTime prend l'offset de la date propre de l'horodatage et écrit le bon 16:00Z
L'offset de fuseau appartient à la date propre de l'horodatage, pas aux règles actuelles de la machine — une API Windows choisit le bon côté d'un changement d'heure d'été, l'autre décale tranquillement d'une heure les horodatages de janvier convertis en juillet
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');
    // Sur une machine réglée sur l'heure d'Europe centrale, core.xml détient désormais
    // <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
    // (UTC+2 en juillet), tandis que ApprovedOn est écrit 16:00Z (UTC+1 en janvier)
  finally
    Book.Free;
  end;
end;

Que ne convertit pas HotXLS à la lecture des horodatages ?

Le lecteur W3CDTF de HotXLS convertit chaque forme marquée d'une zone du profil depuis la v2.384.59, et le seul cas qu'il laisse encore tranquille est une heure sans zone. Avant cette version, l'analyseur prenait les 19 premiers caractères et ne convertissait depuis UTC que quand le caractère 20 était un Z, si bien qu'un horodatage avec secondes fractionnaires (01:30:00.5Z) ou un offset explicite (+08:00) était lu comme heure locale sans ajustement et finissait décalé de l'offset de fuseau. Depuis HotXLS 2.384.59, Created, Modified et les propriétés personnalisées de valeur date analysent des secondes fractionnaires de toute longueur, le Z, et les offsets +hh:mm / -hh:mm, convertissent l'instant en UTC puis en heure locale, et lisent un horodatage date seule tel que 2026-07-01 comme cette date. Un horodatage avec heure mais sans marqueur de zone, que le profil W3CDTF n'autorise pas et pour lequel ECMA-376 Partie 2 ne donne aucune règle, est toujours lu comme heure locale inchangée, et un horodatage qui ne s'analyse pas du tout revient en zéro. Les classeurs qui passent par Excel vont bien ; les paquets produits par d'autres générateurs qui abandonnent la zone méritent un contrôle ponctuel

Les fichiers écrits par d'anciens builds HotXLS sont l'autre frontière honnête. Un horodatage XLSX écrit avant la v2.384.48 était une heure locale portant un Z, et rien dans le fichier ne le distingue d'un correct, si bien que le lecteur actuel le décale de l'offset de fuseau. Les horodatages FILETIME classiques de ces builds prennent le même décalage, et une date de création écrite avant la v2.384.17 se relit en plus un jour trop tard, parce que le jour en trop de l'ancienne constante ne se détecte pas non plus ; seul le codage du temps d'édition et le placement en $0E ont une signature reconnaissable. Gardez aussi en tête que la valeur de l'API est locale à la machine qui lit, si bien qu'un service tournant en UTC et un poste de bureau à Tokyo rapporteront des valeurs CreatedDate différentes pour le même fichier, toutes deux correctes

Comment tester les horodatages de documents ?

Testez les horodatages de documents contre quelque chose que votre propre code n'a pas écrit. Ces deux bogues ont passé une vérification sauvegarde-puis-réouverture, parce qu'une erreur symétrique est invisible à un test symétrique. Comparez contre un classeur sauvegardé par Excel, ou assertez les octets bruts et le texte XML après sauvegarde, et lancez la suite sur une machine réglée sur une zone hors UTC avec une date de test de chaque côté d'un changement d'heure d'été. Un agent de build qui tourne en UTC fera passer joyeusement l'ancien code cassé

Les horodatages de documents sont petits, mais ils font ce par quoi les systèmes de gestion de dossiers, les index de recherche et les pistes d'audit trient, et une date fausse d'un jour ou de huit heures est pire qu'une date manquante parce que personne ne la conteste. Le composant tableur HotXLS pour Delphi gère les calculs d'époque, les ID de propriétés et la conversion UTC pour .xls comme .xlsx, si bien que votre code peut assigner de simples valeurs TDateTime locales et laisser le format de fichier à la bibliothèque