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ę

Diagram ładunku schowka CF_HTML budowanego przez HotXLS w Delphi, pokazujący nagłówek ASCII o pięciu polach i dokument UTF-8 ze znacznikami komentarza StartFragment i EndFragment
Koperta CF_HTML to krótki nagłówek ASCII przed dokumentem UTF-8, z wklejanym wycinkiem oznaczonym komentarzami StartFragment i EndFragment

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ę:

// Kruche: Length() na UnicodeString liczy jednostki kodu UTF-16, a nie bajty
var
  Header: string;
  Fragment: string;
  StartFragmentOfs: Integer;
begin
  Header := 'Version:0.9'#13#10 + 'StartHTML:0000000000'#13#10 + '...';
  StartFragmentOfs := Length(Header) + Pos('<!--StartFragment-->', Fragment);
  // Symbol waluty, półpauza albo akcentowany znak umieszczony
  // przed tym punktem kosztuje jeden znak tutaj, ale dwa albo trzy bajty
  // po zakodowaniu dokumentu w UTF-8, więc StartFragmentOfs wskazuje teraz
  // przed miejsce, gdzie fragment faktycznie zaczyna się na realnym schowku
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

Diagram kontrastujący licznik jednostek kodowych UTF-16 z przesunięciami bajtów UTF-8 w nagłówku CF_HTML Delphi, gdzie znaki z akcentami i znak euro dryfują granice fragmentów
Jeden znak wielobajtowy przesuwa każdy offset bajtowy policzony po nim, więc HotXLS mierzy cały nagłówek w bajtach AnsiString, a nie w jednostkach kodowych

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 cyfr ASCII: stała szerokość wewnątrz, stała szerokość na zewnątrz
var
  Header: AnsiString;           // AnsiString.Length to liczba bajtów, a nie liczba znaków
  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);   // bezpiecznie zmierzyć raz, z wyprzedzeniem
  // ...oblicz prawdziwe przesunięcia względem dokumentu AnsiString...
  // potem przebuduj Header z prawdziwymi liczbami sformatowanymi do tej samej
  // 10-cyfrowej szerokości, więc jego długość bajtowa -- a zatem StartHtmlOfs --
  // nie zmienia się między przebiegiem z placeholderem a końcowym
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

Diagram CopyToClipboard HotXLS zapisującego CF_HTML i CF_UNICODETEXT do schowka Windows, więc Word i przeglądarki wklejają sformatowane tabele, podczas gdy zwykłe edytory dostają tekst rozdzielany tabulatorami
CopyToClipboard zawsze zapisuje połowę zwykłego tekstu obok HTML, więc każdy cel od Worda po Notatnik otrzymuje coś uczciwego

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');
    // Klasyczne zakresy TXLSWorkbook wystawiają identyczną metodę jak
    // 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);   // daj chwilę aplikacji, która trzyma schowek
  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