El HotXLS Delphi Component solo reproduce un gráfico de Excel sin modificaciones byte por byte cuando se cumplen dos condiciones: que el gráfico se haya alcanzado a través de la relación de drawing de la hoja y no por un nombre de parte adivinado, y que el fingerprint de modelo de 64 bits se haya capturado después de que el modelo del gráfico terminara de parsear. La versión 2.382.0 corrigió la primera condición, la 2.382.3 corrigió la segunda y empezó a hacer round-trip de los offsets de anclaje xdr:colOff y xdr:rowOff distintos de cero que el writer de drawings venía forzando a cero. Ambos defectos salieron de un solo caso del corpus local, two-charts.xlsx: primero un assert estructural vio que dos partes de gráfico se volvían tres, y después una comparación byte a byte de cada xl/charts/chartN.xml mostró gráficos que nadie había tocado siendo reescritos — y ninguno de los dos problemas lanzó una excepción ni hizo que Excel se quejara, por eso sobrevivieron tanto tiempo
¿Por qué un libro de dos gráficos volvió 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 viejo 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 paquete. En two-charts.xlsx la primera hoja no tiene drawing ni parte .rels en absoluto, mientras que xl/drawings/drawing1.xml sí existe — pertenece a la segunda hoja, que lo alcanza vía Target="../drawings/drawing1.xml". La hoja 1 heredó entonces un gráfico que nunca referenció, chart1.xml se parseó dos veces, y el guardado escribió el libro con tres partes de gráfico en lugar de dos
El fix de HotXLS v2.382.0 eliminó la adivinanza por completo. El drawing de una hoja ahora se carga únicamente vía ParPartTargets[i].Values[XlsxRtDrawing], el target registrado para el tipo de relación de drawing en esa hoja, y una hoja sin esa relación no obtiene ningún drawing. Ese es el comportamiento que exige el formato: el elemento <drawing r:id="…"/> de la hoja (ECMA-376 Part 1 §18.3.1.36) es el único vínculo entre una hoja y su drawing, y los nombres de parte en un paquete OPC no significan nada más allá de lo que el grafo de relaciones les asigna. Los archivos que escribe Excel casualmente usan los nombres convencionales, y eso es lo que dejó pasar el atajo tanto tiempo; el recorrido de resolución de relaciones OPC en HotXLS explica por qué adivinar un nombre de parte nunca es seguro aunque la adivinanza casi siempre acierte
// Antes de v2.382.0: una relación de drawing ausente caía a 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 por gráfico, si el guardado puede copiar la parte original o debe regenerarla. Al importar, con PreserveUnsupportedParts activado antes de Open, HotXLS conserva los bytes UTF-8 crudos de cada parte de gráfico en FRawChartXml, construye la serialización propia del modelo tipado con BuildChartKnownXml, y guarda 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 el offset basis estándar de 64 bits 14695981039346656037 y el primo 1099511628211. En el momento de guardar, XlsxChartRawModelUnchanged reconstruye el XML conocido y compara longitud y hash; una coincidencia significa que el modelo tipado es exactamente el que era al importar, así que nada de lo que la aplicación pudo haber cambiado cambió
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 conservado
else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
Result := XlsxDecodeChartUtf8(Chart.FRawChartXml) // replay literal
else
Result := XlsxMergeChartXml(
XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // merge estructural
end;
El writer de XLSX va un paso más allá de BuildChartXmlFromKnown. Cuando el modelo está sin cambios y StrictOOXML está desactivado, primero intenta copiar la entrada comprimida directo del paquete fuente a la salida bajo el nuevo nombre de parte del gráfico, así que ni siquiera se decodifican y re-comprimen los bytes. Solo si esa copia no es posible 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 la forma en que dejó de funcionar en silencio
¿Por qué entonces todos los gráficos tomaban 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 un conjunto de pases de recuperación que extraen del texto crudo detalles que los handlers SAX no modelan directamente: XlsxChartParseSeriesFlags lee cada bloque <c:ser> buscando su flag <c:smooth> y los valores srgbClr del relleno y la línea del marcador, y luego recupera los modos de cruce de ejes y los estilos de tick marks mayor y menor para los ejes de categoría y de valores. 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 entonces un modelo que todavía carecía de flags smooth, colores de marcador y tick marks. Al guardar, 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 ownership que hace ganar al modelo tipado en series, ejes y grupos de plot significa que los nodos regenerados reemplazan a los originales. El resultado visible en la corrida del corpus fue colores de serie desplazados en gráficos que nadie había editado — cada gráfico de cada libro preservado, en cada guardado, sin diagnóstico alguno
La reparación es un simple reordenamiento: XlsxChartParseSeriesFlags ahora corre antes de que se construya el XML conocido, de modo que el fingerprint describe el modelo tal como va a 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 tanto como el momento en que se toma, y el momento seguro es después de que termine todo pase que pueda mutar el modelo. HotXLS tiene un segundo sitio de captura para los mismos dos valores, la baseline que reestablece contra el archivo de salida tras un guardado exitoso, y ese sitio siempre corrió contra un modelo completamente parseado; el sitio de importación era el raro
¿Adónde fueron a parar los offsets del anclaje?
A un cero literal. Un twoCellAnchor en la parte de 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 distintos de cero siempre que un gráfico se colocó o redimensionó con el mouse, que son la mayoría. 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 writer de gráficos emitía <xdr:colOff>0</xdr:colOff> y <xdr:rowOff>0</xdr:rowOff> en cada esquina, ajustando cada gráfico a la cuadrícula de celdas al guardar
// Desde v2.382.3 el writer del anclaje 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 ahora lleva FFromColOff, FFromRowOff, FToColOff y FToRowOff, llenados desde el parser de drawings y copiados junto con el resto del estado del anclaje cuando se asigna un gráfico. Son privados a propósito: la superficie pública del anclaje sigue siendo las cuatro coordenadas de celda FromRow, FromCol, ToRow y ToCol, y un gráfico creado desde código Delphi aterriza en los límites de celda como siempre. Los offsets existen para que el round-trip sea fiel, no para exponer posicionamiento sub-celda como feature. Nótese que este fix es independiente del fingerprint: el anclaje vive en la parte de drawing, no en la parte del gráfico, así que un gráfico cuyo ChartML se reprodujera perfectamente igual habría saltado a la cuadrícula sin él. Las conversiones de unidades detrás de esos valores EMU están cubiertas en la nota sobre geometría de imágenes y escalado EMU en HotXLS
¿Cómo se demuestra que un gráfico hace round-trip sin cambios?
Comparando bytes, no abriendo el resultado en Excel. Excel repara y normaliza tanto al cargar que un gráfico desplazado se ve bien hasta que un analista nota que el color del marcador cambió. El test de corpus que atrapó ambos defectos hace tres cosas después de un open-and-save sin ediciones: recorre las relaciones de hojas, drawings y gráficos y falla ante cualquier referencia de gráfico duplicada, huérfana o colgante; compara una firma de tipo de gráfico, fórmulas de series y geometría de anclaje entre original y salida; y para two-charts.xlsx lee cada xl/charts/chartN.xml de ambos paquetes y exige bytes idénticos. El mismo chequeo es fácil de escribir en Delphi con 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 una 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 se olvida. PreserveUnsupportedParts debe ser True antes de Open, o no se capturan bytes crudos y todos los gráficos se reconstruyen 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 busca. Las partes de gráfico además se renumeran desde un contador de todo el libro al guardar, así que un libro cuyo orden de hojas u orden de gráficos cambió colocará bytes idénticos bajo un nombre chartN.xml distinto; el checker del corpus sigue relaciones y no nombres por esa razón
Ambos fixes 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 también renderizadas a PDF a través de una suite de oficina independiente y comparadas página por página contra los originales. HotXLS lee, edita y escribe gráficos XLSX desde código nativo Delphi y C++Builder sin instalación de Excel involucrada, y eso 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 features y una descarga de prueba