مقاله فنی

زمان‌بندی fingerprint و آفست anchor نمودار HotXLS در Delphi

کامپوننت HotXLS Delphi فقط وقتی یک نمودار دست‌نخوردهٔ اکسل را بایت‌به‌بایت بازتولید می‌کند که دو شرط برقرار باشد: نمودار از طریق رابطهٔ drawing کاربرگ پیدا شده باشد نه از روی یک نام حدسی، و fingerprint ۶۴ بیتی مدل بعد از تمام شدن پارس مدل نمودار ثبت شده باشد. نسخهٔ 2.382.0 شرط اول را درست کرد، نسخهٔ 2.382.3 شرط دوم را و شروع کرد به رفت‌وبرگشت دادن آفست‌های anchor غیرصفر xdr:colOff و xdr:rowOff که نویسندهٔ drawing آن‌ها را هاردکد صفر گذاشته بود. هر دو نقص از یک نمونهٔ corpus محلی به نام two-charts.xlsx بیرون آمدند: اول یک assertion ساختاری دید که دو part نمودار شده‌اند سه تا، بعد مقایسهٔ بایتی هر xl/charts/chartN.xml نشان داد نمودارهایی که هیچ‌کس به آن‌ها دست نزده بود باز هم بازنویسی می‌شوند — و هیچ‌کدام از این دو مشکل نه استثنایی بالا آورد و نه اکسل گلایه‌ای کرد، و همین دلیل ماندگاری این‌قدر طولانی‌شان است

چرا یک workbook دو-نموداری با سه part نمودار برگشت؟

چون loader یک fallback داشت که حدس می‌زد. وقتی یک کاربرگ هیچ رابطهٔ drawing در part مربوط به .rels نداشت، کد قدیمی فرض می‌کرد drawing زیر نام متعارف xl/drawings/drawing{i+1}.xml زندگی می‌کند که i همان موقعیت sheet است، و اگر آن part در archive وجود داشت وصلش می‌کرد. در two-charts.xlsx کاربرگ اول هیچ drawing و اصلاً هیچ part مربوط به .rels ندارد، در حالی که xl/drawings/drawing1.xml وجود دارد — این به کاربرگ دوم تعلق دارد که از طریق Target="../drawings/drawing1.xml" به آن می‌رسد. پس Sheet 1 نموداری را به ارث برد که هرگز به آن ارجاع نداده بود، chart1.xml دو بار پارس شد و save کتاب کار را با سه part نمودار به جای دو تا نوشت

چگونه HotXLS در نمونهٔ two-charts drawingهای کاربرگ را حل می‌کند: Sheet1 هیچ رابطهٔ drawing و هیچ part مربوط به rels ندارد در حالی که Sheet2 از طریق ParPartTargets به xl/drawings/drawing1.xml می‌رسد، و fallback پیش از 2.382.0 آن نام متعارف را از موقعیت sheet حدس می‌زد، پس chart1.xml دو بار پارس می‌شد و saveها سه part نمودار می‌نوشتند تا اینکه fix، drawingها را فقط از طریق XlsxRtDrawing بارگذاری کرد
Sheet1 هرگز به نموداری ارجاع نداده بود، پس گراف رابطه‌ها تنها منبع امن برای هدف drawing است، و یک نام متعارف حدسی یک workbook دو-نموداری را به یک save سه-part تبدیل کرد

fix در HotXLS v2.382.0 این حدس را کامل حذف کرد. حالا drawing کاربرگ فقط از طریق ParPartTargets[i].Values[XlsxRtDrawing] بارگذاری می‌شود، همان هدفی که برای نوع رابطهٔ drawing روی آن sheet ثبت شده، و sheetی که چنین رابطه‌ای ندارد اصلاً drawing نمی‌گیرد. این همان رفتاری است که فرمت می‌طلبد: عنصر <drawing r:id="…"/> در کاربرگ (ECMA-376 Part 1 §18.3.1.36) تنها پیوند میان یک sheet و drawingش است، و نام partها در یک بستهٔ OPC هیچ معنایی فراتر از آنچه گراف رابطه‌ها به آن‌ها می‌بخشد ندارند. archiveهایی که اکسل می‌نویسد تصادفاً از همان نام‌های متعارف استفاده می‌کنند و همین باعث شد آن میانبر این‌قدر دوام بیاورد؛ قدم‌به‌قدم حل رابطه‌های OPC در HotXLS توضیح می‌دهد چرا حدس زدن نام part حتی وقتی حدس معمولاً درست است هیچ‌وقت امن نیست

// پیش از v2.382.0: رابطهٔ drawing گمشده به یک حدس می‌افتاد
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
  drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);   // ممکن است به کاربرگ دیگری تعلق داشته باشد

// از v2.382.0 به بعد: یا رابطه یا هیچ
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);

fingerprint نمودار چه چیزی را تضمین می‌کند؟

fingerprint به ازای هر نمودار تعیین می‌کند که آیا save می‌تواند part اصلی را کپی کند یا باید از نو بسازدش. هنگام import، وقتی PreserveUnsupportedParts پیش از Open فعال شده باشد، HotXLS بایت‌های خام UTF-8 هر part نمودار را در FRawChartXml نگه می‌دارد، سریالایز خود مدل تایپ‌دار را با BuildChartKnownXml می‌سازد و طول آن سریالایز را در FRawChartModelLength و هشش را در FRawChartModelHash ذخیره می‌کند. هش همان FNV-1a روی واحدهای کد UTF-16 همان XML تولیدشده است، با مبنای آفست استاندارد ۶۴ بیتی 14695981039346656037 و prime 1099511628211. موقع save، XlsxChartRawModelUnchanged XML شناخته‌شده را از نو می‌سازد و طول و هش را مقایسه می‌کند؛ تطابق یعنی مدل تایپ‌دار دقیقاً همانی است که موقع import بود، پس هیچ‌چیز از آنچه برنامه می‌توانسته تغییر بدهد تغییر نکرده

HotXLS هنگام import fingerprint نمودار را ثبت می‌کند، بایت‌های خام UTF-8 را در FRawChartXml نگه می‌دارد در حالی که BuildChartKnownXml مقدار FRawChartModelLength و یک هش FNV-1a می‌دهد، و موقع save تابع XlsxChartRawModelUnchanged هر دو مقدار را از نو می‌سازد و مقایسه می‌کند، پس تطابق بایت‌های اصلی را بازپخش می‌کند یا entry فشرده را کپی می‌کند و عدم تطابق به XlsxMergeChartXml می‌افتد
fingerprint فقط به‌اندازهٔ لحظه‌ای که گرفته می‌شود ارزش دارد، و ثبتش پیش از تمام شدن همهٔ passهای recovery تضمین می‌کند هشی داشته باشیم که دیگر هرگز با مدل کامل‌شده تطابق پیدا نکند
function XlsxChartRawModelUnchanged(Chart: TXLSXChart;
  const KnownXml: WideString): Boolean;
begin
  Result := (Chart <> nil) and (Chart.FRawChartXml <> '') and
    (Length(KnownXml) = Chart.FRawChartModelLength) and
    (XlsxChartModelHash(KnownXml) = Chart.FRawChartModelHash);
end;

function BuildChartXmlFromKnown(Chart: TXLSXChart;
  const KnownXml: WideString): WideString;
begin
  if Chart.FRawChartXml = '' then
    Result := KnownXml                                  // چیزی حفظ نشده
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // بازپخش عین‌به‌عین
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // merge ساختاری
end;

نویسندهٔ XLSX یک قدم فراتر از BuildChartXmlFromKnown می‌رود. وقتی مدل تغییر نکرده و StrictOOXML خاموش است، اول تلاش می‌کند entry فشرده را مستقیم از archive مبدأ زیر نام جدید part نمودار در خروجی کپی کند، پس بایت‌ها حتی decode و دوباره deflate هم نمی‌شوند. فقط اگر آن کپی ممکن نباشد به مسیر decode-یا-merge می‌افتد. خود مکانیزم — طول به‌علاوهٔ هش، بازپخش در صورت برابری، merge در غیر آن — همانی است که در یادداشت ویرایش نمودارهای اکسل بدون از دست دادن ChartML توصیف شده. این مقاله دربارهٔ راهی است که آن بی‌صدا از کار افتاد

چرا با این حال هر نمودار مسیر merge را می‌رفت؟

چون fingerprint یک فراخوانی زیادی زود ثبت شده بود. پارس نمودار در HotXLS یک pass از نوع SAX روی part نمودار است و بعدش مجموعه‌ای pass بازیابی که جزئیاتی را از متن خام بیرون می‌کشند که handlerهای SAX مستقیماً مدلشان نمی‌کنند: XlsxChartParseSeriesFlags هر بلوک <c:ser> را برای پرچم <c:smooth> و مقدارهای srgbClr رنگ پرکننده و خط marker می‌خواند، و بعد حالت‌های تقاطع محور و سبک‌های تیک بزرگ و کوچک محورهای category و value را بازیابی می‌کند. پیش از v2.382.3 ترتیب انتهای ParseChartXml این بود: گروه‌های محور را دسته‌بندی کن، XML شناخته‌شده را بساز، طول و هش را ثبت کن، و فقط بعدش XlsxChartParseSeriesFlags را اجرا کن. پس fingerprint مدلی را توصیف می‌کرد که هنوز پرچم‌های smooth و رنگ‌های marker و تیک‌ها را نداشت. موقع save، BuildChartKnownXml روی مدل کامل‌شده اجرا می‌شد که حالا <c:smooth val="1"/> و رنگ‌های marker بازیابی‌شده را بیرون می‌داد. XML بلندتر، هش متفاوت، XlsxChartRawModelUnchanged مقدار False برگرداند و نمودار از XlsxMergeChartXml رد شد. merge برای نموداری که کسی ویرایشش کرده عملیات درستی است، ولی بایت‌حفظ نیست: درخت را دوباره سریالایز می‌کند، و قاعدهٔ مالکیتی که برای series و محورها و plot groupها مدل تایپ‌دار را برنده می‌کند یعنی nodeهای بازتولیدشده جای اصلی‌ها را می‌گیرند. نتیجهٔ دیدنی در اجرای corpus رنگ‌های لغزیدهٔ series روی نمودارهایی بود که کسی ویرایششان نکرده بود — هر نمودار در هر workbook حفظ‌شده، در هر save، بدون هیچ تشخیصی در هیچ جا

درمان یک جابه‌جایی ترتیبی ساده است: حالا XlsxChartParseSeriesFlags پیش از ساخت XML شناخته‌شده اجرا می‌شود، پس fingerprint مدل را همان‌طور توصیف می‌کند که وقتی برنامه اولین بار می‌بیندش وجود خواهد داشت. این درس از نمودار فراتر می‌رود. یک fingerprint تغییرسنج فقط به‌اندازهٔ لحظه‌ای که گرفته می‌شود ارزش دارد و لحظهٔ امن وقتی است که همهٔ passهایی که می‌توانند مدل را تغییر بدهند تمام شده باشند. HotXLS یک محل ثبت دوم برای همین دو مقدار دارد، همان baseline که پس از یک save موفق نسبت به فایل خروجی از نو برقرار می‌کند، و آن‌جا همیشه روی مدل کاملاً پارس‌شده اجرا می‌شده؛ محل زمان-import استثنا بود

آفست‌های anchor کجا رفتند؟

رفتند توی یک صفر لفظی. یک twoCellAnchor در part مربوط به drawing نمودار را بین دو سلول میخ می‌کند، و هر گوشه یک اندیس سلول به‌علاوهٔ یک آفست داخل همان سلول دارد: from (ECMA-376 Part 1 §20.5.2.5) و to (§20.5.2.32) هرکدام col و colOff (§20.5.2.4) و row و rowOff دارند. آفست‌ها بر حسب English Metric Units هستند، ۹۱۴۴۰۰ در هر اینچ، و اکسل هر وقت نموداری با ماوس جابه‌جا یا تغییر اندازه داده باشد مقدار غیرصفر می‌نویسد، که بیشتر نمودارها هستند. اولین نمودار در two-charts.xlsx از سطر 0 با rowOff برابر 19049 شروع می‌شود و در ستون 8، سطر 15 با colOff برابر 247650 و rowOff برابر 66674 تمام می‌شود — حدود یک‌چهارم اینچ داخل آخرین ستون. پارسر drawing در HotXLS همیشه آن چهار مقدار را می‌خواند — کد تصویر از آن‌ها استفاده می‌کرد — ولی نویسندهٔ نمودار برای هر گوشه <xdr:colOff>0</xdr:colOff> و <xdr:rowOff>0</xdr:rowOff> بیرون می‌داد و هر نمودار را موقع save روی شبکهٔ سلول‌ها می‌چسباند

کالبدشکافی گوشه‌های xdr:twoCellAnchor برای اولین نمودار نمونهٔ HotXLS: from شامل col 0 و rowOff 19049 است در حالی که to شامل col 8 و colOff 247650 و rowOff 66674 است بر حسب EMU با 914400 در هر اینچ، و نویسنده‌ای که آفست صفر بیرون می‌داد نمودارها را تا وقتی FFromColOff و FToColOff و همتایانشان مقدارهای واردشده را بازپخش نکردند روی شبکه می‌چسباند
anchor در part مربوط به drawing زندگی می‌کند نه در part نمودار، پس این تعمیر مستقل از fix مربوط به fingerprint است، و هر دو باید منتشر می‌شدند تا workbook واقعاً رفت‌وبرگشت کند
// از v2.382.3 به بعد نویسندهٔ anchor آفست‌های EMU واردشده را بازپخش می‌کند
Result := '<xdr:twoCellAnchor' + EditAsAttr + '><xdr:from><xdr:col>' +
  IntToStr(Chart.FromCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FFromColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.FromRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FFromRowOff) + '</xdr:rowOff></xdr:from>' +
  '<xdr:to><xdr:col>' + IntToStr(Chart.ToCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FToColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.ToRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FToRowOff) + '</xdr:rowOff></xdr:to>' + ...

TXLSXChart حالا FFromColOff و FFromRowOff و FToColOff و FToRowOff را حمل می‌کند، که از پارسر drawing پر می‌شوند و موقع assign شدن یک نمودار همراه بقیهٔ حالت anchor کپی می‌شوند. عمداً private هستند: سطح عمومی anchor هنوز همان چهار مختصات سلول است — FromRow و FromCol و ToRow و ToCol — و نموداری که از کد Delphi ساخته شود مثل قبل روی مرزهای سلول می‌نشیند. این آفست‌ها برای وفادار کردن رفت‌وبرگشت وجود دارند، نه برای اینکه موقعیت‌دهی زیر-سلولی را به‌عنوان یک قابلیت عرضه کنند. دقت کن که این fix مستقل از fingerprint است: anchor در part مربوط به drawing است نه در part نمودار، پس نموداری که ChartMLش بی‌کم‌وکاست بازپخش شده باشد باز هم بدون آن به شبکه می‌پرید. تبدیل‌های واحدی که پشت آن مقدارهای EMU است در یادداشت هندسهٔ تصویر و مقیاس‌بندی EMU در HotXLS پوشش داده شده

چگونه ثابت کنیم یک نمودار بدون تغییر رفت‌وبرگشت می‌کند؟

با مقایسهٔ بایت‌ها، نه با باز کردن نتیجه در اکسل. اکسل موقع load آن‌قدر تعمیر و نرمال‌سازی می‌کند که یک نمودار لغزیده تا وقتی یک analyst متوجه تغییر رنگ marker نشود سالم به نظر می‌رسد. تست corpus که هر دو نقص را گرفت پس از یک open-and-save بدون هیچ ویرایشی سه کار می‌کند: رابطه‌های کاربرگ و drawing و نمودار را می‌پیماید و روی هر ارجاع تکراری یا بی‌صاحب یا معلق نمودار fail می‌شود؛ یک امضای نوع نمودار و فرمول‌های series و هندسهٔ anchor را بین اصلی و خروجی مقایسه می‌کند؛ و برای two-charts.xlsx هر xl/charts/chartN.xml را از هر دو archive می‌خواند و بایت‌های یکسان می‌خواهد. همین بررسی با RTL TZipFile در Delphi راحت نوشته می‌شود

uses System.Zip, System.SysUtils;

function ChartPartsIdentical(const Original, Resaved: string): Boolean;
var
  Src, Dst: TZipFile;
  Name: string;
  A, B: TBytes;
begin
  Result := True;
  Src := TZipFile.Create;
  Dst := TZipFile.Create;
  try
    Src.Open(Original, zmRead);
    Dst.Open(Resaved, zmRead);
    for Name in Src.FileNames do
      if Name.StartsWith('xl/charts/chart') and Name.EndsWith('.xml') then
      begin
        Src.Read(Name, A);
        Dst.Read(Name, B);   // اگر part ناپدید شده باشد استثنا می‌دهد
        if (Length(A) <> Length(B)) or
           ((Length(A) > 0) and not CompareMem(@A[0], @B[0], Length(A))) then
        begin
          Writeln('changed: ', Name);
          Result := False;
        end;
      end;
  finally
    Dst.Free;
    Src.Free;
  end;
end;

سه شرط این مقایسه را معنادار می‌کند، و هر کدام اگر فراموش شود بی‌صدا خراب می‌شود. PreserveUnsupportedParts باید پیش از Open مقدار True داشته باشد، وگرنه هیچ بایت خامی ثبت نمی‌شود و هر نمودار از مدل از نو ساخته می‌شود. StrictOOXML باید False باشد، چون حالت strict طبق طراحی بازتولید را تحمیل می‌کند. و برنامه نباید بین open و save به نمودار دست بزند — خواندن پراپرتی‌ها اشکالی ندارد، ولی هر setterی که مدل تایپ‌دار را تغییر بدهد fingerprint را برمی‌گرداند و نمودار را به مسیر merge می‌فرستد، که رفتار درستی است و هدف این تست نیست. partهای نمودار موقع save از یک شمارندهٔ سراسری workbook از نو شماره‌گذاری می‌شوند، پس workbookی که ترتیب sheet یا ترتیب نمودارش عوض شده باشد بایت‌های یکسان را زیر نام دیگری chartN.xml می‌گذارد؛ به همین دلیل checker مربوط به corpus به جای نام‌ها از رابطه‌ها پیروی می‌کند

هر دو fix در HotXLS 2.382.0 و 2.382.3 منتشر شدند و روی Win32 و Win64 در برابر corpus محلی تأیید شده‌اند، و نمونه‌های نمودار دوباره‌ذخیره‌شده از یک مجموعهٔ اداری مستقل هم به PDF رندر شده و صفحه‌به‌صفحه با اصل‌ها مقایسه شده‌اند. HotXLS نمودارهای XLSX را از کد بومی Delphi و C++Builder می‌خواند و ویرایش می‌کند و می‌نویسد بدون اینکه نصب اکسل دخالتی داشته باشد، و همین این سطح از وفاداری را به یک مسئولیت کتابخانه‌ای تبدیل می‌کند — صفحهٔ کامپوننت صفحه‌گستردهٔ HotXLS برای Delphi فهرست قابلیت‌ها و یک نسخهٔ آزمایشی دارد