Artigo Técnico

Implementar o Formato de Área de Transferência CF_HTML em Delphi

Copie um intervalo de uma grelha Delphi e cole-o no Word, e a formatação normalmente desaparece: texto simples, sem cabeçalhos a negrito, sem contornos, sem preenchimentos. O HotXLS fecha essa lacuna com TXLSRange.CopyToClipboard, que coloca um payload de área de transferência CF_HTML — o formato do Windows para HTML estilizado com marcadores de fragmento exatos ao byte — na área de transferência a par de texto Unicode simples

Isso parece simples até se olhar para o que um payload CF_HTML efetivamente exige. O formato precisa de um pequeno cabeçalho de texto que nomeie exatamente onde o fragmento começa e termina dentro do buffer maior da área de transferência, e essas posições são deslocamentos de byte, contados através de seja qual for a codificação multi-byte em que o HTML acabe. Errar a aritmética por um único byte que seja e a aplicação alvo ou agarra a fatia errada de marcação ou desiste e recua para texto simples, e nenhuma das falhas parece um bug no seu código — parece o Word a ser o Word

Porque perde normalmente a cópia-colagem de uma grelha Delphi a sua formatação?

A chamada de área de transferência predefinida do Windows a que a maioria do código Delphi recorre, SetClipboardData com CF_TEXT ou CF_UNICODETEXT, só alguma vez transporta caracteres simples, pelo que qualquer estilo aplicado na grelha de origem não tem para onde ir. O Word, o Outlook, e todos os navegadores baseados em Chromium procuram um formato mais rico ao colar: uma representação HTML da seleção, completa com estilos inline, estrutura de tabela, e ligações. O próprio Excel apoia-se exatamente nesse truque — copie um intervalo no Excel e a área de transferência recebe discretamente vários formatos ao mesmo tempo, o HTML entre eles, pelo que qualquer aplicação em que se cole escolhe o mais rico que entenda. Um componente que só alguma vez escreve CF_UNICODETEXT entrega a cada um desses consumidores mais ricos nada com que trabalhar, e a riqueza visual que o utilizador acabou de copiar simplesmente não está lá para colar

O que é exatamente o formato de área de transferência CF_HTML?

CF_HTML não é um formato fixo do sistema de área de transferência como CF_TEXT; é um registado dinamicamente, pedido pelo nome através de RegisterClipboardFormat('HTML Format'), e o seu payload é um pequeno cabeçalho ASCII seguido de um documento ou fragmento HTML. O cabeçalho transporta cinco campos — Version, StartHTML, EndHTML, StartFragment, EndFragment — onde Version é sempre 0.9 e os outros quatro são números decimais escritos como dígitos ASCII. StartHTML e EndHTML delimitam todo o documento tal como a aplicação recetora o deve analisar para contexto, incluindo tipos de letra e estilos, enquanto StartFragment e EndFragment delimitam a fatia mais estreita que efetivamente cai no cursor, convencionalmente marcada na própria marcação com comentários <!--StartFragment--> e <!--EndFragment-->, para que os limites sobrevivam a uma reserialização ingénua

Deslocamentos de byte, não contagens de caracteres: a armadilha clássica do CF_HTML

Os quatro campos numéricos do cabeçalho CF_HTML são deslocamentos de byte na sequência exata de bytes pousada na área de transferência, contados a partir do próprio primeiro caractere do cabeçalho — não contagens de caracteres, não pontos de código Unicode, e não deslocamentos relativos ao fragmento ou à etiqueta <body>. Essa distinção é onde implementações de CF_HTML feitas à mão falham discretamente: o Length de um UnicodeString Delphi reporta unidades de código UTF-16, que calham de ser iguais à contagem de bytes para texto ASCII simples, pelo que o bug passa limpo por qualquer teste escrito com dados de amostra em inglês e só aparece assim que uma célula copiada contém um travessão longo, um símbolo de moeda, ou um caractere acentuado — um símbolo de euro é uma unidade de código UTF-16 mas três bytes em UTF-8, e cada deslocamento calculado depois desse ponto desvia-se por quantos bytes extra a codificação acrescentou. A falha que se segue não é um crash; é a aplicação recetora a agarrar o intervalo de bytes exato para onde o cabeçalho apontou, a encontrar uma fatia de marcação que começa ou termina a meio de uma etiqueta, e ou a renderizar lixo ou a desistir e recuar para o que quer que texto simples esteja ao lado na área de transferência, silenciosamente, sem nada no seu código a explicar porquê — eis a forma de código que produz exatamente essa falha:

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

Como mantém o HotXLS o cabeçalho exato ao byte

O HotXLS evita esta classe de bug estruturalmente: TXLSRange.CopyToClipboard e a unidade lxClipboard por baixo dela constroem o documento CF_HTML e o seu cabeçalho inteiramente como AnsiString, o tipo de cadeia de bytes do Delphi, pelo que Length e Pos já devolvem posições de byte em toda a parte do cálculo — não há um passo separado, e portanto nenhum passo a esquecer, onde uma contagem de caracteres Unicode precisasse de ser convertida numa contagem de bytes antes de entrar no cabeçalho

Há um segundo truque, mais pequeno, que vale a pena conhecer caso alguma vez se construa um cabeçalho CF_HTML à mão. O cabeçalho é escrito duas vezes: uma com dez dígitos zero a substituir cada um dos quatro deslocamentos, para que o seu próprio comprimento em bytes possa ser medido, e outra com os deslocamentos reais colocados. Porque cada deslocamento real é formatado para essa mesma largura fixa de dez dígitos, o segundo cabeçalho sai com exatamente o mesmo comprimento, byte a byte, que a versão de marcador de posição, o que é exatamente a razão pela qual a medição anterior permanece válida após a reescrita. Ignore a largura fixa, formate um número com um simples IntToStr em vez disso, e o cabeçalho pode encolher ou crescer um dígito entre as duas passagens, invalidando discretamente cada deslocamento que se segue

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;

Porque tem o payload de texto simples de continuar a acompanhar

TXLSRange.CopyToClipboard nunca coloca CF_HTML sozinho na área de transferência; escreve sempre CF_UNICODETEXT na mesma chamada, porque CF_HTML é um formato registado em vez de uma das constantes fixas CF_* que todas as aplicações Windows já sabem procurar — um editor de texto simples, uma grelha legada, ou qualquer coisa que nunca tenha verificado 'HTML Format' não o verá de todo, e o intervalo que copiou ou chega como texto delimitado por tabulações ou não chega. Esse texto delimitado por tabulações também não é uma aproximação grosseira: as células de fórmula copiam-se como a sua cadeia de fórmula com um = inicial restaurado se o texto armazenado o tiver perdido, correspondendo ao comportamento do próprio texto de área de transferência do Excel, as células comuns copiam o seu FormattedText — a cadeia tal como apresentada, pelo que uma célula de moeda copia como $1.234,56, não o valor subjacente 1234.56 — e qualquer campo que contenha uma tabulação, uma aspa, ou uma quebra de linha é colocado entre aspas com as aspas embutidas duplicadas, a mesma convenção que o CSV usa

SaveAsHTML não é um caminho de renderização separado, aparafusado apenas para o caso da área de transferência. CopyToClipboard chama exatamente o mesmo escritor HTML descrito em a exportação CSV, TSV, e HTML do HotXLS, e depois envolve o que quer que esse escritor produza no envelope CF_HTML em vez de o guardar como um ficheiro autónomo, pelo que tudo o que é verdade sobre esse HTML transporta-se diretamente para o que cai na área de transferência. Reunir um intervalo de folha de cálculo em ambos os formatos numa chamada tem este aspeto:

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;

Mantém o intervalo colado os seus tipos de letra, cores, e células combinadas?

Sim, porque a metade HTML do payload é uma renderização completa do intervalo, não um simples despejo de dados: tipos de letra, cores de preenchimento, contornos, formatos numéricos, e células combinadas passam todos como estilos inline e estrutura de tabela, o mesmo mecanismo de estilo abordado em o guia do HotXLS sobre formatação condicional e texto formatado, uma vez que tanto os trechos de texto formatado de uma célula como o resultado da formatação condicional alimentam a mesma renderização de onde CopyToClipboard lê. O que não sobrevive à viagem é o comportamento de fórmula viva: a forma de texto simples de uma célula de fórmula transporta a cadeia de fórmula, pelo que um alvo de colagem consciente de folhas de cálculo poderia em princípio recalculá-la, mas a forma HTML só alguma vez transporta o último resultado calculado, porque o HTML não tem qualquer conceito de fórmula para um navegador ou processador de texto avaliar

Verificar a colagem, e tratar uma área de transferência ocupada

Dois hábitos apanham a maioria dos problemas de área de transferência antes de um cliente o fazer. Cole primeiro no Bloco de Notas para confirmar que o recuo CF_UNICODETEXT é texto delimitado por tabulações são; depois cole a mesma cópia no Word ou num navegador para confirmar que a versão estilizada aparece — um payload que parece correto num e errado no outro normalmente significa que os marcadores de fragmento caíram no sítio errado. Depois trate o resultado booleano que CopyToClipboard devolve como significativo, não decorativo: OpenClipboard pode falhar quando outro processo está a manter a área de transferência aberta, suficientemente comum num ambiente de trabalho ocupado para que uma chamada não verificada acabe por não colar nada sem qualquer erro a explicar porquê, que é contra o que a repetição abaixo se protege:

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;

O próprio formato não é exótico assim que o cabeçalho é exato ao byte e o recuo de texto simples é honesto quanto ao que contém — existe praticamente inalterado desde que o Internet Explorer o definiu pela primeira vez, e todas as principais aplicações Windows continuam a lê-lo da mesma forma. CopyToClipboard situa-se a par de PasteFromClipboard, o lado de leitura da mesma troca, na superfície mais ampla de área de transferência e exportação documentada na página do produto componente HotXLS