مقاله فنی

رمزگشایی XLUnicodeString در BIFF8 با Delphi: cch و fHigh

HotXLS یک XLUnicodeString از نوع BIFF8 را با ابتدا خواندن cch و flag مربوط به fHigh decode می‌کند و سپس reader متناظر با encoding را انتخاب می‌کند: وقتی fHigh برابر 1 است، TXLSBlob.GetWideString با byte count برابر cch * 2 و وقتی fHigh برابر 0 است، TXLSBlob.GetString. این دو را برعکس جفت کنید و record یک string خالی یا نصفه برمی‌گرداند، هرگز exception نمی‌دهد

همین موضوع این دسته از bug را پرهزینه می‌کند. chart باز می‌شود، series درست draw می‌شوند، axisها صحیح‌اند و فقط caption یک trendline خالی است. نه چیزی در log، نه چیزی در exception handler و نه dialog مربوط به corrupt file. file تمام مدت سالم بوده؛ reader تعداد byte اشتباهی خواسته و دقیقاً همان چیزی را گرفته که درخواست کرده است

چرا یک string از نوع BIFF8 خالی برمی‌گردد؟

string از نوع BIFF8 به این دلیل خالی برمی‌گردد که length guard پیش از انجام read، payload را رد کرده یا reader روی نخستین NULای که پیدا کرده متوقف شده است. هر دو مسیر ذاتاً silent هستند. در HotXLS، guard معمولاً یک check صریح DataLength در record handler است و باید بر اساس encoding محاسبه شود: payload شانزده‌بیتی برای body مربوط به SXViewLink به 8 + cch * 2 byte نیاز دارد، اما payload هشت‌بیتی فقط 8 + cch byte لازم دارد. arithmetic مربوط به wide character را روی record هشت‌بیتی اعمال کنید و هر name کوتاه gate را fail می‌کند. رفتار NUL دام دوم است، چون TXLSBlob.GetString و TXLSBlob.GetWideString هر دو result decode‌شده را برای terminator scan و همان‌جا truncate می‌کنند؛ اگر terminator در position اول بنشیند، string خالی برمی‌گردد. body شانزده‌بیتی را با نصف byte count بخوانید و فقط نخستین cch div 2 character را نگه می‌دارید؛ body هشت‌بیتی را از wide reader بخوانید و جفت byteها code pointهای دلخواه می‌سازند. فقط read بیش‌ازحد پرسر‌وصداست: TXLSBlob.EnsureReadable وقتی request از اندازه blob عبور کند، Blob read exceeds data size را raise می‌کند. under-reading هیچ alarmی از این جنس ندارد

GetWideString byte می‌شمارد، نه character

TXLSBlob.GetWideString(Index, Count) مقدار Count را برحسب byte می‌گیرد. این method در داخل، با SetString روی PWideChar و با Count div SizeOf(WideChar) کار می‌کند؛ پس دادن character count، string را بی‌سروصدا نصف می‌کند. از طرف دیگر، layoutهای record در BIFF8 طول string را برحسب character بیان می‌کنند. بنابراین هر call شانزده‌بیتی باید conversion مربوط به * 2 را خودش حمل کند و هر call هشت‌بیتی نباید آن را حمل کند. همین مرز encoding هنگام write کردن text نیز دیده می‌شود نه فقط read کردن آن و اگر pipeline شما stringها را در هر دو جهت جابه‌جا می‌کند، خواندن export spreadsheet امن از نظر Unicode در Delphi در کنار این بحث مفید است

// XLUnicodeStringNoCch شانزده‌بیتی: cch character و cch * 2 byte
Name := Data.GetWideString(Start, cch * 2);        // درست
Name := Data.GetWideString(Start, cch);            // نصف text، بدون error

// XLUnicodeStringNoCch هشت‌بیتی: cch character و cch byte
Name := WideString(Data.GetString(Start, cch));    // درست
Name := Data.GetWideStringWithZero(Start, cch);    // هنوز wide reader است

این convention هرجا که byte stream با دست walk شود برقرار است. وقتی HotXLS یک String record طولانی یعنی $0207، مطابق [MS-XLS] 2.4.268 را از Continue recordهای آن یعنی $003C دوباره به هم می‌دوزد، branch مربوط به wide مقدار segCh را از طول segment حساب می‌کند و سپس GetWideString(3, segCh * 2) را call می‌کند، چون body record از offset 3 شروع می‌شود و count همچنان byte است. reader مربوط به rich-text نیز در نخستین Continue segment، همین کار را از offset 1 انجام می‌دهد. [MS-XLS] 2.5.293 تضمین می‌کند وقتی fHighByte برابر 1 است break روی مرز character دوتایی قرار دارد؛ پس bookkeeping برای partial character لازم نیست، اما arithmetic مربوط به byte همچنان چیزی است که باید درست انجام دهید

GetWideStringWithZero دقیقاً چه می‌کند؟

TXLSBlob.GetWideStringWithZero یک wide-character reader است که NULهای embedded را نگه می‌دارد. suffix مربوط به WithZero علامت نگه‌داری NUL است، نه عرض character: در داخل، همان SetString روی PWideChar و با Count div SizeOf(WideChar) را مانند GetWideString اجرا می‌کند، با این تفاوت که scan مربوط به terminator را ندارد. counterpart یک‌بایتی آن TXLSBlob.GetStringWithZero است که یک AnsiString برمی‌گرداند. از اسم هیچ‌کدام مشخص نیست کدام مربوط به کدام است و همین ambiguity برای این codebase bugهای واقعی ساخته است. نام‌گذاری misreading مشخص ارزش دارد، چون بسیار plausible به نظر می‌رسد: GetWideString به cch * 2 نیاز دارد، پس GetWideStringWithZero باید همان موردی باشد که cch را مستقیم می‌گیرد. واقعاً cch را بدون شکایت می‌گیرد، واقعاً WideString برمی‌گرداند و compiler هم خوشحال است. اما نصف characterها را از جفت byteهای اشتباه assemble می‌کند. مسیر صحیح هشت‌بیتی، TXLSBlob.GetString با plain cch byte count است که هنگام assignment به WideString cast می‌شود. HotXLS 2.376.0 دقیقاً همین misuse را در دو chart decoder اصلاح کرد

SXViewLink و length gate بر اساس encoding

SXViewLink با مقدار $0858 و تعریف [MS-XLS] 2.4.316، cleanترین مثال عملی است، چون هر دو asymmetry را در یک header هشت‌بایتی کنار هم pack می‌کند. layout عبارت است از rt(2)، unused(2)، reserved(2)، cch(1)، fHigh(1) و سپس body از نوع XLUnicodeStringNoCch: fHigh برابر 1 یعنی cch * 2 byte از UTF-16 و fHigh برابر 0 یعنی cch byte از characterهای یک‌بایتی؛ cch نیز به 255 محدود است چون length field یک byte است. HotXLS این record را وقتی chart sheet به PivotTable view لینک است، در chart globals پیش از Units و کنار PivotChartBits با مقدار $0859 مطابق [MS-XLS] 2.4.196 می‌نویسد؛ نمای record-level این machinery در نوشتن recordهای BIFF8 PivotTable در Delphi پوشش داده شده است

// SXViewLink ([MS-XLS] 2.4.316): rt(2) unused(2) reserved(2) cch(1)
// سپس XLUnicodeStringNoCch؛ fHigh(1) و بعد characterها می‌آیند
PivCch := Item.FData.GetByte(6);
if (PivCch > 0) and (Item.FData.GetByte(7) <> 0) and
   (Item.FData.DataLength >= LongWord(8 + PivCch * 2)) then
begin
  Result.PivotSourceName := Item.FData.GetWideString(8, PivCch * 2);
  Result.IsPivotChart := True;
end
else if (PivCch > 0) and (Item.FData.GetByte(7) = 0) and
   (Item.FData.DataLength >= LongWord(8 + PivCch)) then
begin
  Result.PivotSourceName := WideString(Item.FData.GetString(8, PivCch));
  Result.IsPivotChart := True;
end;

دو branch تزئینی نیستند. version قبلی هر دو encoding را پشت expression مربوط به wide character یعنی 8 + cch * 2 gate می‌کرد؛ در نتیجه view name هشت‌بیتی که Excel نوشته بود guard را fail می‌کرد و decoder، PivotSourceName خالی برمی‌گرداند و IsPivotChart را false باقی می‌گذاشت. pivot link بدون هیچ diagnosticی از model ناپدید می‌شد. همین اشتباه عیناً در Trendline decoder با مقدار $2050 و تعریف [MS-XLS] 2.4.328 نیز قرار داشت؛ در آنجا name field بعد از 28 byte payload عددی می‌آید، cch دو بایتی در offset 28، fHigh در offset 30 و characterها در offset 31 قرار دارند و captionهای trendline که Excel با characterهای هشت‌بیتی نوشته بود، به string خالی decode می‌شدند. هر دو در همان release fix شدند. case هشت‌بیتی نیز کنجکاوی legacy محدود به fileهای Excel 2.0 تا 4.0 نیست؛ Excel فعلی هم هر وقت همه characterها در یک byte جا شوند، هنوز payloadهای هشت‌بیتی BIFF8 می‌نویسد

یک record جدید BIFF8 را در Delphi چگونه امن decode کنیم؟

هرجا field واقعاً یک XLUnicodeString استاندارد است، به‌جای branch دست‌ساز از TXLSBlob.GetBiffString استفاده کنید. این method length field را می‌خواند، option byte را می‌خواند، reader متناظر را dispatch می‌کند و cursor را از body عبور می‌دهد. دو parameter Boolean بخش مهمی هستند که باید با دقت خوانده شوند: is8bit عرض length field را توصیف می‌کند، نه عرض characterها، و iswide می‌گوید آیا option byte از نوع fHigh اصلاً حاضر است یا نه. versionهای BIFF پایین‌تر از $0600 هیچ‌کدام را ندارند

var
  Offset: LongWord;
begin
  Offset := 6;  // در SXViewLink، byte مربوط به cch از اینجا شروع می‌شود
  // is8bit یعنی length field یک byte عرض دارد
  // iswide یعنی option byte از نوع fHigh بعد از length field می‌آید
  Name := Data.GetBiffString(Offset, True, True);
  // Offset اکنون به نخستین byte بعد از body مربوط به string اشاره می‌کند

branchهای دست‌ساز وقتی handler باید در برابر inputهای truncated یا hostile زنده بماند، همچنان جای خود را دارند، چون GetBiffString به raise شدن EnsureReadable تکیه می‌کند، نه bounds checkی که کنترلش با شما باشد. به همین دلیل chart decoder مربوط به HotXLS روی DataLength gate می‌کند و به‌جای throw کردن هیچ چیز برنمی‌گرداند: workbook خراب third-party باید فقط یک caption را از شما بگیرد، نه کل document را. این trade عمدی است و دقیقاً به همین دلیل guard مخصوص encoding باید درست باشد، چون guard همان چیزی است که read بد را به سکوت تبدیل می‌کند

یک بخش فرایندی آخر هم هست که در همان release به‌سختی یاد گرفته شد. روی workbook ذخیره‌شده و دوباره بازشده assert کنید، نه روی model داخل memory که همین حالا ساخته‌اید. batch مربوط به 2.376.0 یک emitter از نوع SXEx مطابق [MS-XLS] 2.4.282 را هم پیدا کرد که body بیست‌وچهاربایتی اعلام می‌کرد اما فقط 22 byte می‌نوشت و هر record بعد از PivotTable view را misalign می‌کرد، از جمله worksheet EOF و هر chart sheet substreamی که بعد از آن می‌آمد. testهای pivot موجود هرگز آن را نگرفتند چون همه روی memory assert می‌کردند. string decoding نیز همین ویژگی را دارد: round trip از مسیر file تنها testی است که واقعاً byte countها را exercise می‌کند

اگر با internals مربوط به classic XLS در Delphi یا C++Builder کار می‌کنید و ترجیح می‌دهید BIFF8 record reader خودتان را نگه‌داری نکنید، ruleهای encoding بالا از قبل در HotXLS Delphi spreadsheet component پیاده و regression-test شده‌اند؛ این component فایل‌های XLS و XLSX را بدون Excel یا هر OLE automation می‌خواند و می‌نویسد