El HotXLS Delphi Component solo reproduce un gráfico de Excel sin modificar byte a byte cuando se cumplen dos cosas: que el gráfico se haya alcanzado a través de la relación de drawing de la hoja de cálculo y no de un nombre de parte adivinado, y que el fingerprint de 64 bits del modelo se haya capturado cuando el modelo del gráfico terminó de parsear. La versión 2.382.0 arregló la primera condición, la 2.382.3 arregló la segunda y empezó a hacer round-trip de los offsets de anclaje no nulos xdr:colOff y xdr:rowOff que el escritor de drawings llevaba hardcodeando a cero. Ambos defectos salieron de un único caso del corpus local, two-charts.xlsx: primero una aserción estructural vio que dos partes de gráfico se convertían en tres, y después una comparación byte a byte de cada xl/charts/chartN.xml mostró que gráficos que nadie había tocado seguían reescribiéndose — y ninguno de los dos problemas lanzaba una excepción ni hacía que Excel se quejara, que es justo por lo que sobrevivieron tanto tiempo
¿Por qué un libro de dos gráficos volvía con tres partes de gráfico?
Porque el loader tenía un fallback que adivinaba. Cuando una hoja no tenía relación de drawing en su parte .rels, el código antiguo asumía que el drawing vivía en el nombre convencional xl/drawings/drawing{i+1}.xml, donde i es la posición de la hoja, y adjuntaba esa parte si existía en el archivo. En two-charts.xlsx la primera hoja no tiene drawing ni parte .rels alguna, mientras que xl/drawings/drawing1.xml sí existe — pertenece a la segunda hoja, que llega a él mediante Target="../drawings/drawing1.xml". La hoja 1 heredaba por tanto un gráfico que nunca referenció, chart1.xml se parseaba dos veces, y el guardado escribía el libro con tres partes de gráfico en lugar de dos
El arreglo en HotXLS v2.382.0 eliminó la adivinanza por completo. Un drawing de hoja ahora se carga únicamente a través de ParPartTargets[i].Values[XlsxRtDrawing], el destino registrado para el tipo de relación de drawing en esa hoja, y una hoja sin tal relación no obtiene drawing ninguno. Ese es el comportamiento que exige el formato: el elemento <drawing r:id="…"/> de la hoja de cálculo (ECMA-376 Part 1 §18.3.1.36) es el único enlace entre una hoja y su drawing, y los nombres de las partes en un paquete OPC no significan nada más allá de lo que el grafo de relaciones les asigna. Los archivos que escribe Excel usan los nombres convencionales por casualidad, y de ahí que el atajo pasara inadvertido tanto tiempo; el recorrido de la resolución de relaciones OPC en HotXLS explica por qué adivinar un nombre de parte nunca es seguro ni cuando la adivinanza suele acertar
// Antes de v2.382.0: una relación de drawing ausente caía en una adivinanza
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
LoadDrawing(zip, drawingName); // puede pertenecer a otra hoja
// Desde v2.382.0: relación o nada
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
LoadDrawing(zip, drawingName);
¿Qué garantiza el fingerprint del gráfico?
El fingerprint decide, gráfico a gráfico, si el guardado puede copiar la parte original o debe regenerarla. En la importación, con PreserveUnsupportedParts activado antes de Open, HotXLS guarda los bytes UTF-8 crudos de cada parte de gráfico en FRawChartXml, construye la serialización propia del modelo tipado con BuildChartKnownXml, y almacena la longitud de esa serialización en FRawChartModelLength y su hash en FRawChartModelHash. El hash es FNV-1a sobre las unidades de código UTF-16 del XML generado, con la base de offset estándar de 64 bits 14695981039346656037 y el primo 1099511628211. En el guardado, XlsxChartRawModelUnchanged reconstruye el XML conocido y compara longitud y hash; una coincidencia significa que el modelo tipado es exactamente el que era en la importación, así que nada de lo que la aplicación pudiera haber cambiado ha cambiado
function XlsxChartRawModelUnchanged(Chart: TXLSXChart;
const KnownXml: WideString): Boolean;
begin
Result := (Chart <> nil) and (Chart.FRawChartXml <> '') and
(Length(KnownXml) = Chart.FRawChartModelLength) and
(XlsxChartModelHash(KnownXml) = Chart.FRawChartModelHash);
end;
function BuildChartXmlFromKnown(Chart: TXLSXChart;
const KnownXml: WideString): WideString;
begin
if Chart.FRawChartXml = '' then
Result := KnownXml // nada preservado
else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
Result := XlsxDecodeChartUtf8(Chart.FRawChartXml) // replay literal
else
Result := XlsxMergeChartXml(
XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // merge estructural
end;
El escritor XLSX va un paso más allá de BuildChartXmlFromKnown. Cuando el modelo no ha cambiado y StrictOOXML está desactivado, primero intenta copiar la entrada comprimida directamente del archivo origen al de salida bajo el nuevo nombre de parte del gráfico, de modo que los bytes ni siquiera se decodifican y vuelven a comprimirse. Solo si esa copia no es posible se cae al camino de decodificar o fusionar. El mecanismo en sí — longitud más hash, replay cuando coinciden, merge cuando no — es el que describe la nota sobre editar gráficos de Excel sin perder ChartML. Este artículo va de cómo dejó de funcionar en silencio
¿Por qué entonces todos los gráficos acababan en el camino del merge?
Porque el fingerprint se capturaba una llamada antes de tiempo. El parseo de gráficos en HotXLS es un pase SAX sobre la parte del gráfico seguido de una serie de pases de recuperación que extraen del texto crudo detalles que los manejadores SAX no modelan directamente: XlsxChartParseSeriesFlags lee cada bloque <c:ser> en busca de su flag <c:smooth> y de los valores srgbClr del relleno y la línea del marcador, y después recupera los modos de cruce de ejes y los estilos de marcas principales y secundarias para los ejes de categoría y de valor. Antes de v2.382.3 el orden al final de ParseChartXml era: clasificar los grupos de ejes, construir el XML conocido, capturar longitud y hash, y solo entonces ejecutar XlsxChartParseSeriesFlags. El fingerprint describía por tanto un modelo al que aún le faltaban los flags smooth, los colores de marcador y las marcas de eje. En el guardado, BuildChartKnownXml corría contra el modelo completo, que ahora emitía <c:smooth val="1"/> y los colores de marcador recuperados. XML más largo, hash distinto, XlsxChartRawModelUnchanged devolvía False, y el gráfico pasaba por XlsxMergeChartXml. El merge es una operación correcta para un gráfico que alguien editó, pero no es una operación que preserve bytes: reserializa el árbol, y la regla de propiedad que deja ganar al modelo tipado en series, ejes y grupos de trazado hace que los nodos regenerados sustituyan a los originales. El resultado visible en la pasada del corpus eran colores de serie desviados en gráficos que nadie había editado — todos los gráficos de todos los libros preservados, en cada guardado, sin diagnóstico alguno
La reparación es un simple reordenamiento: XlsxChartParseSeriesFlags ahora corre antes de construir el XML conocido, de modo que el fingerprint describe el modelo tal como existirá cuando la aplicación lo vea por primera vez. La lección generaliza más allá de los gráficos. Un fingerprint de detección de cambios vale lo que vale el momento en que se toma, y el momento seguro es cuando han terminado todos los pases capaces de mutar el modelo. HotXLS tiene un segundo punto de captura para esos mismos dos valores, la línea base que restablece contra el archivo de salida tras un guardado con éxito, y ese punto siempre operó sobre un modelo completamente parseado; el punto de importación era el raro
¿Adónde fueron a parar los offsets del ancla?
A un cero literal. Un twoCellAnchor en la parte del drawing fija un gráfico entre dos celdas, y cada esquina lleva un índice de celda más un offset dentro de esa celda: from (ECMA-376 Part 1 §20.5.2.5) y to (§20.5.2.32) contienen cada uno col, colOff (§20.5.2.4), row y rowOff. Los offsets están en English Metric Units, 914400 por pulgada, y Excel escribe valores no nulos siempre que un gráfico se colocó o redimensionó con el ratón, que son la mayoría de los gráficos. El primer gráfico de two-charts.xlsx empieza en la fila 0 con un rowOff de 19049 y termina en la columna 8, fila 15, con un colOff de 247650 y un rowOff de 66674 — aproximadamente un cuarto de pulgada dentro de la última columna. El parser de drawings de HotXLS siempre leyó esos cuatro valores — el código de imágenes los usaba — pero el escritor de gráficos emitía <xdr:colOff>0</xdr:colOff> y <xdr:rowOff>0</xdr:rowOff> en cada esquina, pegando cada gráfico a la rejilla de celdas al guardar
// Desde v2.382.3 el escritor del ancla reproduce los offsets EMU importados
Result := '<xdr:twoCellAnchor' + EditAsAttr + '><xdr:from><xdr:col>' +
IntToStr(Chart.FromCol - 1) + '</xdr:col><xdr:colOff>' +
IntToStr(Chart.FFromColOff) + '</xdr:colOff>' +
'<xdr:row>' + IntToStr(Chart.FromRow - 1) + '</xdr:row>' +
'<xdr:rowOff>' + IntToStr(Chart.FFromRowOff) + '</xdr:rowOff></xdr:from>' +
'<xdr:to><xdr:col>' + IntToStr(Chart.ToCol - 1) + '</xdr:col><xdr:colOff>' +
IntToStr(Chart.FToColOff) + '</xdr:colOff>' +
'<xdr:row>' + IntToStr(Chart.ToRow - 1) + '</xdr:row>' +
'<xdr:rowOff>' + IntToStr(Chart.FToRowOff) + '</xdr:rowOff></xdr:to>' + ...
TXLSXChart lleva ahora FFromColOff, FFromRowOff, FToColOff y FToRowOff, rellenados desde el parser de drawings y copiados junto con el resto del estado del ancla cuando se asigna un gráfico. Son deliberadamente privados: la superficie pública del ancla sigue siendo las cuatro coordenadas de celda FromRow, FromCol, ToRow y ToCol, y un gráfico creado desde código Delphi aterriza en los bordes de celda como siempre. Los offsets existen para que el round-trip sea fiel, no para exponer el posicionamiento subcelda como característica. Fíjate en que este arreglo es independiente del fingerprint: el ancla vive en la parte del drawing, no en la del gráfico, así que un gráfico cuyo ChartML se reprodujera perfectamente seguiría saltando a la rejilla sin él. Las conversiones de unidad detrás de esos valores EMU están cubiertas en la nota sobre la geometría de imágenes y el escalado EMU en HotXLS
¿Cómo demuestras que un gráfico hace un round-trip intacto?
Comparando bytes, no abriendo el resultado en Excel. Excel repara y normaliza tanto al cargar que un gráfico desviado parece perfecto justo hasta que un analista nota que el color del marcador cambió. El test de corpus que cazó ambos defectos hace tres cosas tras un abrir-y-guardar sin ediciones: recorre las relaciones de hoja, drawing y gráfico y falla ante cualquier referencia de gráfico duplicada, huérfana o colgante; compara una firma del tipo de gráfico, las fórmulas de serie y la geometría del ancla entre original y salida; y para two-charts.xlsx lee cada xl/charts/chartN.xml de ambos archivos y exige bytes idénticos. La misma comprobación es fácil de escribir en Delphi con el TZipFile de la RTL
uses System.Zip, System.SysUtils;
function ChartPartsIdentical(const Original, Resaved: string): Boolean;
var
Src, Dst: TZipFile;
Name: string;
A, B: TBytes;
begin
Result := True;
Src := TZipFile.Create;
Dst := TZipFile.Create;
try
Src.Open(Original, zmRead);
Dst.Open(Resaved, zmRead);
for Name in Src.FileNames do
if Name.StartsWith('xl/charts/chart') and Name.EndsWith('.xml') then
begin
Src.Read(Name, A);
Dst.Read(Name, B); // lanza excepción si la parte desapareció
if (Length(A) <> Length(B)) or
((Length(A) > 0) and not CompareMem(@A[0], @B[0], Length(A))) then
begin
Writeln('changed: ', Name);
Result := False;
end;
end;
finally
Dst.Free;
Src.Free;
end;
end;
Tres condiciones hacen significativa esa comparación, y cada una falla en silencio si la olvidas. PreserveUnsupportedParts debe ser True antes de Open, o no se capturan bytes crudos y cada gráfico se reconstruye desde el modelo. StrictOOXML debe ser False, porque el modo strict fuerza la regeneración por diseño. Y la aplicación no debe tocar el gráfico entre abrir y guardar — leer propiedades está bien, pero cualquier setter que cambie el modelo tipado voltea el fingerprint y manda el gráfico por el camino del merge, que es el comportamiento correcto y no lo que este test persigue. Las partes de gráfico además se renumeran desde un contador de libro al guardar, así que un libro cuyo orden de hojas o de gráficos cambió colocará bytes idénticos bajo un nombre chartN.xml distinto; el verificador del corpus sigue las relaciones y no los nombres por esa razón
Ambos arreglos se publicaron en HotXLS 2.382.0 y 2.382.3 y están verificados en Win32 y Win64 contra el corpus local, con las muestras de gráficos re-guardadas renderizadas además mediante una suite ofimática independiente a PDF y comparadas página a página contra los originales. HotXLS lee, edita y escribe gráficos XLSX desde código nativo Delphi y C++Builder sin que Excel esté instalado, que es lo que convierte este nivel de fidelidad en responsabilidad de la librería — la página del componente de hojas de cálculo HotXLS para Delphi tiene la lista de características y una descarga de prueba