مقال تقني

أرقام Excel التسلسلية للتواريخ في Delphi: 1900 مقابل 1904 و numFmt

افتح جدول بيانات، وانقر فوق خلية تعرض 2026-06-19، ولا يزال شريط الصيغة يقرأ تاريخاً. اقرأ نفس الخلية من Delphi وستحصل على الرقم 46192. كلا العرضين صحيحان، لأن Excel لم يقم بتخزين تاريخ في تلك الخلية أبداً. لقد قام بتخزين رقم تسلسلي (serial number)، وعدد أيام، وأرفق تنسيق أرقام يخبر الشاشة بتقديم العدد كتاريخ تقويمي. لا يوجد نوع تاريخ في قيمة الخلية. هناك رقم وقاعدة عرض (display rule)، وقاعدة العرض هي الشيء الوحيد الذي يميز التاريخ عن الكمية العادية

هذا الفصل هو أصل كل خطأ تاريخ (date bug) يجب على مكتبة جداول البيانات تفاديه. الرقم التسلسلي وحده لا يخبر ما هو اليوم، لأنه لا يخبر ما هو اليوم الصفر. نفس الرقم يعني تاريخين يفصل بينهما أربع سنوات بناءً على علامة مصنف (workbook flag) واحدة. والرقم الذي يجب قراءته كتاريخ سيُقرأ على أنه كمية مجردة (bare quantity) ما لم يفحص شيء ما تنسيقه ويتعرف على نمط تاريخ. هذه هي كيفية بناء نموذج التاريخ في HotXLS، ولماذا يجب أن يكون كذلك

خلية التاريخ عبارة عن رقم بالإضافة إلى تنسيق

يخزن Excel التاريخ كعدد الأيام منذ حقبة (epoch)، مع وجود وقت اليوم في الجزء الكسري (fractional part). يحمل منتصف اليوم على رقم تسلسلي .5. الجزء الصحيح (integer part) هو عدد الأيام. لا شيء في القيمة المخزنة يميزها كزمنية (temporal). ما يميزها هو تنسيق أرقام الخلية: يطلق ECMA-376 على هذا اسم numFmt، والخلية التي يوضح رمز تنسيقها نمط تاريخ أو وقت يتم عرضها كتاريخ. قم بإزالة التنسيق وستعرض الخلية نفسها رقماً؛ القيمة الأساسية لم تتغير أبداً

هذا هو السبب في أن قراءة قيمة خلية يمنحك Variant قد يكون varDate أو قد يكون Double عادياً، ولماذا يكون تنسيق الأرقام في نفس الخلية هو الإشارة التي تقرر أيهما يقصده طرف ثالث. عندما يفتح HotXLS ملف XLSX، تحمل خلية كلاً من Value و NumberFormatIndex الخاصين بها إلى TXLSXCell، ومؤشر التنسيق هو ما تستشيره لمعرفة ما إذا كان الرقم عبارة عن تاريخ

var
  Book: TXLSXWorkbook;
  Cell: TXLSXCell;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.Open('timesheet.xlsx') <> 1 then
      raise Exception.Create('Cannot open workbook');

    Cell := Book.Sheets[0].Cells[1, 1];   // row 1, col 1 (1-based)
    // Value may arrive as varDate or as a plain numeric serial;
    // the format index is the signal that tells them apart.
    Writeln('raw value : ', VarToStr(Cell.Value));
    Writeln('numFmt idx: ', Cell.NumberFormatIndex);
    Writeln('format    : ', Cell.NumberFormat);
  finally
    Book.Free;
  end;
end;

حقبتان، يفصل بينهما 1462 يوماً

نظام التاريخ الافتراضي، الذي يستخدمه كل مصنف Windows، يتم العد من نهاية عام 1899 تماماً، بحيث يقع الرقم التسلسلي 1 في اليوم الأول من عام 1900. يتتبع النظام الآخر إلى أوائل أجهزة Macintosh ويعد من بداية عام 1904، لذا فإن الرقم التسلسلي 1 يكون بعد أربع سنوات ويوم. يسجل المصنف أي نظام يستخدمه في علامة (flag) واحدة. في حزمة OOXML تكون تلك العلامة هي date1904 في جزء المصنف؛ يعرضها HotXLS كخاصية Date1904 للمصنف

الفجوة بين الحقبتين هي 1462 يوماً بالضبط. أي أربع سنوات تقويمية، ثلاث سنوات مكونة من 365 يوماً وسنة واحدة من 366 يوماً، بإجمالي 1461، بالإضافة إلى سنة أخرى لتعويض (offset) اليوم وما يزيد قليلاً بين اصطلاحي (conventions) اليوم الصفر (day-zero). الرقم ثابت ويمكنك حمله في رأسك. تكمن أهميته في أنه ليس صفراً. الرقم التسلسلي المنسوخ من مصنف 1904 والمفسر بموجب قواعد 1900، أو العكس، يجعل كل تاريخ ينحرف بمقدار 1462 يوماً، والذي يظهر كتواريخ خاطئة بأكثر من أربع سنوات بقليل ومن السهل الخلط بينه وبين البيانات التالفة

نظراً لأن TDateTime الخاص بـ Delphi يرتكز على اصطلاح 1900، يجب على المكتبة التي تعين (maps) أرقام Excel التسلسلية إلى TDateTime أن تقوم بتعويض بمقدار 1462 في كلا الاتجاهين عندما يتم تعليم المصنف بعلامة 1904. عند قراءة رقم تسلسلي لعام 1904، اطرح 1462 قبل التعامل معه كـ TDateTime؛ وعند كتابة TDateTime في مصنف 1904، اطرح 1462 من الرقم التسلسلي حتى يقوم Excel بتقديم اليوم الذي تقصده. يطبق HotXLS هذا التحول داخلياً عندما يقوم بتسلسل قيم التاريخ لمصنف تم تعيين Date1904 الخاص به، بحيث تقوم القيمة التي تعينها كـ TDateTime برحلة دائرية (round-trips) إلى نفس اليوم التقويمي على الشاشة

مراوغة السنة الكبيسة لعام 1900 المتعمدة

هناك مشكلة (wrinkle) شهيرة في نظام 1900. يتعامل Excel مع عام 1900 كسنة كبيسة ويقبل 29 فبراير 1900 كتاريخ حقيقي، الرقم التسلسلي 60. لم يكن عام 1900 سنة كبيسة، لأن سنوات القرن لا تكون سنوات كبيسة إلا إذا كانت قابلة للقسمة على 400، وعام 1900 ليس كذلك. اليوم الوهمي هو سلوك توافق متعمد موروث من جدول بيانات مبكر تم شحنه مع الخطأ، وتم الاحتفاظ به منذ ذلك الحين بحيث يظل الحساب التسلسلي متطابقاً عبر عقود من الملفات

النتيجة العملية صغيرة ولكنها حقيقية: لأي تاريخ في أو بعد 1 مارس 1900، يكون الرقم التسلسلي أعلى بواحد مما سيعطيه عدد الأيام الصحيح بدقة، لأن يوم 29 فبراير غير الموجود استهلك رقماً. تعيد مكتبة جداول البيانات إنتاج المراوغة (quirk) بدلاً من إصلاحها، لأن مطابقة حساب Excel بالضبط هي الوظيفة بأكملها. سيؤدي تصحيحه إلى وضع كل تاريخ حديث متأخراً يوماً واحداً عما يعرضه Excel، وهي نتيجة أسوأ من تحمل خطأ بيوم واحد عمره أربعون ألف يوم (forty-thousand-day-old off-by-one) لا يمسه أي تاريخ حقيقي في الاستخدام التجاري أبداً. لا يحتوي نظام 1904 على يوم وهمي مكافئ، وهو أحد أسباب تفضيل بعض المتاجر له تاريخياً

اكتشاف تاريخ من numFmt

عندما يصل رقم من ملف كتبه شخص آخر، فإن تنسيقه هو الدليل الوحيد على أنه تاريخ. يعين ECMA-376 كتلة من معرفات التنسيق المضمنة التي يتم تحديد معناها بواسطة المواصفات، وتحتل تنسيقات التاريخ والوقت نطاقات معروفة. المعرفات من 14 إلى 22 هي تنسيقات التاريخ والوقت المحلية العامة (general-locale)، المألوفة m/d/yyyy، و h:mm، وما يقاربها. المعرفات 45 إلى 47 هي تنسيقات الوقت المنقضي (elapsed-time). النطاقان الإضافيان، من 27 إلى 36 ومن 50 إلى 58، هما تنسيقات التاريخ والوقت الخاصة بالمنطقة المستخدمة في تقويمات CJK، المحددة في ECMA-376 18.8.30. الخلية التي يقع معرف تنسيق أرقامها في أي من هذه النطاقات هي خلية تاريخ أو وقت

تغطي المعرفات المضمنة (Built-in ids) الحالات الشائعة ولكن ليس الحالات المخصصة. عندما يحدد المصنف رمز التنسيق الخاص به، قل ترتيباً غير قياسي أو اسم شهر مترجم (localized month name)، فإن المعرف يكون أعلى من النطاق المضمن ويشير إلى جدول تنسيق الأرقام الخاص بالمصنف. بالنسبة لهذه الحالات، يعني التعرف على التاريخ قراءة سلسلة رمز التنسيق والبحث عن رموز التاريخ (date tokens). يطوي HotXLS كلا الفحصين في مسند داخلي (internal predicate) واحد، وهو XlsxNumFmtIsDate، والذي يعود بصحيح (true) على الفور لنطاقات التاريخ المضمنة وإلا فإنه يحلل رمز التنسيق المخصص من خلال XlsxFormatCodeIsDate. الجانب العام من ذلك هو سلسلة NumberFormat الخاصة بالخلية و NumberFormatIndex الخاص بها، والتي تمنحك كلاً من رمز التنسيق الذي تم حله والمعرف لاختباره

لماذا لا يمكن لمحلل التنسيق (format parser) البحث عن d و m فقط

يبدو تحليل رمز التنسيق (format code) لرموز التاريخ (date tokens) أمراً تافهاً حتى تتذكر ما يعيش أيضاً في تنسيق أرقام. بحث ساذج عن الحروف التي تتهجى التواريخ، d، و m، و y، و h، و s من اليوم (day)، والشهر (month)، والسنة (year)، والساعة (hour)، والثانية (second)، سيخطئ الهدف في هيكلين ليسا رموزاً للتاريخ على الإطلاق

الأول هو حرفية السلسلة المقتبسة (quoted string literal). يمكن أن يدمج تنسيق الأرقام نصاً حرفياً بين علامتي اقتباس مزدوجتين، لذا فإن تنسيقاً مالياً مثل #,##0 "MM" يلحق الحرفين M و M برقم ليس له أي معنى زمني على الإطلاق. الماسح الضوئي (scanner) الذي يحسب الأحرف الموجودة داخل علامات الاقتباس كرموز للأشهر سيشير بشكل خاطئ إلى تنسيق العملة هذا على أنه تاريخ. الثاني هو قسم القوس (bracket section). تحمل تنسيقات الأرقام توجيهات بين قوسين مربعين، وأسماء ألوان مثل [Red]، وشروط مقارنة مثل [>1000]، وعلامات محلية، وعلامات الوقت المنقضي [h] و [mm]. يحتوي بعض محتوى القوس على حروف تاريخ وبعضها لا يحتوي على ذلك، ومعاملة النص الموجود بين قوسين مثل نص التنسيق يؤدي إلى نتائج إيجابية خاطئة وحالات مفقودة

المحلل الصحيح (correct parser) يمشي رمز التنسيق حرفاً بحرف، ويتتبع ما إذا كان داخل قيمة حرفية مقتبسة (quoted literal) ومدى عمقه داخل تداخل القوس (bracket nesting)، كما أنه يكرم خطأ الشرطة المائلة العكسية (backslash escape) التي تقتبس حرفاً تالياً واحداً. يُحسب حرف التاريخ غير المهرب الذي يتم العثور عليه خارج أي سلسلة حرفية وخارج أي قسم قوس فقط كرمز تاريخ حقيقي. هذا هو بالضبط كيف يقوم XlsxFormatCodeIsDate بالمسح: علامة الاقتباس تقلب حالة مضمنة (in-literal state) تقمع اكتشاف الرمز حتى علامة الاقتباس الختامية، وتتخطى الشرطة المائلة العكسية الحرف التالي، ويقمع عداد عمق القوس (bracket-depth counter) الاكتشاف داخل مسارات [...]. المردود هو أن #,##0 "MM" يتم قراءته بشكل صحيح كتنسيق أرقام، بينما لا يزال الرمز المخصص المقتضب (terse custom code) الذي لا يحتوي إلا على m أو d مفرد خارج علامات الاقتباس يتم التعرف عليه بشكل صحيح كتاريخ

قراءة التواريخ من ملفات جهات خارجية (third-party files)

يتقارب كل ما سبق في سير عمل واحد: تحويل رقم كتبه تطبيق آخر إلى تاريخ يمكنك الوثوق به. يمنحك الرقم التسلسلي عدد الأيام، ويخبرك علامة Date1904 للمصنف بالحقبة التي يتم قياس العدد منها، ومعرف تنسيق أرقام الخلية أو الرمز المخصص هو الدليل الوحيد على أن الرقم كان مقصوداً كتاريخ في المقام الأول. أسقط أياً من الثلاثة وستحصل على إجابة خاطئة معقولة (plausible wrong answer) بدلاً من خطأ مرئي (visible error)

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Cell: TXLSXCell;
  r: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.Open('vendor-export.xlsx') <> 1 then
      raise Exception.Create('Cannot open export');

    // The 1904 flag is workbook-wide: read it once, apply it to
    // every serial the workbook hands back.
    if Book.Date1904 then
      Writeln('workbook uses the 1904 date system')
    else
      Writeln('workbook uses the 1900 date system');

    Sheet := Book.Sheets[0];
    for r := 1 to 10 do
    begin
      Cell := Sheet.Cells[r, 1];
      // A date is only a date when its format says so; the same numeric
      // value with a plain format is just a quantity.
      Writeln(Format('row %d  value=%s  numFmt=%d  code="%s"',
        [r, VarToStr(Cell.Value), Cell.NumberFormatIndex, Cell.NumberFormat]));
    end;
  finally
    Book.Free;
  end;
end;

يحتوي جانب BIFF القديم على فخ إضافي واحد يستحق التسمية. في تيار .xls قديم، يمكن تجميع سلسلة من الخلايا الرقمية المتجاورة (run of adjacent numeric cells) في سجل واحد متعدد الخلايا، MULRK، والذي يخزن عدة قيم مع مراجع تنسيقها في بنية واحدة. خلايا التاريخ المخزنة بهذه الطريقة لا تقل عن تواريخ لكونها مجمعة، لذلك يجب أن يصل نفس اختبار معرف التنسيق (format-id test) إلى داخل السجل متعدد الخلايا ويتم تطبيقه لكل خلية، ولا يزال تعويض (offset) 1904 يحكم كل رقم تسلسلي ينتج عنه. القارئ الذي يتفقد سجلات الأرقام المستقلة فقط، ويتخطى السجلات المجمعة، سيحول بشكل صامت عموداً من التواريخ إلى عمود من الأعداد الصحيحة

تعيين (Mapping) الأرقام التسلسلية إلى TDateTime عملياً

بمجرد أن يؤكد فحص التنسيق وجود تاريخ وتُعرف علامة Date1904، يكون التحويل آلياً. القيمة التي يعيدها HotXLS بالفعل على أنها varDate هي TDateTime يمكنك استخدامها مباشرة. يتم تحويل القيمة التي تصل كـ Double مجرد (bare)، والذي يحدث عندما يكتب المصدر رقماً تسلسلياً بدون تنسيق تاريخ معترف به، من خلال قراءتها كعدد أيام على محور 1900، وبالنسبة لمصنف 1904، يتم طرح تعويض (offset) 1462 يوماً أولاً بحيث تصطف الحقب. في الاتجاه الآخر، يؤدي تعيين TDateTime لخلية إلى تخزين الرقم التسلسلي المستند إلى 1900، ويقوم HotXLS بتطبيق نفس تحول (shift) 1462 يوماً عند الحفظ عندما يتم تمييز المصنف بعلامة 1904، لذا يُظهر الملف المحفوظ التاريخ الذي قصدته بدلاً من التاريخ المنحرف بمقدار أربع سنوات

اضبط العلامة (flag) بشكل متعمد عند إنشاء مصنف. يترك الإعداد الافتراضي Date1904 خطأً، وهو ما يطابق Excel لـ Windows وهو ما تريده دائماً تقريباً؛ اضبطه على صحيح فقط عندما تقوم بإعادة إنتاج مصنف مصدره Mac أو عندما يتوقع نظام في المرحلة النهائية (downstream system) محور 1904 بشكل خاص. القاعدة الوحيدة التي تمنع الفئة الكاملة من أخطاء الأربع سنوات هي الاتساق: اختر الحقبة (epoch) مرة واحدة لكل مصنف، واكتب كل تاريخ بموجبها، واقرأ كل رقم تسلسلي مرة أخرى تحت العلامة التي يحملها الملف بالفعل

التواريخ هي عمود واحد في قصة أوسع حول ما تحمله الخلية حقاً. تمت تغطية طبقة البيانات الوصفية (metadata layer) المجاورة، والعنوان والمؤلف والطوابع الزمنية التي تعمل جنباً إلى جنب مع الشبكة، في مقالتنا حول البيانات الوصفية للمصنف وخصائص المستند، حيث يتم تخزين نفس قيم Created و Modified على أنها TDateTime مع نفس اصطلاح غير مضبوط يساوي صفراً (unset-equals-zero convention). عندما يكون التاريخ نتيجة لعملية حسابية وليس قيمة مخزنة، تحدد قواعد التقييم في مقالتنا عن محرك الصيغة والوظائف المخصصة الرقم التسلسلي الذي يقدمه التنسيق بعد ذلك. كلاهما يعمل على نفس نموذج التاريخ الذي يتم شحنه في مكون HotXLS لـ Delphi و C++Builder، والذي يقرأ ويكتب تواريخ XLS و XLSX بدون أتمتة (automation) Excel