Artículo técnico

Tramos de altura para filas ODS repetidas en HotXLS Delphi

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

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 de la fila 46 a la 1048575 a 12,81 pt más una entrada StyleOverlays por columna, mientras que la versión 2.382.1 expandía ese mismo elemento en un millón de entradas SetRowHeight y de objetos de celda
El recuento de repeticiones, la altura de fila ro1 y los estilos por defecto de columna describen todas las filas vacías por debajo de la fila 45, así que el importador puede construir un único registro de tramo y overlays por columna sin tocar un millón de coordenadas

¿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

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

¿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

Qué escribe SaveAsODS en una hoja respaldada por tramos: contentMaxRow se detiene en la fila 45 donde terminan los valores mientras maxRow se extiende por el tramo de alturas y sus overrides, las filas por encima del límite se escriben de una en una, y la cola se emite como elementos table-row repetidos cuyo estilo de fila viene de RowStyleFor y cuyos estilos de celda se resuelven por los overlays
Cada elemento repetido abarca un tramo uniforme y se detiene en el siguiente borde de tramo, entrada de altura, borde de overlay o celda materializada, así que una hoja sin validaciones se guarda como 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 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