مقال تقني

لماذا يرمم Excel ملف XLSX سليمًا: قواعد حزم OPC في دلفي

يعرض Excel «لقد وجدنا مشكلة في بعض المحتوى» على ملف XLSX تفتحه LibreOffice وكل قارئ منزل الصنع دون اعتراض، لأن Excel يفرض شيئين تتجاهله تلك القراءات: السمات التي يشترطها المخطط وقواعد التفرد في Open Packaging Conventions. اصطدم HotXLS، مكوّن جداول Excel الأصلي لدلفي وC++Builder، بذلك بالضبط في v2.382.5 أول مرة مرّ ناتجه عبر نسخة Excel COM حقيقية، وكانت الأسباب الثلاثة <phoneticPr> بلا fontId، وOverride مكرر في [Content_Types].xml، وعلاقتان جذريتان تشتركان في rId4

لماذا يرفض Excel حزمة تقبلها كل القراءات الأخرى؟

لأن رسالة الإصلاح مدقق مخطط وحزمة، لا فشل محلل. كانت مجموعة HotXLS تمر القالب ذا الـ 4805 صيغة في جولات عبر المكتبة وعبر LibreOffice وعبر مدققات XML في جناح الاختبار منذ أسابيع. كان الملف المحفوظ سليمًا بنيويًا بالمعنى OPC المذكور في مقال حل علاقات XLSX OPC: كل جزء قابل للوصول، وكل هدف قابل للحل. ثم صار جهاز Windows عليه Excel 16.0 بناء 20326 متاحًا، ففتح مشغل المجموعة القالب المحفوظ عبر Workbooks.Open في نسخة COM معزولة مع إسكات DisplayAlerts، وفشل الاستدعاء فشلًا تامًا. تفاعليًا ينتج الملف نفسه الحوار المألوف الذي يعرض الإصلاح، وسجل الإصلاح، حين يكلّفه Excel الكتابة، يسمّي الجزء لا القاعدة. ثلاثة عيوب مستقلة كانت تختبئ في تلك الرسالة الواحدة، ولا يقررها Excel واحدًا تلو الآخر؛ إنه يرفض المصنف ويترك لك البحث عنها وتحليلها. ما يلي هو كل قاعدة، وسطر HotXLS الذي خرقها، والإصلاح الذي شُحن، لأن كل واحدة منها قاعدة يمكن لأي كاتب XLSX في دلفي أن يتعثر بها

القاعدة 1: fontId في phoneticPr مطلوب حتى لو كان صفرًا

يحمل عنصر <phoneticPr> سمة fontId معلنة use="required" في ECMA-376 Part 1 §18.4.3، والقيمة 0 فهرس خط مشروع، لا انعدام. كان كاتب أوراق العمل القديم في HotXLS يعامل الصفر بوصفه «غير مضبوط» ولا يصدر السمة إلا عندما Sheet.PhoneticFontId > 0. هذا منعكس دلفي طبيعي، لأن الحقول الصحيحة تُهيأ إلى الصفر افتراضيًا، لكنه ينتج <phoneticPr type="noConversion"/> لأي مصنف صادف خطه الصوتي أنه الخط الأول في styles.xml، وهو بالضبط ما حمله قالب القروض في مجموعة HotXLS. فيرفضه Excel على طريق العودة في قيمة كتبها بنفسه

لماذا طالب Excel بإصلاح جزء ورقة العمل في HotXLS: يعلن عنصر phoneticPr السمة fontId بـ use required في ECMA-376 Part 1، وفهرس الخط 0 قيمة مشروعة، وكان الكاتب القديم الذي يحذف السمة حين يكون PhoneticFontId صفرًا ينتج phoneticPr type noConversion، بينما يمنح المخطط type وalignment قيمتين افتراضيتين ولا يمنح fontId شيئًا
حذف سمة تساوي الافتراضي آمن فقط حين يعلن المخطط ذلك الافتراضي، وكان قالب القروض يحمل خطه الصوتي بوصفه أول مدخل في styles.xml إطلاقًا
// lxHandleX.pas، كاتب ورقة العمل — قبل v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// v2.382.5 — السمة مطلوبة، والصفر مشمول
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
  phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';

ما يزال HotXLS يصدر العنصر فقط حين تكون TXLSXWorksheet.PhoneticType غير فارغة، فالمصنفات التي لم تحمل أبدًا إعدادات صوتية لا تتأثر. اختبار الانحدار PhoneticSettings_DefaultFontIsExplicit يضبط PhoneticFontId على صفر في ورقة جديدة، ويحفظ، ويؤكد وجود <phoneticPr fontId="0" في xl/worksheets/sheet1.xml. الدرس الأوسع أن «احذفها عند الافتراضي» آمن فقط حين يعلن المخطط افتراضيًا؛ type وalignment لهما افتراضيان في ذلك العنصر، وfontId ليس له

القاعدة 2: Override واحد لكل اسم جزء في [Content_Types].xml

يجوز لتدفق أنواع المحتوى أن يعلن كل اسم جزء مرة واحدة على الأكثر، ويعامل Excel الـ Override الثاني لـ PartName نفسه فسادًا حتى لو حمل كلا المدخلين ContentType واحدًا. يملك HotXLS كاتبين يغذيان ذلك التدفق. BuildContentTypesXml يعلن كل جزء يولده نموذج الكائنات: المصنف والأنماط والسلاسل المشتركة والسمة وأوراق العمل، وعندما TXLSXWorkbook.CustomProperties.Count > 0، /docProps/custom.xml. وعندما تكون PreserveUnsupportedParts مفعلة، يلحق TXLSXOpaquePackage بعدها Override لكل جزء التقطه حرفيًا من حزمة المصدر لتبقى تلك البايتات معلنة عند الخروج. التصادم جزء يقيم على الجانبين. خصائص المستند المخصصة تُحلل في النموذج، لكن docProps/custom.xml في حزمة المصدر كان التُقط أيضًا بشكل معتم، فأعلن التدفق المدموج ذلك مرتين، وأجزاء المخططات وذاكرة pivot قد تقع في الموضع نفسه حين يعيد النموذج توليد جزء احتفظت به الطبقة المعتمة أيضًا. قبل v2.382.5 لم يكن ContentTypeOverridesXml يرى ما كتبه النموذج أصلًا، فلم يستطع أن يعلم

كيف اصطدم كاتبا HotXLS في [Content_Types].xml: أعلن BuildContentTypesXml عن docProps/custom.xml من نموذج الكائنات بينما لحّق TXLSXOpaquePackage بقية Override لنفس الجزء الملتقط حرفيًا، ومنذ v2.382.5 تحلل الطبقة المعتمة التدفق المولد أولًا وتطبّع الأسماء بـ OpcLowerPartName وتترك النموذج يفوز بكل تصادم
كان كل كاتب متسقًا منفردًا، والقيد القائل إن كل اسم جزء يظهر مرة واحدة فقط يقيم في الدرزة حيث يُدمج ناتجاهما، ولهذا يمرر الإصلاح تدفق النموذج إليه
<!-- ما رآه Excel قبل v2.382.5 -->
<Override PartName="/docProps/custom.xml"
    ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>
...
<Override PartName="/docProps/custom.xml"
    ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>

يمرر الإصلاح XML المولد إلى ContentTypeOverridesXml ويدع الكاتب المعتم يحلله قبل أن يصدر شيئًا. تفصيلان يحملان الصحة. OpcLowerPartName يحوّل إلى أحرف صغيرة ويقلب الشرطات المائلة الخلفية إلى أمامية وينزع الشرطات البادئة قبل المقارنة، لأن أسماء أجزاء OPC تقارن بلا حساسية لحالة الأحرف ولأن النموذج يكتبها بشرطة بادئة بينما تخزن الطبقة المعتمة أسماء عناصر ZIP بلا واحدة. والمستدعي في BuildContentTypesXml يمرر Result + '</Types>'، فيغلق المستند المبني جزئيًا فيرى TXMLReader مدخلًا مكتمل الشكل لا تدفقًا مبتورًا. القاعدة التي تبرز هي الأول يفوز والنموذج في الصدارة: كل ما يعلنه نموذج الكائنات هو المرجع، وإعادة الإظهار المعتمة تملأ الفجوات فقط

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // حلل التدفق المولد من النموذج واجمع كل PartName معلن.
    while Reader.Read do
      if (Reader.NodeType= xmlntElement)and (Reader.Name= 'Override') then
      begin
        Index:= Reader.AttributeIndex('PartName');
        if Index>= 0 then
          UsedNames.Add(String(OpcLowerPartName(Reader.Attribute[Index].Value)));
      end;
  for i:= 0 to FParts.Count- 1 do
  begin
    Part:= TXLSXOpaquePart(FParts[i]);
    if (Part.ContentType= '')or (LowerCase(ExtractFileExt(String(Part.PartName)))= '.rels')or
      (UsedNames.IndexOf(String(OpcLowerPartName(Part.PartName)))>= 0) then
      Continue;                       // معلن أصلًا، أو جزء rels
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

القاعدة 3: معرفات العلاقات فريدة داخل جزء العلاقات

كل Relationship في جزء .rels يحتاج Id فريدًا داخل ذلك الجزء، ويرفض Excel الحزمة حين يتشارك اثنان واحدًا. يكتب HotXLS الحزمة الجذرية _rels/.rels بمعرفات ثابتة: rId1 للمصنف، وrId2 وrId3 لخصائص المستند الأساسية والموسعة، وrId4 للخصائص المخصصة حين يملك النموذج شيئًا منها. يلحق بعدها الحزمة المعتمة كل علاقات جذرية احتفظ بها من المصدر، ويعيد ترقيم أي معرف موجود مسبقًا في قائمة UsedIds. كانت القائمة تعرف rId1 إلى rId3. ولم تكن تعرف rId4، ولم تكن تعلم أن النموذج على وشك إصدار علاقة خصائصه المخصصة هو، فجاءت حزمة مصدر كانت علاقة خصائصها المخصصة هي أيضًا rId4، وهو ما يكتبه Excel افتراضيًا، بمدخلين rId4 يشيران إلى الهدف نفسه. المستدعي BuildRootRelsXml يمرر الآن Workbook.FCustomProps.Count > 0 وسيطًا ثانيًا، فيسوق الحجز والتخطي الشرط نفسه الذي يقرر ما إذا كان النموذج سيصدر rId4 أصلًا. إعادة الترقيم آمنة عند جذر الحزمة لأن لا شيء داخل المصنف يشير إلى معرفات العلاقات الجذرية بالاسم؛ والحيلة نفسها ستكون خاطئة مستوى واحدًا أسفل، حيث ترتبط سمات r:id في workbook.xml بالمعرفات في جزء علاقات المصنف، ولهذا يحفظ MergeWorkbookRelationshipsXml خريطة معرفات منفصلة

تصادم معرفات العلاقات عند جذر حزمة HotXLS: يكتب النموذج rId1 إلى rId4 مع حجز rId4 للخصائص المخصصة، وأعادت الطبقة المعتمة إظهار علاقة مصدر وصلت هي أيضًا rId4 لأن UsedIds لم تعرف إلا rId1 إلى rId3، ويحجز الإصلاح rId4 مقدمًا كلما صح EmitCustomProps ويعيد ترقيم البقية
إعادة الترقيم آمنة عند جذر الحزمة لأن لا شيء داخل المصنف يشير إلى المعرفات الجذرية بالاسم، والحيلة نفسها مستوى أسفل ستكسر كل ارتباط r:id في workbook.xml
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // محجوز من كاتب النموذج
if EmitDocProps then
begin
  UsedIds.Add('rId2');
  UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
  Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
  // النموذج يملك الخصائص المخصصة الآن؛ لا تعيد إظهار نسخة المصدر.
  if EmitCustomProps and OpcEndsWith(LowerCase(Rel.RelType), '/custom-properties') then
    Continue;
  Id:= Rel.Id;
  if (Id= '')or (UsedIds.IndexOf(String(Id))>= 0) then
    Id:= AllocateRelationshipId(UsedIds);      // أدنى rIdN حر
  UsedIds.Add(String(Id));
  ...
end;

ما المشترك بين الإخفاقات الثلاثة؟

الثلاثة كلها أعراض كاتب يملك مصدرين ولا مالكًا واحدًا لثوابت الحزمة. يولد نموذج الكائنات الأجزاء التي يفهمها؛ وتعيد الطبقة المعتمة إظهار ما لا يفهمه، حتى تحفظ الجولة المخططات وذاكرة pivot وXML المخصص وكل ما وصفته ملاحظات الجولة بلا فقدان للسمة وextLst وcalcChain. كان كل جانب متسقًا منفردًا. القيود التي يفرضها OPC على الحزمة كلها، تفرد أسماء أجزاء Override وتفرد معرفات العلاقات لكل جزء، لا تقيم إلا في الدرزة التي يُدمج فيها الاثنان، وحتى v2.382.5 لم يفحص أحدًا الدرزة. وخطأ fontId من الشكل نفسه مستوى واحدًا أسفل: عرف الكاتب ما أراد حذفه ولم يستشر قط المخطط الذي يقول إنه لا يجوز. والإصلاح الذي استقر عليه HotXLS أسبقية ثابتة لا استدلال دمج. يكتب النموذج أولًا، وترى الطبقة المعتمة ما كُتب وتتنازل عند أي تصادم، وصار مشغل المجموعة يفرض الثوابت من الخارج عبر verify_opc_uniqueness، الذي يقرأ [Content_Types].xml وكل عنصر .rels في حزمة محفوظة ويُفشل الحالة عند أي PartName أو Extension أو Id مكرر. هذا الفحص رخيص، ولا يحتاج Excel، وكان سيصطاد اثنين من العيوب الثلاثة في أول تشغيل للمجموعة

في الدفعة نفسها: مساحات طباعة صيغ لا مديات

أشار مرور Excel أيضًا إلى _xlnm.Print_Area في قالب القروض، الذي أبلغ عنه Excel بقيمة $A$1:$J$29 في الأصل وكان يجب أن يبلغ عنه بالقيمة نفسها في النسخة المحفوظة. كان خلف ذلك التأكيد الواحد خطآن منفصلان. عند الاستيراد كان XlsxStripSheetPrefix يقطع كل شيء حتى أول ! غير مقتبس، فجاءت مساحة طباعة ديناميكية مثل OFFSET('Print Data'!$A$1,0,0,2,2) عائدة بصيغة $A$1,0,0,2,2)، وفقد اتحاد مؤهل مثل 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 بادئته من مقطعه الأول فقط. وعند التصدير كان الكاتب يسبق اسم الورقة مرة واحدة على PrintArea المخزونة كلها، فخرجت من المكتبة باتحاد عادي $A$1:$B$2,$D$1:$E$2 بمقطع أول مؤهل وثانٍ عارٍ، ولا يقبل Excel ذلك تعريفًا لـ _xlnm.Print_Area وفق ECMA-376 Part 1 §18.2.5

// الاستيراد: انزع البادئة فقط إذا بقي sqref عادي
function XlsxPrintAreaFromDefinition(const Formula: WideString): WideString;
begin
  Result:= Formula;
  if not XlsxReadFormulaSheetPrefixAt(Formula, 1, Prefix, SheetPart, Start) then
    Exit;
  Area:= Copy(Formula, Start, Length(Formula));
  if XlsxParseSqrefPart(Area, R1, C1, R2, C2) then
    Result:= Area;                    // 'Sheet'!$A$1:$J$29 -> $A$1:$J$29
end;                                  // تُعاد OFFSET(...) دون مساس

// التصدير: أهلِّ كل مقطع مفصول بفاصلة، أو لا تهلِّ شيئًا منها
function XlsxPrintAreaDefinition(const SheetName, Area: WideString): WideString;
begin
  Result:= Area;
  ... split Area on ',' with StrictDelimiter ...
  for I:= 0 to Parts.Count- 1 do
    if not XlsxParseSqrefPart(WideString(Trim(Parts[I])), R1, C1, R2, C2) then
      Exit;                           // صيغة: تُصدَر حرفيًا
  Result:= '';
  for I:= 0 to Parts.Count- 1 do
  begin
    if I> 0 then Result:= Result+ ',';
    Result:= Result+ XlsxQuoteSheetName(SheetName)+ '!'+ WideString(Trim(Parts[I]));
  end;
end;

قاعدة الاقتران واحدة على الجانبين: مساحة الطباعة مدى عارٍ فقط إذا حُلّ كل مقطع بوصفه كذلك، وإلا فهي صيغة تسافر حرفيًا. يغطي PrintArea_FormulaDefinitionSurvivesRoundTrip الأساس المسمى والأساس المؤهل باسم الورقة والاتحاد عبر دورتي حفظ وإعادة فتح. وتفاعل مساحات الطباعة مع إعداد الصفحة وبقية نموذج الطباعة مغطى في مقال حماية الأوراق وإعداد الصفحة والطباعة

كيف تكتشف أي قاعدة يعترض عليها Excel؟

ابدأ من افتراض أن مدقّقك أنت هو المخطئ، لأنه اجتاز الملف. سيسمي مدقق Open XML SDK خرق مخطط مثل fontId المفقودة بالجزء وXPath، وطبقة التغليف تحته ترفض أصلًا فتح حزمة فيها مدخلات نوع محتوى مكررة، فشغّله قبل أي شيء. وحين يصمت وما يزال Excel يرمم، قسّم الحزمة ثنائيًا: فك الضغط، احذف جزءًا وعلاقته وOverride الخاص به، أعد الضغط وافتح مجددًا، مقسّمًا مجموعة المرشحين نصفين في كل مرة حتى تختفي الرسالة. سقطت العيوب الثلاثة هنا بهذا الترتيب، ولو واحد منها ما كان سيظهر في الملف المرمد الذي يعرض Excel حفظه، لأن الإصلاح يحذف أو يعيد ترقيم المدخلات المخطئة بصمت. وحدود إصلاح v2.382.5 تستحق التصريح بها بالوضوح نفسه. إزالة التكرار بالأول يفوز والنموذج في الصدارة، فإن أعلنت حزمة المصدر نوع محتوى مختلفًا لجزء يولده النموذج أيضًا، فإعلان النموذج هو الغالب ويُهمَل إعلان المصدر، وهذا صحيح للأجزاء التي يعيد HotXLS توليدها وليس دمجًا عامًا. verify_opc_uniqueness يفحص التفرد فقط؛ فهو لا يدقق المخططات، فسمة مطلوبة مستقبلية ستحتاج مع ذلك Excel أو مدقق مخطط لتظهر. ومرور TXMLReader الإضافي على تدفق أنواع المحتوى المولد يعمل في كل حفظ مع تفعيل PreserveUnsupportedParts، كلفة صغيرة على تدفق نادرًا ما يعدو بضعة كيلوبايتات. وبوضع هذه في مكانها، تفتح بنيتا Win32 وWin64 لقالب القروض الآن في Excel دون رسالة، وتعيد حساب الصيغ الـ 4805 المتحقق منها بمطابقات صفرية، وتُبلغ عن مساحة الطباعة نفسها التي أبلغ عنها الأصل

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