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

Реализация формата буфера обмена 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, описанный в статье об экспорте HotXLS в CSV, TSV и HTML, а затем оборачивает то, что произвёл этот писатель, в конверт 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 нет понятия формулы, которую браузер или текстовый процессор мог бы вычислить

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

Две привычки ловят большинство проблем с буфером обмена прежде, чем это сделает клиент. Сначала вставьте в Блокнот, чтобы подтвердить, что запасной вариант 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