บทความเทคนิค

ไทม์สแตมป์เอกสาร Excel ใน Delphi: FILETIME, UTC และ DST

HotXLS เก็บไทม์สแตมป์ document property ของ Excel เป็น UTC ข้างในไฟล์ แล้วเปิดเผยผ่าน API ในรูปเวลาท้องถิ่น: TXLSWorkbook.CreatedDate กับ LastSavedDate สำหรับ .xls, TXLSXWorkbook.Created กับ Modified สำหรับ .xlsx ตั้งแต่ v2.384.48 ทั้งสอง engine แปลงเวลาท้องถิ่นเป็น UTC ตอนเขียน และกลับเป็นท้องถิ่นตอนอ่าน โดยใช้กฎ daylight saving ที่มีผลในวันที่ของ stamp เอง การไปถึงจุดนั้นใช้การแก้สองรอบ และทั้งสอง bug อยู่รอดมาได้ด้วยเหตุผลน่าอายแบบเดียวกัน: automated round trip ผ่านหมดทุกตัว ขณะที่ pane File > Info ของ Excel แสดงวันผิดหรือชั่วโมงผิด ถ้าคุณอ่านภาพรวมเรื่องการตั้ง document properties ของ Excel ใน Delphi ไปแล้ว ตรงนี้คือส่วนที่วันที่หยุดเป็นแค่ค่าธรรมดา

ทำไม test บันทึกแล้วเปิดใหม่ถึงซ่อนความผิดพลาดหนึ่งวันได้

round trip กับตัวเองซ่อนความผิดพลาดไว้ได้ เพราะ writer กับ reader ใช้ constant ผิดตัวเดียวกัน ความผิดพลาดจึงยกเลิกตัวเอง วันที่ใน OLE property set เป็น FILETIME ตัวนับ tick แบบ 100 นาโนวินาที 64 บิต นับตั้งแต่ 1601-01-01 UTC ([MS-DTYP] §2.3.3) ขณะที่ TDateTime ของ Delphi นับวันจาก 1899-12-30 ซึ่งเป็นจุดเริ่ม serial เดียวกับที่เล่าไว้ในเรื่อง Excel date serial ใน Delphi กับระบบ 1900 กับ 1904 ช่องว่างระหว่างสอง epoch คือ 109205 วัน ซึ่งเช็กได้โดยไม่ต้องเปิดปฏิทิน: 25569 (Unix epoch ในรูป TDateTime) บวก 109205 ได้ 134774 ซึ่งคือ Unix epoch นับเป็นวันแบบ FILETIME บิลด์ HotXLS ก่อน v2.384.17 ใช้ 109206 ทุก stamp สร้างและบันทึกจึงถูกเขียนช้าไปหนึ่งวันและอ่านเร็วไปหนึ่งวัน test suite เห็นค่าที่ตัวเองใส่เข้าไป Excel เห็นวันพรุ่งนี้

const
  // จำนวนวันจาก epoch ของ FILETIME (1601-01-01) ถึง epoch ของ TDateTime (1899-12-30)
  // เช็ก: 25569 + 109205 = 134774 คือ Unix epoch นับเป็นวันแบบ FILETIME
  FileTimeDayBias = 109205;

function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
  // ปัดเป็นมิลลิวินาทีเต็มก่อน แล้วค่อยสเกลเป็น tick แบบ 100 ns
  // สเกล Double ตรงเป็น tick เลยจะเปลี่ยน 04:00 เป็น 03:59:59.9999
  Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
ไทม์ไลน์ของ HotXLS แสดง epoch ของ FILETIME 1601-01-01, epoch ของ TDateTime 1899-12-30 และ Unix epoch 1970 พร้อม bias 109205 วันที่อยู่หลัง UtcDateTimeToFileTimeTicks และว่าบิลด์ก่อน v2.384.17 เขียน stamp CreatedDate ทุกตัวช้าไปหนึ่งวันและอ่านเร็วไปหนึ่งวันด้วย 109206
โครงร่าง UtcDateTimeToFileTimeTicks วาง bias ไว้ตรงที่ constant ผิดจะยกเลิกตัวเอง — test สมมาตรแบบบันทึกแล้วเปิดใหม่เห็นค่าที่ตัวเองใส่เข้าไป ขณะที่ pane Info ของ Excel แสดงวันพรุ่งนี้

คอมเมนต์เรื่องการปัดในโครงร่างนั้นคือบทเรียนที่สองซึ่งเล็กกว่า จากโค้ดเดียวกัน คูณ TDateTime ที่มีเศษเวลาตรง ๆ ด้วย 864,000,000,000 tick ต่อวัน ทำให้ error ของ floating point แบบ binary รั่วเข้าไปในหลักท้าย ๆ และ stamp เป๊ะ ๆ ที่ 04:00 กลับมาเป็น 03:59:59.9999 HotXLS v2.384.48 ปัดเป็นมิลลิวินาทีเต็มก่อนสเกล ค่าที่ตรงเต็มชั่วโมงจึงรอดเดินทางมาครบถ้วน release เดียวกันยังเพิ่มขั้นตอนเขตเวลาที่โครงร่างนี้ตั้งใจไม่ใส่ เพราะ input ตรงนี้เป็น UTC อยู่แล้ว

property ID ตัวไหนของ SummaryInformation เก็บวันที่

ใน property set \005SummaryInformation ที่ [MS-OLEPS] นิยามไว้ เวลาสร้างอยู่ภายใต้ property ID $0C (PIDSI_CREATE_DTM) เวลาบันทึกครั้งสุดท้ายอยู่ภายใต้ $0D (PIDSI_LASTSAVE_DTM) และเวลาแก้ไขรวมอยู่ภายใต้ $0A (PIDSI_EDITTIME) บิลด์ HotXLS รุ่นเก่าเขียน stamp บันทึกลง $0E ซึ่งเป็น PIDSI_PAGECOUNT Excel จึงไม่มีวันที่บันทึกจะแสดง และมี property จำนวนหน้าที่เก็บไทม์สแตมป์เอาไว้แทน ตั้งแต่ v2.384.17 ตัวอ่านก็ยอมรับผัง legacy นี้ด้วย: เมื่อไม่มี $0D และ $0E พก VT_FILETIME มา ค่านั้นจะถูกถือเป็นเวลาบันทึกครั้งสุดท้าย ทุก PROPVARIANT ที่อ่านตอนนี้ปล่อยด้วย PropVariantClear ด้วย เพราะไฟล์เสียหายเอา string ไปจอยัดไว้ใต้ ID พวกนี้ก็ได้ ถ้าอยากเห็น stream พวกนี้ด้วยตาตัวเอง walkthrough เรื่องการอ่าน OLE2 compound file ใน Delphi โดยไม่ใช้ COM IStorage แสดงวิธีเข้าถึงมันให้ดู

PIDSI_EDITTIME คือกับดักซ้อนอยู่ในกับดัก property ถูกกำหนด type เป็น VT_FILETIME แต่เก็บค่า duration คือจำนวน tick แบบ 100 ns ที่ผ่านไปดิบ ๆ ไม่มี epoch บวกเพิ่ม ตัวเขียนรุ่นเก่าปฏิบัติมันเหมือนวันที่ เอา EditTimeMinutes หาร 1440 แล้วดันผลลัพธ์ผ่านการแปลง epoch เวลาแก้ไข 125 นาทีจึงลงไปอยู่ในไฟล์ในรูปประมาณ 299 ปี ตัวอ่านปัจจุบันจำการเข้ารหัสแบบนี้ได้จากขนาด: ไม่มี session แก้ไขจริง ๆ ที่กางยาวสามศตวรรษ ค่าใด ๆ ตั้งแต่ 109206 วันขึ้นไปจึงถูกหัก offset รายเก่าออกก่อนเติมลง EditTimeMinutes

ผัง property set 005SummaryInformation ของ HotXLS ที่ PIDSI_CREATE_DTM ที่ $0C เก็บเวลาสร้าง, PIDSI_LASTSAVE_DTM ที่ $0D เก็บ stamp การบันทึก, PIDSI_EDITTIME ที่ $0A เก็บ duration ดิบ ๆ ไม่ใช่วันที่ และ $0E ที่เป็น PIDSI_PAGECOUNT ซึ่งบิลด์เก่าใช้ผิดไปเก็บไทม์สแตมป์
PIDSI_EDITTIME คือกับดักซ้อนในกับดัก — กำหนด type เป็น VT_FILETIME แต่เก็บ tick ที่ผ่านไปโดยไม่มี epoch ซึ่งเคยเปลี่ยนเวลาแก้ไข 125 นาทีให้กลายเป็นประมาณ 299 ปี จนกว่า heuristic ที่ดูจากขนาดของตัวอ่านจะมาถึง
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Title := 'Q3 settlement';
    // ค่าใน API เป็นเวลาท้องถิ่น ไฟล์เก็บเป็น FILETIME แบบ UTC
    Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
    Book.LastSavedDate := Now;     // HotXLS เขียนค่าที่คุณกำหนด ไม่ได้ stamp Now เอง
    Book.RevisionNumber := 7;
    Book.EditTimeMinutes := 125;   // เป็น duration เก็บเป็น tick ดิบ ๆ
    if Book.SaveAs('settlement.xls') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Book.Free;
  end;
end;

ทำไมวันที่ใน XLSX ถึงคลาดเป๊ะ ๆ เท่ากับ offset ของเขตเวลา

วันที่ใน XLSX คลาดไปเท่า offset ของเขตเวลา เพราะ dcterms:created กับ dcterms:modified ใน docProps/core.xml เป็นค่า W3CDTF ที่ติดธง Z ซึ่งแปลว่า UTC ภายใต้โมเดล core properties ของ ECMA-376 Part 2 และ HotXLS เคย stamp เวลาท้องถิ่นโดยติด Z นั้นไปด้วย workbook ที่สร้างเวลา 09:30 บนเครื่องเขต UTC+8 พก 09:30:00Z ออกไป และ Excel บนเครื่องเดียวกันแปลงมันเป็น 17:30 ฝั่ง classic engine ก็มีข้อบกพร่องเดียวกันในค่า FILETIME และ custom date property ที่เพิ่มผ่าน TXLSXWorkbook.CustomProperties.AddDate (เขียนเป็น vt:filetime) ก็ติดเชื้อร่วมด้วย ตั้งแต่ v2.384.48 ทั้งสามเส้นทางแปลงก่อนเขียน และแปลงกลับตอนอ่านทุกครั้งที่ stamp ติดธง Z และตั้งแต่ v2.384.59 ฝั่งอ่านยังรองรับวินาทีทศนิยมกับ offset แบบระบุชัด +hh:mm / -hh:mm อีกด้วย

ตัวการแปลงเองคือจุดที่การแก้แบบไม่คิดพลาด LocalFileTimeToFileTime ใช้ offset ที่มีผลอยู่ตอนนี้ stamp เดือนมกราคมที่แปลงในเดือนกรกฎาคมจึงออกมาคลาดไปหนึ่งชั่วโมงในเขตที่มี daylight saving HotXLS จึงเรียก TzSpecificLocalTimeToSystemTime กับ SystemTimeToTzSpecificLocalTime แทน ซึ่งเลือกเวลา standard หรือ daylight จากวันที่ที่กำลังแปลง และค่าที่ยังไม่ได้ตั้งซึ่งเป็นศูนย์ผ่านไปตามเดิมโดยไม่ถูกแตะ จึงไม่มีวันกลายเป็นวันที่ปี 1899 ที่เลื่อนไปสองสามชั่วโมง

เส้นทางแปลงจากเวลาท้องถิ่นเป็น UTC ของ HotXLS สำหรับ stamp 17:00 CET เดือนมกราคมที่บันทึกในเดือนกรกฎาคม: LocalFileTimeToFileTime ใช้ offset daylight ของวันนี้และไปลงผิดหนึ่งชั่วโมงที่ 15:00Z ขณะที่ TzSpecificLocalTimeToSystemTime เลือก offset จากวันที่ของ stamp เองและเขียน 16:00Z ที่ถูกต้อง
offset ของเขตเวลาเป็นของวันที่ใน stamp เอง ไม่ใช่กฎปัจจุบันของเครื่อง — Windows API หนึ่งตัวเลือกฝั่งที่ถูกของการเปลี่ยน daylight saving อีกตัวเลื่อน stamp มกราคมที่แปลงในเดือนกรกฎาคมเงียบ ๆ ไปหนึ่งชั่วโมง
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');
    // บนเครื่องที่ตั้งเป็น Central European Time core.xml ตอนนี้เก็บ
    // <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
    // (UTC+2 ในเดือนกรกฎาคม) ขณะที่ ApprovedOn ถูกเขียนเป็น 16:00Z (UTC+1 ในเดือนมกราคม)
  finally
    Book.Free;
  end;
end;

HotXLS ไม่แปลงอะไรบ้างตอนอ่านไทม์สแตมป์

ตัวอ่าน W3CDTF ของ HotXLS แปลงรูปแบบที่ติดธงเขตเวลาทุกรูปของ profile ตั้งแต่ v2.384.59 กรณีเดียวที่ยังปล่อยตามเดิมคือเวลาที่ไม่มีเขต ก่อน release นั้น parser เอา 19 ตัวอักษรแรกและแปลงจาก UTC ต่อเมื่อตัวอักษรที่ 20 เป็น Z stamp ที่มีวินาทีทศนิยม (01:30:00.5Z) หรือ offset แบบระบุชัด (+08:00) จึงถูกอ่านเป็นเวลาท้องถิ่นโดยไม่ปรับอะไร และจบด้วยการคลาดเท่า offset ของเขต ตั้งแต่ HotXLS 2.384.59 Created, Modified และ custom property ที่เป็นวันที่ parse วินาทีทศนิยมความยาวใดก็ได้, Z และ offset +hh:mm / -hh:mm แปลงช่วงเวลานั้นเป็น UTC แล้วเป็นเวลาท้องถิ่น และอ่าน stamp ที่มีแต่วันที่อย่าง 2026-07-01 เป็นวันนั้น ส่วน stamp ที่มีเวลาแต่ไม่มีธงเขตเวลา ซึ่ง profile W3CDTF ไม่อนุญาตและ ECMA-376 Part 2 ไม่ให้กฎอะไร ยังถูกอ่านเป็นเวลาท้องถิ่นตามเดิม และ stamp ที่ parse ไม่ได้เลยจะกลับมาเป็นศูนย์ workbook ที่ผ่านมือ Excel ปลอดภัย ส่วน package ที่ generator อื่นผลิตแล้วทิ้งเขตเวลาไป ควรได้รับการสุ่มตรวจ

ไฟล์ที่บิลด์ HotXLS รุ่นเก่าเขียนคือขอบเขตจริงอีกด้านหนึ่ง stamp XLSX ที่เขียนก่อน v2.384.48 เป็นเวลาท้องถิ่นที่สวม Z และไม่มีอะไรในไฟล์บอกได้ว่าต่างจากตัวที่ถูกต้อง ตัวอ่านปัจจุบันจึงเลื่อนมันตาม offset ของเขต stamp FILETIME แบบคลาสสิกจากบิลด์พวกนั้นโดนการเลื่อนแบบเดียวกัน และวันที่สร้างที่เขียนก่อน v2.384.17 ยังถูกอ่านกลับมาช้าไปอีกหนึ่งวัน เพราะวันเกินของ constant ตัวเก่าตรวจจับไม่ได้เช่นกัน มีแค่การเข้ารหัส edit-time กับการวางตำแหน่ง $0E เท่านั้นที่มีลายเซ็นจำได้ อย่าลืมด้วยว่าค่าใน API เป็นเวลาท้องถิ่นของเครื่องที่อ่าน service ที่รันใน UTC กับเดสก์ท็อปที่โตเกียวจึงรายงานค่า CreatedDate ต่างกันสำหรับไฟล์เดียวกัน ทั้งคู่ถูกต้องทั้งนั้น

ควรทดสอบไทม์สแตมป์ของเอกสารอย่างไร

ทดสอบไทม์สแตมป์เอกสารกับสิ่งที่โค้ดของคุณเองไม่ได้เขียน bug ทั้งสองผ่านการเช็กแบบบันทึกแล้วเปิดใหม่ เพราะความผิดพลาดแบบสมมาตรมองไม่เห็นด้วย test แบบสมมาตร เทียบกับ workbook ที่ Excel บันทึก หรือ assert ไบต์ดิบกับข้อความ XML หลังบันทึก แล้วรัน suite บนเครื่องที่ตั้งเขตที่ไม่ใช่ UTC โดยมีวันที่ทดสอบอยู่สองฝั่งของการเปลี่ยน daylight saving build agent ที่รันใน UTC จะผ่านโค้ดเก่าที่พังให้อย่างสบายใจ

ไทม์สแตมป์เอกสารดูเป็นเรื่องเล็ก แต่มันคือสิ่งที่ระบบจัดเก็บข้อมูล ดัชนีค้นหา และ audit trail เอามาเรียงลำดับ และวันที่ที่คลาดไปหนึ่งวันหรือแปดชั่วโมงแย่กว่าการไม่มีเสียอีก เพราะไม่มีใครสงสัยมัน HotXLS Delphi spreadsheet component จัดการคณิต epoch, property ID และการแปลง UTC ให้ทั้ง .xls กับ .xlsx โค้ดของคุณจึงกำหนดค่า TDateTime เวลาท้องถิ่นธรรมดาได้ แล้วปล่อยเรื่อง file format ให้ library จัดการ