مقال تقني

أوقف الحفظ الصامت الذي يعيد حساب الصيغ في XLS لدلفي

يحفظ HotXLS، مكتبة Excel الأصلية لدلفي وC++Builder، مصنف .xls كلاسيكيًا من BIFF8 بالمخبأ أولًا: يسأل TXLSWorksheet.WriteFormula تابع TXLSWorkbook.TryGetCachedFormulaValue عن القيمة التي خزنها Excel بجوار كل صيغة، ولا يستدعي المقيّم إلا حين يكون ذلك المخبأ مفقودًا أو مبتلًا. المصنف الذي فتحته ولم تلمسه يحفظ الأرقام نفسها عائدة، والنتائج الطازجة تحتاج استدعاء Recalculate صريحًا واحدًا بدل أن تكون أثرًا جانبيًا خفيًا لـ SaveAs

كان الخطأ الذي أخرج هذا العقد إلى النور صغيرًا محرجًا. ملف من المجموعة اسمه nested-subtotals.xls يحمل مجموعًا كليًا في R2C4 قيمته المخبأة 37. افتحه بـ HotXLS، واسأل TryGetCachedFormulaValue عن الخلية، تحصل على 37. احفظه دون تغيير خلية واحدة، وافتح النسخة المحفوظة، واسأل السؤال نفسه، تحصل على 67. لم يُطلب من أي شيء في الواجهة أن يحسب شيئًا، ومع ذلك تحرك رقم في الملف بمقدار 30 بالضبط — و30 صدفةً أنها مجموع المجموعتين الجزئيتين، 10 و20، الجاثمتين داخل المدى الذي يغطيه المجموع الكلي

لماذا يغيّر حفظ ملف XLS قيمة صيغة؟

كان لا بد من اصطفاف عيبين مستقلين ليصير 37 رقمًا 67، وإصلاح أحدهما وحده كان سيخفي الآخر. الأول بنيوي: كان الكاتب الكلاسيكي يعيد حساب كل صيغة في كل حفظ. والثاني فحص نوع لا يمكن أن يكون صحيحًا أبدًا لصيغة محملة من القرص، فجعل المقيّم يعدّ خلايا SUBTOTAL المتداخلة مرتين. كان ملف المجموعة ببساطة أول مدخل أعطت فيه إعادة حساب وقت الحفظ جوابًا مخالفًا لجواب Excel وقارن أحدٌ بينهما. العيب البنيوي سهل التصريح: قبل v2.382.3 كان TXLSWorksheet.WriteFormula وأخوه صيغ المشاركة WriteFormulaWithTExp يحصلان على حقل FormulaValue ذي الثمانية بايتات لكل سجل Formula عبر استدعاء TXLSWorkbook.GetFormulaValue، وهو المقيّم ذاته. والمخبأ الذي فك ParseFormula ترميزه بعناية من الملف المصدر عند التحميل لم يُستشر قط على طريق الخروج. وبالفعل كان كل حفظ إعادة حساب كاملةً بتجاوز واجهة إعادة الحساب على مستوى المصنف، فلا شيء تستطيع ضبطه على المصنف كان سيوقفه. وأي موضع يخالف فيه مقيّم HotXLS جواب Excel، سواء دالة غير مدعومة مشروعًا أو خطأ صريح، كان يصير تغيير بيانات صامتًا عند الحفظ

وعاش العيب الثاني في نداء المجموع الجزئي المتداخل الذي يستخدمه المقيّم. يعرف Excel كل صيغة SUBTOTAL بتجاهل الخلايا التي صيغتها الخاصة SUBTOTAL أخرى، فيسلّح حاسبة lxCalc.pas الرايت FIgnoreSubtotalCells أثناء التجميع ويسأل المصنف، عبر TXLSWorkbook.GetClassicIsSubtotalCell، هل كل خلية في المدى واحدة منها. كان ذلك النداء يجلب نص الصيغة بوصفه Variant ويختبره بـ VarType(f) = varOleStr. يعود النص من GetUnCompiledFormula بوصفه String دلفي، وString المسندة إلى Variant تكون varUString لا varOleStr قط. فكان المسند false لكل خلية في كل ملف محمّل، وطرحت المجموعات الجزئية في المجموع الكلي مرة ثانية، وعلى حفظ يعيد حساب كل شيء صار 10 + 20 + 7 تساوي 67

// HotXLS 2.381 وما قبله: Variant صيغة مبني من String
// يكون varUString، فلم تنجح هذه المقارنة قط
Result := (VarType(f) = varOleStr) and
  (SameText(Copy(f, 1, 9), 'SUBTOTAL(') or
   SameText(Copy(f, 1, 10), '=SUBTOTAL('));

// HotXLS 2.382.0: يقبل VarIsStr نوعي varString وvarOleStr وvarUString،
// ويستبعد AGGREGATE من المجاميع الجزئية المحيطة كما يفعل Excel
if VarIsStr(f) then
  Result := SameText(Copy(f, 1, 9), 'SUBTOTAL(') or
    SameText(Copy(f, 1, 10), '=SUBTOTAL(') or
    SameText(Copy(f, 1, 10), 'AGGREGATE(') or
    SameText(Copy(f, 1, 11), '=AGGREGATE(');

شحّن v2.382.0 إصلاح VarIsStr وعلّم في الدالة نفسها النداءَ أن خلايا AGGREGATE مستبعدة كذلك من المجاميع الجزئية المحيطة. ذلك وحده جعل تأكيد المجموعة ينجح، لأن الـ 37 المعاد حسابها صارت تطابق الـ 37 المحملة. لكنه لم يجعل المكتبة أمينة: كان الحفظ ما يزال يعيد الحساب، وكان الاختبار أخضر فقط لأن المقيّم صادف أنه وافق Excel في ذلك الملف بعينه. قواعد الخلايا التي تتخطاها SUBTOTAL وAGGREGATE، ومنها الصفوف المخفية، مغطاة في مقال الصفوف المخفية في SUBTOTAL وAGGREGATE؛ والمقصود هنا ألّا يحصل مقيّم على صوت في ملف لم تطلب منه حسابه

ما الذي يضمنه Excel بشأن القيم المخبأة عند الحفظ؟

يعامل Excel الحفظ بوصفه لقطة لا حدث حساب. القيمة المكتوبة في حقل FormulaValue لسجل Formula ([MS-XLS] §2.4.127، والتخطيط في §2.5.133) هي ما تعرضه الخلية حاليًا، وهو في وضع الحساب اليدوي قد يكون قديمًا منذ سنوات، ومع ذلك يكتبه Excel بأمانة. إعادة الحساب عملية منفصلة بمطلقها الخاص. يتبع HotXLS الآن القاعدة نفسها في الحفظ الكلاسيكي: يستدعي WriteFormula وWriteFormulaWithTExp تابع TryGetCachedFormulaValue أولًا، ويأخذ CacheInfo.Value حين تكون الحالة xlfcsLoaded أو xlfcsCalculated، ولا يسقط إلى GetFormulaValue إلا في xlfcsMissing وxlfcsInvalidated. النصف المقروء من هذا العقد، بما في ذلك معنى كل حالة ولماذا يُعد مخبأ فارغ أو False قيمة، موصوف في قراءة قيم الصيغ المخبأة في Excel لدلفي دون إعادة حساب

قرار المخبأ أولًا في كل حفظ XLS كلاسيكي في HotXLS: يستدعي WriteFormula وWriteFormulaWithTExp تابع TryGetCachedFormulaValue، وحالة xlfcsLoaded أو xlfcsCalculated تكتب CacheInfo.Value حرفيًا، وxlfcsMissing أو xlfcsInvalidated يسقط إلى مقيّم GetFormulaValue، وفشل المقيّم يكتب حمولة صفرية مع ضبط fAlwaysCalc ليعيد Excel الحساب عند الفتح
صيغة أسندت في الجلسة تأتي بلا مخبأ وصيغة مستبدلة تُبلط، فكلتاهما ما تزالان تُقيَّمان وقت الحفظ ويفتح مصنف مولد وفيه أرقام، بينما الملفات التي فتحتها ولم تلمسها تحفظ القيم التي خزنها Excel

مسار السقوط محفوظ عمدًا، لا محذوف. صيغة أسندتها في هذه الجلسة عبر Cells[Row, Col].Formula تأتي بلا مخبأ، وصيغة استبدلتها على خلية محملة تُوسم xlfcsInvalidated بيد _SetCompiledFormula؛ وكلتاهما تُقيَّمان وقت الحفظ تمامًا كما قبل، فيظل المصنف المولد يفتح في Excel وفيه أرقام. وحين لا يستطيع المقيّم حتى أن ينتج قيمة، يصدر الكاتب حمولة صفرية ويضبط fAlwaysCalc (البت 0 من grbit في §2.4.127) ليعيد Excel حساب الخلية عند الفتح بدل الثقة بالعنصر النائب

procedure RoundTripWithoutRecalc(const Source, Target: string);
var
  Book: TXLSWorkbook;
  Before, After: TXLSFormulaCacheInfo;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open(Source);
    // ورقة وصف وعمود بترقيم يبدأ من 1: R2C4 في الورقة الأولى
    if not Book.TryGetCachedFormulaValue(1, 2, 4, Before) then
      raise Exception.Create('R2C4 carries no usable cache');
    Book.SaveAs(Target);        // بلا تورط المقيّم للخلايا المخبأة
  finally
    Book.Free;
  end;

  Book := TXLSWorkbook.Create;
  try
    Book.Open(Target);
    Book.TryGetCachedFormulaValue(1, 2, 4, After);
    // Before.Value = After.Value = 37 لملف nested-subtotals.xls
    // حفظ يعيد الحساب كان سيكتب هنا 67
  finally
    Book.Free;
  end;
end;

أين يحتفظ جذر الصيغة المشتركة في BIFF بقيمته المخبأة؟

في سجل Formula الخاص به، مثل كل خلية صيغة أخرى، وهذا بالضبط ما جعل خلية جذر مجموعة مشتركة الموضع الوحيد الذي ما يزال فيه حفظ المخبأ أولًا يخسر. تُخزن الصيغة المشتركة في BIFF8 بوصفها سجل ShrFmla ([MS-XLS] §2.4.260) يلي سجل Formula للخلية العليا اليسرى، وتحمل كل خلية عضو، الجذر شاملًا، rgce مكونًا من رمز PtgExp واحد (§2.5.198): أول بايت من التعبير المفكوك هو $01، يليه صف وعمود خلية الجذر. خلايا الأتباع مكتفية بذاتها — يقرأ HotXLS قيمة FormulaValue لكل واحدة ويحل التعبير بالبحث عن صيغة الجذر المترجمة. خلية الجذر مختلفة، لأنه عندما يُحلل سجل Formula الخاص بها لا يوجد التعبير بعد؛ فهو يصل سجلًا واحدًا لاحقًا

هذه الفجوة ذات السجل الواحد هي حيث ذهب المخبأ. يفك TXLSReader.ParseFormula ترميز القيمة المخبأة وعند رؤية PtgExp إحداثياته تساوي إحداثيات الخلية ذاتها يتذكر الخلية في FSharedFormulaRow وFSharedFormulaCol وينشر المخبأ إلى الخلية. وعندما يصل سجل ShrFmla ($04BC) يترجم ParseSharedFormula التعبير ويثبته عبر _SetCompiledFormula، ويفعل _SetCompiledFormula ما يجب أن يفعله لأي تغيير صيغة: يمحو FCachedFormulaValue ويعيد الحالة إلى xlfcsMissing. فطُرح الـ 37 المحمل للجذر قبل أن يقرأه أحد، وأبلغ TryGetCachedFormulaValue أن الجذر غير مخبأ، وسقط كاتب المخبأ أولًا مطيعًا إلى المقيّم من أجل الخلية التي كانت كل الأنظار عليها بالضبط. وسجل Array (§2.4.4) يتقاسم الترتيب نفسه وكان له الثقب نفسه

يضيف إصلاح v2.382.3 حقلًا ثالثًا، FSharedFormulaCachedValue، بجوار إحداثيات الجذر المعلقة. يخبئ ParseFormula المخبأ المفكوك هناك حين يتعرف على جذر، ويعيد كل من ParseSharedFormula وParseArrayFormula إظهاره عبر _SetCellCachedFormulaValue فور تثبيت التعبير المترجم، ثم يعيد الخبأة إلى Unassigned. وتنوعة String من المخبأ لا تتأثر بكل ذلك لأن حمولتها تصل في سجل String منفصل وتُوجَّه بإحداثيات الخلية لا بترتيب السجلات. وإن كنت تتعامل مع الجانب OOXML من المفهوم نفسه، فيشرح مقال توسعة si للصيغ المشتركة في XLSX لماذا لا تعاني صيغة الحزمة مشكلة الترتيب هذه لكن لها فخ التوسعة الخاص بها

لماذا فقدت خلية جذر الصيغة المشتركة في BIFF مخبأها البالغ 37 في HotXLS: يحمل سجل Formula رمز PtgExp والمخبأ المفكوك، ويصل تعبير ShrFmla سجلًا واحدًا لاحقًا، وكان تثبيته عبر _SetCompiledFormula يعيد الحالة إلى xlfcsMissing حتى بدأ الإصدار 2.382.3 خبأة FSharedFormulaCachedValue وإعادة إظهاره عبر _SetCellCachedFormulaValue
سجل Array كان له فجوة السجل الواحد نفسها ويعيد ParseArrayFormula إظهار الخبأة بالطريقة نفسها، بينما تُوجَّه تنوعة String من المخبأ بإحداثيات الخلية ولم تعتمد قط على ترتيب السجلات

لماذا يحتاج أتباع الصيغة المشتركة إزاحة نسبية؟

لأن التعبير المخزن في ShrFmla مكتوب بالنسبة إلى خلية الجذر، وتابع يعيد استخدامه حرفيًا يقيّم مراجع الجذر لا مراجعه هو. كان القارئ القديم يثبت Value.GetCopy() على كل تابع، نسخة عميقة بلا إزاحة، فمجموعة جذورها B1 بـ =A1*3 أعطت كل تابع =A1*3 أيضًا. حفظ المخبأ أولًا حجب هذا عمليًا للملفات المحملة، لأن الأتباع كانوا يحملون FormulaValue خاصتهم ولم يحتجوا قط التعبير ليحفظوا صحيحين؛ وظهر الخلل لحظة أي إعادة حساب. يثبت القارئ الآن TXLSCompiledFormula.GetCopy(row - srow, col - scol) الذي يمشي على شجرة الصياغة ويزاح كل مرجع نسبي بمقدار بعد التابع عن الجذر، فيملك التابع عند B2 =A2*3 حقيقية

يحتاج أتباع الصيغة المشتركة إزاحة نسبية في HotXLS: مجموعة جذورها B1 بـ =A1*3 على مدخلات 2 و4 و6 كان يثبت Value.GetCopy حرفيًا فيعيد B2 حساب A1*3 ويعرض 6 حيث يعرض Excel 12، بينما يجعل GetCopy منزاحةً بإزاحة التابع B2 يملك =A2*3 وB3 يملك =A3*3
حجب حفظ المخبأ أولًا الخطأ للملفات المحملة لأن كل تابع حمل مخبأه الخاص، فلا يظهره إلا Recalculate صريح، ويلقح اختبار الانحدار المخابئ الخاطئة 999 و888 التي يجب أن تنجو من حفظ

يستحق اختبار الانحدار الذي يثبت السلوكين القراءة لأنه يرفض أن تمر مصادفة. يبني مصنفًا بـ =A1*3 و=A2*3 فوق المدخلات 2 و4، ثم يحقن المخابئ الخاطئة عمدًا 999 و888 عبر _SetCellCachedFormulaValue، مرة مع UseSharedFormulas مفعلة ومرة معطلة. بعد حفظ وإعادة تحميل يجب أن تظل كلتا الخليتين تبلغان 999 و888 — إثبات أن الحفظ لم يلمس مخبأ الجذر ولا مخبأ التابع. وفقط بعد Recalculate صريح يجب أن تصيرا 6 و12، إثباتًا أن تعبير التابع المنزاح صحيح. اختبار يلقيح القيم الحقيقية كان سينجح أيضًا تحت الكاتب القديم، وهذه هي الغاية كلها من تلقيح خاطئة

var
  Book: TXLSWorkbook;
  Info: TXLSFormulaCacheInfo;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open('quarterly-model.xls');
    Book.Sheets[1].Cells[1, 1].Value := 5;   // غيّر مدخلًا

    // المخابئ المحملة للصيغ التابعة لا تبطلها تعديلة حرفية،
    // لكان SaveAs العادي سيحفظ الأرقام القديمة.
    // اطلب إعادة حساب حين تريد نتائج طازجة فعلًا:
    Book.Recalculate;

    if Book.TryGetCachedFormulaValue(1, 1, 2, Info) then
      Writeln('B1 now ', VarToStr(Info.Value),
        ', state ordinal ', Ord(Info.State));   // xlfcsCalculated
    Book.SaveAs('quarterly-model-updated.xls');
  finally
    Book.Free;
  end;
end;

ما لا يفعله لك عقد المخبأ أولًا

حفظ المخبأ أولًا يحفظ ما حُمل؛ لا يتتبع هل ما حُمل ما يزال صحيحًا. تغيير قيمة literal تعتمد عليها صيغة يوسم مخطط التبعيات اتساخًا من أجل المقيّم، لكنه يترك مخبأ الخلية التابعة xlfcsLoaded في مكانه، وسيرحب الكاتب الكلاسيكي بكتابة تلك القيمة القديمة ما لم تستدعِ Recalculate أو تقرأ Value الخاصة بالخلية أولًا، وهو ما يحسبها وينقل الحالة إلى xlfcsCalculated. هذه هي المقايضة نفسها التي يجريها Excel في وضع الحساب اليدوي، وهي الصحيحة لخط أنابيب يفتح ملفات جهات خارجية ويحرر بضعة عناوين ويحفظ — لكنها تعني أن المصنف الذي يحرر مدخلاته يجب أن يملك خطوة إعادة الحساب صراحة. سياسة RecalcBeforeSave لكاتب XLSX لم يغيّرها هذا العمل ولها وضع يدوي خاص يحفظ المخابئ بروح النفس. ويلتبع ذلك حدان أصغر: مسار المخبأ أولًا لا ينفع إلا الخلايا التي حالتها xlfcsLoaded أو xlfcsCalculated؛ ومولد يكتب صيغًا ولا يقيّمها قط ما يزال يدفع تقييمًا واحدًا لكل خلية وقت الحفظ، تمامًا كما كان. وإصلاح المجموع الجزئي المتداخل يصحح أي خلايا يتخطاها المقيّم، لا كل دالة ينفذها المقيّم — ملف صيغه لا يحسبها HotXLS مطابقةً لـ Excel صار آمنًا الآن للجولة دون مساس، لكن Recalculate متعمد على ذلك الملف ما يزال سينتج جواب المكتبة لا جواب Excel، وعليك أن تقارن بينهما قبل أن تثق بحفظ معاد الحساب

حفظ المخبأ أولًا الكلاسيكي، ومخابئ جذر الصيغ المشتركة والمصفوفية المستردة، وإزاحة المراجع النسبية لأتباع الصيغ المشتركة، وقواعد التعشيش المصححة لـ SUBTOTAL وAGGREGATE، كلها تأتي في مكوّن جداول HotXLS لدلفي القياسي لدلفي وC++Builder، بلا أي اعتماد على Excel أو أي خادم أتمتة OLE؛ وتحمل صفحة المنتج مرجع الواجهة الكامل لنقاط دخول المصنف وقارئ المخبأ وإعادة الحساب المستخدمة هنا