Artículo técnico

Grupos de eje secundario en gráficos BIFF8 con HotXLS

HotXLS escribe grupos de eje secundario en gráficos BIFF8 emitiendo un segundo bloque AxisParent, no añadiendo un segundo grupo de gráficos después de los ejes. En el substream de gráficos Classic XLS, cada grupo de gráficos — ChartFormat, el registro del tipo de gráfico y CrtLink — vive dentro de su propio bloque de grupo de ejes, y cada serie se vincula a uno de ellos mediante SerToCrt. Invierta ese anidamiento y no habrá un segundo grupo de gráficos al que pueda vincularse una serie, por muchos registros de eje que emita

¿Por qué un segundo grupo de gráficos después de los ejes no vincula nada?

La gramática es toda la respuesta, y cabe en una línea de ABNF. La regla CHARTFOMATS de [MS-XLS] 2.1.7.20.1 dice AxesUsed 1*2AXISPARENT y después escribe AXISPARENT = AxisParent Begin Pos [AXES] 1*4CRT End. Lea juntas esas dos producciones y la forma aparece sola: los grupos de gráficos son hijos de un grupo de ejes, no hermanos. Un substream de gráficos con dos pares de ejes y un grupo de gráficos final no es un gráfico de doble eje con una peculiaridad de layout; es un gráfico con un grupo de gráficos y un conjunto de registros de ejes huérfanos. Importa porque SerToCrt ($1045), situado dentro del bloque SERIESFORMAT, lleva un índice de grupo de gráficos basado en cero, no un índice de ejes. Escribir crt = 1 cuando solo existe un bloque CRT apunta una serie a un grupo de gráficos que nunca se emitió. La intuición que lleva a equivocarse procede del nombre del registro: AXESUSED ($1046) parece contar ejes, así que el siguiente movimiento natural es emitir más ejes. Cuenta grupos de ejes y cada grupo de ejes arrastra consigo un área de trazado completa y un grupo de gráficos

Marcar una serie para el grupo de ejes secundario

En el lado de HotXLS, todo se reduce a un booleano. TXLSChartSeriesInfo lleva un campo SecondaryAxis, y establecerlo en cualquier serie del array que entregue a TXLSWorksheets.AddChartSheet cambia todo el builder al modo de dos grupos. No hay una llamada separada para «activar el eje secundario» ni un parámetro de número de ejes, porque el recuento se puede derivar: si alguna serie quiere el grupo secundario, el gráfico necesita dos

var
  Wb: TXLSWorkbook;
  Series: array [0..1] of TXLSChartSeriesInfo;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Sheets.Add.Name := 'Data';
    // ... rellenar A1:C12 con categorías, ingresos y margen ...

    Series[0] := Default(TXLSChartSeriesInfo);   // nunca aplicar FillChar a este record
    Series[0].Name := 'Revenue';
    Series[0].Categories := 'Data!$A$1:$A$12';
    Series[0].Values := 'Data!$B$1:$B$12';

    Series[1] := Default(TXLSChartSeriesInfo);
    Series[1].Name := 'Margin';
    Series[1].Categories := 'Data!$A$1:$A$12';
    Series[1].Values := 'Data!$C$1:$C$12';
    Series[1].SecondaryAxis := True;             // AXESUSED pasa a ser 2

    Wb.Sheets.AddChartSheet('Dual Axis', xlsChartTypeLine,
      'Revenue vs Margin', '', '', Series);
    Wb.SaveAs('dual-axis.xls', xlExcel97);
  finally
    Wb.Free;
  end;
end;

La línea Default(TXLSChartSeriesInfo) no es decorativa. TXLSChartSeriesInfo mezcla campos gestionados (los nombres WideString, el array dinámico de trendline y los arrays de barras de error) con miembros Boolean normales, y Delphi solo garantiza que los campos gestionados se limpien por usted. Deje SecondaryAxis sin inicializar y contendrá lo que hubiera en la pila, lo que en la práctica significa que el mismo binario produce un gráfico de un solo eje desde un host de consola y uno de doble eje bajo el test runner. Mientras tanto, los rangos de categorías y valores se resuelven a través de la tabla EXTERNSHEET del workbook antes de que el builder los vea — la misma maquinaria de índices que se trata en cómo clasifica HotXLS los enlaces externos BIFF SupBook y XTI —, por lo que un rango que nombre una hoja desconocida se degrada a un placeholder BRAI vacío en lugar de hacer fallar la compilación

Qué emite HotXLS cuando una serie es secundaria

El emisor cambia de forma, no solo de valor. Sin series secundarias, AXESUSED contiene 1 (o 0 para pie y pie 3D, que no tienen grupos de ejes) y después viene un bloque AxisParent. Con una serie secundaria, AXESUSED contiene 2 y el builder ejecuta el bloque dos veces, con iax — la primera palabra del payload de 18 bytes de AxisParent ($1041) — fijada en 0 y después en 1. Cada pasada emite Pos, el eje de categorías y el eje de valores (Axis, $101D), el marcador PlotArea ($1035), un Frame predeterminado, después ChartFormat ($1014), el registro del tipo de gráfico, CrtLink ($1022) y dos marcadores End para cerrar el grupo de gráficos y el grupo de ejes. La serie secundaria se vincula entonces con SerToCrt crt = 1, mientras que la primaria conserva crt = 0. Hay un registro que deliberadamente no se duplica: la leyenda se emite solo en el primer grupo, porque Excel da a un gráfico una sola leyenda aunque tenga varios grupos de ejes. Conviene declarar claramente otras dos propiedades. Reestructurar el emisor para colocar el grupo de gráficos dentro del bloque AxisParent no cambió la salida de los gráficos normales: sin series secundarias, el substream es byte a byte idéntico a la versión anterior, ya que parametrizar AddAxisParent con iax = 0 es exactamente la antigua ruta de código. Además, el builder sigue emitiendo un par de ejes completo por grupo, así que un grupo secundario siempre llega con su propio eje de categorías aunque solo le interese su escala de valores

¿Cómo recupera la inspección del gráfico el vínculo del grupo de ejes?

La lectura son dos pasadas sobre la lista de registros y tiene que ser así porque AXESUSED llega antes que los bloques que describe. La primera pasada solo busca $1046 y lee su primera palabra como el recuento de grupos de ejes. Ese valor parte de 1 y solo puede aumentar: HotXLS toma el máximo entre el recuento actual y el declarado, de modo que un AXESUSED malformado o duplicado no puede hacer retroceder un gráfico que ya se ha visto declarar dos grupos. La segunda pasada sigue el grupo de ejes actual, lo actualiza en cada AxisParent y estampa ese índice en cada registro Axis que encuentra hasta que aparece el siguiente AxisParent

var
  Model: TXLSChartModel;
  i: Integer;
begin
  // Sheets[1] es la hoja de datos y Sheets[2] la hoja del gráfico
  Model := Wb.Sheets[2]._Chart.GetChartModel;
  try
    if Model.AxisGroupCount = 2 then
      Writeln('AXESUSED declares a secondary axis group');
    for i := 0 to Model.AxisCount - 1 do
      Writeln('axis ', i, ' group ', Model.GetAxis(i).AxisGroup);
    for i := 0 to Model.SeriesCount - 1 do
      Writeln('series ', i, ' chart group ', Model.GetSeries(i).ChartGroup);
  finally
    Model.Free;
  end;
end;

Conviene nombrar dos límites. TXLSChartModel.AxisGroupCount informa de lo que el fichero declara, no de cuántos bloques AxisParent se encontraron realmente; un fichero que diga 2 y solo lleve un bloque informará de 2 y será AxisCount el que permita detectarlo. TXLSChartAxis.AxisGroup es una marca posicional: registra dentro de qué bloque se leyó un eje, que es lo único que informa el formato. En el lado de las series, la decodificación de SerToCrt está condicionada a que se esté dentro de un bloque Series, porque el mismo ID de registro aparece en contextos donde no es un vínculo de serie y un decoder sin esa condición sobrescribiría alegremente la serie equivocada

Verificar ejes secundarios sin un fichero Excel real

Para verificar esto no hacía falta un fichero Excel con un eje secundario, y esa es la parte útil de la historia. La decodificación estructural es una propiedad de la secuencia de registros, así que una secuencia sintetizada la demuestra con la misma precisión que una capturada. La regresión construye AXESUSED con un payload de 2, después dos bloques AxisParent, cada uno envolviendo un eje de categorías y otro de valores, y afirma que el modelo vuelve con AxisGroupCount = 2, cuatro ejes estampados 0, 0, 1, 1 y los tipos de eje esperados en la segunda pareja

// Verificación estructural sin implicar en absoluto a un fichero Excel
Chart := TXLSCustomChart.Create(nil, $0600);
try
  AddWordRecord($1046, [2]);   // AXESUSED: dos grupos de ejes
  AddAxisParentGroup(0);       // AxisParent iax=0 + Begin + 2 Axis + End
  AddAxisParentGroup(1);       // AxisParent iax=1 + Begin + 2 Axis + End

  Model := Chart.GetChartModel;
  Assert.AreEqual(2, Model.AxisGroupCount);
  Assert.AreEqual(4, Model.AxisCount);
  Assert.AreEqual(0, Model.GetAxis(1).AxisGroup);
  Assert.AreEqual(1, Model.GetAxis(2).AxisGroup);
finally
  Model.Free;
  Chart.Free;
end;

Hay dos notas prácticas si sintetiza registros por su cuenta. TXLSCustomChart.AddData(RecID, Len, nil) desreferencia el payload cuando Len no es cero, así que los marcadores Begin ($1033) y End ($1034) deben añadirse con una longitud cero, no con un blob nil y una longitud obsoleta. Además, una secuencia sintética demuestra el decoder, no que Excel acepte su salida: el lado de escritura se estructuró a partir de la ABNF y después se comprobó haciendo round-trip mediante GetChartModel, con aserciones sobre los stamps por eje 0/0/1/1 y los grupos de gráficos por serie 0/1, mientras la ruta byte a byte idéntica sin secundarios actúa como red de seguridad. Es la misma postura conservadora sobre la que se construye el resto del soporte de gráficos, imágenes y dibujos de HotXLS para Delphi: decodificar lo que dicen los registros y negarse a adivinar un layout binario que no se haya leído en la especificación. La suite completa de Delphi ejecutó 1650 de 1650 en Win32 y Win64 después de incorporar el lado de escritura

Parámetros de escena Chart3d y la trampa de fAuto

Dos detalles adyacentes afectan a quien se aleja del gráfico predeterminado. El primero es Chart3d ($103A, [MS-XLS] 2.4.46), un payload plano de 14 bytes emitido dentro del grupo de gráficos para las variantes 3D: anRot (rotación, 0 a 360), anElev (elevación, con signo, -90 a 90), pcDist (distancia de perspectiva, 0 a 100, ignorada salvo que se establezca fPerspective), pcHeight y pcDepth (porcentaje de la anchura del gráfico, 5 a 500), pcGap (0 a 500) y un grbit cuyos bits son fPerspective $0001, fCluster $0002, fAutoscale $0004, f3DScaling $0010 y f2DWalls $0020. La especificación añade restricciones que el layout del registro no aplicará por usted: en un gráfico de barras transpuesto, anRot y anElev no deben superar 44, y en un gráfico circular anElev no debe ser negativo

El segundo es el bit fAuto, y es el que produce el bug «mis colores se ignoraron». LineFormat ($1007), AreaFormat ($100A) y MarkerFormat ($1009) llevan fAuto en el bit 0 de su grbit, y cuando ese bit está activado Excel aplica su estilo automático y trata los valores RGB explícitos, el estilo de línea, la forma del marcador y su tamaño, situados junto a él, como decoración. Cualquier emisor que escriba un estilo de serie personalizado debe limpiar el bit 0; los emisores predeterminados lo mantienen activado precisamente para que Excel elija la paleta. Si está editando un workbook existente en lugar de construir uno, las reglas de conservación vuelven a cambiar y se explican en la edición de gráficos Excel sin perder el ChartML conservado

Los grupos de ejes secundarios, el vínculo SerToCrt y el modelo de gráficos tipado mostrado aquí forman parte del componente de hojas de cálculo Delphi HotXLS para Delphi y C++Builder, que lee y escribe gráficos BIFF8 sin Excel instalado; la página del producto contiene la referencia completa de registros de gráficos y la lista de overloads de AddChartSheet