O HotXLS guarda timestamps de propriedades de documento Excel como UTC dentro do arquivo e os expõe como hora local pela API: TXLSWorkbook.CreatedDate e LastSavedDate para .xls, TXLSXWorkbook.Created e Modified para .xlsx. Desde a v2.384.48 os dois motores convertem hora local para UTC na gravação e de volta na leitura, usando as regras de horário de verão vigentes na própria data do stamp. Chegar lá levou dois fixes, e ambos os bugs sobreviveram pela mesma razão constrangedora: todo round trip automatizado passava, enquanto o painel File > Info do Excel mostrava o dia errado ou a hora errada. Se você leu nossa visão geral de definir propriedades de documento Excel em Delphi, esta é a parte em que as datas deixam de ser valores simples
Por que um teste de salvar e reabrir escondeu um erro de um dia?
Um round trip consigo mesmo escondeu o erro porque o writer e o reader compartilhavam a mesma constante errada, então o engano se cancelava sozinho. Uma data de property set OLE é um FILETIME, uma contagem de 64 bits de ticks de 100 nanossegundos desde 1601-01-01 UTC ([MS-DTYP] §2.3.3), enquanto um TDateTime do Delphi conta dias a partir de 1899-12-30, a mesma origem serial coberta em seriais de data Excel em Delphi e os sistemas 1900 vs 1904. O vão entre as duas épocas é de 109205 dias, o que você confere sem calendário: 25569 (a época Unix como TDateTime) mais 109205 dá 134774, a época Unix contada em dias de FILETIME. Builds do HotXLS anteriores à v2.384.17 usavam 109206, então todo stamp de criação e save era gravado um dia tarde e lido um dia cedo. A suíte de testes via o valor que ela mesma atribuiu; o Excel via amanhã
const
// dias da época do FILETIME (1601-01-01) até a época do TDateTime (1899-12-30)
// confira: 25569 + 109205 = 134774, a época Unix em dias de FILETIME
FileTimeDayBias = 109205;
function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
// Arredonde para milissegundos inteiros primeiro, depois escale para ticks de 100 ns.
// Escalar o Double direto para ticks transforma 04:00 em 03:59:59.9999
Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
O comentário sobre arredondamento naquele sketch é a segunda, menor, lição do mesmo código. Multiplicar um TDateTime fracionário diretamente por 864.000.000.000 ticks por dia deixa o erro de ponto flutuante binário vazar nos dígitos finais, e um stamp de exatamente 04:00 voltava como 03:59:59.9999. O HotXLS v2.384.48 arredonda para milissegundos inteiros antes de escalar, então valores em hora cheia sobrevivem à viagem intactos. A mesma release adicionou o passo de fuso que este sketch deixa de fora de propósito, porque a entrada aqui já é UTC
Quais IDs de propriedade do SummaryInformation guardam as datas?
No property set \005SummaryInformation definido pelo [MS-OLEPS], a hora de criação vive sob o ID de propriedade $0C (PIDSI_CREATE_DTM), a hora do último save sob $0D (PIDSI_LASTSAVE_DTM), e o tempo total de edição sob $0A (PIDSI_EDITTIME). Builds antigos do HotXLS gravavam o stamp de último save em $0E, que é o PIDSI_PAGECOUNT, então o Excel não tinha data de save para mostrar e uma propriedade de contagem de páginas guardando um timestamp. Desde a v2.384.17 o reader também respeita esse layout legado: quando $0D está ausente e $0E carrega um VT_FILETIME, o valor é tomado como a hora do último save. Todo PROPVARIANT lido também é liberado com PropVariantClear agora, porque um arquivo malformado pode estacionar uma string sob qualquer um desses IDs. Se você quer ver essas streams com os próprios olhos, o passo a passo sobre ler arquivos compostos OLE2 em Delphi sem COM IStorage mostra como alcançá-las
O PIDSI_EDITTIME é a armadilha dentro da armadilha. A propriedade é tipada como VT_FILETIME mas guarda uma duração, o número cru de ticks de 100 ns decorridos sem época alguma somada. O writer antigo a tratava como data, dividindo EditTimeMinutes por 1440 e empurrando o resultado pela conversão de época, então 125 minutos de edição chegavam ao arquivo como cerca de 299 anos. O reader atual reconhece essa codificação pelo tamanho: nenhuma sessão de edição real atravessa três séculos, então qualquer valor de 109206 dias ou mais tem o offset legado subtraído antes de EditTimeMinutes ser preenchido
var
Book: TXLSWorkbook;
begin
Book := TXLSWorkbook.Create;
try
Book.Title := 'Q3 settlement';
// valores da API são hora local; o arquivo guarda FILETIMEs UTC
Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
Book.LastSavedDate := Now; // o HotXLS grava o que você atribui, ele não carimba Now
Book.RevisionNumber := 7;
Book.EditTimeMinutes := 125; // uma duração, guardada como ticks crus
if Book.SaveAs('settlement.xls') <> 1 then
raise Exception.Create('Save failed');
finally
Book.Free;
end;
end;
Por que as datas XLSX saíam deslocadas exatamente pelo offset de fuso?
As datas XLSX saíam deslocadas pelo offset de fuso porque dcterms:created e dcterms:modified no docProps/core.xml são valores W3CDTF marcados com Z, o que significa UTC no modelo de core properties do ECMA-376 Part 2, e o HotXLS carimbava a hora local com esse Z colado. Uma pasta de trabalho criada às 09:30 numa máquina em UTC+8 carregava 09:30:00Z, e o Excel na mesma máquina convertia para 17:30. O motor Classic tinha a falha idêntica nos seus valores de FILETIME, e propriedades de data customizadas adicionadas por TXLSXWorkbook.CustomProperties.AddDate (gravadas como vt:filetime) dividiam o problema também. Desde a v2.384.48 os três caminhos convertem antes de gravar e convertem de volta na leitura sempre que o stamp carrega um Z, e desde a v2.384.59 o lado da leitura também respeita segundos fracionários e offsets explícitos +hh:mm / -hh:mm
A conversão em si é onde um fix ingênuo erra. O LocalFileTimeToFileTime aplica o offset em vigor agora, então um stamp de janeiro convertido em julho sai com uma hora de erro em qualquer fuso com horário de verão. O HotXLS chama TzSpecificLocalTimeToSystemTime e SystemTimeToTzSpecificLocalTime em vez, que escolhem horário padrão ou de verão a partir da data sendo convertida, e um valor não definido de zero passa intocado para nunca virar uma data de 1899 deslocada por algumas 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');
// Numa máquina configurada para Central European Time, o core.xml agora guarda
// <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
// (UTC+2 em julho), enquanto ApprovedOn é gravado como 16:00Z (UTC+1 em janeiro)
finally
Book.Free;
end;
end;
O que o HotXLS não converte ao ler timestamps?
O reader W3CDTF do HotXLS converte toda forma marcada com fuso do perfil desde a v2.384.59, e o único caso que ele ainda deixa quieto é uma hora sem fuso. Antes dessa release o parser pegava os primeiros 19 caracteres e só convertia de UTC quando o caractere 20 era Z, então um stamp com segundos fracionários (01:30:00.5Z) ou um offset explícito (+08:00) era lido como hora local sem ajuste e acabava deslocado pelo offset de fuso. Desde o HotXLS 2.384.59, Created, Modified e propriedades customizadas com valor de data parseiam segundos fracionários de qualquer comprimento, Z, e offsets +hh:mm / -hh:mm, convertem o instante para UTC e depois para hora local, e leem um stamp só de data como 2026-07-01 como essa data. Um stamp com hora mas sem marcador de fuso, que o perfil W3CDTF não permite e o ECMA-376 Part 2 não dá regra para, ainda é lido como hora local inalterado, e um stamp que não parseia de jeito nenhum volta como zero. Pastas de trabalho que passam pelo Excel estão bem; pacotes produzidos por outros geradores que derrubam o fuso merecem uma conferida pontual
Arquivos escritos por builds antigos do HotXLS são a outra fronteira honesta. Um stamp XLSX gravado antes da v2.384.48 era hora local vestindo um Z, e nada no arquivo o distingue de um correto, então o reader atual o desloca pelo offset de fuso. Stamps FILETIME Classic desses builds levam o mesmo deslocamento, e uma data de criação gravada antes da v2.384.17 adicionalmente volta um dia tarde, porque o dia extra da constante antiga também não pode ser detectado; só a codificação de edit-time e a colocação em $0E têm assinatura reconhecível. Tenha em mente também que o valor da API é local à máquina que faz a leitura, então um serviço rodando em UTC e um desktop em Tóquio vão reportar valores de CreatedDate diferentes para o mesmo arquivo, ambos corretos
Como você deve testar timestamps de documento?
Teste timestamps de documento contra algo que o seu próprio código não escreveu. Ambos os bugs desta história passaram numa checagem de salvar e reabrir, porque um engano simétrico é invisível para um teste simétrico. Compare contra uma pasta de trabalho salva pelo Excel, ou dê assert nos bytes crus e no texto XML depois de salvar, e rode a suíte numa máquina configurada para um fuso fora do UTC com uma data de teste em cada lado de uma virada de horário de verão. Um build agent que roda em UTC vai passar o código antigo quebrado todo feliz
Timestamps de documento são pequenos, mas são pelo que sistemas de registros, índices de busca e trilhas de auditoria ordenam, e uma data deslocada por um dia ou por oito horas é pior que uma ausente porque ninguém a questiona. O componente de planilha HotXLS para Delphi cuida da aritmética de épocas, dos IDs de propriedade e da conversão UTC para .xls e .xlsx, então seu código pode atribuir valores TDateTime locais simples e deixar o formato de arquivo para a biblioteca