مقال تقني

طوابع Excel الزمنية في Delphi: FILETIME وUTC والتوقيت الصيفي

يخزن HotXLS طوابع خصائص مستندات Excel الزمنية بصيغة UTC داخل الملف ويكشفها بالتوقيت المحلي عبر الـ API: خاصيتا TXLSWorkbook.CreatedDate وLastSavedDate لـ .xls، وTXLSXWorkbook.Created وModified لـ .xlsx. ومنذ v2.384.48 يحوّل كلا المحركين التوقيت المحلي إلى UTC عند الكتابة وإليه عند القراءة، وفق قواعد التوقيت الصيفي السارية في تاريخ الطابع نفسه. وصلنا إلى هنا عبر إصلاحين، ونجا العيبان معًا للسبب المحرج نفسه: كل دورة ذهاب وإياب آلية كانت تنجح، بينما كان جزء File > Info في Excel يعرض يومًا خاطئًا أو ساعة خاطئة. إن كنت قد قرأت نظرتنا العامة حول ضبط خصائص مستندات Excel في Delphi، فهذا هو الموضع الذي تتوقف فيه التواريخ عن كونها قيمًا بسيطة

لماذا أخفى اختبار الحفظ وإعادة الفتح خطأً بيوم كامل؟

أخفى الذهاب والإياب الذاتي الخطأ لأن الكاتب والقارئ تشاركا الثابت الخاطئ نفسه، فألغى الخطأ نفسه. تاريخ مجموعة خصائص OLE هو FILETIME، أي عدد 64-بت من نبضات 100 نانوثقيقة منذ 1601-01-01 بتوقيت UTC ([MS-DTYP] §2.3.3)، بينما يحسب TDateTime في Delphi الأيام منذ 1899-12-30، وهو الأصل التسلسلي نفسه المغطى في مقالة الأرقام التسلسلية للتواريخ في Delphi ونظامي 1900 و1904. الفجوة بين العصرين 109205 يومًا، ويمكنك التحقق من ذلك دون تقويم: 25569 (عصر Unix كقيمة TDateTime) زائد 109205 يساوي 134774، أي عصر Unix محسوبًا بأيام FILETIME. استخدمت بنوك HotXLS قبل v2.384.17 القيمة 109206، فكُتب كل طابع إنشاء وحفظ متأخرًا يومًا وقُرئ مبكرًا يومًا. رأت جناح الاختبارات القيمة التي أسندها، ورأى Excel الغد

const
  // الأيام من عصر FILETIME (1601-01-01) إلى عصر TDateTime (1899-12-30)
  // تحقق: 25569 + 109205 = 134774، عصر Unix بأيام FILETIME
  FileTimeDayBias = 109205;

function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
  // قرّب إلى ميلي ثوانٍ صحيحة أولًا، ثم حوّل إلى نبضات 100 نانوثقيقة.
  // تحويل Double مباشرة إلى النبضات يجعل 04:00 تخرج 03:59:59.9999
  Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
خط زمني في HotXLS لعصر FILETIME عند 1601-01-01 وعصر TDateTime عند 1899-12-30 وعصر Unix عند 1970، موضحًا إزاحة 109205 يومًا الكامنة وراء UtcDateTimeToFileTimeTicks وكيف كتبت الإصدارات قبل v2.384.17 كل طابع CreatedDate متأخرًا يومًا وقرأته مبكرًا يومًا بالقيمة 109206
يُبقي مسود UtcDateTimeToFileTimeTicks الإزاحة في موضع يلغي فيه الثابت الخطأ نفسه — رأى اختبار حفظ وإعادة فتح متناظر القيمة التي أسندها بينما أظهر جزء Info في Excel الغد

تعليق التقريب في تلك المسودة هو الدرس الثاني الأصغر من الشيفرة نفسها. ضرب قيمة 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، فلم يكن لدى Excel تاريخ حفظ ليعرضه، وأصبحت هناك خاصية عدد صفحات تحمل طابعًا زمنيًا. ومنذ v2.384.17 يحترم القارئ أيضًا ذلك التخطيط القديم: حين تغيب $0D وتحمل $0E قيمة VT_FILETIME، تؤخذ القيمة بوصفها وقت آخر حفظ. وتحرر كل قراءة PROPVARIANT الآن عبر PropVariantClear، لأن ملفًا مشوهًا قد يركن نصًا تحت أي من هذه المعرفات. وإن أردت رؤية تلك التدفقات بعينك، فالجولة العملية حول قراءة ملفات OLE2 المركبة في Delphi دون COM IStorage تريك كيف تصل إليها

PIDSI_EDITTIME هو الفخ داخل الفخ. الخاصية مطبوعة VT_FILETIME لكنها تحمل مدة، أي العدد الخام للنبضات المنقضية بطول 100 نانوثقيقة دون أي عصر مضاف. عاملها الكاتب القديم معاملة تاريخ، فقسم EditTimeMinutes على 1440 ودفعه عبر تحويل العصر، فهبطت 125 دقيقة تحرير في الملف بوصفها نحو 299 سنة. يتعرّف القارئ الحالي على ذلك الترميز من حجمه: لا جلسة تحرير حقيقية تمتد ثلاثة قرون، فأي قيمة تساوي 109206 يومًا أو أكثر يُطرح منها الإزاحة القديمة قبل ملء EditTimeMinutes

خريطة HotXLS لمجموعة خصائص 005SummaryInformation حيث يحمل PIDSI_CREATE_DTM عند $0C وقت الإنشاء، وPIDSI_LASTSAVE_DTM عند $0D طابع الحفظ، وPIDSI_EDITTIME عند $0A مدة خامًا لا تاريخًا، و$0E أي PIDSI_PAGECOUNT هي الخانة التي أساءت الإصدارات الأقدم استخدامها للطوابع الزمنية
PIDSI_EDITTIME هو الفخ داخل الفخ — مطبوع VT_FILETIME ومع ذلك يحمل نبضات منقضية بلا عصر، حوّلت ذات مرة 125 دقيقة تحرير إلى نحو 299 سنة حتى جاءت استدلالة قارئ قائمة على الحجم
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Title := 'Q3 settlement';
    // قيم الـ API توقيت محلي؛ والملف يخزن FILETIMEs بتوقيت 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، وهي تعني UTC وفق نموذج الخصائص الأساسية في ECMA-376 الجزء 2، وكان HotXLS يختم التوقيت المحلي معلّقًا به تلك الـ Z. مصنف أُنشئ الساعة 09:30 على جهاز في UTC+8 حمل 09:30:00Z، فحوّله Excel على الجهاز نفسه إلى 17:30. وكان المحرك الكلاسيكي يحمل العيب ذاته في قيم FILETIME، وشاركه فيه خصائص التواريخ المخصصة المضافة عبر TXLSXWorkbook.CustomProperties.AddDate (المكتوبة vt:filetime). ومنذ v2.384.48 تحوّل المسارات الثلاثة كلها قبل الكتابة وتعود عند القراءة كلما حملت الطابع علامة Z، ومنذ v2.384.59 يحترم جانب القراءة أيضًا أجزاء الثواني الكسرية والإزاحات الصريحة +hh:mm / -hh:mm

التحويل نفسه هو حيث يخطئ الإصلاح الساذج. تطبق LocalFileTimeToFileTime الإزاحة السارية الآن، فطابع يناير المحول في يوليو يخرج منحرفًا ساعة كاملة في أي منطقة ذات توقيت صيفي. يستدعي HotXLS بدلًا منها TzSpecificLocalTimeToSystemTime وSystemTimeToTzSpecificLocalTime، وهي تختاران التوقيت القياسي أو الصيفي من التاريخ الجري تحويله، والقيمة غير المضبوطة صفرًا تمر بلا مساس فلن تتحول أبدًا إلى تاريخ 1899 مزاحًا ببضع ساعات

مسارات تحويل HotXLS من المحلي إلى UTC لطابع يناير 17:00 CET محفوظ في يوليو: تطبق LocalFileTimeToFileTime إزاحة الصيف الحالية فتهبط منحرفة ساعة عند 15:00Z، بينما تختار TzSpecificLocalTimeToSystemTime الإزاحة من تاريخ الطابع نفسه فتكتب 16:00Z الصحيحة
إزاحة المنطقة الزمنية تخص تاريخ الطابع نفسه لا قواعد الجهاز الحالية — إحدى واجهات Windows تختار الجهة الصحيحة من تغيير التوقيت الصيفي، والأخرى تزيح طوابع يناير المحولة في يوليو ساعة كاملة بصمت
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');
    // على جهاز مضبوط على توقيت وسط أوروبا يحمل 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 كل صيغة موسومة بمنطقة من الملف الشخصي منذ v2.384.59، والحالة الوحيدة التي يتركها على حالها وقت بلا منطقة. قبل ذلك الإصدار كان المحلل يأخذ أول 19 محرفًا ويحول من UTC فقط إذا كان المحرف العشرون Z، فطابع بأجزاء ثوانٍ كسرية (01:30:00.5Z) أو بإزاحة صريحة (+08:00) قُرئ توقيتًا محليًا بلا تعديل وانتهى منحرفًا بمقدار إزاحة المنطقة. ومنذ HotXLS 2.384.59 تحلل Created وModified وخصائص التواريخ المخصصة أجزاء الثواني الكسرية بأي طول، وZ، وإزاحات +hh:mm / -hh:mm، وتحول اللحظة إلى UTC ثم إلى التوقيت المحلي، وتقرأ طابع تاريخ فقط مثل 2026-07-01 بوصفه ذلك التاريخ. أما الطابع بوقت من دون علامة منطقة، وهو ما لا يسمح به الملف الشخصي W3CDTF ولا يعطي له ECMA-376 الجزء 2 أي قاعدة، فما يزال يُقرأ توقيتًا محليًا بلا تغيير، والطابع الذي لا يُحلل إطلاقًا يعود صفرًا. المصنفات العابرة عبر Excel سليمة؛ والحزم المنتجة من مولدات أخرى تُسقط المنطقة تستحق فحصًا موضعيًا

الملفات المكتوبة بإصدارات HotXLS الأقدم هي الحد الصادق الآخر. طابع XLSX كُتب قبل v2.384.48 كان توقيتًا محليًا يرتدي Z، ولا شيء في الملف يميزه عن طابع صحيح، فيزيحه القارئ الحالي بمقدار إزاحة المنطقة. وتأخذ طوابع FILETIME الكلاسيكية من تلك الإصدارات الإزاحة نفسها، وتاريخ الإنشاء المكتوب قبل v2.384.17 يعود إضافيًا متأخرًا يومًا، لأن اليوم الزائد من الثابت القديم لا يمكن اكتشافه هو الآخر؛ فقط ترميز وقت التحرير وموضع $0E لهما بصمة معروفة. وتذكر أيضًا أن قيمة الـ API محلية للجهاز الذي يجري القراءة، فخدمة تعمل بتوقيت UTC وسطح مكتب في طوكيو ستبلغان قيمتَي CreatedDate مختلفتين للملف نفسه، وكلتاهما صحيحة

كيف تختبر طوابع المستندات الزمنية؟

اختبر طوابع المستندات مقابل شيء لم تكتبه شفرتك أنت. كلا العيبين اجتاز فحص حفظ ثم إعادة فتح، لأن الخطأ المتناظر غير مرئي لاختبار متناظر. قارن بمصنف حفظه Excel، أو تحقق من البايتات الخام ونص XML بعد الحفظ، وشغّل الجناح على جهاز مضبوط على منطقة غير UTC مع تاريخ اختبار على كل جهة من تغيير التوقيت الصيفي. وكيل بناء يعمل بتوقيت UTC سيمرر الكود القديم المعطوب باحتفاء

طوابع المستندات صغيرة، لكنها ما ترتب به أنظمة السجلات وفهارس البحث ومسارات التدقيق، والتاريخ المنحرف بيوم أو بثماني ساعات أسوأ من التاريخ الغائب لأن أحدًا لن يشكك فيه. يتولى مكوّن HotXLS لجداول Delphi حسابات العصور ومعرفات الخصائص وتحويل UTC لكلا الصيغتين .xls و.xlsx، فتسند شفرتك قيم TDateTime محلية عادية وتترك صيغة الملف للمكتبة