مقاله فنی

خروجی امن برای Unicode در صفحه‌گسترده‌های Delphi: RTF و HTML

یک صفحه‌گسترده ستونی از نام مشتریان دارد. بعضی نام‌ها به چینی هستند، بعضی به سیریلیک، و چندتایی هم umlaut آلمانی یا accent فرانسوی دارند. آن را به CSV صادر می‌کنید و نتیجه را باز می‌کنید؛ همه نویسه‌ها سالم هستند. همان workbook را برای یک الگوی mail merge به RTF صادر می‌کنید، آن را در یک واژه‌پرداز باز می‌کنید، و نام‌های غیر ASCII به ردیف‌هایی از علامت سؤال فرو می‌ریزند. داده اصلاً عوض نشده است. چیزی که عوض شده قرارداد encoding قالبی است که در آن نوشته‌اید، و هر مسیر خروجی قرارداد خودش را حمل می‌کند

این همان تله‌ای است که کتابخانه‌ای را می‌گیرد که روی سطح کاملاً Unicode-aware به نظر می‌رسد. متن سلول‌ها درونی به‌صورت WideString نگه داشته می‌شود، بنابراین مدل هرگز یک نویسه را از دست نمی‌دهد. از‌دست‌رفتن در مرز رخ می‌دهد، در writerای که باید آن متن را به قالبی serialize کند که قوانین خودش را درباره قانونی‌بودن بایت‌ها و نحوه کدگذاری هرچیزی خارج از بازه مجاز دارد. اگر یک writer را درست هم بنویسید، هنوز ممکن است writer دیگری را منتشر کنید که همان متن را خراب کند. راه‌حل یک کلید سراسری نیست. راه‌حل این است که در هر مسیر یک تصمیم مستقل و درست بگیرید

RTF ذاتاً یک قالب 7-bit-safe است

Rich Text Format از Unicode قدیمی‌تر است و طوری تعریف شد که از کانال‌هایی جان سالم به در ببرد که فقط ASCII قابل چاپ را عبور می‌دهند. یک سند RTF در header خودش یک code page اعلام می‌کند، و هر نویسه‌ای که writer نتواند در آن code page نمایش دهد باید به‌صورت escape خروجی داده شود، نه به شکل raw byte. escape مربوطه \u است که یک code unit شانزده‌بیتی signed را حمل می‌کند و بعد از آن یک نویسه fallback از جنس ASCII می‌آید تا readerهای خیلی قدیمی که اصلاً escape را نمی‌فهمند چیزی برای نمایش داشته باشند

HotXLS دقیقاً به همین شکل RTF می‌نویسد. header سند با اعلام code page در فرم \ansi\ansicpg1252\uc1 باز می‌شود، و writer داخل واحد lxRTF از روی همه رشته‌ها عبور می‌کند و هر نویسه بالاتر از ASCII معمولی را به صورت escape از نوع \u می‌نویسد تا stream بایت، فارغ از این‌که code page اعلام‌شده چه چیزی را نگه می‌دارد، 7-bit-clean بماند. یک code point مثل U+4E2D به توالی لفظی \u20013? تبدیل می‌شود، نه به raw byteای که viewer بعداً بخواهد آن را با هر code pageی که حدس زده تفسیر کند. بدون این انضباط، هرچیزی بیرون از code page اعلام‌شده اصلاً نمایش بایتی قانونی ندارد، و writerای که raw value را می‌نویسد همان علامت سؤال‌هایی را تولید می‌کند که ابتدای این مقاله دیدیم

جزئیاتی که باید در ذهن بماند این است که code page اعلام‌شده و escapeها دو نیمه از یک قرارداد هستند. اعلام code page به‌تنهایی هیچ کمکی به متنی که بیرون از آن قرار می‌گیرد نمی‌کند. escape نوشتن بدون code page اعلام‌شده، fallback characterها را مبهم می‌کند. هر دو باید با هم درست باشند، و به همین دلیل writerای که فقط یکی از این دو را رعایت کند، روی نخستین workbook چندزبانه شکست خواهد خورد

escape کردن HTML فقط درباره angle bracketها نیست

خروجی HTML سندی چندبرگه می‌سازد که frameهای navigation آن نام sheetها را به‌صورت متن قابل‌دیدن حمل می‌کنند. این نام‌ها رشته‌هایی هستند که نویسنده کنترلشان می‌کند و می‌توانند هر نویسه‌ای، از جمله نویسه‌های مهم برای نشانه‌گذاری، در خود داشته باشند. sheetای که واقعاً Q1 & Q2 <draft> نام دارد باید به‌شکل entityهای escape‌شده به صفحه برسد؛ وگرنه angle bracketها یک tag خیالی باز می‌کنند و ampersand یک entity reference ناخواسته را شروع می‌کند. این همان escaping عادی HTML است، و جا انداختن آن روی یک برچسب frame همان غفلتی است که تمام تست‌های مبتنی بر نام sheetهای صرفاً ASCII را هم با موفقیت پشت سر می‌گذارد

مسئله encoding یک لایه پایین‌تر می‌نشیند. وقتی نویسه‌های غیر ASCII وارد زمینه‌ای می‌شوند که تضمین نشده با UTF-8 سرو شود، نمایش امن آن‌ها به شکل numeric character reference است، بنابراین U+00E9 به‌صورت reference عددی نوشته می‌شود نه به‌شکل raw byteای که معنایش به charset پاسخ بستگی داشته باشد. تصویر آینه‌ای همین قاعده در مسیر ورودی هم برقرار است. workbookای که از XLSX دوباره خوانده می‌شود shared stringهایی دارد که در آن‌ها یک نویسه ممکن است از قبل به‌صورت XML numeric entity ذخیره شده باشد، و آن entity باید پیش از ورود به مدل سلولی به یک نویسه کامل decode شود. اگر آن را بی‌دقت decode کنید و code point را به بایت‌های جداگانه بشکنید، یک نویسه منفرد به دو تکه mojibake برمی‌گردد که هیچ خروجی بعدی دیگر قادر به تعمیرش نیست

ظرف XLSX یک ZIP است، و ZIP encoding نام مخصوص خودش را دارد

یک فایل XLSX در اصل یک بایگانی ZIP است، و آن بایگانی برای هر memberی که نگه می‌دارد نامی هم ذخیره می‌کند. ZIP آن‌قدر قدیمی است که مشخصات اولیه‌اش درباره encoding این نام‌ها چیزی نمی‌گفت، بنابراین readerای که هیچ علامتی پیدا نکند، local code page آرشیو را فرض می‌گیرد. همین فرض به محض آن‌که نام یک member نویسه غیر ASCII داشته باشد غلط می‌شود، چیزی که در نام‌های بومی‌سازی‌شده بخش‌های worksheet و در mediaهای embedشده‌ای که نام فایلشان accent یا اسکریپت غیرلاتین دارد اتفاق می‌افتد

راه‌حل یک بیت واحد است. general-purpose bit 11 در هر local file header اعلام می‌کند که نام member با UTF-8 کدگذاری شده است. HotXLS هنگام خواندن archive دقیقاً همین بیت را بررسی می‌کند و پرچم‌های عمومی را با mask برابر $0800 می‌سنجد، و reader یا writerای که آن را نادیده بگیرد نامی را که یک پیاده‌سازی درست با UTF-8 ذخیره کرده اشتباه خواهد خواند. این بیت هم برای set کردن ارزان است و هم برای رعایت کردن، و تمام تفاوت بین نام memberی است که round trip را سالم پشت سر می‌گذارد و نامی که پیش از آن‌که حتی محتوای صفحه‌گسترده parse شود خراب می‌رسد

case folding و number scanning همان خطر را در لباس دیگر پنهان می‌کنند

ارزیابی فرمول جایی است که امنیت Unicode دیگر درباره serialization نیست و درباره comparison می‌شود. تابع SEARCH نسبت به حروف بزرگ و کوچک حساس نیست، یعنی باید پیش از جست‌وجوی زیررشته، حروف را fold کند. راه غلط این fold از مسیر ANSI code page می‌گذرد، چون uppercase کردن متن غیر ASCII از آن مسیر، نویسه‌ها را از یک code page باریک عبور می‌دهد و هرچیزی را بیرون از آن خراب می‌کند. راه درست، uppercasing روی wide string است که کل بازه UTF-16 را حفظ می‌کند. HotXLS دقیقاً به همین دلیل از WideUpperCase استفاده می‌کند، تا جست‌وجوی متن accented یا غیرلاتین همان نویسه‌هایی را match کند که به آن داده شده‌اند، نه تقریب کج‌و‌معوجی را که code page به آن‌ها تحمیل کرده است

tokenizer فرمول تعهدی مشابه دارد که اصلاً به حروف ربطی ندارد و کاملاً به این مربوط است که token از کجا تمام می‌شود. notation علمی مثل 1E3 یا 2.5E-3 یک literal عددی واحد است، و scanner باید E، علامت اختیاری بعد از آن، و رقم‌های بعدی را جزئی از همان عدد بداند، نه این‌که ورودی را به یک name و یک عدد جداگانه بشکند. scannerای که این را بد مدیریت کند، یک ثابت کاملاً معتبر را یا به parse error تبدیل می‌کند یا بدتر از آن، به یک expression بی‌سروصدا غلط. این موضوع در همین بحث جا می‌گیرد، چون در هر دو مورد پای یک تصمیم درست در سطح نویسه وسط است: یکی درباره این‌که یک نویسه برای comparison چگونه fold شود، و دیگری درباره این‌که آیا یک نویسه token فعلی را ادامه می‌دهد یا نه

ساختن و خروجی‌گرفتن از یک workbook چندزبانه

API عمومی از شما نمی‌خواهد به هیچ‌کدام از این جزئیات فکر کنید. workbook را از مقادیر سلولی WideString می‌سازید و entry point خروجی دلخواهتان را صدا می‌زنید. تصمیم‌های encoding داخل هر writer گرفته می‌شوند. مثال زیر یک sheet را با متن در چند اسکریپت پر می‌کند و بعد از همان workbook هم یک فایل RTF و هم یک فایل HTML می‌نویسد، تا هر دو مسیر روی ورودی یکسان اجرا شوند

uses
  lxHandle;

procedure ExportMultilingualWorkbook;
var
  Book: IXLSWorkbook;
  Sheet: IXLSWorksheet;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Customers');

    Sheet.Cells[1, 1].Value := 'Name';
    Sheet.Cells[1, 2].Value := 'City';

    // Cell text is held as WideString, so every script survives the model.
    Sheet.Cells[2, 1].Value := '王伟';          // Chinese
    Sheet.Cells[2, 2].Value := '北京';
    Sheet.Cells[3, 1].Value := 'Müller';        // German umlaut
    Sheet.Cells[3, 2].Value := 'Köln';
    Sheet.Cells[4, 1].Value := 'Иванов';        // Cyrillic
    Sheet.Cells[4, 2].Value := 'Москва';
    Sheet.Cells[5, 1].Value := 'Désirée';       // French accents
    Sheet.Cells[5, 2].Value := 'Montréal';

    // RTF: the lxRTF writer declares the code page and emits every
    // non-ASCII character as a \u escape, keeping the file 7-bit clean.
    Book.SaveAsRTF('Customers.rtf');

    // HTML: sheet names are HTML-escaped and non-ASCII text is written
    // so it does not depend on a guessed response charset.
    Book.SaveAsHTML('Customers.html');
  finally
    Book := nil;
  end;
end;

هر دو فراخوانی یک status از نوع Integer برمی‌گردانند و هر دو همان متن یکسان درون حافظه را مصرف می‌کنند. در کد فراخواننده چیزی برای اعلام code page یا escape کردن نویسه‌ها وجود ندارد، چون این مسئولیت بر دوش writerای است که قالب خودش را می‌شناسد. اگر به یک خروجی جداشونده از همین منبع نیاز داشته باشید، SaveAsCSV در سطح workbook هم همین الگو را دنبال می‌کند

// Same workbook, a third export path with its own encoding rules.
Book.SaveAsCSV('Customers.csv');

امنیت Unicode وابسته به مسیر است، نه وابسته به کل کتابخانه

درسی که باید با خود ببرید این است که هیچ نقطه واحدی برای Unicode-safe بودن وجود ندارد. RTF به code page اعلام‌شده به‌علاوه escapeهای \u نیاز دارد. HTML به entity escaping برای نویسه‌های مهم در نشانه‌گذاری و در صورت نبود تضمین charset، به referenceهای عددی نیاز دارد، به‌اضافه decode درست entityهایی که از shared stringها می‌رسند. ظرف ZIP باید general-purpose bit 11 را set داشته باشد تا نام member مبتنی بر UTF-8 واقعاً به‌صورت UTF-8 خوانده شود. ارزیابی فرمول به case folding روی wide string و tokenizerای نیاز دارد که notation علمی را یک‌تکه نگه دارد. هرکدام از این‌ها یک قرارداد متفاوت است، و یک کتابخانه می‌تواند یکی را درست انجام دهد و دیگری را بی‌صدا نقض کند. دقیقاً به همین دلیل است که ابزاری که CSV را درست تحویل می‌دهد هنوز می‌تواند یک RTF پر از علامت سؤال به دستتان بدهد

اگر خروجی‌های شما بیشتر روی قالب‌های جداشونده تکیه دارند، بده‌بستان‌های بین آن‌ها در راهنمای ما درباره خروجی CSV، TSV و HTML پوشش داده شده است، و وقتی منبع داده یک result set است نه یک sheet دست‌ساز، الگوهای خروجی پایگاه‌داده برای گزارش‌های Delphi به‌طور طبیعی با قواعد encoding شرح‌داده‌شده در اینجا جفت می‌شوند. همه این قابلیت‌ها بخشی از HotXLS Component برای Delphi و C++Builder هستند، در کنار APIهای خواندن، فرمول و قالب‌بندی که در سایر بخش‌های این وبلاگ پوشش داده شده‌اند