Artigo Técnico

Linhas repetidas em ODS como runs de altura no HotXLS

O HotXLS Delphi Component guarda uma linha ODS que transporta table:number-rows-repeated e uma altura de linha como um único registo TXLSXRowHeightRun — primeira linha, última linha, uma altura — em vez de uma entrada de altura por linha repetida, e dobra o estilo de célula vazia que essas linhas herdam num único overlay de estilo de intervalo. É esta a razão de o HotXLS 2.382.2 abrir em 0,02 segundos uma folha de cálculo cujo fim repete 1 048 530 linhas vazias onde o 2.382.1 expirava o tempo, e de o mesmo ficheiro voltar a gravar em ODS com a contagem de repetição intacta em vez de um milhão de linhas literais

O ficheiro em questão é banal. O LibreOffice Calc escreve uma folha de catorze colunas com 45 linhas de dados e depois descreve tudo o que fica abaixo delas com um único elemento: <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>. O estilo ro1 define style:row-height="0.452cm", e cada <table:table-column> transporta um table:default-cell-style-name que todas as células vazias do run herdam. O content.xml inteiro tem 103 KB. Nada no ficheiro diz «caro»; o custo era todo nosso

Como o HotXLS transforma uma linha ODS repetida em estado compacto: o elemento do content.xml com table:number-rows-repeated 1048530 e estilo ro1 corresponde a um único registo TXLSXRowHeightRun que abrange as linhas 46 a 1048575 a 12,81 pt mais uma entrada em StyleOverlays por coluna, enquanto a versão 2.382.1 expandia o mesmo elemento num milhão de entradas SetRowHeight e de objetos de célula
A contagem de repetição, a altura de linha do ro1 e os estilos predefinidos das colunas descrevem todas as linhas vazias abaixo da linha 45, pelo que o importador pode construir um único registo de run e overlays por coluna sem tocar num milhão de coordenadas

Porque é que uma linha repetida faz expirar o tempo de uma importação ODS?

Porque o importador costumava expandi-la. Na 2.382.1, o finalizador de linhas fazia um ciclo SetRowHeight(RowIndex + i, RowHeight) uma vez por linha repetida, escrevendo cada altura numa lista de strings Name=Value indexada pelo número da linha. Cada inserção nessa lista corria uma pesquisa IndexOfName sobre tudo o que lá estava, pelo que um milhão de alturas custava um milhão de varrimentos lineares — a pesquisa quadrática na lista contra a qual o HXLS-005 foi registado. Ao mesmo tempo, o OdsCommitRow materializava um objeto de célula para cada coluna que herdava um estilo, em cada uma das linhas repetidas, porque uma célula vazia com estilo continuava a contar como célula

O lado da gravação tinha a sua própria versão do problema. O ficheiro do LibreOffice termina com mais uma linha ro1 depois da grande repetição, pelo que a linha com estilo mais alta ficava mesmo no fundo da folha, e o OdsBuildTableXml percorria todas as linhas até lá, emitindo elementos <table:table-row> um a um. Mesmo um livro importado de forma barata seria gravado de forma caríssima. Corrigir a importação sem corrigir a exportação teria deslocado o tempo limite, não eliminado

O que é um run de altura de linha no HotXLS?

Um run é a coisa mais pequena capaz de descrever «as linhas 46 a 1 048 575 têm todas 12,81 pontos de altura» sem o dizer 1 048 530 vezes. TXLSXRowHeightRun é um registo de FirstRow, LastRow e Height; TXLSXRowHeightRuns é um array dinâmico deles, e cada TXLSXWorksheet guarda um em FRowHeightRuns, ao lado da lista de alturas por linha já existente. Na importação ODS, o finalizador de linhas passa a ramificar pela contagem de repetição: uma contagem de 1 continua a chamar SetRowHeight, qualquer valor maior chama XlsxAssignRowHeightRun uma vez para todo o intervalo. O intervalo é limitado a XlsxMaxRow, que é 1 048 576, pelo que uma contagem de repetição que ultrapasse a folha é truncada em vez de rejeitada

O XlsxAssignRowHeightRun é o único escritor do array, e mantém os runs disjuntos por construção. Dado um novo intervalo, copia todos os runs existentes que fiquem inteiramente fora dele, divide qualquer run que se sobreponha em duas peças, antes e depois, e depois acrescenta o novo intervalo quando Present é true — ou não acrescenta nada quando Present é false, que é como o ClearRowHeight abre um buraco de uma linha. Daí decorrem duas coisas. O array nunca contém intervalos sobrepostos, pelo que uma pesquisa pode parar no primeiro acerto. E o array nunca é alterado no lugar; é construída uma cópia nova em cada chamada, o que não custa nada nos tamanhos em causa e elimina uma classe inteira de bugs de aliasing

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // Uma folha cuja linha final se repete 1 048 530 vezes sob um único estilo de linha
    Workbook.OpenODS('conditional-formatting.ods');
    Sheet := Workbook.Sheets[1];
    // As duas leituras resolvem pelo mesmo run; nada foi expandido
    Writeln(Sheet.RowHeight[46]:0:2, ' pt');
    Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
    // Um override de uma só linha sombreia o run sem o dividir
    Sheet.RowHeight[500000] := 36;
    // Limpar uma linha dentro do run corta o run em duas peças
    Sheet.ClearRowHeight(500001);
    Writeln(Sheet.HasRowHeight(500001)); // False
    Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // ainda a altura do run
  finally
    Workbook.Free;
  end;
end;

A ordem de pesquisa é a parte que vale a pena memorizar. O TXLSXWorksheet.GetRowHeight verifica primeiro a lista por linha e só consulta os runs quando a linha não tem entrada explícita, e o HasRowHeight faz o mesmo. Por isso Sheet.RowHeight[500000] := 36 não toca no run — acrescenta uma entrada à lista por linha, e essa entrada ganha porque é pesquisada primeiro. O ClearRowHeight é o contrário: remove qualquer entrada por linha e depois chama XlsxAssignRowHeightRun com Present = False, porque uma linha limpa tem de ser lida como «sem altura», mesmo que um run a cubra. O ClearRowHeights esvazia as duas estruturas de uma vez

Cirurgia nos runs de altura de linha no HotXLS: depois do OpenODS um run cobre as linhas 46 a 1048575 a 12,81 pt enquanto uma entrada por linha põe a linha 500000 a 36 pt e ganha a pesquisa porque o GetRowHeight verifica primeiro a lista por linha, e o ClearRowHeight da linha 500001 divide o run em duas peças disjuntas em torno do buraco
O XlsxAssignRowHeightRun copia as peças fora do intervalo limpo e não acrescenta nada para o próprio intervalo, pelo que os runs ficam disjuntos por construção e uma pesquisa pode parar no primeiro acerto, enquanto o override na linha 500000 fica intacto

Para onde vão os estilos herdados das células vazias?

Para um overlay de estilo de intervalo por coluna, e não para objetos de célula. O OdsCommitRow decide por valor de coluna se é um branco compacto: a linha repete-se mais do que uma vez, a célula não tem valor, nem fórmula, nem texto formatado. Para um branco compacto, cria uma célula real apenas na primeira linha do run, aplica-lhe o estilo herdado e depois regista os mesmos seis índices de estilo — tipo de letra, preenchimento, contorno, formato numérico, alinhamento, proteção — como um StyleOverlays.Add que cobre as linhas dois até ao fim do run nessa coluna. As linhas a seguir à primeira são totalmente saltadas no ciclo de materialização

O teste de regressão torna a forma concreta. Depois de abrir uma folha cuja segunda linha se repete 1 048 575 vezes sob um estilo predefinido de coluna a negrito, verifica-se que Sheet.Cells.Count é inferior a 10 e que Sheet.Cells[700000, 1].FontIndex continua a resolver no tipo de letra a negrito — o overlay fornece o estilo no momento em que essa coordenada é tocada. É o mesmo mecanismo que impede uma coluna formatada mas vazia de custar um milhão de células do lado do XLSX; as notas sobre armazenamento de células em blocos de linhas e overlays de estilo de intervalo explicam como os overlays se sobrepõem e resolvem. O que é novo aqui é que o importador ODS os cria sozinho, a partir da contagem de repetição, em vez de esperar que uma aplicação formate um intervalo

Como é que o SaveAsODS volta a escrever a contagem de repetição?

Dividindo a cauda vazia da folha apenas onde algo muda de facto. O OdsBuildTableXml passa a acompanhar dois limites: contentMaxRow, a última linha que contém um valor, fórmula, hiperligação ou quebra de linha manual, e maxRow, que se estende adicionalmente pelas células vazias só com estilo, pelas alturas de uma só linha, pelo LastRow de cada run e pela aresta inferior de cada overlay. Uma célula vazia só com estilo deixa de contar como conteúdo — é o TXLSXCells.IsStyleOnlyBlank que a exclui — pelo que a linha final com estilo do ficheiro do LibreOffice deixa de arrastar o limite de conteúdo até ao fundo da folha

Acima de contentMaxRow as linhas são escritas uma a uma exatamente como antes. Abaixo, o writer calcula nextRow como o menor de: o FirstRow do run seguinte, o LastRow + 1 do run atual, a entrada de altura de uma só linha seguinte, a aresta de overlay seguinte e a célula materializada seguinte. Tudo desde a linha atual até nextRow - 1 é então emitido como um único <table:table-row> com table:number-rows-repeated igual à diferença, transportando um <table:table-cell/> por coluna com o nome de estilo resolvido pelo overlay quando um overlay cobre essa coluna. O próprio estilo de linha vem de TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec), que agora dobra o texto da altura — 12.81pt, por exemplo — na sua chave de desduplicação, a par dos flags de linha oculta e de quebra de página, pelo que todas as linhas do run partilham um único estilo ro<N> com uma só propriedade style:row-height

O que o SaveAsODS escreve para uma folha suportada por runs: o contentMaxRow para na linha 45, onde os valores acabam, enquanto o maxRow se estende pelo run de altura e pelos seus overrides, as linhas acima do limite são escritas uma a uma, e a cauda é emitida como elementos table-row repetidos cujo estilo de linha vem do RowStyleFor e cujos estilos de célula resolvem pelos overlays
Cada elemento repetido abrange um troço uniforme e para na aresta de run, entrada de altura, aresta de overlay ou célula materializada seguinte, pelo que uma folha sem validações grava como um punhado de elementos, enquanto as validações ou uma exportação XLSX pagam por linha
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);
    // A cauda vazia é escrita como um punhado de linhas repetidas, não um milhão
    Workbook.SaveAsODS(Saved);
    Writeln('ODS size: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.Open(Saved);
    // O override, o buraco e o run sobrevivem todos ao 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 do run
  finally
    Saved.Free;
    Reopened.Free;
    Workbook.Free;
  end;
end;

O teste que fixa isto verifica que o stream gravado fica abaixo dos 64 KB para uma folha cujo run de altura abrange 1 048 575 linhas com um override e um buraco aberto a meio. Junto a esse número cabem duas fronteiras honestas. Primeira: uma folha de cálculo com quaisquer validações de dados põe o contentMaxRow igual a maxRow, pelo que as validações desativam a compactação da cauda nessa folha e ela volta a ser escrita linha a linha. Segunda: o XLSX não tem atributo de repetição — um <row> de SpreadsheetML descreve uma linha — pelo que exportar uma folha suportada por runs para .xlsx enumera as linhas que o run cobre e escreve um atributo ht em cada uma. O modelo mantém-se compacto em memória; é o formato do ficheiro que decide o aspeto do ficheiro

O que é que cada edição de renumeração de linhas passa a dever aos runs?

Manutenção. Uma nova representação dos metadados de linha só está correta se todas as operações que mudam números de linha a moverem a par das listas por linha que lhe ficam ao lado, e o commit toca em cada uma dessas operações. InsertRows e DeleteRows passam por XlsxShiftRowHeightRuns, que reconstrói o array mantendo a parte de cada run que fica antes do ponto de edição, descartando o que cai dentro de uma janela de eliminação e voltando a acrescentar o resto deslocado pelo delta — pelo que um run que atravessa uma inserção passa a ser dois runs com um espaço entre eles, e um run que atravessa uma eliminação encolhe. O TileRangeAxisMetadata limpa os runs ao longo de todo o intervalo replicado e depois volta a registar cada run de origem uma vez por cópia, no seu deslocamento. O TXLSXWorksheet.CopyFrom e o TXLSXSheets.AddCopy levam um Copy() do array em vez de o atribuírem, e é por isso que o teste pode limpar todas as alturas de um clone e ainda encontrar a folha original intacta na linha 1 048 576

var
  Sheet: TXLSXWorksheet;
begin
  Sheet := Workbook.Sheets[1];
  Sheet.RowHeight[500000] := 36;
  Sheet.ClearRowHeight(500001);
  // Inserir duas linhas em 500000: o override passa para 500002 e o buraco para 500003
  Sheet.InsertRows(500000, 2);
  Writeln(Sheet.RowHeight[500002]:0:2);   // 36.00
  Writeln(Sheet.HasRowHeight(500003));    // False
  // Eliminá-las outra vez: tudo volta a deslocar-se para trás
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);   // 36.00
  // Replicar as linhas 2..4 duas vezes pela folha; as alturas dos runs seguem cada cópia
  Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
  Writeln(Sheet.RowHeight[7]:0:2);        // a altura do run
end;

Os limites do lado da leitura têm a mesma obrigação. O GetUsedRange sobe a sua aresta inferior até ao FirstRow e ao LastRow de cada run, e o BuildRowMajorCellOrder estende a sua linha máxima, incluindo metadados, por todos os runs, para que o writer XLSX continue a visitar as linhas que só têm altura. Se alguma vez acrescentar uma estrutura sua indexada por linha em cima do modelo de objetos do HotXLS, esta é a lista de verificação: inserir, eliminar, replicar, copiar, intervalo usado e todos os serializadores. Falhe um e a falha é silenciosa — as alturas desviam-se pela contagem de inserções e nada lança exceção

O que continua a ser por linha, e como estão agora os números

Os flags de linha oculta, os níveis de estrutura de tópicos e o estado recolhido continuam a expandir-se. O finalizador de linhas faz um ciclo SetRowHidden e SetRowOutlineLevel uma vez por linha repetida, pelo que uma folha que oculte uma cauda de um milhão de linhas, ou a aninhe dentro de um table:table-row-group, paga uma entrada por linha por cada um desses atributos. A alteração da 2.382.2 está limitada às duas coisas que o HXLS-005 realmente mediu — alturas e estilos herdados de células vazias — e a mesma técnica de runs aplicar-se-ia às outras se algum ficheiro o exigisse. O reader ODS também não reage ao style:use-optimal-row-height; um estilo de linha que diga «optimal» e dê uma altura é importado com essa altura

Contra o corpus, o conditional-formatting.ods completa agora o ciclo de abrir, verificar, gravar, reabrir e voltar a verificar em 0,178 segundos em Win32 e 0,158 segundos em Win64, com a própria fase de abertura nos 0,020 segundos, dentro de um orçamento de 60 segundos que antes esgotava. As interfaces ao nível do livro por onde o formato passa estão descritas no percurso sobre abrir e gravar ficheiros ODS, e o conjunto mais amplo de alavancas para ficheiros grandes em desempenho com livros grandes; o próprio elemento de linha ODF, com os seus atributos de repetição e de estilo, está especificado na ODF 1.3 Parte 3 §9.1.4

O HotXLS lê e escreve XLS, XLSX e ODS a partir de código Delphi e C++Builder nativo, sem o Excel ou o LibreOffice instalados, e é por isso que uma repetição de um milhão de linhas é algo que a biblioteca tem de modelar bem em vez de entregar a um processo externo — a página do componente de folhas de cálculo Delphi HotXLS lista os formatos suportados e as versões do RAD Studio