مقاله فنی

رفت‌وبرگشت pivot در ODS با اسکوپ namespace در Delphi

کامپوننت HotXLS Delphi Excel جدول‌های data pilot در OpenDocument را در یک چرخهٔ باز کردن و ذخیره در ODS حفظ می‌کند، با برداشتن عین‌به‌عین زیردرخت <table:data-pilot-tables> از content.xml هنگام open و بازپخشش موقع save، از v2.382.0 به بعد. از v2.382.1 به بعد این قطعه هر بایند namespace در XML را هم که اجدادش اعلام کرده بودند حمل می‌کند، تا تعریف pivot ذخیره‌شده برای هر مصرف‌کننده‌ای well formed بماند، نه فقط برای HotXLS

باگی که هر دو تغییر را تحمیل کرد از یک اجرای سخت‌گیرانهٔ corpus بیرون آمد. نمونهٔ official-pivot.ods که یک build توسعه‌ای از LibreOffice 6.1 نوشته، یک pivot به نام DataPilot1 دارد که Sheet1.A2:E30 را می‌خواند و نتیجه‌اش را در Sheet1.G6:J18 می‌نشاند. با HotXLS بازش کن، بدون تغییر saveش کن، و عنصرهای <table:data-pilot-table> را در خروجی بشمار: یکی داخل، صفر بیرون، روی Win32 و Win64 یکسان. هیچ‌چیز در تست به pivot دست نزده بود. دور اول probeها فقط ثابت‌های سلول را مقایسه کرده و پاس شده بود؛ این assertion ساختاری بود که این از دست رفتن را رو کرد، که یادآور این است که «مقدارها مطابق‌اند» تعریف ضعیفی از وفاداری رفت‌وبرگشت است

چرا یک جدول pivot در ODS بعد از save کتابخانه ناپدید می‌شود؟

یک جدول pivot در ODS ناپدید می‌شود چون HotXLS هیچ مدل درون-حافظه‌ای برای جدول‌های data pilot در OpenDocument ندارد، و نویسندهٔ ODS فایل content.xml را کاملاً از روی مدل می‌سازد. نویسنده استایل‌های خودکار، یک <table:table> به ازای هر کاربرگ، <table:content-validations>، <table:named-expressions> و <table:database-ranges> را سرهم می‌کند، هر کدام تولیدشده از اشیایی که workbook واقعاً در دست دارد. یک تعریف pivot — ODF 1.3 Part 3 §9.6، یک محفظهٔ <table:data-pilot-tables> با یک <table:data-pilot-table> به ازای هر pivot، که table:source-cell-range و فرزندان table:data-pilot-field و table:target-range-address و table:buttonsش را حمل می‌کند — هیچ شیئی برای زندگی کردن ندارد، پس آن part بازتولیدشده به‌سادگی حذفش می‌کند

تضاد با XLSX عمدی است. HotXLS cacheها و جدول‌های pivot در SpreadsheetML را به یک مدل واقعی پارس می‌کند که می‌توانی از Delphi بسازیشان، با فیلدهای محاسبه‌شده گسترششان بدهی و refreshشان کنی، پس آن‌ها از save جان سالم می‌برند چون بازنویسی می‌شوند، نه کپی. pivotهای ODS درخواستی بسیار نادرترند، و مدل‌سازی واژگان data pilot در ODF فقط به خاطر رفت‌وبرگشت کد زیادی می‌شود که هیچ‌کس ویرایشش نمی‌کند. جواب عمل‌گرایانه همانی است که HotXLS از قبل برای بلوک‌های ناشناختهٔ extLst در XLSX به کار می‌برد: آنچه را مدل نمی‌کنی نگه دار، بایت‌به‌بایت اگر بتوانی، رویداد‌به‌رویداد اگر نتوانی

اولین برداشت مبتنی بر Pos چه چیزی را غلط گرفت؟

برداشت v2.382.0 تعریف pivot را به‌صورت یک رشتهٔ ساده از content.xml برش می‌داد، و آن برش اعلام‌های namespace را که آن را معنادار می‌کردند نداشت. پیاده‌سازی همان‌قدر کوتاه بود که به نظر می‌رسد — part را به یک WideString decode کن، تگ بازکننده را با Pos پیدا کن، تگ بسته را بعد از آن پیدا کن، و آن بازه را در FRawOdsDataPilotTablesXml روی workbook کپی کن:

// HotXLS v2.382.0 -- یک انتشار بعد کنار گذاشته شد
function OdsCaptureDataPilotTablesXml(Stream: TStream): WideString;
const
  OpenTag: WideString = '<table:data-pilot-tables';
  CloseTag: WideString = '</table:data-pilot-tables>';
var
  Text: WideString;
  StartPos, ClosePos: Integer;
begin
  Result := '';
  Text := LoadPartAsWideString(Stream);   // کل content.xml در حافظه
  StartPos := Pos(OpenTag, Text);
  if StartPos = 0 then Exit;
  ClosePos := Pos(CloseTag, Copy(Text, StartPos, MaxInt));
  if ClosePos = 0 then Exit;
  Result := Copy(Text, StartPos, ClosePos + Length(CloseTag) - 1);
end;

assertion شمارشی سبز شد و fix منتشر شد. آنچه گرفتش یک بررسی دوم و سخت‌گیرانه‌تر بود که همان روز اضافه شد: هر part از نوع XML در بستهٔ ذخیره‌شده به یک پارسر مستقلِ آگاه به namespace بیرون از HotXLS داده می‌شود، و آن پارسر content.xml جدید را با خطای prefix بایندنشده رد کرد. pivotی که از LibreOffice می‌آید اتریبیوت‌های توسعهٔ تولیدکننده را حمل می‌کند — loext:ignore-selected-page="true" روی یک فیلد page و calcext:repeat-item-labels="false" روی هر سطح — و رشتهٔ برش‌خورده آن اتریبیوت‌ها را داشت ولی اعلام‌های xmlns:loext و xmlns:calcext که بایندشان می‌کردند را نه. آن اعلام‌ها روی ریشهٔ <office:document-content> فایل مبدأ نشسته بودند، سی‌وپنج تا، دو هزار کاراکتر دورتر از pivot

W3C Namespaces in XML 1.0 §6.1 قاعده‌ای را تعریف می‌کند که این را به یک شکست سخت تبدیل می‌کند نه یک مسئلهٔ ظاهری: یک اعلام namespace از تگ شروع عنصری که رویش آمده تا تگ پایانی همان عنصر در scope است، و هر نام پیشوندداری داخل آن scope نسبت به آن حل می‌شود. یک زیردرخت را از سند ببر بیرون و آن را از scope هم بیرون برده‌ای. HotXLS ریشهٔ <office:document-content> خودش را با یازده اعلام می‌نویسد — office و table و text و style و number و fo و draw و svg و xlink و calcext و tableooo — پس calcext: تصادفاً حل می‌شد و table: هم تصادفاً حل می‌شد و loext: نه. یک پارسر آگاه به namespace یک prefix بایندنشده را نقض well-formedness می‌داند، که یعنی کل part ناخواناست، نه فقط یک اتریبیوت

آنچه برداشت مبتنی بر Pos از official-pivot.ods در HotXLS از دست داد: زیردرخت pivot اتریبیوت‌های توسعهٔ loext و calcext را حمل می‌کند در حالی که اعلام‌های xmlns که بایندشان می‌کنند روی ریشهٔ office:document-content و سی‌وپنج بایند دورتر نشسته‌اند، پس قطعهٔ برش‌خورده هر پیشوندی که به کار می‌برد را بایندنشده رها کرد و یک پارسر آگاه به namespace کل content.xml را رد کرد
یک اعلام namespace از تگ شروعش تا تگ پایانی‌اش در scope است، و بریدن یک زیردرخت از سند آن را از آن scope بیرون می‌برد، که یک اتریبیوت را به یک part ناخوانا تبدیل می‌کند

HotXLS بایندهای xmlns اجداد را چطور روی قطعه حمل می‌کند؟

HotXLS v2.382.1 برش رشته‌ای را با یک پاس روی content.xml از طریق TXMLReader استریمینگ خودش جایگزین کرد، با نگه‌داشتن یک پشته از بایندهای namespace که با عمقی که هر کدام در آن اعلام شده برچسب خورده‌اند، و کپی کردن بایندهایی که هنوز در جریان‌اند روی عنصر ریشهٔ قطعه، همان لحظه که به هدف می‌رسد. reader با PreserveWhitespaceText فعال اجرا می‌شود تا nodeهای متن عیناً همان‌طور که نوشته شده‌اند برگردند، و تگ‌های بازساخته‌شده از TXMLReader.RawName و TXMLReader.Attribute[I].RawName استفاده می‌کنند — یعنی املای پیشوند از خود فایل — نه نام‌های کانونیکی که reader معمولاً به پارسرهای part می‌دهد. هستهٔ حلقه این است:

HotXLS v2.382.1 زیردرخت data pilot را چطور همراه با scope مربوط به namespaceاش می‌گیرد: یک پاس TXMLReader استریمینگ پشتهٔ بایندهای xmlns را با عمق اعلام برچسب‌دار نگه می‌دارد، هنگام رسیدن به هدف table:data-pilot-tables آن را از درونی‌ترین به بیرون می‌پیماید، shadowing را از طریق یک مجموعهٔ Seen رعایت می‌کند، پیشوندهایی که خود عنصر اعلام کرده را رد می‌کند و بایندها را هم روی تگ‌های پایانی و هم روی عنصرهای خالی pop می‌کند
تطبیق دادن هدف با نام کانونیک reader باعث می‌شود تولیدکنندگانی که پیشوند table را جور دیگری می‌نویسند هم کار کنند، و زیردرختی که هرگز بسته نشود به جای نوشتن یک قطعهٔ نصفه در save، استثنا می‌دهد
// Namespaces: یک TStringList از 'xmlns:p=uri' با عمق اعلام در Objects[]
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // element، text، CDATA، comment
  if Reader.NodeType = xmlntElement then
  begin
    for I := 0 to Reader.AttributeCount - 1 do
    begin
      AttrName := Reader.Attribute[I].RawName;
      if (AttrName = 'xmlns') or (Pos(WideString('xmlns:'), AttrName) = 1) then
        Namespaces.AddObject(String(AttrName) + '=' + String(Reader.Attribute[I].Value),
          TObject(NativeInt(Depth)));
    end;
    if (CaptureDepth < 0) and (Reader.Name = 'table:data-pilot-tables') then
    begin
      Opening := XlsxRawXmlReaderOpenTag(Reader);   // اول '>' یا '/>' انتهایی را بردار
      ...
      // بایندهای مؤثر اجداد را روی ریشهٔ قطعه حمل کن
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // بایند درونی‌ترین برنده است
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // این‌جا از قبل اعلام شده؟ رد کن
          Opening := Opening + ' ' + AttrName + '="' +
            XlsxEscapeAttr(WideString(Namespaces.ValueFromIndex[I])) + '"';
      end;
      ...
      CaptureDepth := Depth;
    end;
    if not Reader.IsEmptyElement then Inc(Depth);
  end
  else if Reader.NodeType = xmlntEndElement then
  begin
    Dec(Depth);
    if Depth = CaptureDepth then Exit;                           // زیردرخت بسته شد
  end;
  if (Reader.NodeType = xmlntEndElement) or
     ((Reader.NodeType = xmlntElement) and Reader.IsEmptyElement) then
    while (Namespaces.Count > 0) and
          (NativeInt(Namespaces.Objects[Namespaces.Count - 1]) >= Depth) do
      Namespaces.Delete(Namespaces.Count - 1);                   // از scope خارج شو
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

سه جزئیات در آن حلقه درستی را حمل می‌کنند. پیمودن پشته از درونی‌ترین بایند به بیرون و به‌خاطر سپردن هر پیشوند در Seen همان shadowing را پیاده می‌کند: اگر اجدادی نزدیک‌تر xmlns:table را دوباره بایند کند، مقدار نزدیک‌تر برنده است، دقیقاً همان‌طور که §6.1 می‌گوید باید باشد. رد کردن پیشوندهایی که خود عنصر از قبل اعلام کرده از بیرون دادن دوبارهٔ همان اتریبیوت جلوگیری می‌کند، که یک خطای well-formedness متفاوت می‌بود. و قاعدهٔ pop هم روی تگ‌های پایانی فعال می‌شود و هم روی عنصرهای خالی، چون <x/> هرگز رویداد EndElement تولید نمی‌کند — همان تلهٔ self-closing که برداشت extLst در XLSX هم باید یاد می‌گرفت. تطبیق دادن هدف با Reader.Name به جای RawName برد آرام‌تری است: reader یوآرآی namespace جدول ODF را به پیشوند table کانونیک می‌کند، پس تولیدکننده‌ای که آن را t:data-pilot-tables می‌نویسد هم مطابقت پیدا می‌کند، در حالی که قطعهٔ بیرون‌آمده هر پیشوندی که تولیدکننده استفاده کرده را نگه می‌دارد

حلقه از حدس زدن هم امتناع می‌کند. اگر part وقتی برداشت هنوز باز است تمام شود — یک content.xml بریده یا بدشکل — OdsCaptureDataPilotTablesXml به جای برگرداندن یک قطعهٔ نصفه استثنا می‌دهد، چون قطعهٔ نصفه در save دوباره نوشته می‌شد و یک ورودی آسیب‌خورده را به یک خروجی آسیب‌خورده با نام کتابخانه رویش تبدیل می‌کرد

این قطعه در content.xml ذخیره‌شده کجا می‌نشیند؟

HotXLS قطعهٔ برداشته‌شده را داخل <office:spreadsheet> می‌نویسد، بلافاصله بعد از <table:named-expressions> که خودش تولید می‌کند و پیش از <table:database-ranges>. مدل محتوای ODF 1.3 Part 3 برای <office:spreadsheet> یک ترتیب ثابت برای آن فرزندان انتهایی تجویز می‌کند، پس یک بلوک عین‌به‌عین را نمی‌توان هر جایی که نویسنده تصادفاً هست ضمیمه کرد؛ باید در یک شیار مشخص انداخته شود. از سمت فراخوان هیچ API و هیچ چیزی برای تنظیم وجود ندارد؛ تعریف همراه یک open و save معمولی سفر می‌کند:

تعریف pivot برداشته‌شده در یک save مربوط به ODS در HotXLS کجا می‌نشیند: فرزندان office:spreadsheet ترتیب ثابت ODF را از عنصرهای جدول تولیدشده تا table:content-validations و table:named-expressions دنبال می‌کنند، قطعهٔ عین‌به‌عین table:data-pilot-tables پیش از table:database-ranges جا می‌گیرد، و هیچ APIی وجود ندارد چون تعریف همراه OpenODS و SaveAsODS سفر می‌کند
یک بلوک عین‌به‌عین را نمی‌توان هر جایی که نویسنده تصادفاً هست ضمیمه کرد، و نسخه‌های بایندهای اجدادی که حمل می‌کند بی‌ضررند چون Namespaces in XML اعلام دوبارهٔ یک پیشوند در یک scope تودرتو را مجاز می‌داند
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.OpenODS('official-pivot.ods') <> 1 then
      raise Exception.Create('open failed');
    Book.Sheets[0].Cells[2, 5].Value := 1250.0;   // ویرایش داخل محدودهٔ منبع pivot
    Book.SaveAsODS('official-pivot-out.ods');
    // content.xml در خروجی همچنان DataPilot1 را همراه با
    // محدودهٔ منبع، فیلدها، محدودهٔ هدف، دکمه‌ها و اتریبیوت‌های loext:/calcext: حمل می‌کند
  finally
    Book.Free;
  end;
end;

این افزونگی عمدی است و ارزش دانستن دارد. ریشهٔ قطعه حالا xmlns:table و xmlns:calcext را تکرار می‌کند حتی با اینکه ریشهٔ سند ذخیره‌شده هم آن‌ها را اعلام می‌کند؛ Namespaces in XML اعلام دوبارهٔ یک پیشوند در یک scope تودرتو را مجاز می‌داند، پس این تکراری‌ها بی‌ضررند. برای نمونهٔ LibreOffice مجموعهٔ حمل‌شده همهٔ سی‌وپنج اعلام ریشه است، حدود دو کیلوبایت روی تعریف 8,357 کاراکتری، چون برداشت تحلیل نمی‌کند که زیردرخت واقعاً از کدام پیشوندها استفاده می‌کند. یک اسکن پیشوندهای مصرف‌شده این را کم می‌کرد و ممکن است بعداً بیاید؛ اول درستی، دوم فشردگی

قاعده‌ای برای بریدن زیردرخت‌ها از XML برای بازپخش عین‌به‌عین

درس کلی این است که یک زیردرخت فقط وقتی خودبسنده است که خودت خودبسنده‌اش کرده باشی، و scope مربوط به namespace اولین چیزی است که وقتی فراموش کنی می‌شکند. چک‌لیستی که HotXLS حالا روی هر برداشت از نوع «آنچه را مدل نمی‌کنیم نگه دار» اعمال می‌کند:

  • سند را با یک reader واقعی بپیمای و بایندهای داخل scope را ردیابی کن. جست‌وجوی رشته‌ای با Pos اصلاً scope را نمی‌بیند، و همچنین روی عنصرهای تودرتوی هم‌نام، روی رشته‌ای مشابه داخل یک comment یا بخش CDATA، و روی مقدارهای اتریبیوتی که تصادفاً متن تگ را دارند اشتباه تطبیق می‌دهد
  • بایندهای مؤثر را روی ریشهٔ قطعه کپی کن، از درونی‌ترین شروع کن، هر پیشوند یک بار، و آنچه ریشه از قبل اعلام کرده را رد کن
  • املای خام پیشوند را در تگ‌های بیرون‌آمده نگه دار؛ هدف را با namespace حل‌شده تطبیق بده، نه با پیشوند لفظی
  • nodeهای متن فضای سفید را حفظ کن، و یادت باشد که یک عنصر خالی بدون رویداد تگ پایانی scope خودش را می‌بندد
  • part ذخیره‌شده را با پارسری اعتبارسنجی کن که خود کتابخانهٔ تحت تست نیست. کتابخانه با کمال میل خروجی خودش را از همان مسیر کد ملایمی که نوشتش دوباره می‌خواند

نکتهٔ آخر همانی است که بار دوم واقعاً HXLS-003 را پیدا کرد. بررسی پذیرش v2.382.0 یک regex بود که تگ‌های شروع data-pilot-table را در content.xml ذخیره‌شده می‌شمرد، و یک regex یک تگ می‌بیند، نه یک سند — نسبت به اینکه پیشوندهای روی آن تگ بایند شده‌اند یا نه کور است. اجراکنندهٔ سخت‌گیرانهٔ corpus که در v2.382.1 اضافه شد هر part از نوع XML و .rels در بستهٔ ذخیره‌شده را با یک پارسر آگاه به namespace پارس می‌کند و بعد درخت pivot را — تگ، اتریبیوت‌های مرتب‌شده، متن، فرزندان، به‌صورت بازگشتی — با اصل مقایسه می‌کند. آن مقایسه namespace-گسترش‌یافته است، پس املای متفاوت یک پیشوند باز هم پاس می‌شود و یک پیشوند بایندنشده نمی‌تواند

تضمین عین‌به‌عین کجا تمام می‌شود

بازپخش عین‌به‌عین یک تعریف را حفظ می‌کند؛ آن را نمی‌فهمد، و مرزها از همین می‌آیند. HotXLS هیچ APIی برای خواندن، ویرایش یا refresh کردن یک pivot در ODS عرضه نمی‌کند، پس FRawOdsDataPilotTablesXml یک فیلد داخلی است و تنها رفتار قابل مشاهده این است که تعریف زنده می‌ماند. قطعه از رویدادهای reader دوباره سریالایز می‌شود، نه به‌صورت بایت کپی: کوتیشن اتریبیوت‌ها و شکل‌های self-closing نرمال می‌شوند، در حالی که متن و فضای سفید نگه داشته می‌شوند. XML برداشته‌شده فقط توسط نویسندهٔ محتوای ODS بیرون داده می‌شود، پس workbookی که از .ods باز شود و به‌صورت .xlsx ذخیره شود pivot را از دست می‌دهد، و workbookی که از .xlsx باز شده چیزی برای بازپخش در یک save مربوط به .ods ندارد — عدم تقارن‌های مسیرهای import و export در ODS این‌جا هم مثل همه‌جا اعمال می‌شوند. و چون تعریف opaque است، نمی‌تواند ویرایش‌هایت را دنبال کند: Sheet1 را تغییر نام بده یا دادهٔ منبع را در HotXLS جابه‌جا کن و pivot ذخیره‌شده باز هم به Sheet1.A2:E30 اشاره می‌کند، و مصرف‌کننده را رها می‌کند تا موقع refresh بعدی یک بازهٔ شکسته گزارش کند. یک هشدار ترتیبی هم این‌جا جا دارد: HotXLS محدوده‌های AutoFilter را به‌صورت <table:database-ranges> بعد از قطعهٔ pivot بیرون می‌دهد، و نمونهٔ corpus هیچ database range ندارد، پس workbookی که هم filter دارد و هم pivot باید پیش از تکیه کردن به ترتیب نسبی آن دو عنصر از یک validator اسکیمای ODF بگذرد

با فایل‌های تولیدکنندهٔ خودت تست کن، نه فقط با نمونهٔ corpus. حمل namespace هر پیشوندی را که یک تولیدکننده روی یکی از اجداد اعلام کند اداره می‌کند، ولی سندی که یک پیشوند را روی خود عنصر pivot اعلام کند، یا برای واژگان جدول از یک namespace پیش‌فرض استفاده کند، شاخه‌های رد کردن و shadowing را تمرین می‌دهد که نمونهٔ LibreOffice تمرین نمی‌کند. هر دو پیاده شده‌اند؛ هیچ‌کدام هنوز نمونه‌ای در corpus ندارد، و این تفاوت دقیقاً همان چیزی است که یک entry در changelog معمولاً محوش می‌کند

برداشت عین‌به‌عین data pilot در v2.382.0 و fix مربوط به scope در namespace در v2.382.1 در کامپوننت HotXLS Delphi Excel فعلی منتشر شده‌اند، که صفحهٔ محصولش پوشش کامل خواندن و نوشتن ODS و XLSX و XLS را برای Delphi و C++Builder فهرست می‌کند