Teknisk artikel

Implementera CF_HTML-urklippsformatet i Delphi

Kopiera ett intervall ur ett Delphi-rutnät och klistra in det i Word, och formateringen försvinner oftast: enkel text, inga fetstilta rubriker, inga kantlinjer, inga fyllningar. HotXLS tätar den luckan med TXLSRange.CopyToClipboard, som lägger en CF_HTML-urklippspayload — Windows format för formaterad HTML med byteexakta fragmentmarkörer — på urklipp bredvid vanlig Unicode-text

Det låter enkelt tills man tittar på vad en CF_HTML-payload egentligen kräver. Formatet behöver ett kort texthuvud som anger exakt var fragmentet börjar och slutar inuti den större urklippsbufferten, och de positionerna är byteoffsetar, räknade genom vilken flerbyteskodning HTML:en än hamnar i. Räknar man fel med bara en enda byte tar målapplikationen antingen fel del av markeringen eller ger upp och faller tillbaka till vanlig text, och ingetdera felet ser ut som en bugg i din kod — det ser ut som att Word bara är Word

Varför klipp-och-klistra från ett Delphi-rutnät oftast tappar formateringen

Standardanropet till Windows urklipp som de flesta Delphi-koder använder, SetClipboardData med CF_TEXT eller CF_UNICODETEXT, bär bara vanliga tecken, så all formatering som applicerats i käll-rutnätet har ingenstans att ta vägen. Word, Outlook och alla Chromium-baserade webbläsare letar efter ett rikare format när man klistrar in: en HTML-representation av markeringen, komplett med inline-stilar, tabellstruktur och länkar. Excel självt förlitar sig på exakt detta knep — kopiera ett intervall i Excel så tar urklipp tyst emot flera format på en gång, HTML bland dem, så vilken applikation man än klistrar in i väljer den rikaste den förstår. En komponent som bara någonsin skriver CF_UNICODETEXT ger var och en av dessa rikare mottagare ingenting att jobba med, och den visuella rikedomen användaren just kopierade finns helt enkelt inte där att klistra in

Vad exakt är CF_HTML-urklippsformatet?

CF_HTML är inte ett fast systemurklippsformat som CF_TEXT; det är ett dynamiskt registrerat format, begärt vid namn genom RegisterClipboardFormat('HTML Format'), och dess payload är ett kort ASCII-huvud följt av ett HTML-dokument eller -fragment. Huvudet bär fem fält — Version, StartHTML, EndHTML, StartFragment, EndFragment — där Version alltid är 0.9 och de andra fyra är decimaltal skrivna som ASCII-siffror. StartHTML och EndHTML avgränsar hela dokumentet som den mottagande applikationen bör tolka det för kontext, typsnitt och stilar inräknade, medan StartFragment och EndFragment avgränsar den smalare delen som faktiskt hamnar vid markören, konventionellt markerad i själva markeringen med kommentarerna <!--StartFragment--> och <!--EndFragment--> så att gränserna överlever naiv omserialisering

Byteoffsetar, inte teckenantal: den klassiska CF_HTML-fällan

CF_HTML:s fyra numeriska huvudfält är byteoffsetar in i den exakta bytesekvensen som ligger på urklipp, räknade från själva huvudets allra första tecken — inte teckenantal, inte Unicode-kodpunkter, och inte offsetar relativa till fragmentet eller <body>-taggen. Det är just den distinktionen där handbyggda CF_HTML-implementationer tyst går fel: en Delphi-UnicodeStrings Length rapporterar UTF-16-kodenheter, vilket råkar vara lika med bytantalet för vanlig ASCII-text, så buggen går rakt igenom varje test skrivet med engelska exempeldata och visar sig först när en kopierad cell innehåller ett tankstreck, en valutasymbol, eller ett accenttecken — ett eurotecken är en UTF-16-kodenhet men tre byte i UTF-8, och varje offset beräknad efter den punkten glider iväg med hur många extra byte kodningen än lade till. Felet som följer är ingen krasch; det är den mottagande applikationen som griper exakt det byteintervall huvudet pekade på, hittar en del av markeringen som börjar eller slutar mitt i en tagg, och antingen renderar skräp eller ger upp och faller tillbaka till vad för vanlig text som än ligger bredvid på urklipp, tyst, utan något i din kod som förklarar varför — här är formen på kod som producerar exakt det felet:

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

Hur HotXLS håller huvudet byteexakt

HotXLS undviker den här buggkategorin strukturellt: TXLSRange.CopyToClipboard och lxClipboard-enheten under den bygger CF_HTML-dokumentet och dess huvud helt som AnsiString, Delphis bytesträngtyp, så Length och Pos returnerar redan byteposition överallt i beräkningen — det finns inget separat steg, och därmed inget steg att glömma, där ett Unicode-teckenantal skulle behöva konverteras till ett bytantal innan det går in i huvudet

Det finns ett andra, mindre knep värt att känna till om man någonsin bygger ett CF_HTML-huvud för hand. Huvudet skrivs två gånger: en gång med tio nollsiffror som platshållare för var och en av de fyra offsetarna, så att dess egen bytelängd kan mätas, och en gång till med de riktiga offsetarna inklistrade. Eftersom varje riktig offset formateras till samma fasta tiosiffriga bredd blir det andra huvudet byte-för-byte lika långt som platshållarversionen, vilket är exakt varför den tidigare mätningen förblir giltig efter omskrivningen. Hoppar man över den fasta bredden, formaterar ett tal med en vanlig IntToStr istället, kan huvudet krympa eller växa med en siffra mellan de två passen, och tyst göra ogiltig varje offset som följer det:

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;

Varför den enkla textpayloaden ändå måste följa med

TXLSRange.CopyToClipboard lägger aldrig CF_HTML ensamt på urklipp; den skriver alltid CF_UNICODETEXT i samma anrop, eftersom CF_HTML är ett registrerat format snarare än en av de fasta CF_*-konstanterna varje Windows-applikation redan vet att leta efter — en enkel textredigerare, ett äldre rutnät, eller något som aldrig kontrollerade efter 'HTML Format' kommer inte att se det alls, och intervallet man kopierade antingen kommer som tabbavgränsad text eller kommer inte alls. Den tabbavgränsade texten är heller ingen grov approximation: formelceller kopieras som sin formelsträng med ett inledande = återställt om den lagrade texten tappade det, i linje med hur Excels egen urklippstext beter sig, vanliga celler kopierar sin FormattedText — strängen som den visas, så en valutacell kopieras som $1,234.56, inte det underliggande 1234.56 — och alla fält som innehåller en tabb, ett citattecken, eller en radbrytning citeras med inbäddade citattecken dubblerade, samma konvention som CSV använder

SaveAsHTML är ingen separat renderingsväg tillskruvad bara för urklippsfallet. CopyToClipboard anropar precis samma HTML-skrivare som beskrivs i HotXLS export till CSV, TSV och HTML, och sveper sedan in vad den skrivaren än producerar i CF_HTML-kuvertet istället för att spara det som en fristående fil, så allt som stämmer om den HTML:en följer rakt igenom till det som hamnar på urklipp. Att föra samman ett kalkylbladsintervall som båda formaten i ett anrop ser ut så här:

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åller det inklistrade intervallet sina typsnitt, färger och sammanslagna celler?

Ja, eftersom HTML-halvan av payloaden är en fullständig rendering av intervallet, inte en ren datadump: typsnitt, fyllningsfärger, kantlinjer, talformat och sammanslagna celler kommer alla igenom som inline-stilar och tabellstruktur, samma stilmaskineri som täcks i HotXLS guide till villkorsstyrd formatering och rich text, eftersom en cells rich text-segment och resultatet av villkorsstyrd formatering båda matar samma rendering CopyToClipboard läser från. Det som inte överlever resan är levande formelbeteende: en formelcells klartextform bär formelsträngen, så ett kalkylbladsmedvetet inklistringsmål skulle i princip kunna räkna om den, men HTML-formen bär bara någonsin det senast beräknade resultatet, eftersom HTML inte har något begrepp om en formel för en webbläsare eller ordbehandlare att utvärdera

Verifiera inklistringen, och hantera ett upptaget urklipp

Två vanor fångar de flesta urklippsproblem innan en kund gör det. Klistra först in i Anteckningar för att bekräfta att CF_UNICODETEXT-fallbacken är sund tabbavgränsad text, klistra sedan in samma kopia i Word eller en webbläsare för att bekräfta att den formaterade versionen dyker upp — en payload som ser rätt ut i det ena och fel i det andra betyder oftast att fragmentmarkörerna hamnade på fel plats. Behandla sedan det booleska resultatet CopyToClipboard returnerar som meningsfullt, inte dekorativt: OpenClipboard kan misslyckas när en annan process håller urklipp öppet, vanligt nog på ett upptaget skrivbord att ett okontrollerat anrop till slut klistrar in ingenting utan något fel som förklarar varför, vilket är vad omförsöket nedan skyddar mot:

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;

Formatet självt är inte exotiskt när huvudet väl är byteexakt och textfallbacken är ärlig om vad den innehåller — det har existerat i stort sett oförändrat sedan Internet Explorer först definierade det, och varje stor Windows-applikation läser det fortfarande på samma sätt. CopyToClipboard sitter tillsammans med PasteFromClipboard, lässidan av samma utbyte, i den bredare urklipps- och exportytan som dokumenteras på produktsidan för HotXLS-komponenten