مقاله فنی

پیاده‌سازی قالب کلیپ‌بورد CF_HTML در Delphi

یک بازه را از یک grid در Delphi کپی کنید و در Word پیست کنید، و قالب‌بندی معمولاً ناپدید می‌شود: متن ساده، بدون هدر پررنگ، بدون حاشیه، بدون پرشدگی. HotXLS این شکاف را با TXLSRange.CopyToClipboard می‌بندد، که یک payload کلیپ‌بورد CF_HTML — قالب ویندوز برای HTML استایل‌دار با نشانگرهای قطعه‌ی بایت-دقیق — را کنار متن یونیکد ساده روی کلیپ‌بورد قرار می‌دهد

این تا زمانی که به آنچه یک payload CF_HTML واقعاً نیاز دارد نگاه کنید ساده به‌نظر می‌رسد. این قالب به یک هدر متنی کوتاه نیاز دارد که دقیقاً می‌گوید قطعه کجا درون بافر بزرگ‌تر کلیپ‌بورد شروع و تمام می‌شود، و آن موقعیت‌ها افست‌های بایتی هستند، شمارش‌شده در سراسر هر encoding چندبایتی‌ای که HTML در نهایت در آن قرار می‌گیرد. محاسبات را حتی با یک بایت اشتباه بگیرید و برنامه‌ی هدف یا تکه‌ی اشتباهی از markup را می‌گیرد یا تسلیم می‌شود و به متن ساده فرومی‌گردد، و هیچ‌کدام از این شکست‌ها شبیه یک باگ در کد شما به‌نظر نمی‌رسند — شبیه این است که Word باشد

چرا کپی-پیست از یک grid در Delphi معمولاً قالب‌بندی‌اش را از دست می‌دهد

فراخوانی پیش‌فرض کلیپ‌بورد ویندوز که اغلب کدهای Delphi به سراغش می‌روند، SetClipboardData با CF_TEXT یا CF_UNICODETEXT، فقط تا به‌حال نویسه‌های ساده حمل می‌کند، پس هر استایلی که در grid منبع اعمال شده جایی برای رفتن ندارد. Word، Outlook، و هر مرورگر مبتنی بر Chromium وقتی پیست می‌کنید به‌دنبال یک قالب غنی‌تر می‌گردند: یک نمایش HTML از انتخاب، کامل با استایل‌های inline، ساختار جدول، و لینک‌ها. خودِ اکسل دقیقاً به همین ترفند تکیه می‌کند — یک بازه را در اکسل کپی کنید و کلیپ‌بورد بی‌سروصدا چندین قالب را همزمان دریافت می‌کند، HTML از جمله آن‌ها، پس هر برنامه‌ای که در آن پیست می‌کنید غنی‌ترین قالبی که می‌فهمد را انتخاب می‌کند. کامپوننتی که فقط تا به‌حال CF_UNICODETEXT می‌نویسد به هر یک از آن مصرف‌کننده‌های غنی‌تر هیچ چیزی برای کار‌کردن نمی‌دهد، و غنای بصری‌ای که کاربر همین الان کپی کرده به‌سادگی برای پیست‌شدن آنجا نیست

دقیقاً قالب کلیپ‌بورد CF_HTML چیست؟

CF_HTML یک قالب کلیپ‌بورد سیستمی ثابت مثل CF_TEXT نیست؛ این یک قالب پویا-ثبت‌شده است، درخواست‌شده با نام از طریق RegisterClipboardFormat('HTML Format')، و payload آن یک هدر ASCII کوتاه به‌دنبال یک سند یا قطعه‌ی HTML است. هدر پنج فیلد حمل می‌کند — Version، StartHTML، EndHTML، StartFragment، EndFragment — که در آن Version همیشه 0.9 است و چهارتای دیگر اعداد دهدهی‌ای هستند که به‌عنوان رقم‌های ASCII نوشته شده‌اند. StartHTML و EndHTML کل سند را همان‌طور که برنامه‌ی دریافت‌کننده باید آن را برای زمینه تجزیه کند، فونت‌ها و استایل‌ها شامل، محدود می‌کنند، درحالی‌که StartFragment و EndFragment تکه‌ی محدودتری را که واقعاً روی نشانگر فرود می‌آید محدود می‌کنند، به‌طور متعارف در خودِ markup با کامنت‌های <!--StartFragment--> و <!--EndFragment--> نشانه‌گذاری‌شده پس مرزها از دوباره-سریالایز‌شدن ساده‌لوحانه جان سالم به‌در می‌برند

افست‌های بایتی، نه شمارش نویسه: تله‌ی کلاسیک CF_HTML

چهار فیلد عددی هدر CF_HTML افست‌های بایتی درون دنباله‌ی دقیق بایت‌های نشسته روی کلیپ‌بورد هستند، شمارش‌شده از اولین نویسه‌ی خودِ هدر — نه شمارش نویسه، نه نقاط کد یونیکد، و نه افست‌های نسبی به قطعه یا برچسب <body>. آن تمایز جایی است که پیاده‌سازی‌های CF_HTML دستی‌نوشته بی‌سروصدا اشتباه می‌روند: Length یک UnicodeString در Delphi واحدهای کد UTF-16 را گزارش می‌دهد، که اتفاقاً برای متن ASCII ساده با شمارش بایت برابر است، پس این باگ تمیز از هر تستی که با داده‌ی نمونه‌ی انگلیسی نوشته شده عبور می‌کند و فقط زمانی نمایان می‌شود که یک سلول کپی‌شده یک em dash، یک نماد ارزی، یا یک نویسه‌ی دارای تلفظ داشته باشد — یک نشان یورو یک واحد کد UTF-16 است اما سه بایت در UTF-8، و هر افستی که پس از آن نقطه محاسبه شده به‌اندازه‌ی هر تعداد بایت اضافه‌ای که encoding افزوده، منحرف می‌شود. شکستی که به‌دنبال آن می‌آید یک کرش نیست؛ این است که برنامه‌ی دریافت‌کننده دقیقاً همان بازه‌ی بایتی‌ای که هدر به آن اشاره کرده را می‌گیرد، تکه‌ای از markup می‌یابد که در میانه‌ی یک تگ شروع یا تمام می‌شود، و یا زباله را رندر می‌کند یا تسلیم می‌شود و به هر متن ساده‌ای که کنارش روی کلیپ‌بورد نشسته فرومی‌گردد، بی‌سروصدا، بدون هیچ چیزی در کد شما برای توضیح اینکه چرا — این شکل کدی است که دقیقاً همان شکست را تولید می‌کند:

// Fragile: Length() on a UnicodeString counts UTF-16 code units, not bytes
var
  Header: string;
  Fragment: string;
  StartFragmentOfs: Integer;
begin
  Header := 'Version:0.9'#13#10 + 'StartHTML:0000000000'#13#10 + '...';
  StartFragmentOfs := Length(Header) + Pos('<!--StartFragment-->', Fragment);
  // A currency symbol, an em dash, or any accented character placed
  // before this point costs one character here but two or three bytes
  // once the document is UTF-8 encoded, so StartFragmentOfs now points
  // short of where the fragment actually begins on the real clipboard
end;

HotXLS چطور هدر را بایت-دقیق نگه می‌دارد

HotXLS این رده از باگ را ساختاری اجتناب می‌کند: TXLSRange.CopyToClipboard و واحد lxClipboard زیر آن، سند CF_HTML و هدرش را کاملاً به‌عنوان AnsiString، نوع رشته‌ی بایتی Delphi، می‌سازند، پس Length و Pos از پیش موقعیت‌های بایتی را همه‌جا در محاسبات برمی‌گردانند — هیچ گام جداگانه‌ای وجود ندارد، و بنابراین هیچ گامی برای فراموش‌کردن، جایی که یک شمارش نویسه‌ی یونیکد نیاز به تبدیل به یک شمارش بایت پیش از رفتن درون هدر داشت

یک ترفند دوم و کوچک‌تر ارزش دانستن دارد اگر هرگز یک هدر CF_HTML را دستی بسازید. هدر دوبار نوشته می‌شود: یک‌بار با ده رقم صفر که جای هر یک از چهار افست را می‌گیرند، پس طول بایتی خودش قابل‌اندازه‌گیری باشد، و یک‌بار دیگر با افست‌های واقعی که وصله شده‌اند. چون هر افست واقعی به همان عرض ده‌رقمی ثابت قالب‌بندی می‌شود، هدر دوم دقیقاً همان طول بایت‌به‌بایت نسخه‌ی جانگه‌دار درمی‌آید، که دقیقاً همان چیزی است که اندازه‌گیری قبلی را پس از بازنویسی معتبر نگه می‌دارد. عرض ثابت را رد کنید، یک عدد را با یک IntToStr ساده به‌جای آن قالب‌بندی کنید، و هدر می‌تواند بین دو پاس یک رقم کوچک یا بزرگ شود، و بی‌سروصدا هر افستی که به‌دنبال آن می‌آید را باطل کند:

const
  Placeholder = '0000000000';   // 10 ASCII digits: fixed width in, fixed width out
var
  Header: AnsiString;           // AnsiString.Length is a byte count, not a char count
  StartHtmlOfs: Integer;
begin
  Header := 'Version:0.9'#13#10 +
    'StartHTML:' + Placeholder + #13#10 +
    'EndHTML:' + Placeholder + #13#10 +
    'StartFragment:' + Placeholder + #13#10 +
    'EndFragment:' + Placeholder + #13#10;
  StartHtmlOfs := Length(Header);   // safe to measure once, up front
  // ...compute the real offsets against the AnsiString document...
  // then rebuild Header with the real numbers formatted to the same
  // 10-digit width, so its byte length -- and therefore StartHtmlOfs --
  // never moves between the placeholder pass and the final one
end;

چرا payload متن ساده همچنان باید همراهی کند

TXLSRange.CopyToClipboard هرگز CF_HTML را تنها روی کلیپ‌بورد قرار نمی‌دهد؛ همیشه CF_UNICODETEXT را در همان فراخوانی می‌نویسد، چون CF_HTML یک قالب ثبت‌شده است نه یکی از ثابت‌های ثابت CF_*ای که هر برنامه‌ی ویندوز از پیش می‌داند به‌دنبالش بگردد — یک ویرایشگر متن ساده، یک grid قدیمی، یا هر چیزی که هرگز برای 'HTML Format' بررسی نکرده اصلاً آن را نمی‌بیند، و بازه‌ای که کپی کردید یا به‌صورت متن جداشده‌با-تب می‌رسد یا نمی‌رسد. آن متن جداشده‌با-تب هم یک تقریب خام نیست: سلول‌های فرمول به‌عنوان رشته‌ی فرمول‌شان با یک = پیشرو بازگردانده‌شده اگر متن ذخیره‌شده آن را افتاده باشد کپی می‌شوند، مطابق با اینکه متن کلیپ‌بورد خودِ اکسل چطور رفتار می‌کند، سلول‌های معمولی FormattedText خودشان — رشته همان‌طور که نمایش داده می‌شود، پس یک سلول ارزی به‌صورت $1,234.56 کپی می‌شود، نه 1234.56 زیرین — کپی می‌کنند، و هر فیلدی که یک تب، یک نقل‌قول، یا یک شکست خط داشته باشد با نقل‌قول‌های جاسازی‌شده‌ی دوبرابرشده نقل‌قول می‌شود، همان قرارداد CSV

SaveAsHTML یک مسیر رندر جداگانه نیست که فقط برای حالت کلیپ‌بورد پیچ شده باشد. CopyToClipboard همان نویسنده‌ی HTML توصیف‌شده در export به CSV، TSV، و HTML در HotXLS را فرا می‌خواند، سپس هر آنچه آن نویسنده تولید کند را در پاکت CF_HTML می‌پیچد به‌جای اینکه آن را به‌عنوان یک فایل مستقل ذخیره کند، پس هر چیزی که درباره‌ی آن HTML درست است مستقیم به آنچه روی کلیپ‌بورد فرود می‌آید حمل می‌شود. کشیدن یک بازه‌ی برگ‌کاری با هم به‌عنوان هر دو قالب در یک فراخوانی این‌طور به‌نظر می‌رسد:

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarterly-report.xlsx');
    // Classic TXLSWorkbook ranges expose the identical method as
    // Workbook.Sheets[1].Range['A1', 'F40'].CopyToClipboard
    if Book.Sheets[1].Range['A1:F40'].CopyToClipboard then
      ShowMessage('Range copied - press Ctrl+V in Word or a browser')
    else
      ShowMessage('Clipboard was busy; see the retry pattern below');
  finally
    Book.Free;
  end;
end;

آیا بازه‌ی پیست‌شده فونت‌ها، رنگ‌ها، و سلول‌های ادغام‌شده‌اش را نگه می‌دارد؟

بله، چون نیمه‌ی HTML payload یک رندر کامل از بازه است، نه یک تخلیه‌ی داده‌ی عریان: فونت‌ها، رنگ‌های پرشدگی، حاشیه‌ها، قالب‌های عددی، و سلول‌های ادغام‌شده همگی به‌عنوان استایل‌های inline و ساختار جدول عبور می‌کنند، همان ماشین‌آلات استایل‌دهی که در راهنمای HotXLS درباره‌ی قالب‌بندی شرطی و متن غنی پوشش داده شده، چون run‌های متن غنی یک سلول و نتیجه‌ی قالب‌بندی شرطی هر دو همان رندری را که CopyToClipboard از آن می‌خواند تغذیه می‌کنند. آنچه از این سفر جان سالم به‌در نمی‌برد رفتار فرمول-زنده است: شکل متن-ساده‌ی یک سلول فرمول رشته‌ی فرمول را حمل می‌کند، پس یک هدف پیست آگاه از صفحه‌گسترده می‌تواند اصولاً آن را دوباره محاسبه کند، اما شکل HTML فقط تا به‌حال آخرین نتیجه‌ی محاسبه‌شده را حمل می‌کند، چون HTML هیچ مفهومی از یک فرمول برای یک مرورگر یا واژه‌پرداز برای ارزیابی ندارد

تأیید پیست، و مدیریت یک کلیپ‌بورد مشغول

دو عادت اغلب مسائل کلیپ‌بورد را پیش از اینکه یک مشتری بگیرد، می‌گیرند. ابتدا در Notepad پیست کنید تا تأیید کنید fallback CF_UNICODETEXT متن جداشده‌با-تب معقولی است، سپس همان کپی را در Word یا یک مرورگر پیست کنید تا تأیید کنید نسخه‌ی استایل‌دار ظاهر می‌شود — یک payload که در یکی درست به‌نظر می‌رسد و در دیگری اشتباه، معمولاً یعنی نشانگرهای قطعه در جای اشتباه فرود آمده‌اند. سپس نتیجه‌ی بولی‌ای که CopyToClipboard برمی‌گرداند را به‌عنوان معنادار در نظر بگیرید، نه تزئینی: OpenClipboard می‌تواند وقتی فرآیند دیگری کلیپ‌بورد را باز نگه داشته شکست بخورد، به‌اندازه‌ی کافی رایج روی یک دسکتاپ شلوغ که یک فراخوانی بررسی‌نشده در نهایت چیزی پیست نمی‌کند بدون هیچ خطایی برای توضیح اینکه چرا، که این همان چیزی است که تلاش‌مجدد زیر در برابرش محافظت می‌کند:

function TryCopyRangeToClipboard(Workbook: TXLSXWorkbook): Boolean;
var
  Attempt: Integer;
begin
  Result := False;
  for Attempt := 1 to 5 do
  begin
    Result := Workbook.Sheets[1].Range['A1:F40'].CopyToClipboard;
    if Result then
      Break;
    Sleep(50);   // give whichever app is holding the clipboard a moment
  end;
  if not Result then
    raise Exception.Create('Could not take ownership of the clipboard');
end;

خودِ این قالب زمانی که هدر بایت-دقیق باشد و fallback متن ساده درباره‌ی آنچه حمل می‌کند صادق باشد، عجیب نیست — این تقریباً بدون تغییر از زمانی که Internet Explorer برای اولین‌بار آن را تعریف کرد وجود داشته، و هر برنامه‌ی عمده‌ی ویندوز همچنان آن را به همان شکل می‌خواند. CopyToClipboard کنار PasteFromClipboard، سمت خواندن همان تبادل، در سطح گسترده‌تر کلیپ‌بورد و export مستند‌شده در صفحه‌ی محصول کامپوننت HotXLS می‌نشیند