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