HotXLS guarda las marcas de tiempo de las propiedades de documentos Excel como UTC dentro del archivo y las expone como hora local a través de la API: TXLSWorkbook.CreatedDate y LastSavedDate para .xls, TXLSXWorkbook.Created y Modified para .xlsx. Desde v2.384.48 ambos motores convierten hora local a UTC al escribir y de vuelta al leer, usando las reglas de horario de verano vigentes en la fecha propia del sello. Llegar ahí llevó dos fixes, y ambos bugs sobrevivieron por la misma razón embarazosa: todos los round trips automatizados pasaban, mientras el panel Archivo > Información de Excel mostraba el día equivocado o la hora equivocada. Si has leído nuestro repaso a establecer propiedades de documentos Excel en Delphi, esta es la parte donde las fechas dejan de ser valores simples
¿Por qué un test de guardar y reabrir escondía un error de un día?
Un round trip consigo mismo escondía el error porque el escritor y el lector compartían la misma constante equivocada, así que el fallo se cancelaba a sí mismo. Una fecha de un property set OLE es un FILETIME, un conteo de 64 bits de ticks de 100 nanosegundos desde 1601-01-01 UTC ([MS-DTYP] §2.3.3), mientras un TDateTime de Delphi cuenta días desde 1899-12-30, el mismo origen serial que cubre los seriales de fecha de Excel en Delphi y los sistemas 1900 frente a 1904. La distancia entre las dos épocas es de 109205 días, algo que puedes comprobar sin calendario: 25569 (la época Unix como TDateTime) más 109205 da 134774, la época Unix contada en días FILETIME. Los builds de HotXLS anteriores a v2.384.17 usaban 109206, así que cada sello de creación y guardado se escribía un día tarde y se leía un día antes. La suite de tests veía el valor que había asignado; Excel veía mañana
const
// días de la época FILETIME (1601-01-01) a la época TDateTime (1899-12-30)
// comprobación: 25569 + 109205 = 134774, la época Unix en días FILETIME
FileTimeDayBias = 109205;
function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
// Redondea primero a milisegundos enteros, luego escala a ticks de 100 ns.
// Escalar el Double directo a ticks convierte 04:00 en 03:59:59.9999
Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
El comentario sobre el redondeo de ese esbozo es la segunda lección, más pequeña, del mismo código. Multiplicar un TDateTime fraccional directamente por 864.000.000.000 ticks por día deja que el error del coma flotante binario se cuele en los dígitos bajos, y un sello de exactamente 04:00 volvía como 03:59:59.9999. HotXLS v2.384.48 redondea a milisegundos enteros antes de escalar, así que los valores en punto sobreviven al viaje intactos. La misma release añadió el paso de zona horaria que este esbozo deja fuera deliberadamente, porque la entrada aquí ya es UTC
¿Qué IDs de propiedad de SummaryInformation guardan las fechas?
En el property set \005SummaryInformation definido por [MS-OLEPS], la hora de creación vive bajo el ID de propiedad $0C (PIDSI_CREATE_DTM), la última hora de guardado bajo $0D (PIDSI_LASTSAVE_DTM), y el tiempo total de edición bajo $0A (PIDSI_EDITTIME). Los builds antiguos de HotXLS escribían el sello de último guardado en $0E, que es PIDSI_PAGECOUNT, de modo que Excel no tenía fecha de guardado que mostrar y un recuento de páginas llevaba una marca de tiempo. Desde v2.384.17 el lector también honra ese layout legado: cuando $0D no está y $0E lleva un VT_FILETIME, el valor se toma como la hora del último guardado. Cada PROPVARIANT leído también se libera ahora con PropVariantClear, porque un archivo malformado puede aparcar una cadena bajo cualquiera de estos IDs. Si quieres ver esos streams con tus propios ojos, el walk-through sobre leer archivos compuestos OLE2 en Delphi sin COM IStorage muestra cómo llegar hasta ellos
PIDSI_EDITTIME es la trampa dentro de la trampa. La propiedad está tipada como VT_FILETIME pero guarda una duración, el número crudo de ticks de 100 ns transcurridos sin época añadida. El viejo escritor la trataba como una fecha, dividiendo EditTimeMinutes por 1440 y empujando el resultado por la conversión de época, así que 125 minutos de edición aterrizaban en el archivo como unos 299 años. El lector actual reconoce esa codificación por su tamaño: ninguna sesión de edición real atraviesa tres siglos, así que a cualquier valor de 109206 días o más se le resta el offset legado antes de rellenar EditTimeMinutes
var
Book: TXLSWorkbook;
begin
Book := TXLSWorkbook.Create;
try
Book.Title := 'Q3 settlement';
// los valores de la API son hora local; el archivo guarda FILETIMEs UTC
Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
Book.LastSavedDate := Now; // HotXLS escribe lo que asignas, no estampa Now por su cuenta
Book.RevisionNumber := 7;
Book.EditTimeMinutes := 125; // una duración, guardada como ticks crudos
if Book.SaveAs('settlement.xls') <> 1 then
raise Exception.Create('Save failed');
finally
Book.Free;
end;
end;
¿Por qué las fechas XLSX se desviaban exactamente el offset de zona horaria?
Las fechas XLSX se desviaban el offset de zona porque dcterms:created y dcterms:modified en docProps/core.xml son valores W3CDTF etiquetados con Z, que significa UTC bajo el modelo de core properties de ECMA-376 Parte 2, y HotXLS estampaba la hora local con esa Z pegada. Un workbook creado a las 09:30 en una máquina en UTC+8 llevaba 09:30:00Z, y Excel en esa misma máquina lo convertía a 17:30. El motor clásico tenía la misma pega en sus valores FILETIME, y las propiedades de fecha personalizadas añadidas vía TXLSXWorkbook.CustomProperties.AddDate (escritas como vt:filetime) lo compartían también. Desde v2.384.48 los tres caminos convierten antes de escribir y convierten de vuelta al leer siempre que el sello lleve una Z, y desde v2.384.59 el lado de lectura también honra segundos fraccionarios y offsets explícitos +hh:mm / -hh:mm
La conversión en sí es donde un fix ingenuo sale mal. LocalFileTimeToFileTime aplica el offset vigente ahora mismo, así que un sello de enero convertido en julio sale con una hora de desvío en cualquier zona con horario de verano. HotXLS llama en su lugar a TzSpecificLocalTimeToSystemTime y SystemTimeToTzSpecificLocalTime, que escogen hora estándar o de verano según la fecha que se convierte, y un valor sin fijar de cero pasa intacto para que nunca se convierta en una fecha de 1899 desplazada unas horas
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');
// En una máquina en hora de Europa Central, core.xml contiene ahora
// <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
// (UTC+2 en julio), mientras ApprovedOn se escribe como 16:00Z (UTC+1 en enero)
finally
Book.Free;
end;
end;
¿Qué no convierte HotXLS al leer timestamps?
El lector W3CDTF de HotXLS convierte desde v2.384.59 toda forma etiquetada con zona del perfil, y el único caso que aún deja en paz es una hora sin zona. Antes de esa release el parser tomaba los primeros 19 caracteres y solo convertía desde UTC cuando el carácter 20 era Z, así que un sello con segundos fraccionarios (01:30:00.5Z) o un offset explícito (+08:00) se leía como hora local sin ajuste y acababa desviado el offset de zona. Desde HotXLS 2.384.59, Created, Modified y las propiedades personalizadas con fecha parsean segundos fraccionarios de cualquier longitud, Z, y offsets +hh:mm / -hh:mm, convierten el instante a UTC y luego a hora local, y leen un sello solo con fecha como 2026-07-01 como esa fecha. Un sello con hora pero sin marcador de zona, que el perfil W3CDTF no permite y para el que ECMA-376 Parte 2 no da regla, se sigue leyendo como hora local sin cambios, y un sello que no parsea en absoluto vuelve como cero. Los workbooks que pasan por Excel están bien; los paquetes producidos por otros generadores que sueltan la zona merecen una comprobación puntual
Los archivos escritos por builds antiguos de HotXLS son el otro límite honesto. Un sello XLSX escrito antes de v2.384.48 era hora local con una Z puesta, y nada en el archivo lo distingue de uno correcto, así que el lector actual lo desplaza el offset de zona. Los sellos FILETIME clásicos de esos builds reciben el mismo desplazamiento, y una fecha de creación escrita antes de v2.384.17 se lee además un día tarde, porque el día extra de la vieja constante tampoco se puede detectar; solo la codificación del tiempo de edición y la ubicación en $0E tienen una firma reconocible. Ten en cuenta también que el valor de la API es local a la máquina que lee, así que un servicio en UTC y un escritorio en Tokio reportarán valores CreatedDate distintos para el mismo archivo, ambos correctos
¿Cómo deberías probar los timestamps de documentos?
Prueba los timestamps de documentos contra algo que tu propio código no escribió. Ambos bugs de esta historia pasaron una comprobación de guardar y reabrir, porque un error simétrico es invisible para un test simétrico. Compara contra un workbook guardado por Excel, o verifica los bytes crudos y el texto XML tras guardar, y ejecuta la suite en una máquina en una zona distinta de UTC con una fecha de test a cada lado de un cambio de horario de verano. Un agente de build que corre en UTC pasará encantado el código viejo y roto
Los timestamps de documentos son pequeños, pero son aquello por lo que ordenan los sistemas de registros, los índices de búsqueda y las pistas de auditoría, y una fecha desviada un día u ocho horas es peor que una ausente porque nadie la cuestiona. El componente de hojas de cálculo HotXLS para Delphi se encarga de la aritmética de épocas, los IDs de propiedad y la conversión UTC tanto para .xls como para .xlsx, así que tu código puede asignar valores TDateTime locales corrientes y dejarle el formato de archivo a la biblioteca