Bài viết kỹ thuật

Timestamp tài liệu Excel trong Delphi: FILETIME, UTC và DST

HotXLS lưu các timestamp property tài liệu Excel dưới dạng UTC bên trong file và expose chúng dưới dạng giờ local qua API: TXLSWorkbook.CreatedDate và LastSavedDate cho .xls, TXLSXWorkbook.Created và Modified cho .xlsx. Từ v2.384.48 cả hai engine đều chuyển giờ local thành UTC khi ghi và chuyển ngược khi đọc, theo đúng luật daylight saving áp dụng cho chính ngày của stamp. Đường tới đó cần hai lần sửa, và cả hai bug đều sống sót vì cùng một lý do đáng xấu hổ: mọi round trip tự động đều pass, trong khi pane File > Info của Excel lại hiện sai ngày hay sai giờ. Nếu bạn đã đọc bài tổng quan về đặt property tài liệu Excel trong Delphi, thì đây là phần mà các ngày thôi không còn là giá trị đơn giản nữa

Vì sao một test save-rồi-mở-lại che giấu lỗi một ngày?

Một round trip tự so với chính mình che giấu được lỗi vì writer và reader dùng chung một hằng số sai giống nhau, nên sai số tự triệt tiêu. Ngày trong property set OLE là một FILETIME, số đếm 64-bit các tick 100 nanosecond từ 1601-01-01 UTC ([MS-DTYP] §2.3.3), trong khi một TDateTime của Delphi đếm ngày từ 1899-12-30, đúng gốc serial đã trình bày trong date serial Excel trong Delphi và hệ 1900 so với 1904. Khoảng cách giữa hai epoch là 109205 ngày, thứ bạn có thể kiểm tra mà không cần lịch: 25569 (Unix epoch dưới dạng TDateTime) cộng 109205 cho ra 134774, Unix epoch đếm theo ngày FILETIME. Các build HotXLS trước v2.384.17 dùng 109206, nên mọi stamp created và saved bị ghi trễ một ngày và đọc sớm một ngày. Test suite nhìn thấy đúng giá trị nó vừa gán; Excel nhìn thấy ngày mai

const
  // số ngày từ epoch FILETIME (1601-01-01) tới epoch TDateTime (1899-12-30)
  // kiểm tra: 25569 + 109205 = 134774, Unix epoch theo ngày FILETIME
  FileTimeDayBias = 109205;

function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
  // Làm tròn về nguyên millisecond trước, rồi scale lên các tick 100 ns.
  // Scale thẳng Double lên tick biến 04:00 thành 03:59:59.9999
  Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
Dòng thời gian HotXLS của epoch FILETIME 1601-01-01, epoch TDateTime 1899-12-30 và Unix epoch 1970, cho thấy bias 109205 ngày đứng sau UtcDateTimeToFileTimeTicks và cách các build trước v2.384.17 ghi mọi stamp CreatedDate trễ một ngày rồi đọc sớm một ngày với 109206
Phác thảo UtcDateTimeToFileTimeTicks giữ bias ở chỗ một hằng số sai tự triệt tiêu — một test save-rồi-mở-lại đối xứng nhìn thấy đúng giá trị mình gán trong khi pane Info của Excel hiện ngày mai

Chú thích về làm tròn trong phác thảo đó là bài học thứ hai, nhỏ hơn, từ cùng đoạn code. Nhân trực tiếp một TDateTime có phần lẻ với 864.000.000.000 tick mỗi ngày để lỗi float nhị phân rò vào các chữ số cuối, và một stamp đúng 04:00 quay về thành 03:59:59.9999. HotXLS v2.384.48 làm tròn về nguyên millisecond trước khi scale, nên các giá trị đúng giờ tròn khóa giữ nguyên vẹn qua hành trình. Cùng release đó thêm bước múi giờ mà phác thảo này cố tình bỏ qua, vì input ở đây đã là UTC

Property ID SummaryInformation nào giữ các ngày?

Trong property set \005SummaryInformation định nghĩa bởi [MS-OLEPS], thời gian tạo nằm dưới property ID $0C (PIDSI_CREATE_DTM), thời gian lưu cuối dưới $0D (PIDSI_LASTSAVE_DTM), và tổng thời gian chỉnh sửa dưới $0A (PIDSI_EDITTIME). Các build HotXLS cũ ghi stamp last-saved vào $0E, vốn là PIDSI_PAGECOUNT, nên Excel không có ngày lưu nào để hiện và lại có một property số trang mang timestamp. Từ v2.384.17, reader cũng tôn trọng bố cục legacy đó: khi $0D vắng mặt và $0E mang một VT_FILETIME, giá trị được coi là thời gian lưu cuối. Mọi lần đọc PROPVARIANT giờ cũng được giải phóng bằng PropVariantClear, vì một file dị dạng có thể để quên một string dưới bất kỳ ID nào trong số này. Nếu bạn muốn tận mắt thấy các stream đó, bài walk-through về đọc file compound OLE2 trong Delphi không cần COM IStorage cho thấy cách chạm tới chúng

PIDSI_EDITTIME là cái bẫy nằm trong bẫy. Property được khai báo kiểu VT_FILETIME nhưng giữ một khoảng thời gian, số tick 100 ns đã trôi, thô, chẳng cộng epoch nào. Writer cũ đối xử với nó như một ngày, chia EditTimeMinutes cho 1440 rồi đẩy kết quả qua phép chuyển epoch, nên 125 phút chỉnh sửa lọt vào file thành khoảng 299 năm. Reader hiện tại nhận diện kiểu mã hóa đó bằng kích thước: chẳng phiên chỉnh sửa thật nào kéo dài ba thế kỷ, nên mọi giá trị từ 109206 ngày trở lên bị trừ offset legacy trước khi EditTimeMinutes được điền

Sơ đồ property set 005SummaryInformation của HotXLS, nơi PIDSI_CREATE_DTM tại $0C giữ thời gian tạo, PIDSI_LASTSAVE_DTM tại $0D giữ stamp lưu, PIDSI_EDITTIME tại $0A là khoảng thời gian thô chứ không phải ngày, và $0E PIDSI_PAGECOUNT là slot mà các build cũ từng dùng sai cho timestamp
PIDSI_EDITTIME là cái bẫy nằm trong bẫy — khai báo VT_FILETIME mà lại giữ các tick đã trôi không epoch, từng biến 125 phút chỉnh sửa thành khoảng 299 năm cho tới khi một heuristic đọc dựa trên kích thước xuất hiện
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Title := 'Q3 settlement';
    // Giá trị API là giờ local; file lưu các FILETIME UTC
    Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
    Book.LastSavedDate := Now;     // HotXLS ghi đúng cái bạn gán, không tự đóng dấu Now
    Book.RevisionNumber := 7;
    Book.EditTimeMinutes := 125;   // một khoảng thời gian, lưu dạng tick thô
    if Book.SaveAs('settlement.xls') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Book.Free;
  end;
end;

Vì sao ngày trong XLSX lệch đúng bằng offset múi giờ?

Ngày trong XLSX lệch bằng đúng offset múi giờ vì dcterms:created và dcterms:modified trong docProps/core.xml là các giá trị W3CDTF gắn thẻ Z, tức UTC theo mô hình core properties của ECMA-376 Part 2, mà HotXLS ngày xưa đóng dấu giờ local kèm cái Z đó. Một workbook tạo lúc 09:30 trên máy UTC+8 mang 09:30:00Z, và Excel trên chính máy đó chuyển nó thành 17:30. Classic engine mang cùng lỗi hệt trong các giá trị FILETIME, và các property ngày tùy biến thêm qua TXLSXWorkbook.CustomProperties.AddDate (ghi thành vt:filetime) cũng dính chung. Từ v2.384.48 cả ba đường đều chuyển đổi trước khi ghi và chuyển ngược khi đọc mỗi khi stamp mang Z, và từ v2.384.59 phía đọc còn tôn trọng giây lẻ cùng các offset tường minh +hh:mm / -hh:mm

Chính phép chuyển đổi là nơi một bản sửa ngây thơ đi sai. LocalFileTimeToFileTime áp offset đang có hiệu lực ngay lúc này, nên một stamp tháng Giêng được chuyển đổi vào tháng Bảy sẽ ra lệch một giờ ở mọi múi giờ có daylight saving. HotXLS gọi TzSpecificLocalTimeToSystemTime và SystemTimeToTzSpecificLocalTime thay thế, hai hàm chọn giờ chuẩn hay giờ daylight dựa trên ngày đang được chuyển đổi, và một giá trị chưa đặt bằng 0 đi qua nguyên vẹn nên không bao giờ biến thành một ngày 1899 bị lệch vài giờ

Các đường chuyển local sang UTC của HotXLS cho một stamp 17:00 CET tháng Giêng được lưu vào tháng Bảy: LocalFileTimeToFileTime áp offset daylight hôm nay và đáp xuống lệch một giờ tại 15:00Z, trong khi TzSpecificLocalTimeToSystemTime lấy offset từ chính ngày của stamp và ghi đúng 16:00Z
Offset múi giờ thuộc về chính ngày của stamp, không thuộc về luật hiện tại của máy — một Windows API chọn đúng phía của lần đổi daylight saving, hàm kia lặng lẽ dịch các stamp tháng Giêng chuyển vào tháng Bảy một giờ
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');
    // Trên một máy đặt Central European Time, core.xml giờ giữ
    // <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
    // (UTC+2 vào tháng Bảy), còn ApprovedOn được ghi thành 16:00Z (UTC+1 vào tháng Giêng)
  finally
    Book.Free;
  end;
end;

HotXLS không chuyển gì khi đọc timestamp?

Reader W3CDTF của HotXLS chuyển mọi dạng gắn múi giờ của profile từ v2.384.59, và đúng một ca nó vẫn bỏ mặc là giờ không kèm múi giờ. Trước release đó, parser lấy 19 ký tự đầu và chỉ chuyển từ UTC khi ký tự thứ 20 là Z, nên một stamp có giây lẻ (01:30:00.5Z) hay offset tường minh (+08:00) bị đọc như giờ local không điều chỉnh và kết thúc lệch bằng offset múi giờ. Từ HotXLS 2.384.59, Created, Modified và các property tùy biến kiểu ngày parse được giây lẻ độ dài tùy ý, Z, và các offset +hh:mm / -hh:mm, chuyển khoảnh khắc thành UTC rồi thành giờ local, và đọc một stamp chỉ có ngày như 2026-07-01 đúng thành ngày đó. Một stamp có giờ nhưng không có marker múi giờ, thứ profile W3CDTF không cho phép và ECMA-376 Part 2 không có luật nào cho, vẫn được đọc như giờ local nguyên trạng, còn một stamp không parse được gì thì trả về 0. Các workbook đi qua Excel thì ổn; các package do generator khác sinh ra mà đánh rơi múi giờ xứng đáng được soát ngẫu nhiên một chút

Các file do build HotXLS cũ ghi là ranh giới thành thật thứ hai. Một stamp XLSX ghi trước v2.384.48 là giờ local khoác áo Z, và chẳng gì trong file phân biệt được nó với một stamp đúng, nên reader hiện tại dịch nó theo offset múi giờ. Các stamp FILETIME cổ điển từ các build đó nhận cùng phép dịch, và một ngày tạo ghi trước v2.384.17 còn đọc về trễ thêm một ngày, vì ngày dư của hằng số cũ cũng không thể phát hiện; chỉ có kiểu mã hóa edit-time và vị trí $0E là có chữ ký nhận ra được. Cũng nhớ rằng giá trị API là giờ local của chính máy đang đọc, nên một service chạy UTC và một desktop ở Tokyo sẽ báo hai giá trị CreatedDate khác nhau cho cùng một file, và cả hai đều đúng

Bạn nên test timestamp tài liệu thế nào?

Hãy test timestamp tài liệu bằng cách đối chiếu với thứ mà code của bạn không tự viết. Cả hai bug này đều pass một phép kiểm save-rồi-mở-lại, vì một sai sót đối xứng thì vô hình với một test đối xứng. So sánh với một workbook do Excel lưu, hay assert các byte thô và văn bản XML sau khi lưu, và chạy suite trên một máy đặt múi giờ không phải UTC với ngày test nằm hai bên một lần đổi daylight saving. Một build agent chạy trong UTC sẽ pass cái code cũ hỏng kia một cách vui vẻ

Timestamp tài liệu thì nhỏ, nhưng chúng chính là thứ các hệ thống lưu trữ, search index và audit trail sắp xếp theo, và một ngày lệch một ngày hay tám giờ còn tệ hơn một ngày thiếu, vì chẳng ai hoài nghi nó. HotXLS Delphi spreadsheet component lo phép tính epoch, các property ID và phép chuyển UTC cho cả .xls lẫn .xlsx, nên code của bạn chỉ cần gán các giá trị TDateTime local bình thường và để định dạng file cho thư viện