مقال تقني

قراءة ملفات OLE2 المركّبة في Delphi دون COM IStorage

تقرأ وتكتب مكتبة HotXLS Excel لـDelphi وC++Builder حاوية Compound File Binary التي تقف خلف كل ملف .xls قديم في Object Pascal خالص. تنفّذ فئة TlxCompoundFile تخطيط [MS-CFB] الإصدار 3 مباشرة فوق TStream — الترويسة، وDIFAT، وسلاسل FAT، وMiniFAT، وشجرة الدليل — دون ole32.dll ودون COM IStorage في أي موضع من المسار

هذا يبدو كأنبوب سباكة، ولمدة عشرين عامًا كان أنبوب سباكة يملكه شخص آخر. كل قاعدة كود Delphi لمست ملف .xls استدعت StgOpenStorage، وحصلت على IStorage بالمقابل، وسحبت تدفق Workbook منه. ثلاثة أسطر، عملت جيدًا، ولم يفكر فيها أحد مجددًا — إلى أن جاء اليوم الذي كان على الكود نفسه أن يعمل في مكان لا وجود لويندوز فيه

لماذا يتوقف StgOpenStorage عن العمل على خادم؟

تفشل واجهة برمجة التخزين البنيوي structured-storage الخاصة بـCOM بالضبط في أشكال النشر التي يعيش فيها كود Delphi الحديث، لأسباب لا علاقة لها بصيغة الملف. StgOpenStorage نقطة دخول Win32 في ole32.dll: تريد مسارًا على نظام ملفات، وتريد تهيئة COM على الخيط المستدعي، وتريد أن تكون على ويندوز. متطلب المسار يؤلم أولًا، لأن نقطة نهاية REST تستقبل مصنَّفًا مرفوعًا تملك البايتات في مخزن مؤقت، لا على القرص — فتكتب المخزن المؤقت إلى ملف مؤقت، وتفتحه، وتقرأه مجددًا، وتحذفه، وتصبح الآن مالكًا لدورة حياة ملف مؤقت عليك أن تخطئ فيها تحت الحمل. ILockBytes هو المخرج الموثَّق، لكن توصيل تطبيق مخصَّص فوق TMemoryStream تفاعل COM أكثر مما تريده معظم الفرق. متطلب التهيئة يعضّ ثانيًا، عادة في خيط عامل خدمة لم يستدعِ أحد CoInitialize عليه، ومتطلب المنصة ينهي الحوار في اللحظة التي يكون فيها الهدف Linux تحت FPC، أو صورة حاوية container image، أو macOS. لذا تُبقي HotXLS مسار lxOLE الكلاسيكي المبني على StgOpenStorage افتراضيًا، لأنه مختبَر في المعارك وليس على المستدعين الحاليين أن يغيّروا شيئًا؛ أما TlxCompoundFile فهو البديل الاختياري للجميع سواهم

ما تقوله الترويسة وسلاسل FAT فعلًا

الـ512 بايتًا الأولى من ملف مركَّب تجيب عن كل سؤال بنيوي تحتاجه قبل قراءة بايت واحد من الحمولة. يثبّت [MS-CFB] §2.2 توقيع الترويسة عند الإزاحة 0 كثمانية بايتات D0 CF 11 E0 A1 B1 1A E1، وتتحقق lxIsCompoundStream من ذلك بالضبط، وتستعيد موضع التدفق بعدها بحيث يستطيع المستدعي الاستشمام sniff دون إزعاج أي شيء. أربعة حقول إضافية تقرر الهندسة: ترتيب البايتات عند 0x1C يجب أن يكون 0xFFFE، وهو يخدم كفحص توقيع ثانٍ رخيص؛ إزاحة القطاع sector shift عند 0x1E تعطي حجم القطاع كـ1 shl SectorShift، فالإصدار 3 يستخدم إزاحة 9 لقطاعات 512 بايت والإصدار 4 يستخدم إزاحة 12 لـ4096؛ إزاحة القطاع المصغَّر عند 0x20 هي 6، ما يجعل القطاعات المصغَّرة 64 بايتًا؛ وحد قطع التدفق المصغَّر عند 0x38 هو 4096. حساب العناوين الذي يلي هذا هو المكان الأشيع للخطأ. القطاع 0 يبدأ فور انتهاء الترويسة، فالقطاع N يبدأ عند إزاحة البايت 512 + N * SectorSize — لاحظ الرقم الحرفي 512، لا SectorSize. على ملف إصدار 3 الاثنان متطابقان والعلّة تختبئ إلى الأبد؛ على ملف إصدار 4 يقرأ القطاع الخاطئ بصمت، ولهذا تُبقي HotXLS هذا في دالة واحدة، SidToOffset

الملف المركَّب نظام ملفات FAT داخل ملف، فقراءته تعني اجتياز قوائم مترابطة من معرّفات القطاعات حيث يحمل FAT[n] المعرّف التالي للقطاع n. ثلاث علامات حارسة sentinels تُنهي السلسلة أو تُعلّق عليها — ENDOFCHAIN، وFATSECT لقطاع ينتمي إلى FAT نفسه، وDIFSECT لقطاع DIFAT — وتُقرَأ الثلاثة كأعداد صحيحة موقَّعة من 32 بت سالبة، وهذا يبقي شروط الحلقة بسيطة. إيجاد FAT يحتاج تحويلًا إضافيًا واحدًا: DIFAT هي مصفوفة معرّفات القطاعات التي تقول أين تعيش قطاعات FAT، وأول 109 مدخل منها يجلس في الترويسة عند الإزاحة 0x4C. تجتاز TlxCompoundFile تلك الـ109، وتتوقف عند أول مدخل سالب، وتُلصق كل قطاع FAT في مصفوفة Integer واحدة مسطَّحة. هذا 109 قطاع FAT بـ128 مدخلًا لكل منها في قطاع من 512 بايتًا، أي نحو 13,952 قطاعًا قابلًا للعنونة، أي نحو 6.8 ميبيبايت من الحاوية قبل أن يضطر DIFAT للفيضان إلى سلسلته الخاصة

جدول التخصيص الثاني موجود لأن قطاعات الـ512 بايت تهدر معظم مساحتها على التدفقات الصغيرة. أي تدفق دون حد الـ4096 بايت لا يُخزَّن في قطاعات على الإطلاق: بل يعيش داخل التدفق المصغَّر mini stream، وهو نفسه تدفق عادي مُعلَّق على مدخل الدليل الجذر، مقسَّم إلى قطاعات مصغَّرة من 64 بايت ومسلسَل عبر MiniFAT موازٍ متجذّر عند إزاحة الترويسة 0x3C. افتح ملف .xls حقيقي وستجد تدفق Workbook على FAT العادي بينما تجلس تدفقات معلومات الملخّص summary-information أسفل في مساحة القطاعات المصغَّرة، وهذا سبب أن تطبيقًا يغطي مسار FAT فقط يبدو يعمل بشكل صحيح إلى أن يحتاج بيانات وصفية للمستند. الدليل هو البنية الثالثة وهي التي تجعل الحاوية قابلة للتصفح: كل مدخل بالضبط 128 بايتًا، أربعة في كل قطاع من 512 بايت، يحمل اسمًا بترميز UTF-16 في أول 64 بايتًا، وطوله بالبايت عند 0x40، ونوع الكائن عند 0x42 (1 = تخزين، 2 = تدفق، 5 = جذر)، ووصلات الشجرة عند 0x44 و0x48 و0x4C، وقطاع البدء عند 0x74 وحجم التدفق من 32 بت عند 0x78. طول ذلك الاسم يعدّ البايتات متضمنًا الصفر المُنهي، فعدد المحارف هو NameLen div 2 - 1، وإخطاؤه بواحد هو كيف ينتهي بك المطاف بتدفق اسمه Workboo

سحب تدفق Workbook من مخزن مؤقت في الذاكرة

يخفي TlxCompoundFile.OpenStream كل ما سبق خلف استدعاء واحد يأخذ اسم تدفق ويعيد TlxCfbStream يحمل البايتات مُجسَّدة بالكامل. التسلسل بأكمله — استشمام، تحميل، استخراج — يعمل فوق TBytesStream دون أن يلمس شيء القرص أبدًا

uses
  Classes, SysUtils, lxCompoundFile;

function ExtractBiffPayload(const Blob: TBytes): TBytes;
var
  Src: TBytesStream;
  Cfb: TlxCompoundFile;
  Wb: TlxCfbStream;
begin
  SetLength(Result, 0);
  Src:= TBytesStream.Create(Blob);
  try
    if not lxIsCompoundStream(Src) then
      Exit;                            // not a CFB container at all
    Cfb:= TlxCompoundFile.Create;
    try
      Cfb.LoadFromStream(Src);         // header, FAT, directory, MiniFAT
      Wb:= Cfb.OpenStream('Workbook'); // BIFF8
      if Wb = nil then
        Wb:= Cfb.OpenStream('Book');   // BIFF5 / BIFF7
      if Wb <> nil then
      try
        Result:= Wb.Data;
      finally
        Wb.Free;
      end;
    finally
      Cfb.Free;
    end;
  finally
    Src.Free;
  end;
end;

تفصيلان هناك يستحقان الإشارة. تأخذ LoadFromStream علامة AOwnsStream افتراضيتها False، فيحتفظ المستدعي بمسؤولية تدفق المصدر — عمدًا، لأن الحالة الشائعة تدفق يملكه التطبيق أصلًا. وOpenStream تُعيد TlxCfbStream تملك نسختها الخاصة من البايتات، مكشوفة عبر Data وSize وRead وSeek وCopyTo. تلك النسخة كلفة حقيقية على مصنَّف كبير، وهي الثمن الصادق لتصميم يبقى فيه الكائن المُعاد صالحًا بعد تحرير الحاوية. حين يكون المصنَّف كبيرًا بما يكفي بحيث تكون نسخة كاملة في الذاكرة الشكل الخاطئ تمامًا، فإن القارئ المباشر المتدفق للجداول الممتدة الحجم هو نقطة الدخول الأفضل

لماذا يبدو ملف XLSX مشفَّر كملف XLS؟

لأنه كذلك فعلًا، على مستوى الحاوية — وهذه هي الفائدة العملية لامتلاك تلك الطبقة. افتح ملف .xlsx مشفَّرًا في محرِّر سداسي عشري وأول ثمانية بايتات هي D0 CF 11 E0 A1 B1 1A E1، مطابقة بايتًا ببايت لملف .xls من طراز 1997، لأن تشفير [MS-OFFCRYPTO] لا يشفّر حزمة ZIP في مكانها: بل يلفّ الحزمة بأكملها داخل حاوية CFB كتدفق اسمه EncryptedPackage، إلى جانب تدفق EncryptionInfo يصف الخوارزمية. التوقيع إذن يعرّف الحاوية ولا يقول شيئًا عن الحمولة. تمييز مصنَّف BIFF عن حزمة OOXML مشفَّرة يعني قراءة الدليل، وهو بعد LoadFromStream فحص عبر EntryCount وEntries، أو فحصين بـHasStream

type
  TCfbPayload = (cpUnknown, cpBiffWorkbook, cpEncryptedOoxml);

function ClassifyContainer(AStream: TStream): TCfbPayload;
var
  Cfb: TlxCompoundFile;
  E: TlxCfbEntry;
  I: Integer;
begin
  Result:= cpUnknown;
  Cfb:= TlxCompoundFile.Create;
  try
    Cfb.LoadFromStream(AStream);
    for I:= 0 to Cfb.EntryCount - 1 do
    begin
      E:= Cfb.Entries(I);
      if E.EntryType <> cfbStream then
        Continue;
      if E.Name = 'EncryptedPackage' then
        Result:= cpEncryptedOoxml
      else if (E.Name = 'Workbook') or (E.Name = 'Book') then
        Result:= cpBiffWorkbook;
    end;
  finally
    Cfb.Free;
  end;
end;

أسماء الدليل تستحق تحذيرًا خاصًا بها: تدفقات معلومات الملخّص تحمل محرف تحكم 0x05 بادئًا في أسمائها، فمقارنة مكتوبة مقابل سلسلة عرض عادية لن تطابقها أبدًا وسطر سجلّ ساذج يعرضها كبيانات فاسدة. كل ما بعد هذا التصنيف — اشتقاق المفتاح، فحص مدقّق كلمة المرور — مشكلة منفصلة، مشروحة في ملاحظات لماذا يرفض Excel مصنَّفًا مشفَّرًا بوضع تشفير خاطئ. طبقة الحاوية تخبرك فقط بأي باب تقف أمامه

كتابة حاوية سيفتحها Excel فعلًا

جانب الكتابة في TlxCompoundFile أضيق عمدًا من جانب القراءة، وفهم السبب يوفّر عليك جدالًا مع المواصفة. يسمح [MS-CFB] بمساحة هائلة من الحاويات الصحيحة: تخزينات متعددة المستويات، وأشجار دليل حمراء-سوداء متوازنة بشكل صحيح، وتدفقات مصغَّرة، وسلاسل DIFAT. يُصدر Excel زاوية صغيرة من تلك المساحة ويقرأ زاوية أكبر قليلًا. تكتب HotXLS زاوية أصغر بعد — الحد الأدنى الذي يحمّله Excel إثباتًا. كل تدفق يذهب إلى FAT العادي دون مسار تدفق مصغَّر، وهذا يكلّف مساحة قرص ويشتري صحة: تدفق ملخّص من 300 بايت كان سيحزمه Excel في خمسة قطاعات مصغَّرة من 64 بايتًا يشغل بدلًا من ذلك قطاعًا كاملًا من 512 بايتًا، ولمصنَّف هذا ضجيج بجانب صيانة جدول تخصيص ثانٍ، واجتياز سلسلة ثانٍ، وتدفق مدخل الجذر الداعم له على مسار الكتابة. مدخلات الدليل تشكّل سلسلة أشقاء sibling chain مسطَّحة تحت الجذر بكل عقدة ملوَّنة سوداء، وترتيب الإصدار ثابت: عنصر نائب للترويسة، وقطاعات بيانات التدفقات، وقطاعات الدليل، وقطاعات FAT، ثم قفزة seek عائدة لإعادة كتابة الترويسة بمعرّفات القطاعات التي لا تُعرَف إلا في النهاية. FAT يحدد حجمه عبر حلقة نقطة ثابتة قصيرة، لأن إضافة قطاعات FAT قد تدفع عدد القطاعات عاليًا بما يكفي ليتطلب قطاع FAT آخر

procedure SaveAsCompoundFile(const Dest: string; const BiffBytes: TBytes);
var
  FS: TFileStream;
  Cfb: TlxCompoundFile;
begin
  FS:= TFileStream.Create(Dest, fmCreate);
  try
    Cfb:= TlxCompoundFile.Create;
    try
      Cfb.CreateNew(FS);                  // v3 header, 512-byte sectors
      Cfb.AddStream('Workbook', BiffBytes);
      Cfb.Save;                           // data -> dir -> FAT -> header
    finally
      Cfb.Free;
    end;
  finally
    FS.Free;
  end;
end;

أين يتوقف التطبيق

ثلاثة حدود تستحق الذكر الصريح، لأن قارئ حاوية يخطئ بصمت في معالجة حالة حدّية أسوأ من قارئ يرفع استثناءً. تقرأ TlxCompoundFile مدخلات DIFAT الـ109 المقيمة في الترويسة ولا تتبع سلسلة DIFAT عند 0x44 بعدها، ما يحدّ حاوية قابلة للقراءة عند نحو 6.8 ميبيبايت على قطاعات 512 بايت — أعلى بارتياح من ملفات .xls الحقيقية التي تصادفها HotXLS في الميدان، لكنه سقف صلب مع ذلك، والكاتب يفرض الحد نفسه صراحة بدل إصدار حاوية لا يستطيع وصفها. ثانيًا، حاويات الإصدار 4 بقطاعات 4096 بايت تُستوعَب عبر حساب حجم القطاع لكنها ليست ما يُضبط له الكود، وحجم التدفق من 64 بت لا يُستشار: تقرأ HotXLS الـ32 بت الدنيا عند الإزاحة 0x78 وتترك النصف العلوي دون مساس، وهذا صحيح للإصدار 3 وفقط للإصدار 3. ثالثًا، بحث المدخل فحص مسطَّح بالاسم عبر قائمة الدليل بدل اجتياز أسفل الشجرة الحمراء-السوداء من تخزين والد، فالتخزينات المتداخلة تُحلّ بتصادم الاسم لا بالمسار — كل تدفق يحتاجه ملف .xls يجلس على المستوى الأعلى، وهذا ما يجعل التصميم الأبسط مبرَّرًا، لكن كودًا يتوقع عنونة SomeStorage/SomeStream لن يجدها

لا شيء من ذلك يغيّر غرض الوحدة. امتلاك طبقة الحاوية يحوّل معالجة .xls إلى Object Pascal عادي: قابل للتحليل من مصفوفة بايتات، وقابل للاختبار دون نظام ملفات، وقابل للنقل إلى أي منصة يستهدفها المُصرِّف، وخالٍ من شقة apartment خاصة بـCOM. كما يتقاعد اختصارات الاستشمام، لأن التعرّف على مصنَّف الآن يعني قراءة دليله لا أول ثمانية بايتات منه — النظام نفسه خلف سرد أسماء الأوراق دون فتح المصنَّف بأكمله

تُشحن TlxCompoundFile ضمن مكوّن HotXLS Excel لـ Delphi وC++Builder، إلى جانب طبقتي BIFF وOOXML الجالستين فوقها؛ وتحمل صفحة المنتج المرجع الكامل للوحدة ومصفوفة المُصرِّفات المدعومة