مقاله فنی

چرا اکسل XLSX معتبر را تعمیر می‌کند: قواعد OPC در Delphi

اکسل روی XLSXی که LibreOffice و هر خوانندهٔ دست‌ساز دیگری بی‌کم‌وکاست بازش می‌کنند پیام «We found a problem with some content» نشان می‌دهد، چون اکسل دو چیز را تحمیل می‌کند که آن خواننده‌ها نادیده می‌گیرند: اتریبیوت‌هایی که اسکیما required علامت زده و قواعد یکتایی Open Packaging Conventions. HotXLS، همان کامپوننت بومی صفحه‌گستردهٔ اکسل برای Delphi و C++Builder، در v2.382.5 دقیقاً به همین برخورد، اولین باری که خروجی‌اش از یک نمونهٔ واقعی COM اکسل رد شد، و سه علتش یک <phoneticPr> بدون fontId، یک Override تکراری در [Content_Types].xml و دو رابطهٔ ریشه که rId4 مشترک داشتند بود

چرا اکسل بسته‌ای را رد می‌کند که هر خوانندهٔ دیگری قبولش دارد؟

چون پیام تعمیر یک validator اسکیما و بسته است، نه یک خطای پارسر. corpus مربوط به HotXLS هفته‌ها یک قالب وام با 4805 فرمول را از کتابخانه و از LibreOffice و از validatorهای XML موجود در مجموعهٔ تست رفت‌وبرگشت داده بود. فایل ذخیره‌شده به همان معنای OPC که در مقالهٔ حل رابطه‌های OPC در XLSX به کار رفته ساختاراً سالم بود: هر part قابل‌دسترس، هر هدف قابل‌حل. بعد یک ماشین ویندوزی با Excel 16.0 build 20326 در دسترس قرار گرفت، اجراکنندهٔ corpus قالب ذخیره‌شده را از طریق Workbooks.Open در یک نمونهٔ COM مجزا با DisplayAlerts خاموش باز کرد و فراخوانی یکسره شکست خورد. به‌صورت تعاملی همان فایل همان دیالوگ آشنای پیشنهاد تعمیر را می‌سازد، و لاگ تعمیر، وقتی اکسل زحمت نوشتنش را بکشد، نام آن part را می‌گوید ولی قاعده را نه. سه نقص مستقل در همان یک پیام پنهان شده بودند و اکسل آن‌ها را یکی‌یکی گزارش نمی‌کند؛ workbook را رد می‌کند و پیدا کردن و تحلیلشان را به تو می‌سپارد. آنچه در ادامه می‌آید هر قاعده است، سطری از HotXLS که نقضش کرده، و fixی که منتشر شد، چون هر کدامشان قاعده‌ای است که هر نویسندهٔ XLSX در Delphi می‌تواند رویش بلغزد

قاعدهٔ 1: fontId در phoneticPr اجباری است، حتی وقتی صفر باشد

عنصر <phoneticPr> یک اتریبیوت fontId حمل می‌کند که در ECMA-376 Part 1 §18.4.3 با use="required" تعریف شده، و مقدار 0 یک اندیس فونت مجاز است، نه یک غیاب. نویسندهٔ قدیمی کاربرگ در HotXLS با صفر مثل «ست نشده» رفتار می‌کرد و اتریبیوت را فقط وقتی بیرون می‌داد که Sheet.PhoneticFontId > 0 باشد. این یک رفلکس طبیعی در Delphi است، چون فیلدهای صحیح به‌صورت پیش‌فرض صفرند، ولی برای هر workbookی که فونت آوایی‌اش تصادفاً اولین فونت در styles.xml باشد <phoneticPr type="noConversion"/> تولید می‌کند، که دقیقاً همانی است که قالب وام در corpus مربوط به HotXLS حمل می‌کرد. اکسل بعد موقع برگشت مقداری را رد می‌کند که خودش نوشته بود

چرا اکسل برای part کاربرگ HotXLS تعمیر خواست: عنصر phoneticPr در ECMA-376 Part 1 اتریبیوت fontId را با use required تعریف می‌کند، اندیس فونت 0 یک مقدار مجاز است، و نویسندهٔ قدیمی که وقتی PhoneticFontId صفر بود اتریبیوت را حذف می‌کرد phoneticPr با type برابر noConversion تولید می‌کرد، در حالی که اسکیما برای type و alignment پیش‌فرض دارد و برای fontId هیچ
حذف یک اتریبیوت وقتی با پیش‌فرض برابر است فقط وقتی امن است که اسکیما آن پیش‌فرض را اعلام کرده باشد، و قالب وام فونت آوایی‌اش را به‌عنوان همان اولین entry در 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 خالی نباشد، پس workbookهایی که هرگز تنظیمات آوایی نداشته‌اند تحت تأثیر قرار نمی‌گیرند. تست رگرسیون PhoneticSettings_DefaultFontIsExplicit مقدار PhoneticFontId را روی یک sheet تازه صفر می‌گذارد، save می‌کند و تأکید می‌کند که <phoneticPr fontId="0" در xl/worksheets/sheet1.xml حاضر است. درس بزرگ‌تر این است که «وقتی برابر پیش‌فرض بود حذف کن» فقط وقتی امن است که اسکیما یک پیش‌فرض اعلام کرده باشد؛ type و alignment در آن عنصر پیش‌فرض دارند، fontId ندارد

قاعدهٔ 2: یک Override برای هر نام part در [Content_Types].xml

استریم content types می‌تواند هر نام part را حداکثر یک بار اعلام کند، و اکسل یک Override دوم برای همان PartName را خرابی می‌داند حتی وقتی هر دو entry همان ContentType را حمل کنند. HotXLS دو نویسنده دارد که این استریم را تغذیه می‌کنند. BuildContentTypesXml هر partی که مدل شیء تولید می‌کند را اعلام می‌کند: workbook و styles و shared strings و theme و کاربرگ‌ها، و وقتی TXLSXWorkbook.CustomProperties.Count > 0 باشد /docProps/custom.xml. وقتی PreserveUnsupportedParts روشن است، TXLSXOpaquePackage برای هر partی که آن را عیناً از بستهٔ مبدأ برداشته بود یک Override اضافه می‌کند تا آن بایت‌ها در مسیر بیرون اعلام‌شده بمانند. تصادم آن‌جاست که یک part در هر دو سمت زندگی می‌کند. پراپرتی‌های سفارشی سند در مدل پارس می‌شوند، ولی docProps/custom.xml بستهٔ مبدأ هم به‌صورت opaque برداشته شده بود، پس استریم ادغام‌شده دوبار اعلامش می‌کرد، و partهای chart و pivot cache هم می‌توانند در همین نقطه بیفتند وقتی مدل partی را از نو تولید می‌کند که لایهٔ opaque هم نگهش داشته بود. پیش از v2.382.5، ContentTypeOverridesXml هیچ دیدی نسبت به آنچه مدل از قبل نوشته بود نداشت، پس نمی‌توانست بداند

چگونه دو نویسنده در HotXLS داخل [Content_Types].xml با هم تصادم کردند: BuildContentTypesXml مقدار docProps/custom.xml را از مدل شیء اعلام می‌کرد در حالی که TXLSXOpaquePackage برای همان part که عیناً برداشته شده بود یک Override اضافه می‌کرد، و از v2.382.5 به بعد لایهٔ opaque اول استریم تولیدشده را پارس می‌کند، نام‌ها را با OpcLowerPartName نرمال می‌کند و در هر تصادم مدل را برنده می‌کند
هر نویسنده به‌تنهایی سازگار بود، و قید اینکه هر نام part فقط یک بار می‌تواند بیاید تنها در همان درزی وجود دارد که خروجی‌هایشان به هم چسبانده می‌شوند، و همین دلیل این است که fix، استریم مدل را به داخل می‌فرستد
<!-- آنچه اکسل پیش از 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"/>

fix، XML تولیدشده را به داخل ContentTypeOverridesXml می‌فرستد و به نویسندهٔ opaque اجازه می‌دهد پیش از آنکه چیزی بیرون بدهد پارسش کند. دو جزئیات درستی را حمل می‌کنند. OpcLowerPartName پیش از مقایسه حروف را کوچک می‌کند، بک‌اسلش‌ها را به اسلش رو تبدیل می‌کند و اسلش‌های ابتدایی را می‌برد، چون نام partهای OPC بدون حساسیت به حروف مقایسه می‌شوند و مدل آن‌ها را با یک اسلش ابتدایی می‌نویسد در حالی که لایهٔ opaque نام آیتم‌های ZIP را بدون آن ذخیره می‌کند. و فراخوان در BuildContentTypesXml مقدار Result + '</Types>' را می‌فرستد که سند نصفه‌ساخته را می‌بندد تا TXMLReader ورودی well-formed ببیند نه یک استریم بریده. قاعده‌ای که بیرون می‌آید first-wins با مدل در جلو است: هر چه مدل شیء اعلام کند معتبر است، و بازپخش opaque فقط شکاف‌ها را پر می‌کند

// 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;                       // از قبل اعلام شده، یا یک part مربوط به rels
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

قاعدهٔ 3: شناسه‌های رابطه درون یک part مربوط به روابط یکتا هستند

هر Relationship در یک part مربوط به .rels به یک Id نیاز دارد که درون همان part یکتا باشد، و اکسل وقتی دو تا یکی را شریک شوند بسته را رد می‌کند. HotXLS فایل _rels/.rels در سطح بسته را با شناسه‌های ثابت می‌نویسد: rId1 برای workbook، rId2 و rId3 برای پراپرتی‌های اصلی و توسعه‌یافتهٔ سند، و rId4 برای پراپرتی‌های سفارشی وقتی مدل چیزی داشته باشد. بستهٔ opaque بعد هر رابطهٔ ریشه‌ای را که از مبدأ نگه داشته بود اضافه می‌کند و هر شناسه‌ای که از قبل در فهرست UsedIds باشد را از نو شماره می‌دهد. آن فهرست rId1 تا rId3 را می‌شناخت. rId4 را نمی‌شناخت، و نمی‌دانست که مدل دارد رابطهٔ پراپرتی‌های سفارشی خودش را هم بیرون می‌دهد، پس بستهٔ مبدأیی که رابطهٔ پراپرتی‌های سفارشی‌اش هم rId4 بود، که چیزی است اکسل به‌صورت پیش‌فرض می‌نویسد، با دو entry rId4 که به یک هدف اشاره می‌کردند بیرون آمد. فراخوان، BuildRootRelsXml، حالا مقدار Workbook.FCustomProps.Count > 0 را به‌عنوان آرگومان دوم می‌فرستد، پس رزرو کردن و رد کردن با همان شرطی اداره می‌شوند که اصلاً تعیین می‌کند مدل rId4 را بیرون بدهد یا نه. شماره‌گذاری مجدد در ریشهٔ بسته امن است چون هیچ‌چیز داخل workbook به شناسه‌های رابطهٔ ریشه با نام ارجاع نمی‌دهد؛ همین ترفند یک لایه پایین‌تر غلط می‌بود، جایی که اتریبیوت‌های r:id در workbook.xml به شناسه‌های داخل part روابط workbook می‌بندند، و همین دلیل است که MergeWorkbookRelationshipsXml یک نقشهٔ شناسه جداگانه نگه می‌دارد

تصادم شناسهٔ رابطه در ریشهٔ یک بستهٔ HotXLS: مدل rId1 تا rId4 را می‌نویسد و rId4 برای پراپرتی‌های سفارشی رزرو شده، لایهٔ opaque رابطه‌ای از مبدأ را بازپخش کرد که او هم به‌صورت rId4 رسید چون UsedIds فقط rId1 تا rId3 را می‌شناخت، و fix از ابتدا rId4 را رزرو می‌کند هر وقت EmitCustomProps برقرار باشد و بقیه را از نو شماره می‌دهد
شماره‌گذاری مجدد در ریشهٔ بسته امن است چون هیچ‌چیز داخل workbook به شناسه‌های ریشه با نام ارجاع نمی‌دهد، و همین ترفند یک لایه پایین‌تر هر بایند 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;

این سه شکست چه وجه مشترکی دارند؟

هر سه نشانه‌ای از یک نویسنده با دو منبع و بدون مالک واحد برای قواعد ثابت بسته‌اند. مدل شیء partهایی را تولید می‌کند که می‌فهمد؛ لایهٔ opaque partهایی را بازپخش می‌کند که نمی‌فهمد، تا رفت‌وبرگشت نمودارها و pivot cacheها و XML سفارشی و هر چیز دیگری را که در یادداشت رفت‌وبرگشت بی‌اتلاف theme و extLst و calcChain توصیف شده حفظ کند. هر سمت به‌تنهایی سازگار بود. قیدهایی که OPC روی کل بسته می‌گذارد، نام‌های یکتای Override و شناسه‌های یکتای رابطه به ازای هر part، فقط در همان درزی وجود دارند که این دو به هم چسبانده می‌شوند، و تا v2.382.5 هیچ‌کس آن درز را بررسی نکرده بود. باگ fontId همان شکل، یک لایه پایین‌تر است: نویسنده می‌دانست چه را می‌خواهد حذف کند ولی هرگز از اسکیمایی که می‌گوید نمی‌تواند نپرسید. fixی که HotXLS رویش نشست یک تقدم ثابت است نه یک heuristic ادغام. مدل اول می‌نویسد، لایهٔ opaque می‌بیند چه نوشته شده و در هر تصادم کوتاه می‌آید، و اجراکنندهٔ corpus حالا این قیدها را از بیرون با verify_opc_uniqueness تحمیل می‌کند، که [Content_Types].xml و هر آیتم .rels در یک بستهٔ ذخیره‌شده را می‌خواند و روی هر PartName یا Extension یا Id تکراری آن case را رد می‌کند. این بررسی ارزان است، به اکسل نیاز ندارد، و دو تا از آن سه نقص را در همان اولین اجرای corpus می‌گرفت

همان بچ: محدوده‌های چاپ که فرمول‌اند، نه بازه

پاس اکسل همچنین به _xlnm.Print_Area قالب وام ایراد گرفت، که اکسل آن را روی اصل $A$1:$J$29 گزارش کرده بود و باید روی نسخهٔ ذخیره‌شده هم عیناً همین را گزارش می‌کرد. دو باگ جدا پشت همان یک assertion نشسته بودند. هنگام import، XlsxStripSheetPrefix همه‌چیز را تا اولین ! بدون کوتیشن می‌برید، پس یک محدودهٔ چاپ دینامیک مثل OFFSET('Print Data'!$A$1,0,0,2,2) به‌صورت $A$1,0,0,2,2) برمی‌گشت، و یک union واجد شرط مثل 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 پیشوند را فقط روی اولین سگمنتش از دست می‌داد. هنگام export، نویسنده نام sheet را یک بار به کل PrintArea ذخیره‌شده می‌چسباند، پس یک union ساده مثل $A$1:$B$2,$D$1:$E$2 کتابخانه را با یک سگمنت اول واجد شرط و یک سگمنت دوم برهنه رها می‌کرد، که اکسل آن را به‌عنوان تعریف _xlnm.Print_Area زیر ECMA-376 Part 1 §18.2.5 قبول نمی‌کند

// Import: پیشوند را فقط وقتی بردار که باقی‌مانده یک 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(...) دست‌نخورده برگردانده می‌شود

// Export: هر سگمنت جداشده با کاما را واجد شرط کن، یا هیچ‌کدام را
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 پایهٔ نام‌دار و پایهٔ واجد شرط با sheet و union را در دو چرخهٔ save-and-reopen پوشش می‌دهد. این‌که محدوده‌های چاپ چطور با page setup و بقیهٔ مدل چاپ تعامل دارند در مقالهٔ محافظت کاربرگ، page setup و چاپ پوشش داده شده

چطور بفهمیم اکسل به کدام قاعده ایراد دارد؟

از این فرض شروع کن که validator خودت غلط است، چون پاس شده. validator مربوط به Open XML SDK یک نقض اسکیما مثل fontId گمشده را با نام part و XPath می‌گوید، و لایهٔ packaging زیرش اصلاً حاضر نیست بسته‌ای با entryهای content-type تکراری باز کند، پس قبل از هر چیز آن را اجرا کن. وقتی ساکت است و اکسل باز هم تعمیر می‌کند، بسته را دوبخشی کن: unzip کن، یک part و رابطه‌اش و Overrideش را حذف کن، دوباره zip کن و باز کن، و هر بار مجموعهٔ نامزدها را نصف کن تا پیام ناپدید شود. آن سه نقص این‌جا به همین ترتیب بیرون افتادند، و هیچ‌کدامشان در فایل تعمیرشده‌ای که اکسل پیشنهاد ذخیره‌اش را می‌دهد دیده نمی‌شدند، چون تعمیر بی‌صدا entryهای مشکل‌دار را می‌اندازد یا از نو شماره می‌دهد. ارزش دارد مرزهای fix مربوط به v2.382.5 را هم به همین صراحت بگوییم. حذف تکراری‌ها first-wins با مدل در جلو است، پس اگر بستهٔ مبدأ برای partی که مدل هم تولیدش می‌کند content type متفاوتی اعلام کرده باشد، اعلام مدل برنده می‌شود و اعلام مبدأ دور ریخته می‌شود، که برای partهایی که HotXLS از نو تولیدشان می‌کند درست است و یک ادغام عمومی نیست. verify_opc_uniqueness فقط یکتایی را بررسی می‌کند؛ اسکیماها را اعتبارسنجی نمی‌کند، پس یک اتریبیوت اجباری در آینده باز هم به اکسل یا یک validator اسکیما نیاز دارد تا رو شود. و آن پاس اضافی TXMLReader روی استریم content types تولیدشده در هر save با PreserveUnsupportedParts فعال اجرا می‌شود، هزینهٔ کوچکی در برابر استریمی که به‌ندرت از چند کیلوبایت بیشتر است. با این‌ها، هم بیلد Win32 و هم Win64 قالب وام حالا در اکسل بدون هیچ پیامی باز می‌شوند، هر 4805 فرمول تأییدشده را با صفر عدم تطابق دوباره محاسبه می‌کنند و همان محدودهٔ چاپ اصل را گزارش می‌کنند

اگر خودت از Delphi مشغول نوشتن XLSX هستی، چک‌لیست کوتاه است: هر اتریبیوتی که اسکیما required علامت زده را مستقل از مقدارش بیرون بده، هر نام part را یک بار اعلام کن، و به ازای هر part مربوط به روابط یک فهرست از شناسه‌های مصرف‌شده نگه دار که همهٔ نویسنده‌هایی که به آن دست می‌زنند از آن استفاده کنند. اگر ترجیح می‌دهی آن فهرست از قبل وجود داشته باشد و در برابر اکسل تست شود نه فقط در برابر خوانندهٔ خودت، نویسندهٔ بستهٔ توصیف‌شده این‌جا داخل کامپوننت صفحه‌گستردهٔ Delphi مربوط به HotXLS منتشر می‌شود، همراه با رفت‌وبرگشت partهای opaque که همین درز را اول از همه ارزش محافظت پیدا کرد