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
¿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
¿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
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