Technischer Artikel

Das CF_HTML-Clipboard-Format in Delphi implementieren

Kopieren Sie einen Bereich aus einem Delphi-Grid und fügen Sie ihn in Word ein, und die Formatierung verschwindet meist: einfacher Text, keine fetten Überschriften, keine Rahmen, keine Füllungen. HotXLS schließt diese Lücke mit TXLSRange.CopyToClipboard, das eine CF_HTML-Clipboard-Nutzlast — das Windows-Format für formatiertes HTML mit byteexakten Fragment-Markern — neben einfachem Unicode-Text in die Zwischenablage legt

Das klingt einfach, bis man sich ansieht, was eine CF_HTML-Nutzlast tatsächlich verlangt. Das Format braucht einen kurzen Text-Header, der exakt benennt, wo das Fragment innerhalb des größeren Clipboard-Puffers beginnt und endet, und diese Positionen sind Byte-Offsets, gezählt durch welche Mehrbyte-Kodierung das HTML auch immer am Ende hat. Verrechnet man sich auch nur um ein Byte, greift die Zielanwendung entweder den falschen Ausschnitt des Markups oder gibt auf und fällt auf einfachen Text zurück, und keiner der beiden Fehlschläge sieht wie ein Bug im eigenen Code aus — er sieht aus, als würde Word einfach Word sein

Warum Copy-Paste aus einem Delphi-Grid meist die Formatierung verliert

Der Standard-Windows-Clipboard-Aufruf, zu dem der meiste Delphi-Code greift, SetClipboardData mit CF_TEXT oder CF_UNICODETEXT, trägt immer nur reine Zeichen, sodass jede im Quell-Grid angewendete Formatierung nirgendwohin kann. Word, Outlook und jeder Chromium-basierte Browser suchen beim Einfügen nach einem reichhaltigeren Format: einer HTML-Darstellung der Auswahl, komplett mit Inline-Stilen, Tabellenstruktur und Links. Excel selbst verlässt sich genau auf diesen Trick — kopieren Sie einen Bereich in Excel, und die Zwischenablage erhält still mehrere Formate gleichzeitig, HTML darunter, sodass welche Anwendung Sie auch immer zum Einfügen verwenden, das reichhaltigste wählt, das sie versteht. Eine Komponente, die immer nur CF_UNICODETEXT schreibt, gibt jedem dieser reichhaltigeren Konsumenten nichts, womit sie arbeiten können, und die visuelle Reichhaltigkeit, die der Benutzer gerade kopiert hat, ist zum Einfügen einfach nicht da

Was genau ist das CF_HTML-Clipboard-Format?

CF_HTML ist kein festes System-Clipboard-Format wie CF_TEXT; es ist ein dynamisch registriertes, angefordert per Namen über RegisterClipboardFormat('HTML Format'), und seine Nutzlast ist ein kurzer ASCII-Header gefolgt von einem HTML-Dokument oder -Fragment. Der Header trägt fünf Felder — Version, StartHTML, EndHTML, StartFragment, EndFragment —, wobei Version immer 0.9 ist und die anderen vier Dezimalzahlen sind, ausgeschrieben als ASCII-Ziffern. StartHTML und EndHTML begrenzen das gesamte Dokument, so wie die empfangende Anwendung es für den Kontext parsen sollte, Schriften und Stile eingeschlossen, während StartFragment und EndFragment den engeren Ausschnitt begrenzen, der tatsächlich am Cursor landet, üblicherweise im Markup selbst mit den Kommentaren <!--StartFragment--> und <!--EndFragment--> markiert, damit die Grenzen eine naive Neu-Serialisierung überstehen

Byte-Offsets, keine Zeichenanzahl: die klassische CF_HTML-Falle

CF_HTMLs vier numerische Header-Felder sind Byte-Offsets in die exakte Bytefolge, die in der Zwischenablage liegt, gezählt ab dem allerersten Zeichen des Headers selbst — keine Zeichenanzahl, keine Unicode-Codepunkte, und keine Offsets relativ zum Fragment oder zum <body>-Tag. Genau an dieser Unterscheidung gehen handgestrickte CF_HTML-Implementierungen still schief: Die Length-Eigenschaft eines Delphi-UnicodeString meldet UTF-16-Codeeinheiten, was zufällig für reinen ASCII-Text der Byteanzahl entspricht, sodass der Bug problemlos durch jeden mit englischen Beispieldaten geschriebenen Test schlüpft und erst zutage tritt, sobald eine kopierte Zelle einen Halbgeviertstrich, ein Währungssymbol oder ein akzentuiertes Zeichen enthält — ein Eurozeichen ist eine UTF-16-Codeeinheit, aber drei Bytes in UTF-8, und jeder danach berechnete Offset driftet um so viele zusätzliche Bytes, wie die Kodierung hinzugefügt hat. Der daraus folgende Fehlschlag ist kein Absturz; die empfangende Anwendung greift sich exakt den Byte-Bereich, auf den der Header zeigte, findet einen Markup-Ausschnitt, der mitten in einem Tag beginnt oder endet, und rendert entweder Kauderwelsch oder gibt auf und fällt still auf was auch immer als einfacher Text daneben in der Zwischenablage liegt zurück, ohne dass irgendetwas in Ihrem Code erklärt, warum — hier die Form von Code, der genau diesen Fehlschlag erzeugt:

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

Wie HotXLS den Header byte-genau hält

HotXLS umgeht diese Fehlerklasse strukturell: TXLSRange.CopyToClipboard und die darunterliegende lxClipboard-Unit bauen das CF_HTML-Dokument und seinen Header vollständig als AnsiString, Delphis Byte-String-Typ, auf, sodass Length und Pos überall in der Berechnung bereits Byte-Positionen zurückgeben — es gibt keinen separaten Schritt, und daher keinen Schritt zu vergessen, bei dem eine Unicode-Zeichenanzahl vor dem Eintrag in den Header in eine Byteanzahl umgerechnet werden müsste

Es gibt einen zweiten, kleineren Trick, den es sich zu kennen lohnt, falls Sie jemals einen CF_HTML-Header von Hand bauen. Der Header wird zweimal geschrieben: einmal mit zehn Nullziffern als Platzhalter für jeden der vier Offsets, damit dessen eigene Bytelänge gemessen werden kann, und ein weiteres Mal mit den echten Offsets eingesetzt. Weil jeder echte Offset auf dieselbe feste Zehn-Ziffern-Breite formatiert wird, kommt der zweite Header Byte für Byte auf dieselbe Länge wie die Platzhalterversion heraus, genau deshalb bleibt die frühere Messung nach dem Neuschreiben gültig. Lässt man die feste Breite weg und formatiert eine Zahl stattdessen mit einem schlichten IntToStr, kann der Header zwischen den beiden Durchläufen um eine Ziffer schrumpfen oder wachsen, und macht dabei still jeden danach folgenden Offset ungültig:

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;

Warum die Klartext-Nutzlast trotzdem mitfahren muss

TXLSRange.CopyToClipboard legt CF_HTML nie allein in die Zwischenablage; es schreibt im selben Aufruf immer auch CF_UNICODETEXT, weil CF_HTML ein registriertes Format ist statt einer der festen CF_*-Konstanten, nach denen jede Windows-Anwendung bereits weiß zu suchen — ein einfacher Texteditor, ein Legacy-Grid oder alles, das nie nach 'HTML Format' geprüft hat, sieht es überhaupt nicht, und der kopierte Bereich kommt entweder als tabgetrennter Text an oder gar nicht. Dieser tabgetrennte Text ist auch keine grobe Annäherung: Formelzellen kopieren als ihr Formel-String mit einem wiederhergestellten führenden =, falls der gespeicherte Text es fallengelassen hatte, passend dazu, wie sich Excels eigener Clipboard-Text verhält, gewöhnliche Zellen kopieren ihren FormattedText — den String, wie angezeigt, sodass eine Währungszelle als $1.234,56 kopiert, nicht als der zugrunde liegende Wert 1234.56 —, und jedes Feld mit einem Tab, einem Anführungszeichen oder einem Zeilenumbruch wird zitiert, mit verdoppelten eingebetteten Anführungszeichen, dieselbe Konvention, die CSV verwendet

SaveAsHTML ist kein separater Rendering-Pfad, der nur für den Zwischenablage-Fall angeflanscht wurde. CopyToClipboard ruft genau denselben HTML-Writer auf, der in HotXLSs CSV-, TSV- und HTML-Export beschrieben wird, und wickelt dann, was dieser Writer produziert, in die CF_HTML-Hülle ein, statt es als eigenständige Datei zu speichern, sodass alles, was für dieses HTML zutrifft, direkt in das übergeht, was in der Zwischenablage landet. Einen Arbeitsblattbereich in einem Aufruf als beide Formate zusammenzuziehen sieht so aus:

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;

Behält der eingefügte Bereich seine Schriften, Farben und verbundenen Zellen?

Ja, weil die HTML-Hälfte der Nutzlast ein vollständiges Rendering des Bereichs ist, kein bloßer Datendump: Schriften, Füllfarben, Rahmen, Zahlenformate und verbundene Zellen kommen alle als Inline-Stile und Tabellenstruktur durch, dieselbe Formatierungsmaschinerie, die in HotXLSs Leitfaden zu bedingter Formatierung und Rich Text behandelt wird, da sowohl die Rich-Text-Runs einer Zelle als auch das Ergebnis der bedingten Formatierung dasselbe Rendering speisen, aus dem CopyToClipboard liest. Was die Reise nicht übersteht, ist lebendes Formelverhalten: Die Klartextform einer Formelzelle trägt den Formel-String, sodass ein tabellenkalkulationsbewusstes Einfügeziel ihn im Prinzip neu berechnen könnte, aber die HTML-Form trägt immer nur das zuletzt berechnete Ergebnis, weil HTML kein Konzept einer Formel hat, das ein Browser oder eine Textverarbeitung auswerten könnte

Das Einfügen verifizieren und eine belegte Zwischenablage behandeln

Zwei Gewohnheiten fangen die meisten Clipboard-Probleme ab, bevor ein Kunde es tut. Zuerst in Notepad einfügen, um zu bestätigen, dass der CF_UNICODETEXT-Fallback vernünftiger tabgetrennter Text ist, dann dieselbe Kopie in Word oder einen Browser einfügen, um zu bestätigen, dass die formatierte Version erscheint — eine Nutzlast, die in einem richtig und im anderen falsch aussieht, bedeutet meist, dass die Fragment-Marker am falschen Ort gelandet sind. Dann das boolesche Ergebnis, das CopyToClipboard zurückgibt, als bedeutsam behandeln, nicht als dekorativ: OpenClipboard kann fehlschlagen, wenn ein anderer Prozess die Zwischenablage offen hält, häufig genug auf einem ausgelasteten Desktop, dass ein ungeprüfter Aufruf irgendwann nichts einfügt, ohne Fehler, der erklärt, warum, wogegen der folgende Retry schützt:

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;

Das Format selbst ist nicht exotisch, sobald der Header byte-genau ist und der Klartext-Fallback ehrlich darüber ist, was er enthält — es existiert weitgehend unverändert, seit der Internet Explorer es erstmals definierte, und jede größere Windows-Anwendung liest es weiterhin auf dieselbe Weise. CopyToClipboard steht neben PasteFromClipboard, der Lese-Seite desselben Austauschs, in der breiteren Clipboard- und Export-Oberfläche, die auf der Produktseite der HotXLS-Komponente dokumentiert ist