Artículo técnico

Eje de fechas en HotXLS: ticks fieles al calendario

HotXLS dibuja los ejes de fecha de los gráficos de Excel con aritmética de calendario, no con conteos fijos de días. La clase TXLSChartDateAxisTransform de la unidad lxChart mantiene cada coordenada como serial del workbook, calcula cada tick de mes o de año directamente desde el mínimo del eje mediante IncMonth e IncYear, y ajusta los fines de mes al calendario real, así que una serie anclada al 31 de enero recibe ticks en el 28 de febrero, el 31 de marzo y el 30 de abril en vez de irse deslizando. La misma transformación elige automáticamente unidades de día, mes o año, y la comparten los renderizadores de gráficos HTML, SVG y PDF paginado

El bug que esto evita le resulta familiar a cualquiera que haya graficado un cierre de fin de mes. Un renderizador que trata el mes como 30 días acumula cinco días de adelanto al terminar el primer año. Uno más listo que usa meses de calendario pero avanza cada tick a partir del anterior falla con menos ruido: el 31 de enero se convierte en 28 de febrero, el paso siguiente cae en 28 de marzo, y todos los ticks posteriores se quedan clavados en el 28. El gráfico parece plausible, las etiquetas están mal, y nadie se entera hasta que el director financiero pregunta por qué el balance de marzo está pintado tres días antes del cierre de trimestre

¿Por qué se desvían los ticks de mes cuando se acumulan?

Los ticks de mes se desvían porque el ajuste tira información a la basura. Una vez que el 31 de enero se ha ajustado al 28 de febrero, el dato de que la serie quería el día 31 ha desaparecido, y cualquier paso dado desde el valor ajustado hereda la pérdida. TXLSChartDateAxisTransform.BuildTicks jamás avanza desde un tick anterior. El tick i se calcula siempre como AddUnits(MinValue, UnitKind, Step * i, ...), que es min + unidad × índice medido desde el mínimo del eje. AddUnits suma el conteo directamente al serial para unidades de día y, para unidades de mes y año, convierte el serial a una fecha real, llama a IncMonth o IncYear y convierte de vuelta. El bucle además está acotado por todos lados: el array de resultado se limita a 4096 entradas pida lo que pida quien llama, Step * i se comprueba contra MaxInt antes de multiplicar, y la generación se detiene en cuanto un tick deja de crecer o pasa el máximo del eje

Dos maneras en que un eje de fechas de gráfico de HotXLS puede colocar los ticks de mes tras un anclaje al 31 de enero de 2026: avanzar cada tick desde el valor ajustado anterior los deja clavados en el 28, mientras que BuildTicks calcula AddUnits(MinValue, xcduMonths, Step por i) desde el mínimo del eje y los ticks caen en 28 de febrero, 31 de marzo y 30 de abril
El ajuste tira información, así que TXLSChartDateAxisTransform jamás avanza desde un tick anterior y recalcula cada posición desde el mínimo del eje, ajustando los fines de mes al calendario real
uses
  SysUtils, lxChart;

var
  Ticks: TXLSChartDateValues;
  MinSerial, MaxSerial: Double;
  I, Count: Integer;
begin
  // Sistema de fechas 1900 (Dates1904 = False), serie anclada a fin de mes
  MinSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 1, 31), False);
  MaxSerial := TXLSChartDateAxisTransform.DateTimeToSerial(
    EncodeDate(2026, 12, 31), False);

  // Paso de un mes, apuntando a 12 ticks, nunca más de 64
  Count := TXLSChartDateAxisTransform.BuildTicks(MinSerial, MaxSerial,
    1, xcduMonths, False, 12, 64, Ticks);

  for I := 0 to Count - 1 do
    Writeln(TXLSChartDateAxisTransform.FormatValue(Ticks[I], False));
  // 2026-01-31, 2026-02-28, 2026-03-31, 2026-04-30 ... 2026-12-31
end;

Hay una aproximación honesta dentro de BuildTicks. Cuando el paso pedido es cero, el método estima un tamaño de paso tratando el mes como 30 días y el año como 365, y luego redondea el paso crudo a un múltiplo 1, 2 o 5 de una potencia de diez. Esa estimación solo decide cuántas unidades hay entre ticks. Las posiciones de los ticks siguen viniendo de IncMonth e IncYear, así que la aproximación puede cambiar la densidad de ticks pero jamás mueve un tick fuera del calendario

¿Por qué conservar seriales del workbook en vez de convertir a TDateTime?

Un eje de fechas en HotXLS conserva los seriales del workbook como su espacio de coordenadas porque el TDateTime de Delphi no tiene hueco para el serial 60, el fantasma 29 de febrero de 1900 que el sistema de fechas 1900 arrastró como rareza de compatibilidad. TrySerialToDateTime desplaza un día los seriales por debajo de 60 y pliega el serial 60 sobre el 28 de febrero, lo cual va bien para etiquetar un punto aislado pero es fatal para la geometría: convierte antes cada punto a TDateTime y los dos días a los lados del día fantasma acaban una unidad más juntos de como los dibuja Excel, así que todo lo pintado antes de marzo de 1900 se desplaza respecto a todo lo posterior. TXLSChartDateAxisTransform.FormatValue trata ese mismo hueco como caso especial y lo etiqueta 1900-02-29 exactamente como hace Excel. En un workbook 1904, el serial 0 es el 1 de enero de 1904, y la transformación aplica el offset fijo de 1462 días en ambas direcciones; la historia completa de las dos épocas está en nuestro artículo sobre los seriales de fecha de Excel, el sistema 1904 y los formatos numéricos

Por qué un eje de fechas de HotXLS conserva los seriales del workbook en vez de convertir a TDateTime: el serial 60 es el fantasma 29 de febrero de 1900, TrySerialToDateTime desplaza los seriales por debajo de 60 y pliega el 60 sobre el 28 de febrero, de modo que los días a ambos lados acaban una unidad más juntos de como los dibuja Excel, mientras FormatValue etiqueta el serial 60 como 1900-02-29
El plegado va bien para etiquetar un punto aislado pero es fatal para la geometría, porque todo lo pintado antes de marzo de 1900 se desplaza contra todo lo posterior; un workbook 1904 aplica en cambio el offset fijo de 1462 días

¿Cómo elige HotXLS días, meses o años automáticamente?

TXLSChartDateAxisTransform.DetectUnit elige la unidad de calendario más fina que los huecos de los datos necesitan de verdad. El método descarta primero los valores NaN e infinitos, ordena el resto una sola vez en O(n log n) y salta las fechas duplicadas. Si algún hueco adyacente es más corto que un mes de calendario, la unidad son días; si no, si algún hueco es más corto que un año de calendario, la unidad son meses; y si no, años. El test es IncMonth(Previous, 1) > Current, no un umbral de 30 días, así que una serie de fin de mes que va 31 de enero, 28 de febrero, 31 de marzo se detecta correctamente como datos mensuales. Con menos de dos fechas válidas distintas no hay hueco que medir, y el método se repliega en días

ResolveUnits fusiona después la unidad detectada con lo que el gráfico declare. Una unidad base explícita se respeta, y una unidad major o minor ausente hereda la detectada cuando esta es más gruesa que la base. El último paso es el que la gente se pierde: si la unidad major o minor efectiva es más fina que la unidad base, la base se rebaja para igualar. La unidad base es a lo que se normalizan los puntos de datos antes de que exista ningún tick, con Normalize ajustando un valor a su día, al primero de su mes o al 1 de enero, de modo que una base mensual bajo una unidad major diaria colapsaría un mes de puntos en una sola ranura antes de que el código de ticks llegara a verlos. La densidad de ticks también está acotada, por la longitud del área de trazado, el ancho medido de la fuente del eje, el formato numérico de las etiquetas y la proyección de las etiquetas rotadas, y cada backend dibuja etiquetas, marcas y gridlines desde un único array de ticks compartido y acotado, con el minor tick que coincide con un major tick dibujado una sola vez

Cómo elige HotXLS unidades del eje de fechas automáticamente: DetectUnit descarta los valores NaN e infinitos, ordena una sola vez y mide los huecos de calendario con IncMonth en vez de un umbral de 30 días, así que una serie 31 de enero, 28 de febrero, 31 de marzo se detecta como mensual, y ResolveUnits respeta una base explícita rebajándola cuando la unidad major es más fina
Normalize ajusta cada valor a su día, al primero de su mes o al 1 de enero antes de que exista ningún tick, porque una base mensual bajo una unidad major diaria colapsaría un mes de puntos en una sola ranura

¿Qué guarda el elemento dateAx y qué significa omitirlo?

En ChartML cada ajuste de calendario de un eje de fechas es opcional, y omitir uno no es lo mismo que escribir su valor por defecto. El tipo CT_DateAx de ECMA-376 Parte 1, §21.2 (DrawingML Charts) deja que baseTimeUnit, majorTimeUnit, minorTimeUnit y auto aparezcan o no, y un baseTimeUnit omitido le dice a Excel que decida él, que no es lo mismo que un days explícito. TXLSXChartAxis guarda por tanto cada valor junto a un flag de presencia: BaseTimeUnit con BaseTimeUnitSet, MajorTimeUnit con MajorTimeUnitSet, MinorTimeUnit con MinorTimeUnitSet, y AutoDateAxis con AutoDateAxisSet. Asignar un valor activa su flag, limpiar el flag devuelve el elemento a la omisión, y el escritor XLSX solo emite un elemento cuando su flag está activo. Ten en cuenta que auto significa detección automática de categoría frente a fecha, no unidades de tick automáticas

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Chart: TXLSXChart;
  Axis: TXLSXChartAxis;
  M: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Close');
    Sheet.Cells[1, 1].Value := 'Month end';
    Sheet.Cells[1, 2].Value := 'Balance';
    for M := 1 to 12 do
    begin
      Sheet.Cells[M + 1, 1].Value :=
        EncodeDate(2026, M, DaysInAMonth(2026, M));   // DateUtils
      Sheet.Cells[M + 1, 2].Value := 1000 + M * 75;
    end;

    Chart := Sheet.AddLineChart('Month-end balance',
      'Close!$A$2:$A$13', 'Close!$B$2:$B$13', 15, 1, 32, 9);
    Axis := Chart.CategoryAxis;
    Axis.Kind := xlsxAxisDate;                  // escribe c:dateAx
    Axis.BaseTimeUnit := xlsxChartTimeDays;     // también activa BaseTimeUnitSet
    Axis.MajorTimeUnit := xlsxChartTimeMonths;
    Axis.MajorUnit := 1;                        // > 0 marca majorUnit como fijado
    Axis.NumberFormat := 'mmm yyyy';
    Axis.NumberFormatSourceLinked := False;
    Axis.TextStyle.Rotation := -45;             // grados; se almacena como 1/60000
    // AutoDateAxis sin tocar: no se escribe ningún elemento c:auto

    Book.SaveAs('month-end-balance.xlsx');
  finally
    Book.Free;
  end;
end;

Las etiquetas y la orientación siguen las mismas reglas de presencia. El numFmt del eje va tras el título y antes de las marcas, su atributo sourceLinked vale true por defecto, y TXLSXChartAxis guarda por separado el código de formato, el valor de source-linked y NumberFormatSet, así que un código vacío jamás se confunde con un elemento ausente. La rotación del texto se almacena en 1/60000 de grado con los valores positivos significando sentido horario; TextStyle.Rotation toma grados a secas, la salida SVG usa el ángulo tal cual, y el backend paginado, cuyos ángulos positivos van en sentido antihorario, invierte el signo en una única frontera compartida. Un eje maxMin invertido refleja juntos los ticks major y minor, las gridlines, los puntos de datos, las líneas de tendencia y las barras de error, y nunca solo las etiquetas. Cuando abres un workbook escrito por otro, los flags te dicen qué especificó de verdad su autor

var
  Book: TXLSXWorkbook;
  Axis: TXLSXChartAxis;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.Open('forecast.xlsx') <> 1 then
      raise Exception.Create('Cannot open workbook');
    Axis := Book.Sheets[0].Charts[0].CategoryAxis;
    if Axis.Kind = xlsxAxisDate then
    begin
      if Axis.BaseTimeUnitSet then
        Writeln('baseTimeUnit = ', Ord(Axis.BaseTimeUnit))
      else
        Writeln('baseTimeUnit omitted: Excel chooses at render time');
      if Axis.AutoDateAxisSet then
        Writeln('auto = ', Axis.AutoDateAxis);
    end;
  finally
    Book.Free;   // guárdalo otra vez y los elementos omitidos siguen omitidos
  end;
end;

¿Dónde se detiene el modelo de calendario?

El modelo de calendario se detiene en los formatos que no pueden expresar unidades de calendario, y HotXLS no finge lo contrario. ODF 1.2 ofrece chart:interval-major, que es un número a secas sin noción de días, meses ni años, así que un eje mensual de Excel guardado como ODS no puede llevar su unidad, y HotXLS no inventa un atributo no estándar para aparentarla. En el lado legado de BIFF8, el record AxcExt contiene nueve palabras fijas de 16 bits, y sus flags automáticos solo enmascaran campos en vez de eliminarlos; HotXLS conserva el mínimo, máximo, intervalo, unidad y cruce enmascarados al leer y reescribir, mantiene intactos los códigos de unidad desconocidos en el modelo Classic, y mapea a XLSX solo las unidades de fecha válidas 0, 1 y 2 (días, meses, años)

Los ejes de fechas son una capa de un modelo de gráficos que además tiene que sobrevivir a archivos que no creó él. Cuando un workbook de Excel combina una línea con eje de fechas y una serie de columnas en un eje secundario, nuestro artículo sobre ChartML conservado y gráficos combinados muestra cómo HotXLS reproduce el XML original del gráfico byte a byte cuando nada cambió y fusiona en él las ediciones tipadas cuando algo cambió, y nuestra visión general de gráficos, imágenes y drawings cubre las APIs de anclaje y de series usadas arriba. Todo esto viene en el HotXLS Delphi Component para Delphi y C++Builder, que lee, escribe y renderiza gráficos XLS y XLSX sin automatización de Excel