مقال تقني

حل إشارات علاقات OPC في XLSX داخل محلّلات Delphi

ملف xlsx صحيح ليس مُلزَمًا باحتواء xl/worksheets/sheet1.xml. HotXLS، مكوّن جدول بيانات Excel الأصلي لـDelphi وC++Builder، تحدّد موقع كل جزء عبر رسم علاقات OPC بدل تخمين الأسماء، لأن ISO/IEC 29500-2 يضمن فقط أن الأجزاء قابلة للوصول من _rels/.rels، لا أنها تجلس أبدًا عند مسارات تقليدية

لماذا يفشل محلّلي على ملف xlsx صحيح؟

لأن أسماء الأجزاء التي حفظتها اتفاقية لمُنتِج واحد، لا متطلَّبًا للصيغة. كل مسار ثبّته يومًا في الكود، xl/workbook.xml وxl/sharedStrings.xml وxl/styles.xml وxl/worksheets/sheetN.xml، هو ما يصادف أن يُصدره كاتب Excel لسطح المكتب. حزمة متوافقة يمكن أن تضع المصنَّف عند office/book.xml وورقة العمل الأولى عند xl/custom/data-sheet.xml وتبقى SpreadsheetML شرعية، طالما أن العلاقات تشير إلى هناك. هذا هو السبب الأشيع على الإطلاق لأن يُبلغ قارئ محلّي الصنع "لا يمكن إيجاد sheet1.xml" على ملف يفتحه Excel وLibreOffice وNumbers جميعًا دون شكوى

المنتِجون الذين يفعلون هذا ليسوا استثنائيين. مولّدات تقارير من جانب الخادم تعيد استخدام حزمة قالب وتبقي تخطيطها الأصلي. خطوط أنابيب تصدير تدمج مصنَّفين تعيد ترقيم الأوراق وتترك فجوات، فمصنَّف من خمس أوراق يملك sheet1 وsheet2 وsheet4 وsheet7 وsheet9. أدوات تحذف ورقة لا تعيد ترقيم الناجيات دومًا. في كل حالة من تلك يقرأ التخمين القائم على الفهرس xl/worksheets/sheet + IntToStr(i + 1) + .xml بصمت الورقة الخاطئة أو لا يقرأ شيئًا، وهذا أسوأ من استثناء لأن المصنَّف يُحمَّل والأرقام خاطئة. الحزمة الأدنى أدناه تُشغّل المشكلة بأكملها، وهي الشكل الذي تختبر HotXLS انحداره مقابله

<!-- _rels/.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
      Target="office/book.xml"/>
</Relationships>

<!-- office/_rels/book.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId42"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
      Target="../xl/custom/data-sheet.xml"/>
</Relationships>

<!-- xl/custom/_rels/data-sheet.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="note7"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
      Target="../notes/review.xml"/>
</Relationships>

ما الذي يضمنه ISO/IEC 29500-2 فعلًا؟

يضمن قابلية الوصول، لا الموقع. ISO/IEC 29500-2 هو جزء اتفاقيات التعبئة المفتوحة Open Packaging Conventions من المعيار، وتحدد فقرة العلاقات فيه نقطة دخول ثابتة واحدة بالضبط: جزء علاقات الحزمة عند _rels/.rels. من هناك تتبع العلاقة التي يكون Type فيها http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument للوصول إلى جزء المصنَّف، وكل جزء آخر يُكتشَف بقراءة جزء علاقات ذلك الجزء نفسه واتباع حواف مصنَّفة نوعًا typed edges إلى الخارج

قاعدتان إضافيتان من المعيار نفسه تقومان بالعمل الحقيقي. فقرة تسمية الأجزاء تثبّت أين يعيش جزء علاقات: لجزء عند <folder>/<name>، علاقاته عند <folder>/_rels/<name>.rels، ولجزء عند جذر الحزمة يكون المجلد ببساطة _rels/. فقرة ترميز العلاقات تنص على أن Target إشارة URI تُحل مقابل URI الجزء المصدر، بالمعنى العادي لـRFC 3986، ما لم يعلّمها TargetMode="External" على أنها تشير إلى خارج الحزمة. الحل النسبي للمصدر هو الخطوة التي يتخطاها الجميع، وهو سبب أن السلسلة الحرفية نفسها ../notes/review.xml تعني شيئًا داخل xl/custom/_rels/data-sheet.xml.rels وشيئًا مختلفًا كليًا داخل ملف rels أعمق بمجلد واحد. ثنية أخيرة واحدة تجلس بين النموذج المنطقي والبايتات على القرص: أسماء الأجزاء في النموذج المنطقي مطلقة وتبدأ بشرطة مائلة أمامية، لكن فقرة التخطيط الفيزيائي لـZIP تحذف تلك الشرطة حين تحوّل اسم جزء إلى اسم عنصر ZIP، فمُحلِّل ينسى ذلك يبحث عن /xl/sharedStrings.xml في الأرشيف ولا يجد شيئًا

داخل XlsxResolveRelationshipTarget

تركّز HotXLS قاعدة الحل بأكملها في دالة واحدة، XlsxResolveRelationshipTarget، مُعلَنة في lxHandleX.pas كـfunction XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. تأخذ اسم عنصر ZIP الخاص بالجزء المصدر وسمة Target الخام، وتُعيد اسم عنصر ZIP بلا شرطة مائلة بادئة، جاهزًا للتسليم مباشرة إلى الأرشيف. تمرير OwnerPartName فارغ يحلّ مقابل جذر الحزمة، وهذا بالضبط ما يحتاجه جزء علاقات الحزمة. ترتيب العمليات يهم أكثر من الخطوات الفردية: تُطبَّع الشرطات المائلة الخلفية إلى شرطات مائلة أمامية أولًا، لأن بعض المنتِجين يكتبون فواصل ويندوز في Target؛ وأي جزء fragment يُدخله # يُقطَع قبل معالجة المسار، فـ../charts/chart1.xml#Sheet1 يحلّ إلى اسم جزء لا إلى مدخل أرشيف غير موجود؛ فقط بعد ذلك تفصل الدالة المطلق عن النسبي

// Normalization core, as implemented in lxHandleX.pas.
combined := StringReplace(Target, '\', '/', [rfReplaceAll]);
p := Pos('#', combined);
if p > 0 then
  combined := Copy(combined, 1, p - 1);
if (combined <> '') and (combined[1] = '/') then
  Delete(combined, 1, 1)              // package-absolute: strip the slash only
else
begin
  p := LastDelimiter('/', String(OwnerPartName));
  if p > 0 then
    baseName := Copy(OwnerPartName, 1, p)
  else
    baseName := '';
  combined := baseName + combined;    // relative to the source part folder
end;

source.StrictDelimiter := True;       // '/' only, no quote or space handling
source.Delimiter := '/';
source.DelimitedText := String(combined);
for i := 0 to source.Count - 1 do
begin
  segment := WideString(source[i]);
  if (segment = '') or (segment = '.') then
    Continue;                         // empty and dot segments vanish
  if segment = '..' then
  begin
    if parts.Count > 0 then
      parts.Delete(parts.Count - 1);  // pop, and never below the root
  end
  else
    parts.Add(String(segment));
end;

حلقة الأجزاء اجتياز مكدس عادي: تُحذَف الأجزاء الفارغة و.، و.. تسحب مستوى واحدًا، و.. التي كانت ستفلت من جذر الحزمة تُمتَص بدل إنتاج فهرس سالب أو اسم يبدأ بـ../. تعيين StrictDelimiter := True ليس تجميليًا. بدونه تعامل TStringList في Delphi المسافات كفواصل وتحترم أحرف الاقتباس، ما يشوّه أي اسم جزء يحتوي مسافة، وأسماء الأجزاء بمسافات شرعية

اتباع الرسم: المصنَّف، ورقة العمل، الرسم

تجتاز HotXLS ثلاث طبقات من أجزاء العلاقات على مسار TXLSXWorkbook.Open. طبقة الحزمة تعالجها XlsxFindOfficeDocumentPart، التي تقرأ _rels/.rels وتُعيد هدف officeDocument. طبقة المصنَّف تقرأ جزء علاقات المصنَّف وتبني خريطتين معًا: خريطة معرّفات لبحث r:id وخريطة أنواع للأجزاء المفردة singleton. طبقتا ورقة العمل والرسم تكرران النمط بـParseWorksheetRelsXml وParseDrawingRelsXml، كل منهما يمرّر اسم جزئه الخاص كأساس للحل بحيث يهبط رسم يشير إلى ../media/image3.png على الكتلة الصحيحة

// Tier 1: the only fixed name in the whole format.
WorkbookPartName := XlsxFindOfficeDocumentPart(zip);
if WorkbookPartName = '' then
  WorkbookPartName := 'xl/workbook.xml';        // legacy fallback
if not zip.Exists(WorkbookPartName) then
  Exit;

// Tier 2: <folder>/_rels/<name>.rels for the workbook part itself.
relsName := XlsxRelationshipPartName(WorkbookPartName);
if zip.Exists(relsName) then
begin
  relsStream := zip.OpenFile(relsName);
  try
    ParsePartRelationshipsXml(relsStream, WorkbookPartName,
      WorkbookTargetById, WorkbookTargetsByType);
  finally
    relsStream.Free;
  end;
end;

// Typed singletons resolve by relationship type URI.
PartName := WorkbookTargetsByType.Values[XlsxRtSharedStrings];
if PartName = '' then
  PartName := 'xl/sharedStrings.xml';

الأوراق تحديدًا يجب أن تمر عبر خريطة المعرّفات، لا خريطة الأنواع. عناصر <sheet> في جزء المصنَّف تحمل سمات r:id، وذلك المعرّف هو الشيء الوحيد الذي يربط اسم ورقة بجزء. تجمع HotXLS تلك المعرّفات أثناء ParseWorkbookXml وتحلّ كل واحد منها مقابل خريطة علاقات المصنَّف، وتتراجع إلى الاسم المرقَّم التقليدي فقط حين يكون المعرّف غائبًا أو يتعذّر حله

// Tier 2b: r:id -> worksheet part, per sheet, in workbook order.
PartName := '';
if (i < SheetRelIds.Count) and (SheetRelIds[i] <> '') then
  PartName := WorkbookTargetById.Values[SheetRelIds[i]];
if PartName = '' then
  PartName := 'xl/worksheets/sheet' + IntToStr(i + 1) + '.xml';
SheetPartNames.Add(String(PartName));

// Tier 3: each worksheet resolves its own satellites against its own name.
relsName := XlsxRelationshipPartName(PartName);
if zip.Exists(relsName) then
begin
  relsStream := zip.OpenFile(relsName);
  try
    ParseWorksheetRelsXml(relsStream, PartName,
      FParRels[i], ParTableTargets[i], ParPartTargets[i]);
  finally
    relsStream.Free;
  end;
end;

كل ما بعد هذا يعتمد على الآلية نفسها. السلاسل المشتركة، والأنماط styles، والسمة theme، ومشروع VBA تحت النوع المسمى بمساحة اسم مايكروسوفت http://schemas.microsoft.com/office/2006/relationships/vbaProject، والروابط الخارجية، وجزء الشخص على مستوى المصنَّف، والتعليقات القديمة، والتعليقات المُسلسلة threaded comments، ورسم VML الذي يحمل هندسة فقاعة التعليق، والرسومات، والصور، والمخططات البيانية، والجداول، وجداول PivotTable، كلها تصل إلى بايتاتها عبر أهداف مُحلَّلة. جزء السمة theme تحديدًا يجب أن يُحدَّد موقعه بشكل صحيح وإلا فإن رحلة ذهاب وإياب round-trip تستبدل بصمت لوحة علامة تجارية للعميل بسمة Office المخزونة، وهي أحد أنماط الفشل المشروحة في ملاحظات رحلة ذهاب وإياب بلا فقدان لملف XLSX عبر theme وextLst وcalcChain. قراءة العلاقات هي أيضًا سبب أن التحميل مُرحَّل بالطريقة التي هو عليها: كل وصول إلى الأرشيف يحدث على خيط واحد قبل تحليل XML الخاص بورقة العمل، لأن حالة إلغاء الضغط لأرشيف ZIP ليست آمنة للخيوط thread-safe، وهو قيد مشروح في المقال عن تحليل XLSX المتوازي ومخصِّص الذاكرة

لماذا يكسر rId مكرَّر التوجيه القائم على النوع؟

لأن مدخلًا مشوَّهًا لاحقًا يمكن أن يكتب فوق مدخل صحيح سابق ويختطف البحث. من المفترض أن تكون معرّفات العلاقات فريدة داخل جزء علاقات، لكن الحزم المشوَّهة تعيد استخدامها، وتعيين Values[Id] := ساذج هو آخر-كتابة-تفوز. إن أشار rId3 أولًا إلى ورقة عمل حقيقية وأشار rId3 ثانٍ إلى هدف غير مدعوم أو فارغ، فإن آخر-كتابة-تفوز تخسر ورقة العمل. لذا تطبّق ParsePartRelationshipsXml قاعدة أول-يفوز بشرطين: يجب أن يكون الهدف المُحلَّل غير فارغ، ويجب ألا يكون المعرّف موجودًا أصلًا. الشرطان معًا هما ما يجعلان ذلك آمنًا، لأن فحص عدم الفراغ يمنع علاقة بـTarget مفقود من احتلال الخانة قبل وصول واحدة قابلة للاستخدام

if (TargetById <> nil) and (Id <> '') and (resolvedTarget <> '') and
  (TargetById.IndexOfName(String(Id)) < 0) then
  TargetById.Values[String(Id)] := String(resolvedTarget);
if (TargetsByType <> nil) and (relType <> '') and (resolvedTarget <> '') then
  TargetsByType.Add(String(relType + '=' + resolvedTarget));

لاحظ عدم التماثل المتعمَّد في ذلك المقتطف. خريطة المعرّفات خريطة حقيقية بحارس أول-يفوز، بينما مجموعة الأنواع قائمة إلحاق فقط من أزواج type=target. ذلك التمييز يحمل وزنًا حقيقيًا: يملك مصنَّف علاقة سلاسل مشتركة واحدة بالضبط لكن علاقات ورقة عمل وروابط خارجية كثيرة، فبحث النوع عبر Values[] يعيد أول تطابق للمفردات، والأنواع متعددة القيم مثل externalLink تُعدَّد باجتياز القائمة

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

الحدود الصادقة تهم أكثر من قصة نظيفة. تتراجع HotXLS إلى الأسماء التقليدية كلما غابت علاقة، فحزمة بجزء علاقات تالف أو مفقود ما تزال تُفتَح إن صادف أنها تتبع تخطيط Excel؛ ذلك التراجع سمة توافق، لا مصدر ثانٍ للحقيقة، ويمكن أن يخفي علّة منتِج أثناء الاختبار. ثلاثة حدود أخرى تستحق المعرفة. الأهداف الموسومة بـTargetMode="External" تُخزَّن حرفيًا بدل أن تُحَل، وهذا صحيح للروابط التشعبية hyperlinks ولعلاقة externalLinkPath الحاملة عنوان URL لمصنَّف بعيد، لكن هذا يعني أن القيمة التي تحصل عليها هي أيًا كان ما كتبه المنتِج. أجزاء المخططات البيانية المُكتشَفة عبر جزء علاقات رسم تُقرَن بمراسي الرسم positionally لا بمعرّف، فترتيب مرساة غير معتاد يمكن أن يُخِلّ بربط المخططات. والقارئ المباشر المتدفق في lxDirectRead.pas يحتفظ بمعالجة مسار أخف خاصة به مرتَّبة على xl/، فالمُحلِّل الكامل الموصوف هنا يحكم نقطتي دخول TXLSXWorkbook.Open وGetSheetNames، لا مسار الفحص منخفض التخصيص الموثَّق في المقال عن القارئ المباشر المتدفق لـDelphi

إن كنت تبني هذا بنفسك، فأقصر خلاصة صحيحة هي: لا تبنِ اسم جزء أبدًا، احلّ واحدًا دومًا. اقرأ _rels/.rels، واتبع officeDocument، وحلّ كل Target مقابل الجزء الذي أعلنه، ووجّه الأوراق عبر r:id. إن كنت تفضّل أن يكون ذلك مُختبَرًا بالفعل مقابل أجزاء أُعيدت تسميتها، وترقيم أوراق غير متجاور، ومعرّفات علاقات مكرَّرة، فإن المُحلِّل الموصوف هنا يُشحَن في مكوّن جدول بيانات Delphi من HotXLS، إلى جانب آلية رحلة الذهاب والإياب التي تبقي الأجزاء التي لا تحلّلها سليمة