Un libro con dos gráficos pasa por un open-and-save que no edita nada, y por el otro lado pueden salir tres cosas distintas. Una salida tiene tres partes de gráfico donde la fuente tenía dos. Otra sigue teniendo dos, pero una comparación byte a byte muestra que cada xl/charts/chartN.xml se reescribió y los colores de las series cambiaron. Una tercera conserva el XML de los gráficos intacto mientras cada gráfico saltó unos píxeles hasta las líneas de la cuadrícula de celdas. Excel abre las tres sin aviso de reparación. Cada síntoma viene de una decisión en el camino de carga y guardado: cómo encuentra su drawing una hoja, cuándo se toma el fingerprint de cambios del gráfico, y si los offsets de anclaje dentro de la parte de drawing se reescriben
Una hoja encuentra su drawing a través de sus relaciones
El síntoma de las tres partes viene de un loader que adivina nombres de partes. En un paquete XLSX una hoja apunta a su drawing con un elemento <drawing r:id="…"/> (ECMA-376 Part 1 §18.3.1.36), y el r:id se resuelve a través de la parte .rels de la hoja hacia un target como ../drawings/drawing1.xml. Excel numera los drawings de forma predecible, así que un reader escrito a mano suele desarrollar un fallback: cuando la relación no está, probar xl/drawings/drawing{n}.xml para la posición de hoja n y usarlo si el archivo lo tiene
// A shortcut that shows up in hand-written XLSX readers (do not ship this)
function GuessDrawingPart(Zip: TZipFile; SheetPos: Integer;
const RelTarget: string): string;
begin
Result := RelTarget; // resuelto desde el .rels de la hoja, '' cuando no está
if Result = '' then
begin
Result := 'xl/drawings/drawing' + IntToStr(SheetPos + 1) + '.xml';
if Zip.IndexOf(Result) < 0 then
Result := '';
end;
end;
La adivinanza se sostiene hasta que una hoja sin gráficos va primero. En un libro cuya primera hoja no tiene drawing ni parte .rels, mientras la segunda hoja llega a xl/drawings/drawing1.xml a través de Target="../drawings/drawing1.xml", el fallback le entrega a la hoja 1 un drawing que nunca referenció. chart1.xml se parsea dos veces, una por hoja, y el guardado escribe tres partes de gráfico. Los nombres de partes en un paquete OPC no significan nada más allá de lo que les asigna el grafo de relaciones, que es el argumento completo del recorrido sobre resolución de relaciones OPC en HotXLS
HotXLS Delphi Component toma la lectura estricta. El drawing de una hoja se carga únicamente desde el target de la relación de drawing de esa hoja, y una hoja sin esa relación no obtiene ningún drawing, sin importar qué archivos estén en xl/drawings/. Una parte de drawing que nada referencia nunca se adjunta a una hoja
¿Qué decide si un gráfico sin tocar se copia o se reconstruye?
Lo decide un fingerprint por gráfico. Cuando PreserveUnsupportedParts está en True al momento del Open (True es el valor por defecto), HotXLS guarda los bytes originales de cada parte de gráfico junto al modelo de gráfico parseado. Después serializa el modelo con su propio writer de gráficos y registra dos números para esa salida: su longitud y un hash FNV-1a de 64 bits sobre sus unidades de código UTF-16, con el offset basis estándar 14695981039346656037 y el primo 1099511628211. TXLSXChart.HasPreservedXml informa si un gráfico lleva esos bytes originales
Al guardar, el modelo del gráfico se serializa otra vez y ambos números se comparan. Longitud igual y hash igual significan que el modelo es exactamente el que era al importar, así que nada de lo que la aplicación pudo cambiar cambió. En ese caso, con StrictOOXML desactivado, el writer copia la entrada comprimida del archivo fuente directo a la salida bajo el nuevo nombre de parte del gráfico; los bytes nunca se decodifican ni se re-comprimen. Si alguno de los dos números difiere, el XML original se fusiona con el modelo. Cambiar el formato de línea de una barra de error o de una línea de tendencia también cuenta como edición, aunque ese formato no sea parte del modelo serializado
El merge es la operación correcta para un gráfico que alguien editó. Conserva las partes del XML original que el modelo no cubre, deja que el modelo gane en series, ejes y grupos de plot, y reserializa el resultado. Lo que no puede hacer es reproducir los bytes originales. La nota sobre editar gráficos de Excel sin perder ChartML cubre lo que el merge conserva; este artículo va de las condiciones bajo las cuales un gráfico sin editar nunca llega a él
uses System.SysUtils, lxHandleX;
procedure ResaveUntouched(const Src, Dst: string);
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
S, C: Integer;
begin
Book := TXLSXWorkbook.Create;
try
Book.PreserveUnsupportedParts := True; // el valor por defecto; debe valer antes de Open
Book.StrictOOXML := False; // la salida Strict regenera todos los gráficos
if Book.Open(Src) <> 1 then
raise Exception.Create('Cannot open ' + Src);
for S := 1 to Book.Sheets.Count do // Sheets[] es 1-based
begin
Sheet := Book.Sheets[S];
for C := 0 to Sheet.Charts.Count - 1 do // Charts[] es 0-based
Writeln(Sheet.Name, ' / ', Sheet.Charts[C].Name, ': original kept = ',
BoolToStr(Sheet.Charts[C].HasPreservedXml, True));
end;
if Book.SaveAs(Dst) <> 1 then
raise Exception.Create('Cannot save ' + Dst);
finally
Book.Free;
end;
end;
Cuando un gráfico debe regenerarse desde el modelo a propósito, por ejemplo para quitar formato que traía la fuente, TXLSXChart.ClearPreservedXml descarta los bytes guardados y ese gráfico se escribe solo desde el modelo
¿Por qué entonces todos los gráficos tomarían el camino del merge?
Porque un fingerprint tomado un paso antes de tiempo nunca coincide. El XML de un gráfico rara vez se lee en una sola pasada. Un parseo por streaming construye las series, los ejes y los grupos de plot, y luego un conjunto de pases posteriores lee del texto crudo del gráfico detalles que son más fáciles de recuperar una vez que las series existen: el flag <c:smooth> de cada <c:ser>, los valores srgbClr del relleno y la línea del marcador, los modos de cruce de ejes y los estilos de tick marks mayor y menor. La colección de series directamente no existe todavía mientras corre la pasada de streaming, así que esos pases tienen que venir después
Suponga que el fingerprint se captura al final de la pasada de streaming, antes de que corran los pases posteriores. Describe entonces un modelo sin flags smooth, sin colores de marcador y con tick marks por defecto. Al guardar, el serializador corre contra el modelo completo y emite <c:smooth val="1"/> más los colores recuperados. El XML es más largo, el hash difiere, y cada gráfico de cada libro preservado pasa por el merge en cada guardado. En un gráfico de líneas con series suavizadas y marcadores personalizados, los nodos <c:ser> regenerados reemplazan a los originales, y de ahí viene el desplazamiento de colores
HotXLS captura el fingerprint solo después de que termina cada pase de recuperación, así que el hash describe el modelo tal como lo ve por primera vez la aplicación. Los mismos dos números se reestablecen después de un guardado exitoso, esta vez desde el archivo de salida, y esa segunda captura también parte de un modelo completamente parseado. Un hash de detección de cambios tiene que tomarse después del último pase que pueda mutar el modelo. Tomarlo antes produce una verificación que falla en cada guardado
Los offsets del anclaje viven en la parte de drawing
El síntoma del ajuste a la cuadrícula no tiene nada que ver con el XML del gráfico. Dónde está sentado un gráfico en la hoja se guarda en la parte de drawing, y la forma habitual es xdr:twoCellAnchor (ECMA-376 Part 1 §20.5): una esquina from y una esquina to, cada una con col, colOff, row y rowOff. Los offsets están en English Metric Units, 914400 por pulgada y 12700 por punto, y Excel escribe valores distintos de cero siempre que un gráfico se arrastró o redimensionó con el mouse. Para un archivo que declara una esquina from en la columna 0, fila 0 con un rowOff de 19049, y una esquina to en la columna 8, fila 15, con un colOff de 247650 y un rowOff de 66674, el gráfico arranca 1.5 puntos debajo del tope de la fila 1 y termina algo más de un cuarto de pulgada dentro de la columna I
Un writer que lee esos cuatro números y después emite <xdr:colOff>0</xdr:colOff> para cada esquina mueve cada gráfico hasta las líneas de la cuadrícula. La parte del gráfico puede reproducirse byte a byte y el gráfico igual se mueve, porque los dos viven en partes distintas. HotXLS conserva el tipo de anclaje que cargó (twoCellAnchor, oneCellAnchor o absoluteAnchor, expuesto como PhysicalAnchorKind) junto con sus valores EMU, y los reescribe sin cambios. Los offsets se leen como FromOffsetXEMU, FromOffsetYEMU, ToOffsetXEMU y ToOffsetYEMU, y las conversiones de unidades detrás de ellos están desarrolladas en la nota sobre geometría de imágenes y escalado EMU en HotXLS
procedure NudgeChart(Chart: TXLSXChart);
const
EmuPerPoint = 12700;
begin
if Chart.PhysicalAnchorKind <> xlsxAnchorTwoCell then
Exit;
Writeln(Format('from R%dC%d +(%d, %d) EMU to R%dC%d +(%d, %d) EMU',
[Chart.FromRow, Chart.FromCol, Chart.FromOffsetXEMU, Chart.FromOffsetYEMU,
Chart.ToRow, Chart.ToCol, Chart.ToOffsetXEMU, Chart.ToOffsetYEMU]));
// Reemplace el marco completo en una sola llamada: filas y columnas son 1-based,
// los offsets son EMU dentro de la celda, el último argumento es el comportamiento editAs
Chart.SetTwoCellAnchor(Chart.FromRow, Chart.FromCol, Chart.ToRow, Chart.ToCol,
Chart.FromOffsetXEMU + 6 * EmuPerPoint, Chart.FromOffsetYEMU,
Chart.ToOffsetXEMU + 6 * EmuPerPoint, Chart.ToOffsetYEMU, Chart.AnchorType);
end;
Use SetTwoCellAnchor cuando la intención es mover o redimensionar. Asignar FromRow, FromCol, ToRow o ToCol en un gráfico de dos celdas cambia solo ese índice de celda y deja los offsets donde estaban, que es el comportamiento correcto para un ajuste pequeño y el equivocado para colocar un gráfico desde cero. SetTwoCellAnchor valida el marco como un todo y lanza ERangeError cuando la esquina final precede a la inicial o los offsets caen fuera del rango EMU de Excel. Un gráfico creado con AddChart arranca con offsets en cero, así que queda exactamente sobre los límites de celda hasta que usted le dé alguno
Demostrar el round trip con una comparación de bytes
Abrir la salida en Excel prueba muy poco acá. Excel normaliza lo suficiente al cargar que un gráfico reserializado se ve idéntico justo hasta que alguien nota que cambió un color de marcador, y un ajuste a la cuadrícula de unos puntos es fácil de pasar por alto en pantalla. La verificación que vale la pena automatizar compara las partes de gráfico byte a byte después de un open-and-save sin ediciones, y el TZipFile de la RTL es todo lo que hace falta
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;
Algunas condiciones deciden si una discrepancia en esa verificación significa algo:
PreserveUnsupportedPartsdebe ser True antes deOpen; desactivado, no se conservan bytes originales y cada gráfico se escribe desde el modeloStrictOOXMLdebe ser False, porque la salida Strict regenera los gráficos por diseño. Déjelo desactivado en cualquier pipeline que prometa que los gráficos sin tocar sobreviven a un guardado- Un paquete fuente escrito con namespaces Strict OOXML se convierte a namespaces transitivos en un guardado transitivo, así que sus bytes de gráfico difieren aunque nada más haya cambiado
- El código entre
OpenySaveAspuede leer propiedades de gráficos libremente, pero cualquier setter que cambie el modelo cambia el fingerprint y manda ese gráfico al merge, lo que es correcto y está fuera de lo que esta prueba mide - Las partes de gráfico se reenumeran desde un contador de todo el libro al guardar, así que un libro cuyo orden de hojas o de gráficos cambió pone bytes idénticos bajo un nombre
chartN.xmldistinto; un checker para ese caso sigue las relaciones de drawing en lugar de comparar nombres
Los anclajes necesitan una comparación por valor. La parte de drawing se escribe nueva alrededor de los gráficos que contiene, así que sus bytes son lo equivocado para hacer diff; reabrir ambos archivos y comparar FromOffsetXEMU a ToOffsetYEMU gráfico por gráfico detecta un writer que convierte 247650 en 0. Una verificación estructural que recorre las relaciones de hoja, drawing y gráfico y falla ante cualquier referencia de gráfico duplicada, huérfana o colgante detecta el caso de las tres partes antes de que corra la comparación de bytes
Todo esto corre en código nativo Delphi y C++Builder sin instalación de Excel en la máquina, así que el libro que vuelve de un trabajo por lotes es la única evidencia de lo que pasó con sus gráficos. Licencias, la API completa de gráficos y una build de prueba están en la página del componente de hojas de cálculo HotXLS para Delphi