مقال تقني

انحراف طول سجل BIFF في كاتب XLS لـDelphi

أصلح HotXLS 2.376.0 انحرافًا في طول سجل BIFF داخل كاتب XLS التقليدي: فقد أعلن مُصدّر SXEx لواجهات PivotTable عن جسم طوله 24 بايتًا في رأسه، ثم أضاف 26 بايتًا. ويثق قارئ BIFF في الطول المعلن، ولذلك أخرج البايتان الزائدان كل ما بعده عن التزامنه، وفقدت دفاتر العمل التي تجمع PivotTable مع ورقة مخطط المخطط عند إعادة فتحها

الجزء المثير للاهتمام ليس كلمة off-by-one. بل المسافة بين الخطأ والعرض. لم يفشل شيء عند موضع الخلل. تسلسلت سجلات pivot نظيفًا، وكُتب الملف بلا خطأ، وفتحه Excel، ولم يظهر الضرر إلا بعد مئات البايتات في تدفق فرعي لا علاقة له تمامًا. وهذه المسافة سمة لكل صيغة ثنائية مسبوقة بالطول، ومن المفيد فهمها قبل كتابة مُصدّر آخر لإحداها

لماذا يدمر طول سجل خاطئ واحد تدفق ورقة عمل كاملًا؟

لا يملك تدفق مصنف BIFF8 أي تأطير خارج حساباته الخاصة. فكل سجل رأس من 4 بايتات يتكون من معرّف سجل (2 بايت) وطول جسم (2 بايت)، يتبعه العدد نفسه بالضبط من بايتات الحمولة ([MS-XLS] 2.1.4). لا يوجد فاصل، ولا بايت سحري، ولا checksum، ولا نقطة إعادة تزامن. ولا يصل القارئ إلى السجل التالي إلا لأن السجل السابق أخبره بالحقيقة عن حجمه. والطول المعلن ليس بيانات وصفية عن السجل، بل هو المؤشر إلى السجل التالي. لذلك تتبع ما فعله البايتان الزائدان. استهلك القارئ رأس SXEx، وتجاوز الـ24 بايتًا التي وعد بها الرأس، وحطّ قبل موضعه ببايتين على زوج من الأصفار المتبقية من الجسم الأكبر. ثم قرأ هذين الصفرين كمعرّف سجل قيمته $0000، وقرأ معرّف سجل EOF التالي لورقة العمل ($000A) كطول لذلك السجل الوهمي، وتجاوز عشرة بايتات إلى ما يأتي بعده كما ينبغي. ومن هناك قُرئ كل رأس عند إزاحة خاطئة. وفي دفتر العمل الفاشل أدى ذلك إلى ورقة مخطط كان _Chart فيها nil بعد إعادة الفتح، وإلى تفريغ تصحيح يظهر $18AF مفسرًا كمعرّف سجل. ولا تظهر أي من القيمتين قرب كود pivot أصلًا

المُصدّر والكاتب لا يقارنان ملاحظاتهما أبدًا

السبب البنيوي الذي أتاح الانحراف هو أن HotXLS يبني سجل BIFF على شكل TXLSBlob يكون رأسه وحمولته حقيقتين مستقلتين. تكتب EmitSXEx معرّف السجل، ثم Blob.AddWord(24) للطول، ثم تضيف حقول الجسم واحدًا تلو الآخر. وهذه القيمة 24 ثابت محسوب يدويًا، لا مشتق ولا مفحوص مقابل البايتات التي تتبعها. كما أن مسار الكتابة لا يغلق الفجوة: إذ يمرر AddRec الكائن إلى TXLSBlobList.Append الذي ينسخ Data.DataLength بايتًا حرفيًا إلى تدفق الإخراج. وDataLength هو العدد الحقيقي للبايتات، ولذلك يصدر الكاتب بأمانة 26 بايتًا من الجسم خلف رأس يدعي 24. ينفذ الطرفان ما طُلب منهما تمامًا، وليس من مهمة أحد ملاحظة التناقض بينهما. وتتجنب HotXLS هذا أصلًا حيث تعيد تشغيل حمولات محفوظة: تحسب TXLSWorkbook.StoreDConnBlobs كلمة طول الرأس من طول الجسم الحقيقي بدل literal، وهو بالضبط سبب عدم انحراف إعادة تشغيل blob قط

ما الذي يثبته [MS-XLS] 2.4.282 بشأن SXEx؟

المواصفة لا لبس فيها بشأن الحجم، وهو ما جعل الإصلاح ميكانيكيًا. يعرّف [MS-XLS] 2.4.282 جسم SXEx كـgrbit من 4 بايتات يتبعه عشرة حقول، كل منها 2 بايت: csxformat وcchErrorString وcchNullString وcchTag وcsxselect وcrwPage وccolPage وcchPageFieldStyle وcchTableStyle وcchVacateStyle. أربعة زائد عشرين تساوي أربعة وعشرين. وقد كتب المُصدّر القديم إحدى عشرة كلمة صفرية حيث تعرف المواصفة عشرًا، كما أن استدعاءات AddWord(0) المجهولة لم تحمل أسماء حقول، ولذلك كان عدها بالعين أثناء المراجعة موثوقًا بقدر ما يوحي به ذلك. وكان الحجز المسبق هو الدليل على أن التخطيط فُهم لكن الحلقة لم تُفهم: فـTXLSBlob.Create(28) يطلب بالضبط أربعة بايتات للرأس وجسمًا من 24 بايتًا، ومع ذلك تجاوز blob هذا التلميح في كل استدعاء، ونما بصمت لأن AdjustBufferSize يعيد التخصيص عند الطلب. وأي تلميح سعة يتجاوزه الكود فورًا يستحق نظرة ثانية في أي serializer

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // رأس 4 بايتات + جسم 24 بايتًا
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // تكمل عشر كلمات صفرية جسم الـ24 بايتًا وفق [MS-XLS] 2.4.282
  // يجب أن يطابق الطول المعلن البايتات المكتوبة، وإلا سيُحلل كل سجل
  // بعد هذا السجل بصورة خاطئة
  Blob.AddWord(0);               // csxformat
  Blob.AddWord(0);               // cchErrorString
  Blob.AddWord(0);               // cchNullString
  Blob.AddWord(0);               // cchTag
  Blob.AddWord(0);               // csxselect
  Blob.AddWord(0);               // crwPage
  Blob.AddWord(0);               // ccolPage
  Blob.AddWord(0);               // cchPageFieldStyle
  Blob.AddWord(0);               // cchTableStyle
  Blob.AddWord(0);               // cchVacateStyle
  AddRec(DataList, Blob);
  Result := 1;
end;

لماذا صمد ذلك أمام مجموعة اختبارات PivotTable كاملة؟

لأن اختبارات pivot الموجودة لم تعد عبر ملف ذهابًا وإيابًا. فقد بنت مصنفًا، وتحققت من النموذج في الذاكرة، وتوقفت هناك، ولا تستطيع التأكيدات في الذاكرة رؤية عدم تطابق طول لا يوجد إلا في تدفق البايتات المتسلسل. وكانت مجموعة السجلات التي تغطيها كتابة سجلات PivotTable لـBIFF8 من Delphi مختبرة جيدًا وفق ذلك المعيار، ومع ذلك شحنت مُصدّرًا يفسد التدفق. كما احتاج العيب إلى ميزة ثانية كي يصبح مرئيًا: كانت ورقة العمل ذات pivot تعاد فتحها عندما لا يتبعها شيء مهم، لأن الفساد كان يمتد إلى نهاية تدفق فرعي لا يفحصه أحد. ولم يحول عدم المحاذاة الصامت إلى كائن مفقود مرئي إلا اجتماع PivotTable مع ورقة مخطط، حيث تشغل أوراق المخططات والرسومات تدفقًا فرعيًا يأتي بعد ورقة العمل

// PivotChartRoundTripThroughLinkRecords، بصيغة مختصرة
Wb.Sheets.Add.Name := 'Report';
Wb.Sheets[2].AddPivotTable('Data!A1:B3', 2, 2, 'SalesPivot');
Wb.Sheets.AddChartSheet('PivotView', TXLSChartType(2), '', '', '',
  Series, No3D, PivotInfo);
Assert.AreEqual(1, Wb.SaveAs(TempPath));
Wb.Free;
Wb := TXLSWorkbook.Create;
Wb.Open(TempPath);                       // يحدث التحليل الخاطئ هنا
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

قبل الإصلاح، كانت قيمة Wb.Sheets[3]._Chart هي nil في ذلك السطر، لأن القارئ فقد حد التدفق الفرعي قبل أن يصل بوقت طويل إلى BOF الخاص بالمخطط. والتأكيد الذي التقط أخيرًا خطأ في تسلسل pivot كان تأكيدًا يتعلق بمخطط

كيف تقرأ تدفق BIFF غير المحاذى عائدًا إلى أول سجل سيئ؟

تتبع سلسلة الرؤوس واطبعها، لأن تدفق BIFF فاقد التزامنه يعلن عن نفسه بنيويًا قبل أن تبدو البيانات خاطئة بوقت طويل. ابدأ عند BOF للتدفق الفرعي ($0809)، واقرأ المعرّف والطول، وتقدم بمقدار أربعة زائد الطول، وكرر. وطالما كان التدفق محاذيًا، تصل إلى معرّفات سجلات معقولة وتنتهي السلسلة تمامًا عند EOF ($000A). وبمجرد أن ينحرف، تحصل على معرّفات غير موجودة، أو أطوال تتجاوز المخزن، أو سلسلة تسير مباشرة بعد موضع EOF المفترض

// تجول في تدفق سجلات BIFF وتوقف عند أول رأس لا يمكن أن يكون حقيقيًا
procedure ScanRecords(Buf: PByte; Size: LongWord);
var
  Pos: LongWord;
  Id, Len: Word;
begin
  Pos := 0;
  while Pos + 4 <= Size do
  begin
    Id  := PWord(Buf + Pos)^;
    Len := PWord(Buf + Pos + 2)^;
    // المعرّف الصفري ليس سجلًا قانونيًا أبدًا، والجسم الذي يتجاوز
    // المخزن دليل على أن السلسلة انحرفت مسبقًا في موضع أعلى
    if (Id = 0) or (Pos + 4 + LongWord(Len) > Size) then
    begin
      WriteLn(Format('desync at %d: id=$%.4x len=%d', [Pos, Id, Len]));
      Break;
    end;
    WriteLn(Format('%6d  id=$%.4x  len=%d', [Pos, Id, Len]));
    if Id = $000A then
      WriteLn('-- EOF, substream ends cleanly --');
    Inc(Pos, 4 + LongWord(Len));
  end;
end;

ثم اقرأ الناتج بالعكس، وتمسك بقاعدة واحدة: السجل الأول الذي يفشل في التحليل نادرًا ما يكون الجاني. إنه الضحية. والجاني هو السجل الذي يسبقه مباشرة، أي آخر سجل حُلّل بلا شكوى، لأن الكاذب بشأن طوله يحلل نفسه دائمًا بصورة سليمة. وفي هذه الحالة توقف التجول عند سجل وهمي $0000، وكان السجل الذي قبله هو SXEx. قارن طول ذلك السجل المعلن بقائمة الحقول في المواصفة، بايتًا ببايت، وستعرف إن كان الحساب يتطابق أم لا. وإذا لم يصل التجول حتى إلى أول سجل سليم، فالمشكلة في طبقة أدنى، في ملف OLE2 المركب الذي يحمل تدفق Workbook، ولن يفيد أي قدر من تفريغ مستوى السجلات

مُصدّر لا يستطيع الكذب بشأن طوله

الإصلاح المتين ليس ثابتًا صحيحًا، بل إزالة فرصة كتابة ثابت خاطئ. احجز كلمة الطول، وأصدر الجسم، ثم صحح الرأس من عدد البايتات الذي أنتجته فعلًا. وتوفر HotXLS ما يلزم لذلك: إذ يعطي TXLSBlob.DataLength الإزاحة الحالية، وتكتب SetWord مجددًا في موضع أُصدر بالفعل

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // تذكر موضع وجود كلمة الطول
  Blob.AddWord(0);             // قيمة مؤقتة، يصححها EndRecord
end;

procedure EndRecord(Blob: TXLSBlob; LenPos: LongWord);
var
  Body: LongWord;
begin
  Body := Blob.DataLength - LenPos - SizeOf(Word);
  if Body > 8224 then
    raise Exception.Create('BIFF body exceeds 8224 bytes, split with Continue');
  Blob.SetWord(Word(Body), LenPos);
end;

كن صريحًا بشأن موضع توقف هذا الضمان. فالتأكيد العام على أن البايتات المصدرة تساوي 2 + 2 + المعلن لا يصح إلا للسجلات التي تناسب حد BIFF8 البالغ 8224 بايتًا من الحمولة. فالأجسام الأكبر تعلن بحق 8224 في الرأس وتستمر في سجلات Continue ذات القيمة $003C، وهو بالضبط ما يفعله كاتبا pivot cache وconnection في HotXLS مع الحمولات الكبيرة، ولذلك يكون الثابت مشروطًا: تحت الحد يجب أن يساوي طول blob المصدر طول المعلن زائد أربعة، وفوقه يتولى المقسّم الحساب بدلًا منه. شفّر هذا التمييز في المساعد لا في تعليق. وينتقل المنطق نفسه إلى كل صيغة tag-length-value، لا إلى BIFF وحده. فالمُصدّر الذي يعلن الحجم قبل معرفته كتب ادعاء لا يستطيع الكود فحصه ولا يستطيع المراجع عده، ويعمل بصورة صحيحة إلى أن تهبط ميزة ثانية بعد الأولى

يأتي كاتب BIFF8 ومُصدّرو سجلات pivot وتدفق المخطط المذكور هنا ضمن مكوّن جداول HotXLS لـDelphi لـDelphi وC++Builder، الذي يقرأ ويكتب XLS وXLSX وODS من دون تثبيت Excel