Техническая статья

Реализация формата буфера обмена 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 в буфере обмена, собранной HotXLS в Delphi: ASCII-заголовок из пяти полей и документ UTF-8 с маркерами-комментариями StartFragment и EndFragment
Конверт CF_HTML — это короткий ASCII-заголовок перед документом UTF-8, причём вставляемый срез помечен комментариями StartFragment и EndFragment

Байтовые смещения, а не количество символов: классическая ловушка CF_HTML

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

// Хрупко: Length() для UnicodeString считает UTF-16 кодовые единицы, а не байты
var
  Header: string;
  Fragment: string;
  StartFragmentOfs: Integer;
begin
  Header := 'Version:0.9'#13#10 + 'StartHTML:0000000000'#13#10 + '...';
  StartFragmentOfs := Length(Header) + Pos('<!--StartFragment-->', Fragment);
  // Символ валюты, длинное тире или любой диакритический символ, помещённый
  // до этой точки занимает здесь один символ, но два или три байта
  // как только документ закодирован в UTF-8, поэтому StartFragmentOfs теперь указывает
  // не доходя до того места, где фрагмент реально начинается в буфере обмена
end;

Как HotXLS сохраняет точность заголовка на уровне байтов

HotXLS структурно избегает этого класса ошибок: TXLSRange.CopyToClipboard и лежащий под ним модуль lxClipboard строят документ CF_HTML и его заголовок целиком как AnsiString, байтовый строковый тип Delphi, так что Length и Pos уже везде в расчётах возвращают байтовые позиции — здесь нет отдельного шага, а значит, и шага, который можно забыть, на котором количество символов Unicode нужно было бы преобразовать в количество байтов перед тем, как оно попадёт в заголовок

Диаграмма контраста: счёт кодовых единиц UTF-16 против байтовых смещений UTF-8 в заголовке CF_HTML в Delphi, где символы с диакритикой и знак евро сдвигают границы фрагмента
Один многобайтовый символ сдвигает каждое байтовое смещение, вычисленное после него, поэтому HotXLS меряет весь заголовок в байтах AnsiString, а не в кодовых единицах

Есть ещё один, менее заметный приём, который стоит знать, если вы когда-нибудь будете строить заголовок CF_HTML вручную. Заголовок пишется дважды: один раз с десятью нулевыми цифрами вместо каждого из четырёх смещений, чтобы можно было измерить его собственную байтовую длину, и ещё раз с уже подставленными реальными смещениями. Поскольку каждое реальное смещение форматируется с той же фиксированной десятизначной шириной, второй заголовок получается побайтово той же длины, что и версия с заглушками, — именно поэтому предыдущее измерение остаётся действительным после перезаписи. Пропустите фиксированную ширину, отформатируйте число простым IntToStr вместо этого, и заголовок может сжаться или вырасти на одну цифру между двумя проходами, незаметно делая недействительным каждое смещение, идущее после него:

const
  Placeholder = '0000000000';   // 10 ASCII цифр: фиксированная ширина на входе и выходе
var
  Header: AnsiString;           // AnsiString.Length — это счётчик байтов, а не символов
  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);   // безопасно измерить один раз заранее
  // ...вычислите реальные смещения относительно AnsiString-документа...
  // затем пересоберите Header с реальными числами, отформатированными в ту же
  // 10-значную ширину, чтобы его длина в байтах -- и, следовательно, StartHtmlOfs --
  // не менялась между проходом с заглушками и финальным
end;

Почему полезной нагрузке простого текста всё равно приходится ехать рядом

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

Диаграмма CopyToClipboard в HotXLS: запись CF_HTML и CF_UNICODETEXT в буфер обмена Windows, чтобы Word и браузеры вставляли стилизованные таблицы, а простые редакторы получали текст с разделителями табуляции
CopyToClipboard всегда пишет текстовую половину рядом с HTML, поэтому каждая цель от Word до Блокнота получает что-то честное

SaveAsHTML — не отдельный путь рендеринга, привинченный специально для случая буфера обмена. CopyToClipboard вызывает тот же самый писатель HTML, описанный в статье об экспорте HotXLS в CSV, TSV и HTML, а затем оборачивает то, что произвёл этот писатель, в конверт CF_HTML вместо сохранения как отдельного файла, так что всё, что верно для этого HTML, напрямую переносится в то, что попадает в буфер обмена. Сведение диапазона листа воедино в оба формата за один вызов выглядит так:

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarterly-report.xlsx');
    // Классические диапазоны TXLSWorkbook предоставляют тот же метод, что и
    // 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 нет понятия формулы, которую браузер или текстовый процессор мог бы вычислить

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

Две привычки ловят большинство проблем с буфером обмена прежде, чем это сделает клиент. Сначала вставьте в Блокнот, чтобы подтвердить, что запасной вариант 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);   // дайте приложению, удерживающему буфер обмена, мгновение
  end;
  if not Result then
    raise Exception.Create('Could not take ownership of the clipboard');
end;

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