Artykuł techniczny

Implementacja formatu schowka CF_HTML w Delphi

Skopiuj zakres z siatki Delphi i wklej go do Worda, a formatowanie zwykle znika: zwykły tekst, bez pogrubionych nagłówków, bez obramowań, bez wypełnień. HotXLS zamyka tę lukę za pomocą TXLSRange.CopyToClipboard, który umieszcza w schowku ładunek CF_HTML — format Windows dla stylizowanego HTML z dokładnymi co do bajtu znacznikami fragmentu — obok zwykłego tekstu Unicode

To brzmi prosto, dopóki nie spojrzy się na to, czego naprawdę wymaga ładunek CF_HTML. Format potrzebuje krótkiego nagłówka tekstowego wskazującego dokładnie, gdzie fragment zaczyna się i kończy wewnątrz większego bufora schowka, a te pozycje to przesunięcia bajtowe, liczone przez dowolne wielobajtowe kodowanie, w którym ostatecznie znajdzie się HTML. Pomyl się w arytmetyce nawet o jeden bajt, a docelowa aplikacja albo pobierze zły fragment znaczników, albo się podda i przełączy na zwykły tekst, i żadna z tych awarii nie wygląda jak błąd w twoim kodzie — wygląda jak Word będący sobą

Dlaczego kopiowanie i wklejanie z siatki Delphi zwykle traci formatowanie

Domyślne wywołanie schowka Windows, po które sięga większość kodu Delphi, SetClipboardData z CF_TEXT lub CF_UNICODETEXT, niesie wyłącznie zwykłe znaki, więc jakikolwiek styl zastosowany w źródłowej siatce nie ma dokąd trafić. Word, Outlook i każda przeglądarka oparta na Chromium przy wklejaniu szukają bogatszego formatu: reprezentacji HTML zaznaczenia, kompletnej ze stylami inline, strukturą tabeli i linkami. Sam Excel opiera się dokładnie na tej sztuczce — skopiuj zakres w Excelu, a schowek po cichu otrzymuje kilka formatów naraz, w tym HTML, więc niezależnie od tego, do jakiej aplikacji wklejasz, wybiera ona najbogatszy format, który rozumie. Komponent, który zapisuje wyłącznie CF_UNICODETEXT, nie daje żadnemu z tych bogatszych odbiorców nic do pracy, a wizualne bogactwo, które użytkownik właśnie skopiował, po prostu nie jest dostępne do wklejenia

Czym dokładnie jest format schowka CF_HTML?

CF_HTML nie jest stałym systemowym formatem schowka jak CF_TEXT; jest formatem rejestrowanym dynamicznie, żądanym po nazwie przez RegisterClipboardFormat('HTML Format'), a jego ładunek to krótki nagłówek ASCII, po którym następuje dokument lub fragment HTML. Nagłówek niesie pięć pól — Version, StartHTML, EndHTML, StartFragment, EndFragment — gdzie Version to zawsze 0.9, a pozostałe cztery to liczby dziesiętne zapisane jako cyfry ASCII. StartHTML i EndHTML ograniczają cały dokument tak, jak powinna go analizować aplikacja odbierająca dla kontekstu, wliczając czcionki i style, podczas gdy StartFragment i EndFragment ograniczają węższy wycinek, który faktycznie ląduje przy kursorze, konwencjonalnie oznaczany w samym znaczniku komentarzami <!--StartFragment--> i <!--EndFragment-->, tak aby granice przetrwały naiwną ponowną serializację

Przesunięcia bajtowe, nie liczba znaków: klasyczna pułapka CF_HTML

Cztery liczbowe pola nagłówka CF_HTML to przesunięcia bajtowe w dokładnej sekwencji bajtów leżącej w schowku, liczone od samego pierwszego znaku samego nagłówka — nie liczba znaków, nie punkty kodowe Unicode i nie przesunięcia względem fragmentu czy znacznika <body>. To rozróżnienie jest miejscem, w którym ręcznie pisane implementacje CF_HTML po cichu się psują: Length dla UnicodeString w Delphi zgłasza jednostki kodu UTF-16, co akurat równa się liczbie bajtów dla zwykłego tekstu ASCII, więc błąd przechodzi czysto przez każdy test napisany z angielskimi danymi przykładowymi i ujawnia się dopiero, gdy skopiowana komórka zawiera pauzę, symbol waluty lub znak z akcentem — znak euro to jedna jednostka kodu UTF-16, ale trzy bajty w UTF-8, i każde przesunięcie obliczone po tym punkcie dryfuje o tyle dodatkowych bajtów, ile dodało kodowanie. Awaria, która następuje, to nie zawieszenie się programu; to aplikacja odbierająca, chwytająca dokładnie ten zakres bajtów, na który wskazywał nagłówek, znajdująca fragment znaczników zaczynający się lub kończący w środku znacznika, i albo renderująca bezsens, albo poddająca się i przełączająca na zwykły tekst leżący obok w schowku, po cichu, bez niczego w twoim kodzie, co wyjaśniałoby dlaczego — oto kształt kodu, który generuje dokładnie taką awarię:

// 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;

Jak HotXLS utrzymuje dokładność bajtową nagłówka

HotXLS unika tej klasy błędów strukturalnie: TXLSRange.CopyToClipboard oraz leżąca pod nim jednostka lxClipboard budują dokument CF_HTML i jego nagłówek w całości jako AnsiString, bajtowy typ łańcuchowy Delphi, więc Length i Pos od razu zwracają pozycje bajtowe wszędzie w obliczeniach — nie ma osobnego kroku, a więc nie ma kroku do zapomnienia, w którym liczba znaków Unicode musiałaby zostać przeliczona na liczbę bajtów, zanim trafi do nagłówka

Jest jeszcze druga, mniejsza sztuczka, warta poznania, jeśli kiedykolwiek zbudujesz nagłówek CF_HTML ręcznie. Nagłówek jest zapisywany dwukrotnie: raz z dziesięcioma zerowymi cyframi zastępującymi każde z czterech przesunięć, aby zmierzyć jego własną długość bajtową, i drugi raz z wstawionymi prawdziwymi przesunięciami. Ponieważ każde prawdziwe przesunięcie jest formatowane do tej samej stałej szerokości dziesięciu cyfr, drugi nagłówek wychodzi bajt w bajt tej samej długości co wersja z symbolami zastępczymi, i właśnie dlatego wcześniejszy pomiar pozostaje ważny po przepisaniu. Pomiń stałą szerokość, sformatuj liczbę zwykłym IntToStr, a nagłówek może skurczyć się lub urosnąć o jedną cyfrę między dwoma przebiegami, po cichu unieważniając każde przesunięcie, które po nim następuje:

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;

Dlaczego ładunek zwykłego tekstu wciąż musi jechać razem

TXLSRange.CopyToClipboard nigdy nie umieszcza CF_HTML w schowku samodzielnie; zawsze zapisuje w tym samym wywołaniu CF_UNICODETEXT, ponieważ CF_HTML jest formatem rejestrowanym, a nie jedną ze stałych stałych CF_*, których szuka każda aplikacja Windows z automatu — zwykły edytor tekstu, przestarzała siatka, czy cokolwiek, co nigdy nie sprawdzało 'HTML Format', w ogóle go nie zobaczy, a skopiowany zakres albo dotrze jako tekst rozdzielany tabulatorami, albo w ogóle nie dotrze. Ten tekst rozdzielany tabulatorami to też nie zgrubne przybliżenie: komórki z formułami kopiują się jako ich łańcuch formuły z przywróconym wiodącym znakiem =, jeśli zapisany tekst go pominął, zgodnie z tym, jak zachowuje się własny tekst schowka Excela, zwykłe komórki kopiują swój FormattedText — łańcuch tak, jak jest wyświetlany, więc komórka walutowa kopiuje się jako $1,234.56, a nie jako bazowe 1234.56 — a każde pole zawierające tabulator, cudzysłów lub znak nowej linii jest ujmowane w cudzysłów z podwojonymi cudzysłowami wewnętrznymi, tą samą konwencją, jakiej używa CSV

SaveAsHTML nie jest osobną ścieżką renderowania doklejoną wyłącznie na potrzeby przypadku schowka. CopyToClipboard wywołuje dokładnie ten sam moduł zapisu HTML opisany w eksporcie CSV, TSV i HTML w HotXLS, a następnie opakowuje to, co ten moduł wygeneruje, w kopertę CF_HTML zamiast zapisywać to jako samodzielny plik, więc wszystko, co jest prawdą dla tego HTML, przechodzi wprost do tego, co ląduje w schowku. Zebranie zakresu arkusza w oba formaty w jednym wywołaniu wygląda tak:

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;

Czy wklejony zakres zachowuje czcionki, kolory i scalone komórki?

Tak, ponieważ połowa ładunku dotycząca HTML jest pełnym renderowaniem zakresu, a nie gołym zrzutem danych: czcionki, kolory wypełnienia, obramowania, formaty liczb i scalone komórki — wszystko to przechodzi jako style inline i struktura tabeli, ta sama maszyneria stylowania opisana w przewodniku HotXLS po formatowaniu warunkowym i tekście sformatowanym, ponieważ zarówno przebiegi tekstu sformatowanego komórki, jak i wynik formatowania warunkowego zasilają to samo renderowanie, z którego czyta CopyToClipboard. To, co nie przetrwa podróży, to zachowanie żywej formuły: tekstowa postać komórki z formułą niesie łańcuch formuły, więc docelowe miejsce wklejenia świadome arkuszy kalkulacyjnych mogłoby w zasadzie ją przeliczyć, ale postać HTML niesie zawsze tylko ostatni obliczony wynik, ponieważ HTML nie ma pojęcia formuły, które przeglądarka lub edytor tekstu mogłyby obliczyć

Weryfikacja wklejenia i obsługa zajętego schowka

Dwa nawyki wyłapują większość problemów ze schowkiem, zanim zrobi to klient. Najpierw wklej do Notatnika, aby potwierdzić, że zapasowy CF_UNICODETEXT jest sensownym tekstem rozdzielanym tabulatorami, następnie wklej tę samą kopię do Worda lub przeglądarki, aby potwierdzić, że pojawia się wersja stylizowana — ładunek, który wygląda dobrze w jednym miejscu, a źle w drugim, zwykle oznacza, że znaczniki fragmentu wylądowały w złym miejscu. Następnie traktuj logiczny wynik zwracany przez CopyToClipboard jako znaczący, a nie dekoracyjny: OpenClipboard może zawieść, gdy inny proces trzyma schowek otwarty, co jest wystarczająco częste na zajętym pulpicie, że jedno niesprawdzone wywołanie w końcu wklei nic bez żadnego błędu wyjaśniającego dlaczego — a właśnie przed tym chroni poniższe ponowienie:

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;

Sam format nie jest egzotyczny, gdy tylko nagłówek jest dokładny co do bajtu, a zapasowy zwykły tekst jest uczciwy co do tego, co zawiera — istnieje w niemal niezmienionej formie odkąd Internet Explorer po raz pierwszy go zdefiniował, i każda ważniejsza aplikacja Windows nadal odczytuje go tak samo. CopyToClipboard stoi obok PasteFromClipboard, strony odczytu tej samej wymiany, w szerszej powierzchni schowka i eksportu udokumentowanej na stronie produktu komponentu HotXLS