Artículo técnico

Filas ODS repetidas como tramos de altura en HotXLS Delphi

El componente HotXLS Delphi guarda una fila ODS que trae table:number-rows-repeated y una altura de fila como un único registro TXLSXRowHeightRun — primera fila, última fila, una sola altura — en vez de una entrada de altura por cada fila repetida, y pliega el estilo de celda en blanco que heredan esas filas en una sola capa de estilo por intervalo. Esa es toda la razón por la que HotXLS 2.382.2 abre en 0.02 segundos una hoja cuya cola repite 1,048,530 filas en blanco, donde la 2.382.1 se quedaba sin tiempo, y por la que el mismo archivo se guarda de vuelta a ODS con el conteo de repetición intacto en lugar de como un millón de filas literales

El archivo en cuestión es de lo más común. LibreOffice Calc escribe una hoja de catorce columnas con 45 filas de datos y después describe todo lo que hay debajo con un solo elemento: <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>. El estilo ro1 fija style:row-height="0.452cm", y cada <table:table-column> lleva un table:default-cell-style-name que hereda toda celda en blanco del tramo. El content.xml completo pesa 103 KB. Nada en el archivo dice "caro"; el costo era todo nuestro

Cómo convierte HotXLS una fila ODS repetida en estado compacto: el elemento de content.xml con table:number-rows-repeated 1048530 y estilo ro1 se mapea a un único registro TXLSXRowHeightRun que abarca las filas 46 a 1048575 a 12.81 pt más una entrada StyleOverlays por columna, mientras la versión 2.382.1 expandía el mismo elemento en un millón de entradas SetRowHeight y objetos de celda
El conteo de repetición, la altura de fila de ro1 y los estilos por defecto de columna describen cada fila en blanco por debajo de la fila 45, así que el importador puede armar un solo registro de tramo y las capas por columna sin tocar un millón de coordenadas

¿Por qué una sola fila repetida agota el tiempo de una importación ODS?

Porque el importador la expandía. En la 2.382.1 el finalizador de filas hacía un bucle SetRowHeight(RowIndex + i, RowHeight) una vez por fila repetida y escribía cada altura en una lista de cadenas Name=Value indexada por número de fila. Cada inserción en esa lista corría una búsqueda IndexOfName sobre todo lo que ya tenía dentro, así que un millón de alturas costaba un millón de escaneos lineales — la búsqueda cuadrática en lista contra la que se abrió HXLS-005. Al mismo tiempo OdsCommitRow materializaba un objeto de celda por cada columna que heredaba un estilo, en cada una de las filas repetidas, porque una celda en blanco con estilo seguía contando como celda

El lado del guardado tenía su propia versión del problema. El archivo de LibreOffice termina con una fila ro1 más después de la gran repetición, así que la fila con estilo más alta quedaba al puro final de la hoja, y OdsBuildTableXml recorría todas las filas hasta ella emitiendo elementos <table:table-row> uno por uno. Incluso un libro importado barato se habría escrito caro. Arreglar la importación sin arreglar la exportación habría movido el timeout, no lo habría eliminado

¿Qué es un tramo de altura de fila en HotXLS?

Un tramo es lo más pequeño que puede describir "las filas 46 a 1,048,575 miden todas 12.81 puntos" sin decirlo 1,048,530 veces. TXLSXRowHeightRun es un registro de FirstRow, LastRow y Height; TXLSXRowHeightRuns es un arreglo dinámico de ellos, y cada TXLSXWorksheet guarda uno en FRowHeightRuns junto a la lista de alturas por fila que ya existía. En la importación ODS el finalizador de filas ahora bifurca según el conteo de repetición: un conteo de 1 sigue llamando a SetRowHeight, cualquier valor mayor llama una sola vez a XlsxAssignRowHeightRun para todo el tramo. El tramo se recorta a XlsxMaxRow, que es 1,048,576, así que un conteo de repetición que se pasa de la hoja se trunca en lugar de rechazarse

XlsxAssignRowHeightRun es el único escritor del arreglo, y mantiene los tramos disjuntos por construcción. Dado un intervalo nuevo, copia cada tramo existente que queda por completo fuera de él, parte cualquier tramo que se solape en la pieza anterior y la pieza posterior, y después añade el intervalo nuevo cuando Present es true — o no añade nada cuando Present es false, que es como ClearRowHeight abre un hueco de una sola fila. De ahí se siguen dos cosas. El arreglo nunca contiene intervalos solapados, así que una consulta puede detenerse en el primer acierto. Y el arreglo nunca se muta en el lugar; en cada llamada se construye una copia nueva, lo que no cuesta nada a los tamaños que estamos hablando y elimina toda una clase de bugs de aliasing

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // Una hoja cuya fila de cola se repite 1,048,530 veces bajo un solo estilo de fila
    Workbook.OpenODS('conditional-formatting.ods');
    Sheet := Workbook.Sheets[1];
    // Ambas lecturas se resuelven por el mismo tramo; no se expandió nada
    Writeln(Sheet.RowHeight[46]:0:2, ' pt');
    Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
    // Un override de una sola fila ensombrece el tramo sin partirlo
    Sheet.RowHeight[500000] := 36;
    // Limpiar una fila dentro del tramo corta el tramo en dos piezas
    Sheet.ClearRowHeight(500001);
    Writeln(Sheet.HasRowHeight(500001)); // False
    Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // sigue siendo la altura del tramo
  finally
    Workbook.Free;
  end;
end;

El orden de consulta es la parte que vale la pena memorizar. TXLSXWorksheet.GetRowHeight revisa primero la lista por fila y solo consulta los tramos cuando la fila no tiene entrada explícita, y HasRowHeight hace lo mismo. Así que Sheet.RowHeight[500000] := 36 no toca el tramo en absoluto — añade una entrada a la lista por fila, y esa entrada gana porque se consulta primero. ClearRowHeight es lo contrario: quita cualquier entrada por fila y después llama a XlsxAssignRowHeightRun con Present = False, porque una fila limpiada debe leerse como "sin altura" aunque un tramo la cubra. ClearRowHeights vacía las dos estructuras de una vez

Cirugía sobre los tramos de altura de fila en HotXLS: tras OpenODS un tramo cubre las filas 46 a 1048575 a 12.81 pt mientras una entrada por fila pone la fila 500000 en 36 pt y gana la consulta porque GetRowHeight revisa primero la lista por fila, y ClearRowHeight de la fila 500001 parte el tramo en dos piezas disjuntas alrededor del hueco
XlsxAssignRowHeightRun copia las piezas que quedan fuera del intervalo limpiado y no añade nada para el intervalo en sí, así que los tramos quedan disjuntos por construcción y una consulta puede detenerse en el primer acierto mientras el override de la fila 500000 queda intacto

¿A dónde van los estilos heredados de celdas en blanco?

A una sola capa de estilo por intervalo y por columna, no a objetos de celda. OdsCommitRow decide por valor de columna si es un blanco compacto: que la fila se repita más de una vez y que la celda no tenga valor, ni fórmula, ni texto enriquecido. Para un blanco compacto crea una celda real solo en la primera fila del tramo, le aplica el estilo heredado y después registra los mismos seis índices de estilo — fuente, relleno, borde, formato de número, alineación, protección — como un StyleOverlays.Add que cubre de la segunda fila al final del tramo en esa columna. Las filas posteriores a la primera se saltan por completo en el bucle de materialización

La prueba de regresión le da forma concreta. Tras abrir una hoja cuya segunda fila se repite 1,048,575 veces bajo un estilo por defecto de columna en negrita, se verifica que Sheet.Cells.Count quede por debajo de 10, y que Sheet.Cells[700000, 1].FontIndex siga resolviendo a la fuente en negrita — la capa entrega el estilo en el momento en que se toca esa coordenada. Es el mismo mecanismo que evita que una columna con formato pero vacía cueste un millón de celdas del lado de XLSX; las notas sobre almacenamiento de celdas por bloques de fila y capas de estilo por intervalo cubren cómo se apilan y se resuelven las capas. Lo nuevo aquí es que el importador de ODS las crea por su cuenta, a partir del conteo de repetición, en vez de esperar a que una aplicación dé formato a un rango

¿Cómo vuelve SaveAsODS a escribir el conteo de repetición?

Partiendo la cola vacía de la hoja solo donde algo cambia de verdad. OdsBuildTableXml ahora sigue dos límites: contentMaxRow, la última fila que tiene un valor, fórmula, hipervínculo o salto de fila manual, y maxRow, que además se extiende por las celdas en blanco solo con estilo, las alturas de una sola fila, el LastRow de cada tramo y el borde inferior de cada capa. Una celda en blanco solo con estilo ya no cuenta como contenido — TXLSXCells.IsStyleOnlyBlank es lo que la excluye — así que la fila con estilo del final del archivo de LibreOffice deja de arrastrar el límite de contenido hasta el fondo de la hoja

Por encima de contentMaxRow las filas se escriben una por una exactamente como antes. Por debajo, el writer calcula nextRow como el menor de: el FirstRow del tramo siguiente, el LastRow + 1 del tramo actual, la siguiente entrada de altura de una sola fila, el siguiente borde de capa y la siguiente celda materializada. Todo lo que va de la fila actual hasta nextRow - 1 se emite entonces como un solo <table:table-row> con table:number-rows-repeated puesto a la diferencia, llevando un <table:table-cell/> por columna con el nombre de estilo resuelto por la capa cuando una capa cubre esa columna. El estilo de fila en sí viene de TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec), que ahora pliega el texto de la altura — 12.81pt, digamos — en su clave de deduplicación junto a los flags de oculto y salto de página, así que cada fila del tramo comparte un estilo ro<N> con una sola propiedad style:row-height

Lo que escribe SaveAsODS para una hoja respaldada por tramos: contentMaxRow se detiene en la fila 45 donde terminan los valores mientras maxRow se extiende por el tramo de altura y sus overrides, las filas por encima del límite se escriben una por una, y la cola se emite como elementos table-row repetidos cuyo estilo de fila sale de RowStyleFor y cuyos estilos de celda se resuelven por las capas
Cada elemento repetido abarca un tramo uniforme y se detiene en el siguiente borde de tramo, entrada de altura, borde de capa o celda materializada, así que una hoja sin validaciones se guarda en un puñado de elementos mientras que las validaciones o una exportación a XLSX pagan por fila
var
  Workbook, Reopened: TXLSXWorkbook;
  Saved: TMemoryStream;
begin
  Workbook := TXLSXWorkbook.Create;
  Reopened := TXLSXWorkbook.Create;
  Saved := TMemoryStream.Create;
  try
    Workbook.OpenODS('conditional-formatting.ods');
    Workbook.Sheets[1].RowHeight[500000] := 36;
    Workbook.Sheets[1].ClearRowHeight(500001);
    // La cola vacía se escribe como un puñado de filas repetidas, no un millón
    Workbook.SaveAsODS(Saved);
    Writeln('ODS size: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.Open(Saved);
    // El override, el hueco y el tramo sobreviven todos al round trip
    Writeln(Reopened.Sheets[1].RowHeight[500000]:0:2);   // 36.00
    Writeln(Reopened.Sheets[1].HasRowHeight(500001));    // False
    Writeln(Reopened.Sheets[1].RowHeight[500002]:0:2);   // altura del tramo
  finally
    Saved.Free;
    Reopened.Free;
    Workbook.Free;
  end;
end;

La prueba que fija esto verifica que el stream guardado quede por debajo de 64 KB para una hoja cuyo tramo de altura abarca 1,048,575 filas con un override y un hueco abierto en el medio. Junto a ese número van dos límites honestos. Primero, una hoja con cualquier validación de datos pone contentMaxRow igual a maxRow, así que las validaciones desactivan la compactación de la cola en esa hoja y se vuelve a escribir fila por fila. Segundo, XLSX no tiene atributo de repetición — un <row> de SpreadsheetML describe una fila — así que exportar una hoja respaldada por tramos a .xlsx enumera las filas que cubre el tramo y escribe un atributo ht en cada una. El modelo sigue compacto en memoria; el formato de archivo decide cómo se ve el archivo

¿Qué le debe ahora a los tramos cada edición que renumera filas?

Mantenimiento. Una representación nueva de los metadatos de fila solo es correcta si toda operación que cambia números de fila la mueve junto con las listas por fila que tiene al lado, y el commit toca cada una de esas operaciones. InsertRows y DeleteRows pasan por XlsxShiftRowHeightRuns, que reconstruye el arreglo conservando la parte de cada tramo anterior al punto de edición, descartando lo que cae dentro de una ventana de borrado y volviendo a añadir el resto desplazado por el delta — así que un tramo que cruza una inserción se vuelve dos tramos con un hueco, y un tramo que cruza un borrado se encoge. TileRangeAxisMetadata limpia los tramos en todo el espacio replicado y después vuelve a registrar cada tramo de origen una vez por copia en su offset. TXLSXWorksheet.CopyFrom y TXLSXSheets.AddCopy toman un Copy() del arreglo en vez de asignarlo, y por eso la prueba puede limpiar todas las alturas en un clon y aun así encontrar la hoja original intacta en la fila 1,048,576

var
  Sheet: TXLSXWorksheet;
begin
  Sheet := Workbook.Sheets[1];
  Sheet.RowHeight[500000] := 36;
  Sheet.ClearRowHeight(500001);
  // Insertar dos filas en 500000: el override pasa a 500002 y el hueco a 500003
  Sheet.InsertRows(500000, 2);
  Writeln(Sheet.RowHeight[500002]:0:2);   // 36.00
  Writeln(Sheet.HasRowHeight(500003));    // False
  // Borrarlas otra vez: todo vuelve a su sitio
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);   // 36.00
  // Replicar las filas 2..4 dos veces hacia abajo; las alturas del tramo siguen a cada copia
  Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
  Writeln(Sheet.RowHeight[7]:0:2);        // la altura del tramo
end;

Los límites del lado de lectura tienen la misma obligación. GetUsedRange sube su borde inferior hasta el FirstRow y el LastRow de cada tramo, y BuildRowMajorCellOrder extiende su fila máxima con metadatos incluidos por cada tramo, para que el writer de XLSX siga visitando las filas que solo tienen altura. Si alguna vez añade una estructura propia indexada por fila encima del modelo de objetos de HotXLS, esta es la lista: insertar, borrar, replicar, copiar, rango usado y cada serializador. Falle uno y el fallo es silencioso — las alturas se desfasan por el número de inserciones y nada lanza una excepción

Qué sigue siendo por fila y cómo se ven los números ahora

Los flags de oculto, los niveles de esquema y el estado colapsado todavía se expanden. El finalizador de filas hace un bucle de SetRowHidden y SetRowOutlineLevel una vez por fila repetida, así que una hoja que oculte una cola de un millón de filas, o que la anide dentro de un table:table-row-group, paga una entrada por fila por cada uno de esos atributos. El cambio de la 2.382.2 está acotado a las dos cosas que HXLS-005 midió de verdad — alturas y estilos heredados de celdas en blanco — y la misma técnica de tramos se aplicaría a las demás si algún archivo llegara a exigirlo. El reader de ODS tampoco actúa sobre style:use-optimal-row-height; un estilo de fila que dice "optimal" y da una altura se importa con esa altura

Contra el corpus, conditional-formatting.ods ahora completa el ciclo de abrir, verificar, guardar, reabrir y volver a verificar en 0.178 segundos en Win32 y 0.158 segundos en Win64, con la etapa de apertura en 0.020 segundos, dentro de un presupuesto de 60 segundos que antes agotaba. Las interfaces a nivel de libro por las que pasa el formato se describen en el recorrido de abrir y guardar archivos ODS, y el conjunto más amplio de palancas para archivos grandes en rendimiento con libros grandes; el propio elemento de fila de ODF, con sus atributos de repetición y estilo, está especificado en ODF 1.3 Part 3 §9.1.4

HotXLS lee y escribe XLS, XLSX y ODS desde código nativo de Delphi y C++Builder sin tener Excel ni LibreOffice instalados, y por eso una repetición de un millón de filas es algo que la librería tiene que modelar bien en lugar de delegar a un proceso externo — la página del componente de hojas de cálculo HotXLS para Delphi lista los formatos soportados y las versiones de RAD Studio