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

Реализиране на CF_HTML формата за клипборда в Delphi

Копирате диапазон от Delphi таблица и го поставяте в Word, но форматирането обикновено изчезва: остава обикновен текст без удебелени заглавия, рамки и запълване. HotXLS решава този проблем чрез TXLSRange.CopyToClipboard, който поставя в клипборда CF_HTML данни — Windows форматът за стилизиран HTML с маркери за фрагмента, зададени до точния байт — едновременно с обикновен Unicode текст

Това звучи просто, докато не разгледате какво всъщност изискват CF_HTML данните. Форматът има кратък текстов хедър, който точно посочва къде започва и завършва фрагментът в по-големия буфер на клипборда, а тези позиции са байтови отмествания, изчислени според многобайтовото кодиране, до което стига HTML съдържанието. Дори една сгрешена позиция кара целевото приложение да вземе неправилен откъс от маркировката или да се откаже и да премине към обикновен текст, като нито един от двата проблема не изглежда като грешка във вашия код — изглежда просто като поредния каприз на Word

Защо копирането от Delphi таблица обикновено губи форматирането

Стандартното извикване на Windows за клипборда, към което се обръща повечето Delphi код, SetClipboardData с CF_TEXT или CF_UNICODETEXT, пренася само символи и затова стиловете от изходната таблица няма къде да отидат. Word, Outlook и всеки браузър, базиран на Chromium, търсят по-богат формат при поставяне: HTML представяне на избраното със стилове на място, структура на таблица и връзки. Самият 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 на Delphi UnicodeString връща 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