Artículo técnico

Fingerprint de gráficos HotXLS y offsets en Delphi

Un libro con dos gráficos pasa por un abrir-y-guardar que no edita nada, y tres cosas distintas pueden salir por el otro lado. Una salida tiene tres partes de gráfico donde el origen 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 se han desviado. Una tercera mantiene el XML del gráfico intacto mientras cada gráfico ha saltado unos píxeles hasta las líneas de cuadrícula de las celdas. Excel abre los 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 del ancla dentro de la parte de drawing se escriben de vuelta

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 destino como ../drawings/drawing1.xml. Excel numera los drawings de una forma predecible, así que un lector escrito a mano suele desarrollar un fallback: cuando falta la relación, probar xl/drawings/drawing{n}.xml para la posición de hoja n y usarlo si el archivo lo tiene

// Un atajo que aparece en lectores XLSX escritos a mano (no lo ponga en producción)
function GuessDrawingPart(Zip: TZipFile; SheetPos: Integer;
  const RelTarget: string): string;
begin
  Result := RelTarget;   // resuelto desde el .rels de la hoja, '' cuando falta
  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 ni 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ó. El chart1.xml se parsea dos veces, una por hoja, y el guardado escribe tres partes de gráfico. Los nombres de las partes en un paquete OPC no significan nada más allá de lo que les asigna el grafo de relaciones, que es el argumento central del recorrido sobre la resolución de relaciones OPC en HotXLS

Cómo encuentra su drawing una hoja en un libro de dos gráficos: Sheet1 no lleva ni relación de drawing ni parte rels, mientras Sheet2 llega a xl/drawings/drawing1.xml a través de su relación de drawing, así que un loader que adivina ese nombre convencional desde la posición de la hoja parsea chart1.xml dos veces y guarda tres partes de gráfico, por eso HotXLS carga un drawing solo a través de la relación de drawing de la hoja
Sheet1 nunca referenció un gráfico, así que el grafo de relaciones es la única fuente segura para el destino del drawing, y un nombre convencional adivinado convierte un libro de dos gráficos en un guardado de tres partes

HotXLS Delphi Component toma la lectura estricta. El drawing de una hoja se carga solo desde el destino de la relación de drawing de esa hoja, y una hoja sin ella no obtiene drawing, sean cuales sean los archivos que haya en xl/drawings/. Una parte de drawing que nada referencia nunca se adjunta a una hoja

Qué decide si un gráfico intacto se copia o se reconstruye

Lo decide un fingerprint por gráfico. Cuando PreserveUnsupportedParts es True en el 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 escritor 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 la base de offset estándar 14695981039346656037 y el primo 1099511628211. TXLSXChart.HasPreservedXml informa de si un gráfico lleva esos bytes originales

Al guardar, el modelo de gráfico se serializa de nuevo y se comparan ambos números. 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 ha cambiado. En ese caso, con StrictOOXML desactivado, el escritor copia la entrada comprimida del archivo de origen directamente a la salida bajo el nuevo nombre de parte del gráfico; los bytes nunca se decodifican ni se recomprimen. Si cualquiera de los dos números difiere, el XML original se fusiona con el modelo en su lugar. 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 forme parte del modelo serializado

HotXLS captura un fingerprint de gráfico al importar, conservando los bytes originales de la parte de gráfico y registrando la longitud y el hash FNV-1a de 64 bits de su propia serialización del modelo de gráfico, y al guardar reconstruye y compara ambos valores, así que una coincidencia repite los bytes originales o copia la entrada comprimida y una discrepancia fusiona el XML original con el modelo
El fingerprint solo vale lo que vale el momento en que se toma, y capturarlo antes de que terminen todas las pasadas de recuperación garantiza un hash que nunca coincide con el modelo completado

La fusión 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 trazado, 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 la fusión conserva; este artículo va de las condiciones bajo las cuales un gráfico sin editar nunca llega a ella

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 estar en vigor antes de Open
    Book.StrictOOXML := False;              // la salida Strict regenera cada gráfico
    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 el origen llevaba, TXLSXChart.ClearPreservedXml suelta los bytes guardados y ese gráfico se escribe solo desde el modelo

¿Por qué acabarían todos los gráficos en el camino del merge?

Porque un fingerprint tomado un paso demasiado pronto nunca coincide. El XML de un gráfico rara vez se lee en una sola pasada. Un parseo en streaming construye las series, los ejes y los grupos de trazado, y después una ronda de pasadas de seguimiento lee detalles del texto crudo del gráfico 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 marcas mayores y menores. La colección de series simplemente no existe todavía mientras corre la pasada de streaming, así que esas pasadas tienen que ir después

Suponga que el fingerprint se captura al final de la pasada de streaming, antes de que corran las pasadas de seguimiento. Describe entonces un modelo sin flags de suavizado, sin colores de marcador y con marcas de graduación por defecto. Al guardar, el serializador corre contra el modelo completado 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 la fusión 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 ahí está el origen de la desviación de color

HotXLS captura el fingerprint solo después de que terminan todas las pasadas 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 restablecen tras un guardado con éxito, 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 de la última pasada que aún pueda mutar el modelo. Tomarlo antes da una comprobación que falla en cada guardado

Los offsets del ancla 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á 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 son English Metric Units, 914400 por pulgada y 12700 por punto, y Excel escribe valores distintos de cero cada vez que un gráfico se arrastra o se redimensiona con el ratón. 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 empieza 1,5 puntos por debajo del inicio de la fila 1 y termina algo más de un cuarto de pulgada dentro de la columna I

Anatomía de las esquinas xdr:twoCellAnchor de un gráfico de ejemplo: from guarda col 0 y rowOff 19049 mientras to guarda col 8, colOff 247650 y rowOff 66674 en EMU de 914400 por pulgada, y como un escritor que emite offsets a cero ajusta los gráficos a la cuadrícula, HotXLS escribe los offsets importados de vuelta al guardar
El ancla vive en la parte de drawing y no en la parte del gráfico, así que la repetición de offsets es independiente del fingerprint, y un round trip fiel necesita ambas cosas

Un escritor 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 cuadrícula. La parte del gráfico puede repetirse byte a byte y el gráfico aún así se mueve, porque los dos viven en partes distintas. HotXLS conserva la clase de ancla que cargó (twoCellAnchor, oneCellAnchor o absoluteAnchor, expuesta como PhysicalAnchorKind) junto con sus valores EMU, y los escribe de vuelta sin cambios. Los offsets se leen como FromOffsetXEMU, FromOffsetYEMU, ToOffsetXEMU y ToOffsetYEMU, y las conversiones de unidad que hay detrás se desarrollan 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]));
  // Sustituye el marco completo en una 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 sea 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 empieza con offsets a cero, así que se sitúa exactamente sobre los bordes de celda hasta que usted le dé otros

Demostrar el round trip con una comparación byte a byte

Abrir la salida en Excel demuestra muy poco aquí. Excel normaliza lo suficiente al cargar que un gráfico reserializado parece idéntico justo hasta que alguien nota que un color de marcador cambió, y un salto a la cuadrícula de unos puntos es fácil de perder en pantalla. La comprobación que vale la pena automatizar compara las partes de gráfico byte a byte después de un abrir-y-guardar 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 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;

Unas cuantas condiciones deciden si una discrepancia en esa comprobación significa algo:

  • PreserveUnsupportedParts debe ser True antes del Open; con él desactivado no se conservan bytes originales y cada gráfico se escribe desde el modelo
  • StrictOOXML debe 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 editar sobreviven a un guardado
  • Un paquete de origen escrito con namespaces Strict OOXML se convierte a namespaces transicionales en un guardado transicional, así que sus bytes de gráfico difieren aunque nada más cambiara
  • El código entre Open y SaveAs puede leer propiedades de gráficos libremente, pero cualquier setter que cambie el modelo cambia el fingerprint y manda ese gráfico a la fusión, lo cual es correcto y queda fuera de lo que esta prueba mide
  • Las partes de gráfico se renumeran desde un contador de todo el libro al guardar, así que un libro cuyo orden de hojas o gráficos cambió pone bytes idénticos bajo un nombre chartN.xml distinto; un comprobador para ese caso sigue las relaciones de drawing en lugar de emparejar nombres

Los anclas necesitan una comparación por valor. La parte de drawing se escribe de nuevo alrededor de los gráficos que contiene, así que sus bytes son lo equivocado para diff; reabrir ambos archivos y comparar de FromOffsetXEMU a ToOffsetYEMU gráfico a gráfico caza a un escritor que convierte 247650 en 0. Una comprobación estructural que recorra las relaciones de hoja, drawing y gráfico y falle ante cualquier referencia de gráfico duplicada, huérfana o colgante caza el caso de las tres partes antes incluso de que corra la comparación de bytes

Todo esto corre en código Delphi y C++Builder nativo sin instalación de Excel en la máquina, así que el libro que vuelve de un proceso 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