Технічна стаття

Реалізація формату буфера обміну CF_HTML у Delphi

Скопіюйте діапазон із сітки Delphi й вставте його у Word — і форматування зазвичай зникає: простий текст, ні жирних заголовків, ні меж, ні заливок. HotXLS закриває цю прогалину через TXLSRange.CopyToClipboard, що кладе в буфер обміну корисне навантаження CF_HTML — формат Windows для стильованого HTML із точними до байта маркерами фрагмента — поряд зі звичайним текстом Unicode

Це звучить просто, доки не подивишся, чого насправді вимагає корисне навантаження CF_HTML. Формат потребує короткого текстового заголовка, що називає точно, де фрагмент починається й закінчується всередині більшого буфера обміну, і ці позиції — зсуви байтів, підраховані через яке б багатобайтове кодування не опинилось у HTML. Помилися в арифметиці хоч на один байт — і цільовий застосунок або захопить не той зріз розмітки, або здасться й відкотиться до простого тексту, і жоден з цих збоїв не виглядає як помилка у вашому коді — виглядає як «Word є Word»

Чому копіювання-вставлення з сітки Delphi зазвичай втрачає форматування

Типовий виклик буфера обміну Windows, до якого зазвичай звертається код Delphi, SetClipboardData з CF_TEXT чи CF_UNICODETEXT, несе лише прості символи, тож будь-яке стилювання, застосоване у вихідній сітці, немає куди подітися. Word, Outlook та кожен браузер на основі Chromium шукають багатший формат, коли ви вставляєте: HTML-представлення виділення, з вбудованими стилями, структурою таблиці й посиланнями. Сам Excel покладається саме на цей трюк — скопіюйте діапазон в Excel, і буфер обміну тихо отримує кілька форматів одночасно, серед них HTML, тож який би застосунок ви не вставляли, він обирає найбагатший, який розуміє. Компонент, що пише лише CF_UNICODETEXT, не дає жодному з цих багатших споживачів нічого для роботи, і візуальна багатство, яке користувач щойно скопіював, просто відсутнє для вставлення

Що таке формат буфера обміну CF_HTML?

CF_HTML — не фіксований системний формат буфера обміну на кшталт CF_TEXT; він динамічно реєструється, запитується за іменем через RegisterClipboardFormat('HTML Format'), а його корисне навантаження — короткий заголовок ASCII, за яким іде HTML-документ чи фрагмент. Заголовок несе п'ять полів — Version, StartHTML, EndHTML, StartFragment, EndFragment, — де Version завжди 0.9, а інші чотири — десяткові числа, записані як цифри ASCII. StartHTML та EndHTML обмежують увесь документ так, як приймаюча програма має розібрати його для контексту, включно зі шрифтами й стилями, тоді як StartFragment та EndFragment обмежують вужчий зріз, що справді потрапляє під курсор, за угодою позначений у самій розмітці коментарями <!--StartFragment--> та <!--EndFragment-->, щоб межі пережили наївну повторну серіалізацію

Зсуви байтів, а не кількість символів: класична пастка CF_HTML

Чотири числові поля заголовка CF_HTML — це зсуви байтів у точній послідовності байтів, що сидить у буфері обміну, підраховані від найпершого символу самого заголовка, — не кількість символів, не кодові точки Unicode, і не зсуви відносно фрагмента чи тега <body>. Саме на цій відмінності тихо ламаються саморобні реалізації CF_HTML: Length у UnicodeString Delphi повідомляє одиниці коду UTF-16, що випадково дорівнює кількості байтів для звичайного тексту ASCII, тож помилка проходить чисто через будь-який тест, написаний із англійськими даними-зразками, і проявляється лише тоді, коли скопійована клітинка містить тире, символ валюти чи символ із діакритикою — знак євро — одна одиниця коду UTF-16, але три байти в UTF-8, і кожен зсув, обчислений після цієї точки, зсувається на стільки додаткових байтів, скільки додало кодування. Наступний збій — не аварія; це приймаюча програма, що захоплює точний діапазон байтів, на який вказав заголовок, знаходить зріз розмітки, що починається чи закінчується посеред тега, і або рендерить сміття, або здається й відкочується до звичайного тексту, що лежить поруч у буфері обміну, мовчки, без нічого у вашому коді, що пояснило б чому — ось форма коду, що спричиняє точно цей збій:

// 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 уже повертають байтові позиції всюди в обчисленні — тут немає окремого кроку, а отже, немає кроку, який можна забути, де кількість символів Unicode потребувала б перетворення на кількість байтів перед потраплянням у заголовок

Є другий, менший трюк, вартий знання, якщо ви коли-небудь будуєте заголовок 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;

Чому корисне навантаження звичайного тексту все одно мусить їхати поряд

TXLSRange.CopyToClipboard ніколи не кладе CF_HTML у буфер обміну самотньо; він завжди записує CF_UNICODETEXT тим самим викликом, бо CF_HTML — зареєстрований формат, а не одна з фіксованих констант CF_*, які будь-який застосунок Windows уже знає, як шукати, — звичайний текстовий редактор, застаріла сітка чи будь-що, що ніколи не перевіряло 'HTML Format', взагалі його не побачить, і скопійований вами діапазон або прибуде як текст із роздільниками-табуляціями, або не прибуде взагалі. Цей текст із табуляціями теж не грубе наближення: клітинки формул копіюються як їхній рядок формули з відновленим початковим =, якщо збережений текст його втратив, відповідно до поведінки власного тексту буфера обміну Excel, звичайні клітинки копіюють свій FormattedText — рядок, як він відображається, тож клітинка з валютою копіюється як $1,234.56, а не базове 1234.56, — а будь-яке поле, що містить табуляцію, лапку чи розрив рядка, беруться в лапки з подвоєними внутрішніми лапками, за тією самою угодою, що й CSV

SaveAsHTML — не окремий шлях рендерингу, приладнаний лише для випадку буфера обміну. CopyToClipboard викликає той самий писар HTML, описаний у статті про експорт 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 — повний рендер діапазону, а не голе скидання даних: шрифти, кольори заливки, межі, числові формати та об'єднані клітинки — усе проходить як вбудовані стилі та структура таблиці, той самий механізм стилювання, описаний у посібнику HotXLS з умовного форматування та форматованого тексту, оскільки прогони форматованого тексту клітинки та результат умовного форматування обидва живлять той самий рендеринг, з якого читає CopyToClipboard. Що не переживає подорож — це жива поведінка формул: текстова форма клітинки формули несе рядок формули, тож ціль вставлення, що розуміє електронні таблиці, могла б у принципі перерахувати її, але форма HTML несе лише останній обчислений результат, бо HTML не має поняття формули, яку браузер чи текстовий процесор могли б обчислити

Перевірка вставлення й обробка зайнятого буфера обміну

Дві звички ловлять більшість проблем буфера обміну ще до того, як це зробить клієнт. Спочатку вставте в Notepad, щоб підтвердити, що резервний варіант CF_UNICODETEXT — здоровий текст із табуляціями, потім вставте ту саму копію у Word чи браузер, щоб підтвердити, що з'являється стильована версія — корисне навантаження, що виглядає правильним в одному й неправильним в іншому, зазвичай означає, що маркери фрагмента приземлилися не там. Потім трактуйте булевий результат, який повертає 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;

Сам формат не екзотичний, щойно заголовок точний до байта, а резервний варіант простого тексту чесний щодо того, що він містить, — він існує майже незмінним із того часу, коли Internet Explorer вперше його визначив, і кожен великий застосунок Windows досі читає його так само. CopyToClipboard сидить поряд із PasteFromClipboard, стороною читання того самого обміну, у ширшій поверхні буфера обміну та експорту, задокументованій на сторінці продукту компонента HotXLS