Artigo Técnico

Datas de documentos Excel no Delphi: FILETIME, UTC e DST

O HotXLS guarda os carimbos de data/hora das propriedades de documentos Excel como UTC dentro do ficheiro e expõe-nos como hora local através da API: TXLSWorkbook.CreatedDate e LastSavedDate para .xls, TXLSXWorkbook.Created e Modified para .xlsx. Desde a v2.384.48 que ambos os motores convertem hora local para UTC na escrita e de volta na leitura, usando as regras de hora de verão aplicáveis à própria data do carimbo. Chegar lá levou duas correções, e ambos os bugs sobreviveram pela mesma razão embaraçosa: todas as rondas automáticas passavam, enquanto o painel File > Info do Excel mostrava o dia ou a hora errados. Se leu a nossa visão geral de definir propriedades de documentos Excel no Delphi, esta é a parte em que as datas deixam de ser valores simples

Porque é que um teste de gravar e reabrir escondia um erro de um dia?

Uma ronda consigo própria escondia o erro porque o escritor e o leitor partilhavam a mesma constante errada, por isso o engano anulava-se a si próprio. Uma data num property set OLE é um FILETIME, uma contagem de 64 bits de ticks de 100 nanosegundos desde 1601-01-01 UTC ([MS-DTYP] §2.3.3), enquanto um TDateTime Delphi conta dias desde 1899-12-30, a mesma origem de série coberta em seriais de datas Excel no Delphi e os sistemas 1900 vs 1904. O vão entre as duas épocas é de 109205 dias, o que pode verificar sem calendário: 25569 (a época Unix como TDateTime) mais 109205 dá 134774, a época Unix contada em dias de FILETIME. As versões do HotXLS anteriores à v2.384.17 usavam 109206, por isso cada carimbo de criação e gravação era escrito um dia tarde e lido um dia cedo. A suíte de testes via o valor que tinha atribuído; o Excel via amanhã

const
  // dias da época FILETIME (1601-01-01) à época TDateTime (1899-12-30)
  // verificação: 25569 + 109205 = 134774, a época Unix em dias FILETIME
  FileTimeDayBias = 109205;

function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
  // Arredondar primeiro a milissegundos inteiros, depois escalar para ticks de 100 ns.
  // Escalar o Double diretamente para ticks transforma 04:00 em 03:59:59.9999
  Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
Cronologia HotXLS da época FILETIME 1601-01-01, da época TDateTime 1899-12-30 e da época Unix de 1970, mostrando o desvio de 109205 dias por trás do UtcDateTimeToFileTimeTicks e como as versões anteriores à v2.384.17 escreviam cada carimbo CreatedDate um dia tarde e liam-no um dia cedo com 109206
O esboço do UtcDateTimeToFileTimeTicks mantém o desvio onde uma constante errada se anula a si própria — um teste simétrico de gravar e reabrir via o valor que atribuiu enquanto o painel Info do Excel mostrava amanhã

O comentário sobre arredondamento naquele esboço é a segunda lição, mais pequena, do mesmo código. Multiplicar um TDateTime fracionário diretamente por 864.000.000.000 ticks por dia deixa o erro de vírgula flutuante binária vazar para os dígitos finais, e um carimbo de exatamente 04:00 voltava como 03:59:59.9999. O HotXLS v2.384.48 arredonda a milissegundos inteiros antes de escalar, por isso os valores à hora certinha sobrevivem à viagem intactos. A mesma versão acrescentou o passo de fuso horário que este esboço deliberadamente omite, porque aqui a entrada já é UTC

Que IDs de propriedades 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 da última gravação sob o $0D (PIDSI_LASTSAVE_DTM), e o tempo total de edição sob o $0A (PIDSI_EDITTIME). Versões mais antigas do HotXLS escreviam o carimbo da última gravação no $0E, que é o PIDSI_PAGECOUNT, por isso o Excel não tinha data de gravação para mostrar e uma propriedade de contagem de páginas a segurar um carimbo. Desde a v2.384.17 que o leitor também respeita esse legado: quando o $0D está ausente e o $0E transporta um VT_FILETIME, o valor é tomado como a hora da última gravação. Cada PROPVARIANT lido também é libertado com PropVariantClear agora, porque um ficheiro malformado pode aparar uma string debaixo de qualquer destes IDs. Se quiser ver esses streams com os seus próprios olhos, o passo a passo sobre ler ficheiros compostos OLE2 no Delphi sem COM IStorage mostra como chegar lá

O PIDSI_EDITTIME é a armadilha dentro da armadilha. A propriedade é tipada como VT_FILETIME mas guarda uma duração, o número bruto de ticks de 100 ns decorridos sem época nenhuma acrescentada. O velho escritor tratava-a como uma data, dividindo o EditTimeMinutes por 1440 e empurrando o resultado pela conversão de época, por isso 125 minutos de edição aterravam no ficheiro como cerca de 299 anos. O leitor atual reconhece essa codificação pelo tamanho: nenhuma sessão de edição real atravessa três séculos, por isso qualquer valor de 109206 dias ou mais tem o desvio do legado subtraído antes de o EditTimeMinutes ser preenchido

Mapa HotXLS do property set 005SummaryInformation em que PIDSI_CREATE_DTM em $0C guarda a hora de criação, PIDSI_LASTSAVE_DTM em $0D o carimbo de gravação, PIDSI_EDITTIME em $0A uma duração bruta em vez de uma data, e $0E PIDSI_PAGECOUNT a posição que versões antigas usavam mal para carimbos
O PIDSI_EDITTIME é a armadilha dentro da armadilha — tipado como VT_FILETIME mas a segurar ticks decorridos sem época, o que um dia transformou 125 minutos de edição em cerca de 299 anos até chegar uma heurística de leitura baseada no tamanho
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Title := 'Q3 settlement';
    // valores da API são hora local; o ficheiro guarda FILETIMEs UTC
    Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
    Book.LastSavedDate := Now;     // o HotXLS escreve o que atribui, não carimba Now ele próprio
    Book.RevisionNumber := 7;
    Book.EditTimeMinutes := 125;   // uma duração, guardada como ticks brutos
    if Book.SaveAs('settlement.xls') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Book.Free;
  end;
end;

Porque é que as datas XLSX falhavam exatamente pelo desvio do fuso horário?

As datas XLSX falhavam pelo desvio do fuso porque o dcterms:created e o dcterms:modified no docProps/core.xml são valores W3CDTF marcados com Z, que significa UTC no modelo de propriedades core do ECMA-376 Part 2, e o HotXLS carimbava a hora local com esse Z apanhado. Um livro criado às 09:30 numa máquina em UTC+8 transportava 09:30:00Z, e o Excel nessa mesma máquina convertia-o para 17:30. O motor clássico tinha a falha idêntica nos seus valores FILETIME, e as propriedades de data personalizadas acrescentadas através do TXLSXWorkbook.CustomProperties.AddDate (escritas como vt:filetime) partilhavam-na também. Desde a v2.384.48 que todos os três caminhos convertem antes de escrever e convertem de volta na leitura sempre que o carimbo transporta um Z, e desde a v2.384.59 que o lado da leitura também respeita segundos fracionários e desvios explícitos +hh:mm / -hh:mm

A conversão em si é onde uma correção ingénua erra. O LocalFileTimeToFileTime aplica o desvio em vigor neste momento, por isso um carimbo de janeiro convertido em julho sai uma hora errado em qualquer fuso com hora de verão. O HotXLS chama antes o TzSpecificLocalTimeToSystemTime e o SystemTimeToTzSpecificLocalTime, que escolhem hora padrão ou de verão a partir da data a converter, e um valor não definido de zero passa intacto para nunca virar uma data de 1899 deslocada por umas horas

Caminhos de conversão local para UTC no HotXLS para um carimbo de janeiro 17:00 CET gravado em julho: o LocalFileTimeToFileTime aplica o desvio de verão de hoje e aterra uma hora errado em 15:00Z, enquanto o TzSpecificLocalTimeToSystemTime escolhe o desvio pela própria data do carimbo e escreve o correto 16:00Z
O desvio do fuso pertence à própria data do carimbo, não às regras atuais da máquina — uma Windows API escolhe o lado certo de uma mudança de hora de verão, a outra desloca em silêncio carimbos de janeiro convertidos em julho por uma hora
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 posta na Hora da Europa Central, o core.xml passa a conter
    // <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
    // (UTC+2 em julho), enquanto ApprovedOn é escrito como 16:00Z (UTC+1 em janeiro)
  finally
    Book.Free;
  end;
end;

O que é que o HotXLS não converte ao ler carimbos?

O leitor W3CDTF do HotXLS converte todas as formas do perfil marcadas com fuso desde a v2.384.59, e o único caso que ainda deixa quieto é uma hora sem fuso. Antes dessa versão o parser levava os primeiros 19 caracteres e só convertia de UTC quando o caráter 20 era Z, por isso um carimbo com segundos fracionários (01:30:00.5Z) ou um desvio explícito (+08:00) era lido como hora local sem ajuste nenhum e acabava falhado pelo desvio do fuso. Desde o HotXLS 2.384.59, o Created, o Modified e as propriedades personalizadas com data analisam segundos fracionários de qualquer comprimento, Z, e desvios +hh:mm / -hh:mm, convertem o instante para UTC e depois para hora local, e leem um carimbo só com data como 2026-07-01 como essa data. Um carimbo com hora mas sem marcador de fuso, que o perfil W3CDTF não permite e para o qual o ECMA-376 Part 2 não dá regra, ainda é lido como hora local inalterado, e um carimbo que não analise de todo volta como zero. Livros que passem pelo Excel estão bem; pacotes produzidos por outros geradores que larguem o fuso merecem uma verificação pontual

Ficheiros escritos por versões antigas do HotXLS são a outra fronteira honesta. Um carimbo XLSX escrito antes da v2.384.48 era hora local vestida de Z, e nada no ficheiro o distingue de um correto, por isso o leitor atual desloca-o pelo desvio do fuso. Carimbos FILETIME clássicos dessas versões levam a mesma deslocação, e uma data de criação escrita antes da v2.384.17 lê-se ainda um dia tarde, porque o dia a mais da velha constante também não pode ser detetado; só a codificação do tempo de edição e a colocação no $0E têm assinatura reconhecível. Tenha também em mente que o valor da API é local à máquina que faz a leitura, por isso um serviço a correr em UTC e um desktop em Tóquio reportarão valores CreatedDate diferentes para o mesmo ficheiro, ambos corretos

Como deve testar carimbos de data/hora de documentos?

Teste carimbos de data/hora de documentos contra algo que o seu próprio código não escreveu. Ambos estes bugs passaram numa verificação de gravar e reabrir, porque um engano simétrico é invisível a um teste simétrico. Compare contra um livro gravado pelo Excel, ou verifique os bytes brutos e o texto XML depois de gravar, e corra a suíte numa máquina posta num fuso diferente de UTC com uma data de teste de cada lado de uma mudança de hora de verão. Um build agent que corra em UTC passa felizes o código velho e partido

Os carimbos de data/hora de documentos são pequenos, mas são o que os sistemas de registo, índices de pesquisa e trilhos de auditoria usam para ordenar, e uma data falhada por um dia ou por oito horas é pior do que uma em falta porque ninguém a questiona. O componente de folhas de cálculo HotXLS para Delphi trata da aritmética de épocas, dos IDs de propriedades e da conversão UTC para .xls e .xlsx, por isso o seu código pode atribuir valores TDateTime locais simples e deixar o formato de ficheiro à biblioteca