مقاله فنی

حل رابطه 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 اتفاقاً منتشر می‌کند. یک بسته منطبق می‌تواند workbook را در office/book.xml و اولین برگه‌کار را در xl/custom/data-sheet.xml بگذارد و همچنان SpreadsheetML قانونی باشد، تا وقتی روابط به آنجا اشاره کنند. این رایج‌ترین دلیل است که یک خواننده خانگی روی فایلی که Excel، LibreOffice، و Numbers همه بدون شکایت باز می‌کنند «نمی‌تواند sheet1.xml را بیابد» گزارش می‌دهد

تولیدکنندگانی که این کار را می‌کنند عجیب نیستند. مولدهای گزارش سمت‌سرور یک بسته الگو را دوباره استفاده می‌کنند و چیدمان اصلی‌اش را نگه می‌دارند. خطوط لوله صادرات که دو workbook را ادغام می‌کنند برگه‌ها را دوباره شماره‌گذاری می‌کنند و شکاف باقی می‌گذارند، پس یک workbook پنج‌برگه‌ای sheet1، sheet2، sheet4، sheet7، و sheet9 دارد. ابزارهایی که یک برگه را حذف می‌کنند همیشه بازماندگان را دوباره شماره‌گذاری نمی‌کنند. در هر یک از این موارد، حدس شاخص‌محور xl/worksheets/sheet + IntToStr(i + 1) + .xml در سکوت برگه اشتباه را می‌خواند یا هیچ نمی‌خواند، که بدتر از یک استثناست چون workbook بارگذاری می‌شود و اعداد اشتباه‌اند. بسته مینیمال زیر کل مسئله را تمرین می‌کند، و شکلی است که 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 بخش قراردادهای بسته‌بندی باز (OPC) استاندارد است، و بند روابطش دقیقاً یک نقطه ورود ثابت تعریف می‌کند: بخش رابطه بسته در _rels/.rels. از آنجا رابطه‌ای که Type اش http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument است را دنبال می‌کنید تا به بخش workbook برسید، و هر بخش دیگر با خواندن بخش رابطه خودِ آن بخش و دنبال‌کردن یال‌های تایپ‌شده به بیرون کشف می‌شود

دو قاعده دیگر از همان استاندارد کار واقعی را انجام می‌دهند. بند نام‌گذاری بخش تعیین می‌کند یک بخش رابطه کجا زندگی می‌کند: برای بخشی در <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 می‌نویسند؛ هر قطعه‌ای که با # معرفی شده پیش از مدیریت مسیر بریده می‌شود، پس ../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 فاصله‌ها را به‌عنوان جداکننده رفتار می‌کند و کاراکترهای نقل‌قول را رعایت می‌کند، که هر نام بخشی حاوی فاصله را خراب می‌کند، و نام‌های بخش با فاصله قانونی‌اند

دنبال‌کردن گراف: workbook، برگه‌کار، طراحی

HotXLS سه لایه بخش رابطه را در مسیر TXLSXWorkbook.Open می‌پیماید. لایه بسته توسط XlsxFindOfficeDocumentPart مدیریت می‌شود، که _rels/.rels را می‌خواند و هدف officeDocument را برمی‌گرداند. لایه workbook بخش رابطه workbook را می‌خواند و دو نقشه را همزمان می‌سازد: یک نقشه شناسه برای جست‌وجوهای r:id و یک نقشه نوع برای بخش‌های تک‌نمونه‌ای. لایه‌های برگه‌کار و طراحی الگو را با 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> در بخش workbook ویژگی‌های r:id حمل می‌کنند، و آن شناسه تنها چیزی است که یک نام برگه را به یک بخش پیوند می‌زند. HotXLS آن شناسه‌ها را در طول ParseWorkbookXml جمع‌آوری می‌کند و هرکدام را علیه نقشه رابطه workbook حل می‌کند، و فقط وقتی شناسه غایب یا حل‌نشدنی باشد به نام شماره‌گذاری‌شده متعارف بازمی‌گردد

// 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;

هرچیز پایین‌دست همین ماشین سواری می‌کند. رشته‌های مشترک، سبک‌ها، تم، پروژه VBA زیر نوع فضای‌نام مایکروسافت http://schemas.microsoft.com/office/2006/relationships/vbaProject، لینک‌های خارجی، بخش شخص در سطح workbook، کامنت‌های قدیمی، کامنت‌های رشته‌ای، طراحی VML که هندسه بادکنک کامنت را حمل می‌کند، طراحی‌ها، تصاویر، نمودارها، جدول‌ها، و PivotTable ها همه بایت‌هایشان را از راه اهداف حل‌شده می‌رسانند. بخش تم به‌خصوص باید درست شناسایی شود وگرنه یک رفت‌وبرگشت در سکوت پالت برند مشتری را با تم استوک آفیس بازنویسی می‌کند، یکی از حالت‌های شکست پوشش‌داده‌شده در یادداشت‌های رفت‌وبرگشت بدون‌اتلاف XLSX از تم، extLst و calcChain. خواندن رابطه همچنین چرا بارگذاری به این شکل مرحله‌بندی شده: تمام دسترسی آرشیو روی یک رشته پیش از تجزیه XML برگه‌کار اتفاق می‌افتد، چون وضعیت inflate یک آرشیو ZIP رشته‌ایمن نیست، محدودیتی که در نوشتار تجزیه موازی 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 است. آن تمایز باربر است: یک workbook دقیقاً یک رابطه رشته‌های مشترک دارد اما بسیاری روابط برگه‌کار و لینک خارجی، پس جست‌وجوی نوع از راه Values[] اولین تطابق را برای تک‌نمونه‌ها برمی‌گرداند، و انواع چندمقداری مانند externalLink با پیمایش فهرست شمارش می‌شوند

جایی که دنبال‌کردن رابطه متوقف می‌شود

مرزهای صادقانه بیشتر از یک داستان تمیز اهمیت دارند. HotXLS هرجا رابطه‌ای غایب باشد به نام‌های متعارف بازمی‌گردد، پس یک بسته با یک بخش رابطه آسیب‌دیده یا گم‌شده همچنان باز می‌شود اگر اتفاقاً چیدمان Excel را دنبال کند؛ آن بازگشت یک ویژگی سازگاری است، نه یک منبع دوم حقیقت، و می‌تواند یک باگ تولیدکننده را در طول آزمون پنهان کند. سه محدودیت دیگر ارزش دانستن دارند. اهدافی که با TargetMode="External" علامت خورده‌اند کلمه‌به‌کلمه ذخیره می‌شوند نه حل‌شده، که برای هایپرلینک‌ها و برای رابطه externalLinkPath که یک URL workbook دوردست حمل می‌کند درست است، اما یعنی مقداری که پس می‌گیرید هرچیزی است که تولیدکننده نوشته. بخش‌های نمودار کشف‌شده از راه یک بخش رابطه طراحی به‌صورت موقعیتی نه با شناسه با لنگرهای طراحی جفت می‌شوند، پس یک ترتیب لنگر غیرمعمول می‌تواند اتصالات نمودار را ناهم‌ترازی کند. و خواننده مستقیم استریمینگ در lxDirectRead.pas مسیر سبک‌تر خودش را با کلید xl/ نگه می‌دارد، پس ریزالورکننده کامل که در اینجا شرح داده شد نقاط ورودی TXLSXWorkbook.Open و GetSheetNames را مدیریت می‌کند، نه مسیر اسکن کم‌تخصیص که در مقاله خواننده مستقیم استریمینگ برای Delphi مستند شده

اگر خودتان این را می‌سازید، کوتاه‌ترین خلاصه درست این است: هرگز یک نام بخش نسازید، همیشه یکی را حل کنید. _rels/.rels را بخوانید، officeDocument را دنبال کنید، هر Target را علیه بخشی که آن را اعلان کرده حل کنید، و برگه‌ها را با r:id مسیریابی کنید. اگر ترجیح می‌دهید آن از قبل علیه بخش‌های تغییرنام‌یافته، شماره‌گذاری برگه ناپیوسته، و شناسه‌های رابطه تکراری آزموده شده باشد، ریزالورکننده‌ای که در اینجا شرح داده شد در مؤلفه صفحه‌گسترده Delphi HotXLS ارائه می‌شود، در کنار ماشین رفت‌وبرگشت که بخش‌هایی که تجزیه نمی‌کند را دست‌نخورده نگه می‌دارد