HotXLS Delphi Component guarda una fila ODS que lleva table:number-rows-repeated y una altura de fila como un único registro TXLSXRowHeightRun — primera fila, última fila, una altura — en lugar de una entrada de altura por fila repetida, y pliega el estilo de celda vacía que heredan esas filas en un solo overlay de estilo por intervalo. Esa es toda la razón de que HotXLS 2.382.2 abra en 0,02 segundos una hoja cuya cola repite 1.048.530 filas vacías allí donde 2.382.1 se quedaba colgada, y de que el mismo archivo vuelva a guardarse en ODS con el recuento de repeticiones intacto en vez de como un millón de filas literales
El archivo en cuestión es de lo más normal. LibreOffice Calc escribe una hoja de catorce columnas con 45 filas de datos y luego 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 vacía del tramo. El content.xml completo son 103 KB. Nada del archivo dice «caro»; el gasto era enteramente nuestro
¿Por qué una sola fila repetida cuelga una importación ODS?
Porque el importador solía expandirla. En 2.382.1 el finalizador de filas recorría SetRowHeight(RowIndex + i, RowHeight) una vez por fila repetida, escribiendo cada altura en una lista de cadenas Name=Value indexada por número de fila. Cada inserción en esa lista ejecutaba un IndexOfName sobre todo lo que ya había dentro, así que un millón de alturas costaba un millón de búsquedas 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 todas y cada una de las filas repetidas, porque una celda vacía 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 estaba en el fondo mismo de la hoja, y OdsBuildTableXml recorría todas las filas hasta ella emitiendo elementos <table:table-row> de uno en uno. Incluso un libro importado barato 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 con FirstRow, LastRow y Height; TXLSXRowHeightRuns es un array 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 recuento de repeticiones: un recuento de 1 sigue llamando a SetRowHeight, cualquier valor mayor llama a XlsxAssignRowHeightRun una vez para todo el intervalo. El intervalo se recorta a XlsxMaxRow, que es 1.048.576, así que un recuento de repeticiones que se pasa de la hoja se trunca en vez de rechazarse
XlsxAssignRowHeightRun es el único escritor del array, y mantiene los tramos disjuntos por construcción. Dado un intervalo nuevo, copia todos los tramos existentes que caen enteros fuera de él, parte cualquier tramo que lo solape en la pieza anterior y la posterior, y luego añade el intervalo nuevo cuando Present es true — o no añade nada cuando Present es false, que es como ClearRowHeight hace un agujero de una fila. De ahí salen dos cosas. El array nunca contiene intervalos solapados, así que una consulta puede parar en el primer acierto. Y el array nunca se muta in situ; en cada llamada se construye una copia nueva, lo que a estos tamaños no cuesta nada y elimina toda una clase de bugs de aliasing
var
Workbook: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Workbook := TXLSXWorkbook.Create;
try
// Una hoja cuya fila final 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 parte 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 conviene memorizar. TXLSXWorksheet.GetRowHeight mira 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 antes. ClearRowHeight es lo contrario: quita cualquier entrada por fila y luego llama a XlsxAssignRowHeightRun con Present = False, porque una fila limpiada debe leerse como «sin altura» aunque un tramo la cubra. ClearRowHeights vacía ambas estructuras a la vez
¿Dónde van a parar los estilos de celda vacía heredados?
A un overlay de estilo por intervalo en cada columna, no a objetos de celda. OdsCommitRow decide valor a valor de columna si es un vacío 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 vacío compacto crea una celda real solo en la primera fila del tramo, le aplica el estilo heredado y luego registra los mismos seis índices de estilo — fuente, relleno, borde, formato de número, alineación y 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
El test de regresión concreta la forma. Tras abrir una hoja cuya segunda fila se repite 1.048.575 veces bajo un estilo por defecto de columna en negrita, se afirma que Sheet.Cells.Count está por debajo de 10, y Sheet.Cells[700000, 1].FontIndex sigue resolviendo a la fuente en negrita — el overlay aporta el estilo en el momento en que se toca esa coordenada. Es el mismo mecanismo que en el lado XLSX evita que una columna con formato pero vacía cueste un millón de celdas; las notas sobre almacenamiento de celdas por bloques de fila y overlays de estilo por intervalo cubren cómo se apilan y se resuelven los overlays. Lo nuevo aquí es que el importador de ODS los crea por su cuenta, a partir del recuento de repeticiones, en vez de esperar a que una aplicación dé formato a un rango
¿Cómo escribe SaveAsODS el recuento de repeticiones de vuelta?
Partiendo la cola vacía de la hoja solo allí donde algo cambia de verdad. OdsBuildTableXml ahora sigue dos límites: contentMaxRow, la última fila que tiene un valor, una fórmula, un hipervínculo o un salto de fila manual, y maxRow, que además se extiende por las celdas vacías solo con estilo, las alturas de una sola fila, el LastRow de cada tramo y el borde inferior de cada overlay. Una celda vacía solo con estilo ya no cuenta como contenido — TXLSXCells.IsStyleOnlyBlank es lo que la excluye —, así que la fila final con estilo 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 de una en una igual que antes. Por debajo, el escritor calcula nextRow como el menor de: el FirstRow del siguiente tramo, el LastRow + 1 del tramo actual, la siguiente entrada de altura de una sola fila, el siguiente borde de overlay 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 overlay cuando un overlay 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, por ejemplo — en su clave de deduplicación junto a los flags de oculta y salto de página, de modo que todas las filas del tramo comparten un mismo 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 agujero y el tramo sobreviven todos a la ida y vuelta
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;
El test que fija esto afirma que el stream guardado queda por debajo de 64 KB en una hoja cuyo tramo de alturas abarca 1.048.575 filas con un override y un agujero hecho en medio. Junto a ese número van dos límites honestos. Primero, una hoja con cualquier validación de datos pone contentMaxRow a maxRow, así que las validaciones desactivan la compactación de la cola en esa hoja y se vuelve a escribir fila a 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 del archivo decide cómo se ve el archivo
¿Qué debe ahora cada edición que renumera filas a los tramos?
Mantenimiento. Una representación nueva de metadatos de fila solo es correcta si toda operación que cambia números de fila la mueve junto a 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 array 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í, un tramo que queda a caballo de una inserción se convierte en dos tramos con un hueco, y uno que queda a caballo de un borrado encoge. TileRangeAxisMetadata limpia los tramos de todo el intervalo tileado y luego vuelve a registrar cada tramo de origen una vez por copia en su offset. TXLSXWorksheet.CopyFrom y TXLSXSheets.AddCopy toman un Copy() del array en vez de asignarlo, y por eso el test puede limpiar todas las alturas de un clon y seguir encontrando 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);
// Inserta dos filas en 500000: el override pasa a 500002 y el agujero a 500003
Sheet.InsertRows(500000, 2);
Writeln(Sheet.RowHeight[500002]:0:2); // 36.00
Writeln(Sheet.HasRowHeight(500003)); // False
// Bórralas otra vez: todo vuelve a desplazarse atrás
Sheet.DeleteRows(500000, 2);
Writeln(Sheet.RowHeight[500000]:0:2); // 36.00
// Tilea las filas 2..4 dos veces hacia abajo; las alturas de 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 al FirstRow y al LastRow de cada tramo, y BuildRowMajorCellOrder extiende su fila máxima, incluyendo metadatos, por todos los tramos para que el escritor XLSX siga visitando las filas que solo tienen altura. Si algún día añades una estructura tuya indexada por fila encima del modelo de objetos de HotXLS, esta es la lista de comprobación: insertar, borrar, tilear, copiar, rango usado y todos los serializadores. Si te saltas uno, el fallo es silencioso — las alturas se desvían en la cuenta de inserciones y no salta nada
¿Qué sigue siendo por fila y cómo quedan los números ahora?
Los flags de oculta, los niveles de esquema y el estado contraído siguen expandiéndose. El finalizador de filas recorre SetRowHidden y SetRowOutlineLevel una vez por fila repetida, así que una hoja que oculta una cola de un millón de filas, o que la anida 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 se limita a las dos cosas que HXLS-005 midió de verdad — alturas y estilos de vacío heredados —, y la misma técnica de tramos se aplicaría a las demás si algún archivo llegara a exigirlo. El lector de ODS tampoco actúa sobre style:use-optimal-row-height; un estilo de fila que dice «óptimo» y da una altura se importa con esa altura
Contra el corpus, conditional-formatting.ods completa ahora el ciclo de abrir, afirmar, guardar, reabrir y volver a afirmar en 0,178 segundos en Win32 y 0,158 en Win64, con la fase 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 fluye 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 necesidad de tener Excel o 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 vez de delegar a un proceso externo — la página del componente de hoja de cálculo HotXLS para Delphi lista los formatos soportados y las versiones de RAD Studio