مقاله فنی

شاخص فونت BIFF8 و پرش از 4: ران‌های rich text در HotXLS

HotXLS هر ارجاع فونت BIFF8 را همان‌طور که [MS-XLS] §2.5.129 با نام FontIndex تعریفش می‌کند شماره می‌زند: مقادیر 0 تا 3 صفرمبنا هستند، مقادیر بزرگ‌تر از 4 یک‌مبنا هستند، و 4 هرگز ظاهر نمی‌شود، پس رکورد FONT پنجم می‌شود ifnt برابر 5 و بزرگ‌ترین ifnt معتبر برابر تعداد رکوردهای FONT. از HotXLS 2.384.4 به بعد، writer و reader مربوط به XF، ران‌های رشتهٔ rich text و مهاجرت ران‌ها بین کتاب‌های کاری همه از همین قاعده پیروی می‌کنند، و 2.384.5 و 2.384.6 آن را به ران‌های کامنت و text box گسترش می‌دهند، از جمله در کپی‌ها و درج سطرها

این قاعده شبیه یک تایپو است تا وقتی به آن بخوری. یکی کتاب کاری با هشت رکورد FONT باز می‌کند، یک XF پیدا می‌کند که به فونت 8 اشاره دارد و نتیجه می‌گیرد writer شاخصی بیرون از محدوده تولید کرده. دقیقاً همین استدلال در HotXLS 2.384.1 به‌عنوان یک «fix» عرضه شد و یک پیاده‌سازی درست را تبدیل کرد به پیاده‌سازی‌ای که هر فونت سفارشی را در فایلی که اکسل باز می‌کند یک جایگاه جلوتر می‌نشاند. نکتهٔ جالب خودِ باگ off-by-one نیست؛ این است که در یک کتابخانهٔ BIFF8 چند جای مختلف همین قرارداد را حمل می‌کنند، و اینکه یک گره فونت چطور می‌تواند از یک ذخیره سالم بیرون بیاید و در ذخیرهٔ دوم بشکند. اگر پیش‌تر با عجیب‌گیری‌های طول و encoding که در رمزگشایی cch و fHigh در XLUnicodeString با BIFF8 پوشش داده شده درگیر بوده‌اید، این از همان خانوادهٔ باگ است: فایل سالم است، حساب و کتاب سالم نیست

قاعدهٔ FontIndex در [MS-XLS] واقعاً چه می‌گوید؟

[MS-XLS] §2.5.129 می‌گوید یک FontIndex کوچک‌تر از 4 یک موقعیت رکورد صفرمبنا است، یک FontIndex بزرگ‌تر از 4 یک موقعیت رکورد یک‌مبنا است، و مقدار 4 نباید استفاده شود. همین نوع FontIndex توسط رکوردهای XF، ران‌های قالب‌بندی SST و ران‌های قالب‌بندی TXO استفاده می‌شود، پس یک قاعدهٔ بدخوانده هر سه را خراب می‌کند. شواهد را با فایل‌های ساختهٔ اکسل راحت می‌شود بازتولید کرد: SOLVSAMP.XLS همراه Office سقف ifnt در XFهایش 19 است و 19 رکورد FONT دارد، یک کتاب کار 43 رکوردی به 43 می‌رسد، و فایلی که Excel 16 با 30 رکورد FONT ذخیره کرده سلول‌های Courier New خودش را به ifnt برابر 22 اشاره می‌دهد، یعنی رکورد بیست‌ودوم. هیچ‌کدام هرگز یک 4 در خود ندارند. اگر در یک ابزار تشخیصی خودتان باید نگاشت را تحلیل کنید، تبدیل دو تابع کوتاه است

// [MS-XLS] 2.5.129 FontIndex: 0..3 صفرمبنا، > 4 یک‌مبنا، 4 نامعتبر
function FontIndexToRecordNo(Ifnt: Word): Integer;  // رکورد FONT یک‌مبنا
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4 نباید رخ دهد
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
نگاشت FontIndex در HotXLS طبق MS-XLS 2.5.129 که در آن ifnt از 0 تا 3 موقعیت‌های رکورد FONT صفرمبنا هستند، ifnt برابر 5 و بالاتر یک‌مبنا هستند و مقدار 4 هرگز رخ نمی‌دهد، همراه با تبدیل FontIndexToRecordNo و شواهد از کتاب‌های کاری ساختهٔ اکسل مثل SOLVSAMP.XLS
رکورد FONT پنجم می‌شود ifnt 5، نه 4 — یک کتاب کار 19 رکوردی به ifnt برابر 19 می‌رسد و هیچ فایل ساختهٔ اکسل هرگز مقدار ممنوعِ میانشان را ذخیره نمی‌کند

داخل HotXLS همین قاعده در دو نقطهٔ آینه‌ای زندگی می‌کند. TXLSFontList.GetSaveIndex موقعیت یک‌مبنای فونت در فهرست ارجاع‌شده را می‌گیرد و فقط موقعیت‌های 1 تا 4 را یکی کم می‌کند، پس موقعیت 5 به‌صورت ifnt برابر 5 نوشته می‌شود. TXLSReader.ParseXF روی load کار معکوس را می‌کند: هر ifnt برابر 5 یا بیشتر به یک جایگاه صفرمبنای فهرست فونت کم می‌شود و هرچه زیر آن است سر جایش می‌ماند. نگاشت دوبارهٔ ران‌های غنی SST و CountRichRunFontRefs همان تبدیل ifnt >= 5 را اعمال می‌کنند، و این همان نکته است: یک قرارداد، همهٔ مصرف‌کننده‌ها

// TXLSFontList.GetSaveIndex (سمت writer)
Result := inherited GetSaveIndex(Index);   // موقعیت ارجاع‌شدهٔ یک‌مبنا
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 می‌شوند 0..3، 5 به بعد دست‌نخورده

// TXLSReader.ParseXF (سمت reader)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 یعنی جایگاه 4 فهرست فونت

چرا یک «fix» صفرمبنا همهٔ فونت‌های سفارشی را یکی جابه‌جا کرد؟

بازنویسی صفرمبنای HotXLS 2.384.1 همهٔ فونت‌های سفارشی را جابه‌جا کرد چون یک شاخص یک‌مبنا را صفرمبنا می‌خواند و بعد چهار نقطهٔ فراخوانی را با همان بدخوانی هماهنگ کرد: GetSaveIndex، ParseXF، نگاشت دوبارهٔ ران‌های SST و مهاجرت ران‌ها بین کتاب‌های کاری در Sheets.AddCopy. round-tripهای HotXLS هنوز سالم به نظر می‌رسیدند، چون writer و reader با هم توافق داشتند. اکسل توافق نداشت. فایلی که 2.384.1 می‌نوشت اولین فونت سفارشی را در ifnt برابر 4 می‌گذاشت، که اکسل آن را فونت پیش‌فرض می‌بیند، و هر فونت سفارشی بعدی یک رکورد جلوتر می‌افتاد؛ باز کردن یک فایل اکسل هم به همان بده‌بستان برعکس می‌افتاد و هر فونت را یک رکورد دیرتر می‌بست

بازگشت پسرفت در HotXLS 2.384.1 که در آن GetSaveIndex و ParseXF مقادیر یک‌مبنای FontIndex را صفرمبنا می‌خواندند، اولین فونت سفارشی را به‌صورت ifnt ممنوعِ 4 می‌نوشتند که اکسل آن را فونت پیش‌فرض تفسیر می‌کند، و هر فونت بعدی را یک رکورد جلوتر می‌نشاندند در حالی که round-tripها همچنان قبول می‌شدند
writer و reader روی همان بدخوانی توافق داشتند، پس یک آزمون ذخیره-بعد-بازکردن سبز می‌ماند در حالی که هر فونتی که اکسل باز می‌کند یک جایگاه خطا دارد — وقتی یک قرارداد در هفت جا زندگی می‌کند و شما چهار تا را عوض می‌کنید، اول به تغییر خودتان شک کنید

نشانه‌ای که باید جلوی این تغییر را می‌گرفت در همان کدبیس نشسته بود. CountRichRunFontRefs، نگاشت دوبارهٔ FONTX و FBI در چارت‌ها، و فهرست فونت موتور استایل هرگز دست نخوردند و همچنان از skip-4 استفاده می‌کردند، پس کتابخانه در همان لحظه‌ای که 2.384.1 فرود آمد با خودش تناقض داشت، و فقط این تصادف که فونت‌های rich-text معمولاً توسط یک XF هم ارجاع می‌شدند تناقض را پنهان نگه می‌داشت. وقتی یک قرارداد در هفت جا ظاهر می‌شود و شما چهار تا را عوض می‌کنید، قبل از شک کردن به سه تای دیگر به تغییر خودتان شک کنید. نسخهٔ 2.384.4 شماره‌گذاری spec را در هر چهار جا برگرداند، و آزمون بازگشتی قدیمی که ifnt < FontCount را assert می‌کرد و همان بدخوانی را در خود کدگذاری کرده بود، با آزمون‌هایی عوض شد که هر ifnt نوشته‌شده را از طریق فرمول spec به نام یک رکورد FONT نگاشت می‌کنند. یک محدودیت صادقانه باقی می‌ماند: فایل‌هایی که نسخه‌های 2.384.1 تا 2.384.3 با پنج فونت یا بیشتر ذخیره کرده‌اند شاخص‌های جابه‌جاشده‌ای دارند که یک reader نمی‌تواند از دادهٔ معتبر تشخیص‌شان دهد، پس تنها درمان بازتولیدشان است

چرا ران‌های فونت کامنت فقط در ذخیرهٔ دوم می‌شکنند؟

ران‌های کامنت و text box در ذخیرهٔ دوم می‌شکستند چون HotXLS اولین N-1 رکورد FONT را بی‌قید و شرط نگه می‌داشت و فقط وقتی هیچ XF به آخرین رکورد ارجاع نمی‌داد آن را حذف می‌کرد، در حالی که ران‌های قالب‌بندی TXO ([MS-XLS] §2.4.329) بایت‌به‌بایت و بدون شماره‌گذاری دوباره پس‌نوشته می‌شدند. فایل‌های .xls ساختهٔ اکسل همیشه با یک فونت انتهایی بی‌مرجع تمام می‌شوند (یک DengXian با اندازهٔ 9pt روی سیستم با locale چینی)، پس در اولین ذخیره فونتی که فقط توسط یک ران کامنت استفاده می‌شد هرگز آخرین نبود و چیزی به‌چشم نمی‌آمد. اما همان ذخیرهٔ اول فونت انتهایی را حذف و فونتِ مخصوص کامنت را به موقعیت آخر ترفیع داد. ذخیرهٔ دوم آن را به‌عنوان بی‌مرجع دور انداخت، ifnt ران به بعد از انتهای فایل اشاره می‌کرد و اکسل به فونت پیش‌فرض برمی‌گشت؛ اگر کتاب کار در این میان فونت تازه‌ای گرفته بود، ران بی‌سروصدا به همان گره می‌خورد، که در آزمون‌ها یک ران text box استایل‌دار را تبدیل به Arial کرد. فایل‌های پرکامنت مثل همان‌هایی که در ساخت گردش کار بازبینی کامنت‌ها و هایپرلینک‌ها توصیف شده دقیقاً همان‌جایی‌اند که این باگ گاز می‌گیرد، چون مدام باز می‌شوند، حاشیه‌نویسی می‌شوند و ذخیره می‌شوند

جدول فونت HotXLS در طول دو ذخیره که در آن رکورد FONT انتهایی بی‌مرجعی که اکسل همیشه می‌نویسد اول حذف می‌شود، فونتِ مخصوص کامنت آخر می‌شود و بعد دور انداخته می‌شود چون ران‌های قالب‌بندی TXO بدون شمردن ارجاع‌ها پس‌نوشته می‌شدند، تا آنکه CountRichRunFontRefs فیلتر بقای فونت را در 2.384.5 اصلاح کرد
ذخیرهٔ اول تمیز به نظر می‌رسید چون فونت انتهایی قربانی می‌شد و فونت کامنت فقط در ذخیرهٔ دوم ناپدید می‌شد — به‌جای اعتماد به یک آزمون درون‌حافظه‌ای، هر ifnt را در طول ذخیره‌ها به نام یک رکورد FONT نگاشت کنید

HotXLS 2.384.5 با ران‌های TXO مثل ران‌های SST رفتار می‌کند. CountRichRunFontRefs حالا هر TMSOShapeTextBox را در هر کاربرگ می‌گردد، ifnt با skip-4 هر ران را به یک جایگاه تبدیل و آن را یک ارجاع می‌شمارد، پس فونتی که فقط ران دارد از فیلتر ذخیره جان سالم به در می‌برد. جدول جایگاه-به-save-index حاصل به FontRunRemap هر رسم می‌رود، و TMSOShapeTextBox.Store شاخص‌های ران‌ها را روی یک کپی خصوصی از بایت‌های خام ران بازنویسی می‌کند و TxOLastRun انتهایی را که فونتی حمل نمی‌کند به حال خود می‌گذارد. برای کد اپلیکیشن قرارداد ساده است: TXLSComment.TextRuns.FontIndex و TXLSTextBox.TextRuns.FontIndex از شماره‌گذاری فایل استفاده می‌کنند، یعنی 4 پرش شده، دقیقاً همان‌طور که خوانده شده؛ شاخص ران‌ها یک‌مبناست و CharIndex آفست کاراکتری است که ران از آن شروع می‌شود. بعد از یک ذخیره ممکن است عدد ذخیره‌شده با چیزی که ست کرده بودید فرق کند، اما همچنان به همان فونت اشاره می‌کند

var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  I: Integer;
  Ifnt: Word;
begin
  Book := TXLSWorkbook.Create;
  if Book.Open('review-notes.xls') <> 1 then
    raise Exception.Create('Cannot open review-notes.xls');
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  if Note <> nil then
    for I := 1 to Note.TextRuns.Count do
    begin
      Ifnt := Note.TextRuns.FontIndex[I];   // شماره‌گذاری فایل، 4 پرش شده
      if Ifnt = 4 then
        raise Exception.CreateFmt('Run %d uses invalid ifnt 4', [I]);
      Writeln(Format('run %d at char %d: ifnt %d = FONT record #%d',
        [I, Note.TextRuns.CharIndex[I], Ifnt, FontIndexToRecordNo(Ifnt)]));
    end;
end;

کپی‌ها، درج سطر و مهاجرت ران بین کتاب‌های کاری

از HotXLS 2.384.6 به بعد، هر مسیر کپی موتور کلاسیک ران‌های قالب‌بندی کامنت را نگه می‌دارد، چون Range.Copy، CopyRange، Sheets.AddCopy و جابه‌جایی سلول‌ها پشت Range.Insert و Range.Delete همه از TXLSRange.CopyCell می‌گذرند، و CopyCell قبلاً فقط متن کامنت و نویسنده‌اش را کپی می‌کرد. یک جابه‌جایی یعنی یک کپی به‌علاوهٔ یک پاک‌سازی، پس درج یک سطر بالای یادداشتی با دو ران، آن را با صفر ران و یک فونت باقی می‌گذاشت. fix هر ران را کپی می‌کند و فونتش را از طریق TXLSWorkbook.MigrateRunFontIndex جابه‌جا می‌کند، که شاخص skip-4 را به جایگاه تبدیل می‌کند، فونت را با مقدار به جدول فونت مقصد می‌برد و برمی‌گرداند به شماره‌گذاری فایل؛ مهاجرت rich-text مربوط به SST در Sheets.AddCopy هم حالا همین تابع را صدا می‌زند به‌جای حمل نسخهٔ خودش از همین حساب و کتاب. دو حالت مرزی هم سر راه بود: paste درجایی که مبدأ و مقصد یک کامنت واحدند نباید پیش از خواندن ران‌هایش آن‌ها را پاک کند، و Sheets.AddCopy حالا برای کامنت‌های چسبیده به سلول‌هایی که رکورد سلول ذخیره‌شده ندارند هم یک دور دوم می‌زند که قبلاً کلاً از آن‌ها رد می‌شد. سمت جدول فونت، کپی بین کتاب‌های کاری همان منطق مقدارمحور سمت فرمول‌ها را دنبال می‌کند که در کپی بین کتاب‌های کاری و گره دوبارهٔ فرمول‌ها پوشش داده شده. روی موتور XLSX مسیرهای کپی از قبل ران‌ها را با مقدار کلون می‌کردند؛ شکاف در خود بخش کامنت‌ها بود، جایی که reader به rFont، strike، u و vertAlign بی‌توجه بود و writer هرگز u یا vertAlign صادر نمی‌کرد، پس حالا ران‌ها ذخیره و بازکردن را متقارن از سر می‌گذرانند

شاخص‌های فونت را در فایل‌های BIFF8 چطور باید آزمود؟

شاخص‌های فونت را با ذخیره و بازکردن دوباره آزمایش کنید، ترجیحاً بیش از یک نسل، و هر ifnt را به یک رکورد FONT نگاشت کنید به‌جای assert گرفتن روی یک بازهٔ عددی. هر باگ در این ماجرا یک آزمون درون‌حافظه‌ای را پاس کرده بود: پسرفت 2.384.1 در یک جفت writer و reader هماهنگ زندگی می‌کرد، رانش TXO به دو ذخیره با یک تغییر جدول فونت در میان نیاز داشت، و ران‌های گم‌شدهٔ کامنت روی XLSX فقط بعد از یک reopen خودش را نشان داد. یک هارنس مفید یک نمونهٔ ساختهٔ اکسل را باز می‌کند، دو بار از طریق HotXLS ذخیره می‌کند، بین ذخیره‌ها یک فونت اضافه یا حذف می‌کند، و بعد موقعیت ران‌ها و در سطح بایت، نام فونت‌های پشت هر ifnt را بررسی می‌کند. مقادیر FontIndex را قبل و بعد از ذخیره با هم مقایسه نکنید، چون شماره‌گذاری دوباره مشروع است

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // ساختهٔ اکسل، C2 دو ران دارد
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  RunCount := Note.TextRuns.Count;
  SecondRunAt := Note.TextRuns.CharIndex[2];

  Book.Sheets[1].Range['C2', 'C2'].Copy(Book.Sheets[1].Range['E5', 'E5']);
  Book.Sheets[1].Range['C1', 'C1'].Insert(xlShiftDown);   // C2 می‌رود به C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // بازکردن دوباره، هرگز به حافظه اعتماد نکن
  Assert(Book.Open(OutFile) = 1);
  Note := Book.Sheets[1].Range['C3', 'C3'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
  Assert(Note.TextRuns.CharIndex[2] = SecondRunAt);
  Note := Book.Sheets[1].Range['E5', 'E5'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
end;

اگر از دلفی یا C++Builder با XLS کلاسیک کار خواندن و نوشتن دارید و دوست ندارید دنبال کنید کدام‌یک از مصرف‌کننده‌های فراوان فونت در کتابخانه هنوز با [MS-XLS] §2.5.129 هم‌عقیده است، شماره‌گذاری skip-4، شماره‌گذاری دوبارهٔ ران‌ها هنگام ذخیره و مهاجرت ران با مقدار که اینجا توصیف شد در کامپوننت صفحه‌گستردهٔ دلفی HotXLS ساخته شده، که XLS و XLSX را بدون اکسل و بدون OLE automation می‌خواند و می‌نویسد