Artículo técnico

Implementar el formato de portapapeles CF_HTML en Delphi

Copie un rango desde una grilla de Delphi y péguelo 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 una carga útil de portapapeles CF_HTML —el formato de Windows para HTML con estilo con marcadores de fragmento exactos en bytes— en el portapapeles junto al texto Unicode plano

Eso suena simple hasta que se ve lo que realmente requiere una carga útil CF_HTML. El formato necesita un encabezado de texto corto que nombre exactamente dónde empieza y termina el fragmento dentro del búfer de portapapeles más grande, y esas posiciones son desplazamientos de byte, contados a través de cualquier codificación multi-byte en la que termine el HTML. Si la aritmética se equivoca por siquiera un byte, la aplicación destino o bien toma el fragmento equivocado de marcado o se rinde y recurre al texto plano, y ninguno de los dos fallos se ve como un bug en su código: se ve como Word siendo Word

Por qué copiar y pegar desde una grilla de Delphi normalmente pierde su formato

La llamada de portapapeles de Windows predeterminada a la que recurre la mayoría del código Delphi, SetClipboardData con CF_TEXT o CF_UNICODETEXT, solo lleva caracteres planos, así que cualquier estilo aplicado en la grilla de origen no tiene adónde ir. Word, Outlook, y cada 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 —copie un rango en Excel y el portapapeles recibe silenciosamente varios formatos a la vez, HTML entre ellos, así que la aplicación en la que pegue elige el más rico que entienda. Un componente que solo escribe CF_UNICODETEXT no le da a ninguno de esos consumidores más ricos nada con qué trabajar, y la riqueza visual que el usuario acaba de copiar simplemente no está ahí para pegar

¿Qué es exactamente el formato de portapapeles CF_HTML?

CF_HTML no es un formato de portapapeles del sistema fijo como CF_TEXT; es uno registrado dinámicamente, solicitado por nombre mediante RegisterClipboardFormat('HTML Format'), y su carga útil es un encabezado ASCII corto seguido de un documento o fragmento HTML. El encabezado 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 todo el documento tal como la aplicación receptora debería analizarlo para obtener contexto, fuentes y estilos incluidos, mientras que StartFragment y EndFragment delimitan el fragmento 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 conteos de caracteres: la trampa clásica de CF_HTML

Los cuatro campos numéricos del encabezado de CF_HTML son desplazamientos de byte en la secuencia exacta de bytes que está en el portapapeles, contados desde el primer carácter del propio encabezado —no conteos de caracteres, no puntos de código Unicode, y no desplazamientos relativos al fragmento o a la etiqueta <body>. Esa distinción es donde las implementaciones de CF_HTML hechas a mano se equivocan silenciosamente: el Length de un UnicodeString de Delphi reporta unidades de código UTF-16, que resulta ser igual al conteo de bytes para texto ASCII plano, así que el bug pasa limpio a través de 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 signo 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 por la cantidad de bytes extra que agregó la codificación. El fallo que sigue no es un cierre inesperado; es la aplicación receptora tomando exactamente el rango de bytes al que apuntaba el encabezado, encontrando un fragmento de marcado que empieza o termina a mitad de una etiqueta, y o bien renderizando basura o rindiéndose y recurriendo a cualquier texto plano que esté junto a él en el portapapeles, silenciosamente, sin nada en su código que explique por qué —aquí está la forma de 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 el encabezado preciso en bytes

HotXLS evita esta clase de bug estructuralmente: TXLSRange.CopyToClipboard y la unidad lxClipboard que hay debajo construyen el documento CF_HTML y su encabezado enteramente como AnsiString, el tipo de cadena de bytes de Delphi, así que Length y Pos ya devuelven posiciones de byte en todas partes del cálculo —no hay un paso separado, y por lo tanto ningún paso que olvidar, donde un conteo de caracteres Unicode necesitaría convertirse en un conteo de bytes antes de entrar en el encabezado

Hay un segundo truco, más pequeño, que vale la pena conocer si alguna vez construye un encabezado CF_HTML a mano. El encabezado se escribe dos veces: una con diez dígitos cero en representación de 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, el segundo encabezado resulta tener exactamente la misma longitud en bytes que la versión de marcador de posición, que es precisamente por qué la medición anterior sigue siendo válida después de la reescritura. Omita el ancho fijo, formatee un número con un simple IntToStr en su lugar, y el encabezado puede encogerse o crecer un dígito entre las dos pasadas, invalidando silenciosamente cada desplazamiento que le 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 útil de texto plano igual tiene que viajar junto

TXLSRange.CopyToClipboard nunca coloca CF_HTML solo en el portapapeles; siempre escribe CF_UNICODETEXT en la misma llamada, porque CF_HTML es un formato registrado en lugar de una de las constantes fijas CF_* que toda aplicación de Windows ya sabe buscar —un editor de texto plano, una grilla heredada, o cualquier cosa que nunca comprobó 'HTML Format' no lo verá en absoluto, y el rango que copió o bien llega como texto delimitado por tabulaciones o no llega en absoluto. Ese texto delimitado por tabulaciones tampoco es una aproximación aproximada: las celdas de fórmula se copian como su cadena de fórmula con un = inicial restaurado si el texto almacenado lo había omitido, coincidiendo con 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 valor subyacente 1234.56— y cualquier campo que contenga una tabulación, 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 ruta de renderizado separada añadida solo para el caso del portapapeles. CopyToClipboard llama exactamente al mismo escritor HTML descrito en la exportación CSV, TSV y HTML de HotXLS, y luego envuelve lo que produce ese escritor en el sobre CF_HTML en lugar de guardarlo como un archivo independiente, así que todo lo que es cierto sobre ese HTML pasa directamente a lo que aterriza en el portapapeles. Reunir un rango de hoja de cálculo como ambos formatos en una sola llamada se ve así:

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 útil es un renderizado completo del rango, no un simple volcado de datos: fuentes, colores de relleno, bordes, formatos de número, 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 ejecuciones de texto enriquecido de una celda como el resultado del formato condicional alimentan el mismo renderizado del que lee CopyToClipboard. Lo que no sobrevive el viaje es el comportamiento de fórmula en vivo: 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 el último resultado calculado, porque HTML no tiene ningún concepto de fórmula que un navegador o un procesador de texto pueda evaluar

Verificar el pegado, y manejar un portapapeles ocupado

Dos hábitos atrapan la mayoría de los problemas de portapapeles antes que un cliente. Pegue primero en el Bloc de notas para confirmar que el respaldo CF_UNICODETEXT es texto delimitado por tabulaciones sensato, luego pegue la misma copia en Word o un navegador para confirmar que aparece la versión con estilo —una carga útil que se ve bien en uno y mal en el otro normalmente significa que los marcadores de fragmento aterrizaron en el lugar equivocado. Luego trate el resultado booleano que devuelve CopyToClipboard como significativo, no decorativo: OpenClipboard puede fallar cuando otro proceso mantiene el portapapeles abierto, lo bastante común en un escritorio ocupado como para que una llamada no comprobada eventualmente no pegue 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 el encabezado es preciso en bytes 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 importante de Windows todavía lo lee de la misma manera. CopyToClipboard se ubica 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 del producto componente HotXLS