مقاله فنی

مهرهای زمانی سند اکسل در دلفی: FILETIME، UTC و DST

HotXLS مهرهای زمانی پراپرتی‌های سند اکسل را به‌صورت UTC داخل فایل ذخیره می‌کند و از طریق API به‌شکل ساعت محلی عرضه‌شان می‌کند: TXLSWorkbook.CreatedDate و LastSavedDate برای .xls، و TXLSXWorkbook.Created و Modified برای .xlsx. از v2.384.48 به بعد هر دو موتور هنگام نوشتن ساعت محلی را به UTC تبدیل می‌کنند و هنگام خواندن برعکس، با قواعد ساعت تابستانی که روی تاریخ خودِ مهر اعمال می‌شود. رسیدن به این نقطه دو fix برد، و هر دو باگ به همان دلیل خجالت‌آور زنده مانده بودند: هر round-trip خودکاری قبول می‌شد، در حالی که پنجرهٔ File > Info اکسل روز یا ساعت غلط نشان می‌داد. اگر مرور ما دربارهٔ تنظیم پراپرتی‌های سند اکسل در دلفی را خوانده باشید، این همان بخشی است که تاریخ‌ها از مقدار ساده خارج می‌شوند

چرا یک آزمون ذخیره-و-بازکردن خطای یک‌روزه را پنهان کرد؟

یک round trip درون خودش خطا را پنهان می‌کرد چون writer و reader ثابت غلط مشترکی داشتند، پس اشتباه خودش را خنثی می‌کرد. تاریخ یک مجموعه پراپرتی OLE یک FILETIME است، شمارندهٔ 64 بیتی از تیک‌های 100 نانوثانیه از 1601-01-01 UTC ([MS-DTYP] §2.3.3)، در حالی که TDateTime دلفی روزها را از 1899-12-30 می‌شمارد؛ همان مبدأ سریالی که در شماره سریال تاریخ اکسل در دلفی و سیستم‌های 1900 در برابر 1904 پوشش داده شده. فاصلهٔ این دو epoch برابر 109205 روز است که بدون تقویم هم می‌شود بررسی‌اش کرد: 25569 (epoch یونیکس به‌صورت TDateTime) به‌علاوهٔ 109205 می‌شود 134774، یعنی epoch یونیکس بر حسب روز FILETIME. بیلدهای HotXLS پیش از v2.384.17 عدد 109206 را به کار می‌بردند، پس هر مهرِ ایجاد و ذخیره یک روز دیر نوشته و یک روز زود خوانده می‌شد. مجموعهٔ آزمون مقداری را می‌دید که خودش اختصاص داده بود؛ اکسل فردا را می‌دید

const
  // فاصلهٔ روز از epoch فایل FILETIME (1601-01-01) تا epoch تایپ TDateTime (1899-12-30)
  // بررسی: 25569 + 109205 = 134774، یعنی epoch یونیکس بر حسب روز FILETIME
  FileTimeDayBias = 109205;

function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
  // اول به هزارم ثانیهٔ کامل گرد کن، بعد به تیک‌های 100 ns مقیاس بده.
  // مقیاس کردن مستقیم Double به تیک، 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 و epoch یونیکس 1970، که بایاس 109205 روز پشت UtcDateTimeToFileTimeTicks و نحوهٔ نوشتن هر مهر CreatedDate توسط بیلدهای پیش از v2.384.17 با 109206 یک روز دیر و خواندنش یک روز زود را نشان می‌دهد
طرح UtcDateTimeToFileTimeTicks بایاس را همان‌جا نگه می‌دارد که ثابت غلط خودش را خنثی می‌کند — یک آزمون متقارنِ ذخیره-و-بازکردن مقدار اختصاص‌داده‌شده را می‌دید در حالی که پنجرهٔ Info اکسل فردا را نشان می‌داد

نظر گرد کردن در آن طرح، درس دوم و کوچک‌تر از همان کد است. ضرب کردن مستقیم یک TDateTime اعشاری در 864,000,000,000 تیک در روز اجازه می‌دهد خطای ممیز شناور دودویی به ارقام پایینی نشت کند، و مهرِ دقیقاً 04:00 به شکل 03:59:59.9999 برمی‌گشت. HotXLS در v2.384.48 پیش از مقیاس دادن به هزارم ثانیهٔ کامل گرد می‌کند، پس مقادیر سرِ ساعت سالم از مسیر عبور می‌کنند. همان انتشار مرحلهٔ منطقهٔ زمانی را هم اضافه کرد که این طرح عمداً از آن حرف نمی‌زند، چون ورودی اینجا از قبل UTC است

کدام شناسه‌های پراپرتی SummaryInformation تاریخ‌ها را حمل می‌کنند؟

در مجموعه پراپرتی \005SummaryInformation که [MS-OLEPS] تعریفش می‌کند، زمان ایجاد زیر شناسهٔ پراپرتی $0C (PIDSI_CREATE_DTM) است، زمان آخرین ذخیره زیر $0D (PIDSI_LASTSAVE_DTM)، و زمان کل ویرایش زیر $0A (PIDSI_EDITTIME). بیلدهای قدیمی‌تر HotXLS مهر ذخیره را در $0E می‌نوشتند که PIDSI_PAGECOUNT است، پس اکسل تاریخ ذخیره‌ای برای نشان دادن نداشت و پراپرتی تعداد صفحه‌ای داشت که مهر زمانی در خود حمل می‌کرد. از v2.384.17 به بعد reader هم به آن چیدمان قدیمی احترام می‌گذارد: وقتی $0D غایب است و $0E یک VT_FILETIME حمل می‌کند، مقدار به‌عنوان زمان آخرین ذخیره گرفته می‌شود. هر PROPVARIANT خوانده‌شده هم حالا با PropVariantClear آزاد می‌شود، چون یک فایل ناقص می‌تواند زیر هر کدام از این شناسه‌ها رشته‌ای پارک کند. اگر می‌خواهید آن استریم‌ها را با چشم خودتان ببینید، گام‌به‌گامِ خواندن فایل‌های مرکب OLE2 در دلفی بدون COM IStorage نشان می‌دهد چطور به آن‌ها برسید

PIDSI_EDITTIME دامِ درون دام است. نوع پراپرتی VT_FILETIME است اما یک مدت‌زمان را حمل می‌کند، یعنی شمار خام تیک‌های 100 نانوثانیهٔ گذشته بدون اضافه شدن هیچ epoch. writer قدیمی با آن مثل یک تاریخ رفتار می‌کرد، EditTimeMinutes را بر 1440 تقسیم می‌کرد و نتیجه را از تبدیل epoch می‌گذراند، پس 125 دقیقه ویرایش به شکل تقریباً 299 سال در فایل می‌نشست. reader فعلی آن رمزگذاری را از روی اندازه‌اش می‌شناسد: هیچ نشست ویرایش واقعی سه قرن را درنمی‌گیرد، پس هر مقدار برابر 109206 روز یا بیشتر پیش از پر شدن EditTimeMinutes آفست قدیمی از آن کم می‌شود

نگاشت HotXLS از مجموعه پراپرتی 005SummaryInformation که در آن PIDSI_CREATE_DTM در $0C زمان ایجاد را حمل می‌کند، PIDSI_LASTSAVE_DTM در $0D مهر ذخیره را، PIDSI_EDITTIME در $0A یک مدت خام است نه تاریخ، و $0E یعنی PIDSI_PAGECOUNT جایگاهی است که بیلدهای قدیمی برای مهرهای زمانی اشتباه به کار می‌بردند
PIDSI_EDITTIME دامِ درون دام است — از نوع VT_FILETIME اما در حال حمل تیک‌های سپری‌شده بدون هیچ epoch، که یک بار 125 دقیقه ویرایش را به تقریباً 299 سال تبدیل کرد تا آنکه یک مکانیزم مبتنی بر اندازه در reader رسید
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 همان چیزی را می‌نویسد که اختصاص می‌دهید، خودش Now مهر نمی‌زند
    Book.RevisionNumber := 7;
    Book.EditTimeMinutes := 125;   // یک مدت‌زمان است که به‌صورت تیک خام ذخیره می‌شود
    if Book.SaveAs('settlement.xls') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Book.Free;
  end;
end;

چرا تاریخ‌های XLSX دقیقاً به اندازهٔ آفست منطقهٔ زمانی خطا داشتند؟

تاریخ‌های XLSX به اندازهٔ آفست منطقه خطا داشتند چون dcterms:created و dcterms:modified در docProps/core.xml مقادیر W3CDTF با برچسب Z هستند، که در مدل core properties بخش 2 از ECMA-376 یعنی UTC، و HotXLS ساعتی می‌زد که محلی بود با همان Z چسبیده به دنبالش. کتاب کاری که روی ماشینی با UTC+8 در ساعت 09:30 ساخته می‌شد 09:30:00Z حمل می‌کرد و اکسل روی همان ماشین آن را 17:30 می‌دید. موتور کلاسیک همان نقص را در مقادیر FILETIME خودش داشت، و پراپرتی‌های تاریخ سفارشی که از طریق TXLSXWorkbook.CustomProperties.AddDate اضافه می‌شدند (نوشته‌شده به‌صورت vt:filetime) هم شریک آن بودند. از v2.384.48 به بعد هر سه مسیر پیش از نوشتن تبدیل می‌کنند و هنگام خواندن برعکس، هر وقت مهر Z حمل کند، و از v2.384.59 سمت خواندن ثانیه‌های اعشاری و آفست‌های صریح +hh:mm / -hh:mm را هم می‌فهمد

خود تبدیل جایی است که یک fix ساده‌لوحانه خراب می‌شود. LocalFileTimeToFileTime آفستِ هم‌اکنون جاری را اعمال می‌کند، پس مهر ژانویه‌ای که در جولای تبدیل شود در هر منطقه‌ای با ساعت تابستانی یک ساعت خطا دارد. HotXLS به‌جایش TzSpecificLocalTimeToSystemTime و SystemTimeToTzSpecificLocalTime را صدا می‌زند که ساعت استاندارد یا تابستانی را از تاریخِ در حال تبدیل برمی‌گزینند، و مقدار ست‌نشدهٔ صفر هم دست‌نخورده عبور می‌کند تا هرگز به تاریخی از 1899 جابه‌جاشده با چند ساعت تبدیل نشود

مسیرهای تبدیل محلی به UTC در HotXLS برای مهر 17:00 به وقت CET ژانویه که در جولای ذخیره می‌شود: LocalFileTimeToFileTime آفست تابستانی امروزی را اعمال می‌کند و یک ساعت خطا در 15:00Z فرود می‌آید، در حالی که TzSpecificLocalTimeToSystemTime آفست را از تاریخ خودِ مهر برمی‌گزیند و 16:00Z درست را می‌نویسد
آفست منطقهٔ زمانی متعلق به تاریخ خودِ مهر است، نه قواعد جاری ماشین — یک API ویندوز سمت درست تغییر ساعت تابستانی را برمی‌گزیند و دیگری بی‌سروصدا مهرهای ژانویه‌ای را که در جولای تبدیل می‌شوند یک ساعت جابه‌جا می‌کند
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 هنگام خواندن مهرهای زمانی چه چیزی را تبدیل نمی‌کند؟

reader مربوط به W3CDTF در HotXLS از v2.384.59 به بعد هر شکل برچسب‌دار از پروفایل را تبدیل می‌کند، و تنها موردی که هنوز به حال خودش می‌گذارد زمانی بدون منطقه است. پیش از آن انتشار، پارسر 19 کاراکتر اول را می‌گرفت و فقط وقتی کاراکتر بیستم Z بود از UTC تبدیل می‌کرد، پس مهرِ دارای ثانیهٔ اعشاری (01:30:00.5Z) یا آفست صریح (+08:00) به‌عنوان ساعت محلی و بدون هیچ تعدیلی خوانده می‌شد و به اندازهٔ آفست منطقه خطا می‌نشست. از HotXLS 2.384.59 به بعد، Created، Modified و پراپرتی‌های سفارشیِ تاریخی ثانیه‌های اعشاری با هر طول، Z و آفست‌های +hh:mm / -hh:mm را parse می‌کنند، لحظه را به UTC و بعد به ساعت محلی تبدیل می‌کنند، و مهرِ فقط-تاریخی مثل 2026-07-01 را همان تاریخ می‌خوانند. مهرِ دارای زمان اما بدون نشان منطقه، که پروفایل W3CDTF اجازه‌اش را نمی‌دهد و ECMA-376 بخش 2 هم قاعده‌ای برایش ندارد، همچنان بدون تغییر به‌عنوان ساعت محلی خوانده می‌شود و مهری که اصلاً parse نشود صفر برمی‌گردد. کتاب‌های کاری که از اکسل عبور کرده‌اند سالم‌اند؛ بسته‌هایی که تولیدکننده‌های دیگر با حذف منطقه ساخته‌اند شایسته یک بازبینی موردی‌اند

فایل‌های نوشته‌شده توسط بیلدهای قدیمی‌تر HotXLS مرز صادقانهٔ دیگرند. مهر XLSX نوشته‌شده پیش از v2.384.48 ساعت محلی بود با پوشیده‌شدن در Z، و هیچ چیز در فایل آن را از مهر درست قابل تشخیص نمی‌کند، پس reader فعلی آن را به اندازهٔ آفست منطقه جابه‌جا می‌کند. مهرهای کلاسیک FILETIME از آن بیلدها همان جابه‌جایی را می‌گیرند، و تاریخ ایجاد نوشته‌شده پیش از v2.384.17 یک روز دیر هم برمی‌گردد، چون روز اضافهٔ ثابت قدیمی هم قابل شناسایی نیست؛ فقط رمزگذاری edit-time و جای‌گذاری $0E امضای قابل تشخیص دارند. به یاد داشته باشید که مقدار API به ماشینِ در حال خواندن محلی است، پس سرویسی که در UTC اجرا می‌شود و دسکتاپی در توکیو برای یک فایل واحد مقادیر CreatedDate متفاوتی گزارش می‌کنند، هر دو درست

مهرهای زمانی سند را چطور باید آزمود؟

مهرهای زمانی سند را با چیزی مقایسه کنید که کد خودتان ننوشته. هر دوی این باگ‌ها از یک بررسی ذخیره-بعد-بازکردن عبور می‌کردند، چون اشتباه متقارن برای آزمون متقارن نامرئی است. با کتاب کاری که اکسل ذخیره کرده مقایسه کنید، یا بعد از ذخیره بایت‌های خام و متن XML را assert بگیرید، و مجموعهٔ آزمون را روی ماشینی که در منطقه‌ای غیر UTC تنظیم شده اجرا کنید با تاریخ آزمونی در هر دو سمت یک تغییر ساعت تابستانی. build agentی که در UTC اجرا می‌شود کد قدیمی و خراب را با کمال میل قبول می‌کند

مهرهای زمانی سند کوچک‌اند، اما همان چیزی‌اند که سیستم‌های رکورد، شاخص‌های جستجو و مسیرهای حسابرسی بر اساسشان مرتب می‌کنند، و تاریخی که یک روز یا هشت ساعت خطا دارد از تاریخی که نیست بدتر است چون هیچ‌کس زیر سؤالش نمی‌برد. کامپوننت صفحه‌گستردهٔ دلفی HotXLS حساب epoch، شناسه‌های پراپرتی و تبدیل UTC را برای هر دو قالب .xls و .xlsx انجام می‌دهد، تا کد شما مقدارهای TDateTime محلیِ ساده اختصاص دهد و قالب فایل را به کتابخانه واگذار کند