Technický článek

Implementace formátu schránky CF_HTML v Delphi

Zkopírujte rozsah z mřížky v Delphi a vložte jej do Wordu, a formátování většinou zmizí: prostý text, žádná tučná záhlaví, žádné ohraničení, žádné výplně. HotXLS tuto mezeru uzavírá metodou TXLSRange.CopyToClipboard, která do schránky vedle prostého unikódového textu vloží payload schránky CF_HTML — formát Windows pro stylovaný HTML s bajtově přesnými značkami fragmentu

To zní jednoduše, dokud se nepodíváte na to, co payload CF_HTML skutečně vyžaduje. Formát potřebuje krátkou textovou hlavičku pojmenovávající přesně to, kde fragment uvnitř většího bufferu schránky začíná a končí, a tyto pozice jsou bajtové posuny, počítané přes jakékoli vícebajtové kódování, ve kterém HTML nakonec skončí. Spleťte si aritmetiku byť jen o jeden bajt a cílová aplikace buď popadne špatný úsek značkování, nebo to vzdá a spadne na prostý text, a ani jedno selhání nevypadá jako chyba ve vašem kódu — vypadá to, jako by se Word choval jako Word

Proč kopírování a vkládání z mřížky v Delphi obvykle ztratí formátování

Výchozí volání schránky Windows, po kterém většina kódu v Delphi sáhne, SetClipboardData s CF_TEXT nebo CF_UNICODETEXT, vždy nese jen prosté znaky, takže jakékoli stylování aplikované ve zdrojové mřížce nemá kam jít. Word, Outlook a každý prohlížeč postavený na Chromiu při vkládání hledají bohatší formát: reprezentaci výběru v HTML, kompletní s inline styly, strukturou tabulky a odkazy. Samotný Excel se spoléhá přesně na tento trik — zkopírujte rozsah v Excelu a schránka tiše obdrží několik formátů najednou, HTML mezi nimi, takže ať už vkládáte do jakékoli aplikace, ta si vybere nejbohatší formát, kterému rozumí. Komponenta, která zapisuje jen CF_UNICODETEXT, nedává žádnému z těchto bohatších konzumentů nic k práci, a vizuální bohatost, kterou uživatel právě zkopíroval, prostě není k dispozici k vložení

Co přesně je formát schránky CF_HTML?

CF_HTML není pevný systémový formát schránky jako CF_TEXT; je to dynamicky registrovaný formát, vyžádaný jménem přes RegisterClipboardFormat('HTML Format'), a jeho payload je krátká ASCII hlavička následovaná dokumentem nebo fragmentem HTML. Hlavička nese pět polí — Version, StartHTML, EndHTML, StartFragment, EndFragment — kde Version je vždy 0.9 a ostatní čtyři jsou desítková čísla zapsaná jako ASCII číslice. StartHTML a EndHTML ohraničují celý dokument tak, jak by jej přijímající aplikace měla parsovat kvůli kontextu, včetně fontů a stylů, zatímco StartFragment a EndFragment ohraničují užší úsek, který skutečně skončí na kurzoru, konvenčně označený přímo ve značkování komentáři <!--StartFragment--> a <!--EndFragment-->, aby hranice přežily naivní opětovnou serializaci

Bajtové posuny, ne počty znaků: klasická past CF_HTML

Čtyři číselná pole hlavičky CF_HTML jsou bajtové posuny do přesné sekvence bajtů sedící ve schránce, počítané od úplně prvního znaku samotné hlavičky — ne počty znaků, ne body kódu Unicode, a ne posuny relativní k fragmentu nebo tagu <body>. Toto rozlišení je místo, kde ručně psané implementace CF_HTML tiše selhávají: Length u UnicodeString v Delphi hlásí jednotky kódu UTF-16, což se náhodou rovná počtu bajtů u prostého ASCII textu, takže chyba projde čistě jakýmkoli testem napsaným s anglickými vzorovými daty a projeví se teprve ve chvíli, kdy zkopírovaná buňka drží pomlčku em, symbol měny, nebo znak s diakritikou — znak eura je jedna jednotka kódu UTF-16, ale tři bajty v UTF-8, a každý posun spočítaný po tomto bodě se posune o tolik dodatečných bajtů, kolik jich kódování přidalo. Selhání, které následuje, není pád; je to přijímající aplikace, která popadne přesně ten bajtový rozsah, na který hlavička ukazovala, najde úsek značkování, který začíná nebo končí uprostřed tagu, a buď vykreslí nesmysl, nebo to vzdá a spadne na jakýkoli prostý text sedící vedle na schránce, tiše, bez čehokoli ve vašem kódu, co by vysvětlilo proč — zde je tvar kódu, který přesně toto selhání produkuje:

// 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 udržuje hlavičku bajtově přesnou

HotXLS se této třídě chyb vyhýbá strukturálně: TXLSRange.CopyToClipboard a jednotka lxClipboard pod ní staví dokument CF_HTML a jeho hlavičku úplně jako AnsiString, bajtový řetězcový typ Delphi, takže Length a Pos už vrací bajtové pozice všude ve výpočtu — neexistuje žádný samostatný krok, a tedy žádný krok k zapomenutí, kde by bylo potřeba převést počet unikódových znaků na počet bajtů dřív, než se dostane do hlavičky

Existuje druhý, menší trik, který stojí za znalost, pokud kdy budete stavět hlavičku CF_HTML ručně. Hlavička se zapisuje dvakrát: jednou s deseti nulovými číslicemi zastupujícími každý ze čtyř posunů, aby se dala změřit vlastní bajtová délka, a znovu se skutečnými posuny doplněnými dovnitř. Protože je každý skutečný posun naformátován na stejnou pevnou šířku deseti číslic, druhá hlavička vyjde bajt po bajtu stejně dlouhá jako verze se zástupnými znaky, což je přesně důvod, proč dřívější měření zůstává platné i po přepsání. Vynechte pevnou šířku, naformátujte číslo obyčejným IntToStr místo toho, a hlavička se mezi oběma průchody může o číslici zmenšit nebo zvětšit, čímž tiše zneplatní každý posun, který po ní následuje:

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;

Proč musí payload prostého textu pořád jet s sebou

TXLSRange.CopyToClipboard nikdy neumístí CF_HTML na schránku samotné; ve stejném volání vždy zapíše i CF_UNICODETEXT, protože CF_HTML je registrovaný formát, ne jedna z pevných konstant CF_*, které už každá aplikace Windows ví, že má hledat — obyčejný textový editor, starší mřížka, nebo cokoli, co nikdy nekontrolovalo 'HTML Format', jej vůbec neuvidí, a rozsah, který jste zkopírovali, buď dorazí jako text oddělený tabulátory, nebo nedorazí vůbec. Tento text oddělený tabulátory navíc není hrubá aproximace: vzorcové buňky se kopírují jako svůj řetězec vzorce s obnoveným vedoucím =, pokud jej uložený text vypustil, což odpovídá tomu, jak se chová vlastní kopírovaný text Excelu, obyčejné buňky kopírují svůj FormattedText — řetězec tak, jak je zobrazen, takže buňka s měnou se zkopíruje jako 1 234,56 Kč, ne jako podkladová hodnota 1234.56 — a jakékoli pole obsahující tabulátor, uvozovku, nebo zalomení řádku se uzavře do uvozovek se zdvojenými vnořenými uvozovkami, stejná konvence, jakou používá CSV

SaveAsHTML není samostatná vykreslovací cesta přišroubovaná jen pro případ schránky. CopyToClipboard volá přesně ten samý zapisovač HTML popsaný v exportu CSV, TSV a HTML v HotXLS, a pak obalí cokoli, co tento zapisovač vyprodukuje, do obálky CF_HTML místo toho, aby to uložil jako samostatný soubor, takže cokoli je pravda o tomto HTML, se přenese přímo do toho, co skončí na schránce. Sloučit rozsah listu do obou formátů jedním voláním vypadá takto:

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;

Podrží si vložený rozsah své fonty, barvy a sloučené buňky?

Ano, protože HTML polovina payloadu je plné vykreslení rozsahu, ne holý výpis dat: fonty, barvy výplně, ohraničení, formáty čísel a sloučené buňky se všechny přenesou jako inline styly a struktura tabulky, stejný stylovací mechanismus popsaný v průvodci HotXLS pro podmíněné formátování a formátovaný text, protože běhy formátovaného textu buňky a výsledek podmíněného formátování živí totéž vykreslení, ze kterého CopyToClipboard čte. Co cestu nepřežije, je živé chování vzorce: prostá textová forma vzorcové buňky nese řetězec vzorce, takže cíl vkládání znalý tabulek by jej v principu mohl přepočítat, ale HTML forma vždy nese jen poslední spočítaný výsledek, protože HTML nemá koncept vzorce, který by prohlížeč nebo textový procesor mohl vyhodnotit

Ověření vložení a zvládnutí zaneprázdněné schránky

Dva zvyky zachytí většinu problémů se schránkou dřív, než je zachytí zákazník. Vložte nejdřív do Poznámkového bloku, abyste potvrdili, že náhrada CF_UNICODETEXT je rozumný text oddělený tabulátory, pak vložte stejnou kopii do Wordu nebo prohlížeče, abyste potvrdili, že se objeví stylovaná verze — payload, který vypadá správně v jednom a špatně v druhém, obvykle znamená, že značky fragmentu skončily na špatném místě. Pak berte booleovský výsledek, který CopyToClipboard vrací, jako smysluplný, ne dekorativní: OpenClipboard může selhat, když jiný proces drží schránku otevřenou, dost běžné na zaneprázdněném desktopu na to, že jedno nezkontrolované volání nakonec nevloží nic bez žádné chyby, která by vysvětlila proč — přesně proti tomu chrání opakování níže:

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;

Samotný formát není exotický, jakmile je hlavička bajtově přesná a náhrada v podobě prostého textu je poctivá ohledně toho, co obsahuje — existuje víceméně nezměněný od doby, kdy jej poprvé definoval Internet Explorer, a každá velká aplikace Windows jej pořád čte stejným způsobem. CopyToClipboard sedí vedle PasteFromClipboard, čtecí strany téže výměny, v širší ploše schránky a exportu zdokumentované na stránce produktu komponenta HotXLS