Artículo técnico

Implementar el formato de portapapeles CF_HTML en Delphi

Copiad un rango de una cuadrícula Delphi y pegadlo en Word, y el formato normalmente desaparece: texto plano, sin encabezados en negrita, sin bordes, sin rellenos. HotXLS cierra esa brecha con TXLSRange.CopyToClipboard, que coloca en el portapapeles una carga CF_HTML, el formato de Windows para HTML con estilo con marcadores de fragmento exactos a nivel de byte, junto al texto Unicode plano

Eso suena sencillo hasta que se examina lo que realmente exige una carga CF_HTML. El formato necesita una cabecera de texto corta que indique exactamente dónde empieza y dónde termina el fragmento dentro del búfer de portapapeles más amplio, y esas posiciones son desplazamientos de byte, contados a través de cualquiera que sea la codificación multibyte en la que acabe el HTML. Equivocaos en la aritmética aunque sea por un solo byte y la aplicación de destino o bien captura el trozo equivocado de marcado o bien se rinde y recae en texto plano, y ninguno de los dos fallos parece un error en vuestro código, parece que Word está siendo Word

Por qué copiar y pegar desde una cuadrícula Delphi suele perder el formato

La llamada de portapapeles de Windows por defecto a la que recurre la mayoría del código Delphi, SetClipboardData con CF_TEXT o CF_UNICODETEXT, solo transporta caracteres planos en cualquier caso, así que cualquier estilo aplicado en la cuadrícula de origen no tiene adónde ir. Word, Outlook y cualquier navegador basado en Chromium buscan un formato más rico al pegar: una representación HTML de la selección, completa con estilos en línea, estructura de tabla y enlaces. El propio Excel se apoya exactamente en este truco: copiad un rango en Excel y el portapapeles recibe discretamente varios formatos a la vez, HTML entre ellos, así que la aplicación en la que peguéis elige el más rico que entienda. Un componente que solo escriba CF_UNICODETEXT no le da a ninguno de esos consumidores más ricos nada con lo que trabajar, y la riqueza visual que el usuario acaba de copiar sencillamente no está ahí para pegar

¿Qué es exactamente el formato de portapapeles CF_HTML?

CF_HTML no es un formato de portapapeles de sistema fijo como CF_TEXT; es uno registrado dinámicamente, solicitado por nombre mediante RegisterClipboardFormat('HTML Format'), y su carga es una cabecera ASCII corta seguida de un documento o fragmento HTML. La cabecera lleva cinco campos, Version, StartHTML, EndHTML, StartFragment, EndFragment, donde Version siempre es 0.9 y los otros cuatro son números decimales escritos como dígitos ASCII. StartHTML y EndHTML delimitan el documento entero tal como la aplicación receptora debería analizarlo por contexto, fuentes y estilos incluidos, mientras que StartFragment y EndFragment delimitan el trozo más estrecho que realmente aterriza en el cursor, marcado convencionalmente en el propio marcado con comentarios <!--StartFragment--> y <!--EndFragment--> para que los límites sobrevivan a una reserialización ingenua

Desplazamientos de byte, no recuentos de caracteres: la trampa clásica de CF_HTML

Los cuatro campos numéricos de la cabecera de CF_HTML son desplazamientos de byte dentro de la secuencia exacta de bytes que hay en el portapapeles, contados desde el primer carácter mismo de la cabecera, no recuentos de caracteres, no puntos de código Unicode, y no desplazamientos relativos al fragmento ni a la etiqueta <body>. Esa distinción es donde las implementaciones de CF_HTML hechas a mano se equivocan silenciosamente: la propiedad Length de un UnicodeString de Delphi informa de unidades de código UTF-16, que resulta que coincide con el recuento de bytes para texto ASCII plano, así que el fallo pasa limpio por cualquier prueba escrita con datos de muestra en inglés y solo aparece en cuanto una celda copiada contiene una raya, un símbolo de moneda o un carácter acentuado, un símbolo de euro es una unidad de código UTF-16 pero tres bytes en UTF-8, y cada desplazamiento calculado después de ese punto se desvía tantos bytes extra como haya añadido la codificación. El fallo que sigue no es un cuelgue; es la aplicación receptora tomando exactamente el rango de bytes al que apuntaba la cabecera, encontrando un trozo de marcado que empieza o termina a mitad de una etiqueta, y renderizando basura o rindiéndose y recayendo en cualquier texto plano que haya junto a él en el portapapeles, en silencio, sin nada en vuestro código que explique por qué; así es la forma del código que produce exactamente ese fallo:

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

Cómo mantiene HotXLS la cabecera exacta a nivel de byte

HotXLS evita esta clase de fallo estructuralmente: TXLSRange.CopyToClipboard y la unidad lxClipboard que hay debajo construyen el documento CF_HTML y su cabecera enteramente como AnsiString, el tipo de cadena de bytes de Delphi, así que Length y Pos ya devuelven posiciones de byte en todos los puntos del cálculo; no hay ningún paso aparte, y por tanto ningún paso que olvidar, en el que un recuento de caracteres Unicode necesitara convertirse en un recuento de bytes antes de entrar en la cabecera

Hay un segundo truco, más pequeño, que merece la pena conocer si alguna vez construís una cabecera CF_HTML a mano. La cabecera se escribe dos veces: una con diez dígitos cero sustituyendo cada uno de los cuatro desplazamientos, para poder medir su propia longitud en bytes, y otra más con los desplazamientos reales ya insertados. Como cada desplazamiento real se formatea con ese mismo ancho fijo de diez dígitos, la segunda cabecera sale con exactamente la misma longitud en bytes que la versión de marcador de posición, que es precisamente por lo que la medición anterior sigue siendo válida después de la reescritura. Saltaos el ancho fijo, formatead un número con un simple IntToStr en su lugar, y la cabecera puede encogerse o crecer un dígito entre las dos pasadas, invalidando silenciosamente cada desplazamiento que la sigue:

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;

Por qué la carga de texto plano tiene que seguir viajando de todos modos

TXLSRange.CopyToClipboard nunca coloca CF_HTML en el portapapeles a solas; siempre escribe CF_UNICODETEXT en la misma llamada, porque CF_HTML es un formato registrado y no una de las constantes CF_* fijas que toda aplicación Windows ya sabe buscar; un editor de texto plano, una cuadrícula heredada, o cualquier cosa que nunca haya comprobado 'HTML Format' sencillamente no lo verá, y el rango que copiasteis o bien llega como texto delimitado por tabuladores o no llega. Ese texto delimitado por tabuladores tampoco es una aproximación tosca: las celdas de fórmula se copian como su cadena de fórmula con un = inicial restaurado si el texto almacenado lo había eliminado, igualando cómo se comporta el propio texto de portapapeles de Excel, las celdas ordinarias copian su FormattedText, la cadena tal como se muestra, así que una celda de moneda se copia como $1.234,56, no el 1234.56 subyacente, y cualquier campo que contenga un tabulador, una comilla o un salto de línea se entrecomilla con las comillas incrustadas duplicadas, la misma convención que usa CSV

SaveAsHTML no es una vía de renderizado aparte añadida solo para el caso del portapapeles. CopyToClipboard llama exactamente al mismo escritor HTML descrito en la exportación a CSV, TSV y HTML de HotXLS, y luego envuelve lo que sea que ese escritor produzca en el sobre CF_HTML en lugar de guardarlo como un archivo independiente, así que cualquier cosa cierta de ese HTML se traslada directamente a lo que aterriza en el portapapeles. Reunir un rango de hoja de cálculo como ambos formatos en una sola llamada tiene este aspecto:

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;

¿El rango pegado conserva sus fuentes, colores y celdas combinadas?

Sí, porque la mitad HTML de la carga es un renderizado completo del rango, no un simple volcado de datos: fuentes, colores de relleno, bordes, formatos numéricos y celdas combinadas llegan todos como estilos en línea y estructura de tabla, la misma maquinaria de estilo cubierta en la guía de HotXLS sobre formato condicional y texto enriquecido, ya que tanto las tiradas de texto enriquecido de una celda como el resultado del formato condicional alimentan el mismo renderizado del que lee CopyToClipboard. Lo que no sobrevive al viaje es el comportamiento de fórmula viva: la forma en texto plano de una celda de fórmula lleva la cadena de la fórmula, así que un destino de pegado consciente de hojas de cálculo podría en principio recalcularla, pero la forma HTML solo lleva jamás el último resultado calculado, porque HTML no tiene ningún concepto de fórmula que un navegador o un procesador de textos puedan evaluar

Verificar el pegado y gestionar un portapapeles ocupado

Dos hábitos atrapan la mayoría de los problemas de portapapeles antes de que lo haga un cliente. Pegad primero en el Bloc de notas para confirmar que el respaldo CF_UNICODETEXT es texto delimitado por tabuladores sensato, y después pegad la misma copia en Word o en un navegador para confirmar que aparece la versión con estilo; una carga que se ve bien en uno y mal en el otro normalmente significa que los marcadores de fragmento aterrizaron en el sitio equivocado. Después tratad el resultado booleano que devuelve CopyToClipboard como algo con significado, no decorativo: OpenClipboard puede fallar cuando otro proceso mantiene el portapapeles abierto, algo lo bastante común en un escritorio ocupado como para que una llamada sin comprobar acabe pegando nada sin ningún error que explique por qué, que es contra lo que protege el reintento de abajo:

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;

El formato en sí no es exótico una vez que la cabecera es exacta a nivel de byte y el respaldo de texto plano es honesto sobre lo que contiene; ha existido en gran medida sin cambios desde que Internet Explorer lo definió por primera vez, y cada aplicación Windows importante lo sigue leyendo del mismo modo. CopyToClipboard se sitúa junto a PasteFromClipboard, el lado de lectura del mismo intercambio, en la superficie más amplia de portapapeles y exportación documentada en la página de producto del componente HotXLS