Artigo Técnico

Runs de altura para linhas repetidas do ODS no Delphi

O HotXLS Delphi Component guarda uma linha ODS que carrega table:number-rows-repeated e uma altura de linha como um único registro 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. É essa a razão de o HotXLS 2.382.2 abrir uma planilha cuja cauda repete 1.048.530 linhas vazias em 0,02 segundos onde a 2.382.1 estourava o tempo, e de o mesmo arquivo voltar para ODS com a contagem de repetição intacta em vez de virar um milhão de linhas literais

O arquivo em questão é comum. O LibreOffice Calc escreve uma planilha de catorze colunas com 45 linhas de dados e depois descreve tudo 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> carrega um table:default-cell-style-name que toda célula vazia do run herda. Todo o content.xml tem 103 KB. Nada no arquivo diz caro; o custo era inteiramente nosso

Como o HotXLS transforma uma linha ODS repetida em estado compacto: o elemento de content.xml com table:number-rows-repeated 1048530 e estilo ro1 mapeia para um único registro TXLSXRowHeightRun cobrindo as linhas 46 a 1048575 a 12,81 pt mais uma entrada de StyleOverlays por coluna, enquanto a versão 2.382.1 expandia o mesmo elemento num milhão de entradas de SetRowHeight e objetos de célula
A contagem de repetição, a altura da linha ro1 e os estilos padrão das colunas descrevem toda linha vazia abaixo da linha 45, então o importador consegue montar um registro de run e overlays por coluna sem tocar num milhão de coordenadas

Por que uma linha repetida estoura o tempo de uma importação ODS?

Porque o importador costumava expandi-la. Na 2.382.1 o finalizador de linha fazia um laço de SetRowHeight(RowIndex + i, RowHeight) uma vez por linha repetida, gravando cada altura numa lista de strings Name=Value indexada pelo número da linha. Toda inserção nessa lista rodava um IndexOfName por tudo o que já estava nela, então um milhão de alturas custava um milhão de varreduras lineares — a busca quadrática em lista contra a qual o HXLS-005 foi aberto. Ao mesmo tempo 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 ainda contava como célula

O lado do save tinha sua própria versão do problema. O arquivo do LibreOffice termina com mais uma linha ro1 depois da grande repetição, então a linha com estilo mais alta ficava no fundo da planilha, e OdsBuildTableXml percorria toda linha até ela emitindo elementos <table:table-row> um a um. Mesmo uma pasta de trabalho cuja importação tivesse saído barata teria uma gravação cara. Corrigir a importação sem corrigir a exportação teria movido o estouro de tempo, não o removido

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

Um run é a menor coisa capaz de descrever as linhas 46 a 1.048.575 com 12,81 pontos de altura sem dizê-lo 1.048.530 vezes. TXLSXRowHeightRun é um registro 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 linha agora decide pela contagem de repetição: contagem 1 ainda chama SetRowHeight, qualquer coisa maior chama XlsxAssignRowHeightRun uma vez para todo o trecho. O trecho é limitado a XlsxMaxRow, que é 1.048.576, então uma contagem de repetição que ultrapassa a planilha é truncada em vez de rejeitada

XlsxAssignRowHeightRun é o único writer do array, e ela mantém os runs disjuntos por construção. Dado um intervalo novo, ela copia todo run existente que fica inteiramente fora dele, divide qualquer run que o sobreponha na parte anterior e na parte posterior, e então anexa o intervalo novo quando Present é true — ou não anexa nada quando Present é false, que é como ClearRowHeight abre um buraco de uma linha. Duas coisas decorrem disso. O array nunca contém intervalos sobrepostos, então uma consulta pode parar no primeiro acerto. E o array nunca é alterado no lugar; uma cópia nova é construída a cada chamada, o que não custa nada nos tamanhos em questão e elimina uma classe inteira de bugs de alias

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // Uma planilha cuja linha da cauda se repete 1.048.530 vezes sob um 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 linha sombreia o run sem dividi-lo
    Sheet.RowHeight[500000] := 36;
    // Limpar uma linha dentro do run corta o run em dois pedaços
    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 consulta é a parte que vale memorizar. TXLSXWorksheet.GetRowHeight confere primeiro a lista por linha e só consulta os runs quando a linha não tem entrada explícita, e HasRowHeight faz o mesmo. Então Sheet.RowHeight[500000] := 36 não toca no run em nada — ela adiciona uma entrada à lista por linha, e essa entrada vence porque é consultada primeiro. ClearRowHeight é o oposto: remove qualquer entrada por linha e então chama XlsxAssignRowHeightRun com Present = False, porque uma linha limpa precisa ler como sem altura mesmo se um run a cobrir. ClearRowHeights esvazia as duas estruturas de uma vez

Cirurgia de run 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 em 36 pt e vence a consulta porque GetRowHeight confere primeiro a lista por linha, e ClearRowHeight da linha 500001 divide o run em dois pedaços disjuntos em volta do buraco
XlsxAssignRowHeightRun copia os pedaços fora do intervalo limpo e não anexa nada para o intervalo em si, então os runs ficam disjuntos por construção e uma consulta pode parar no primeiro acerto enquanto o override da 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. OdsCommitRow decide, coluna a coluna, se o valor é um vazio compacto: a linha se repete mais de uma vez, a célula não tem valor, nem fórmula, nem rich text. Para um vazio compacto ela cria uma célula de verdade só na primeira linha do run, aplica o estilo herdado a ela, e então registra os mesmos seis índices de estilo — fonte, preenchimento, borda, formato numérico, alinhamento, proteção — como um StyleOverlays.Add cobrindo da segunda linha até o fim do run naquela coluna. As linhas depois da primeira são puladas por completo no laço de materialização

O teste de regressão torna a forma concreta. Depois de abrir uma planilha cuja segunda linha se repete 1.048.575 vezes sob um estilo padrão de coluna em negrito, afirma-se que Sheet.Cells.Count fica abaixo de 10, e Sheet.Cells[700000, 1].FontIndex ainda resolve para a fonte em negrito — o overlay fornece o estilo no momento em que aquela coordenada é tocada. É o mesmo mecanismo que impede uma coluna formatada mas vazia de custar um milhão de células no lado XLSX; as notas sobre armazenamento de células em blocos de linha e overlays de estilo por intervalo cobrem como os overlays se empilham e resolvem. O que é novo aqui é que o importador ODS os cria por conta própria, a partir da contagem de repetição, em vez de esperar que uma aplicação formate um intervalo

Como o SaveAsODS grava a contagem de repetição de volta?

Dividindo a cauda vazia da planilha só onde algo realmente muda. OdsBuildTableXml agora acompanha dois limites: contentMaxRow, a última linha que carrega valor, fórmula, hyperlink ou quebra de linha manual, e maxRow, que além disso se estende por células vazias só com estilo, alturas de uma linha, o LastRow de cada run e a borda inferior de cada overlay. Uma célula vazia só com estilo deixou de contar como conteúdo — é o TXLSXCells.IsStyleOnlyBlank que a exclui — então a linha final com estilo do arquivo do LibreOffice para de arrastar o limite de conteúdo até o fundo da planilha

Acima de contentMaxRow as linhas são escritas uma a uma exatamente como antes. Abaixo dele o writer calcula nextRow como o menor entre: o FirstRow do próximo run, o LastRow + 1 do run atual, a próxima entrada de altura de uma linha, a próxima borda de overlay e a próxima célula materializada. Tudo da linha atual até nextRow - 1 é então emitido como um <table:table-row> com table:number-rows-repeated igual à diferença, carregando um <table:table-cell/> por coluna com o nome de estilo resolvido pelo overlay quando um overlay cobre aquela coluna. O estilo da linha em si vem de TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec), que agora dobra o texto da altura — 12.81pt, por exemplo — na própria chave de desduplicação junto com as flags de linha oculta e de quebra de página, então toda linha do run compartilha um estilo ro<N> com uma única propriedade style:row-height

O que o SaveAsODS escreve para uma planilha apoiada em runs: contentMaxRow para na linha 45, onde os valores acabam, enquanto maxRow se estende pelo run de altura e 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 de RowStyleFor e cujos estilos de célula resolvem pelos overlays
Cada elemento repetido cobre um trecho uniforme e para na próxima borda de run, entrada de altura, borda de overlay ou célula materializada, então uma planilha sem validações salva como um punhado de elementos, enquanto 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);
    // Override, buraco e 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 isso afirma que o stream salvo fica abaixo de 64 KB para uma planilha cujo run de altura cobre 1.048.575 linhas com um override e um buraco aberto no meio. Dois limites honestos cabem ao lado desse número. Primeiro, uma worksheet com qualquer validação de dados define contentMaxRow igual a maxRow, então validações desligam a compactação da cauda naquela planilha e ela volta a ser escrita linha a linha. Segundo, o XLSX não tem atributo de repetição — um <row> do SpreadsheetML descreve uma linha — então exportar uma planilha apoiada em runs para .xlsx enumera as linhas que o run cobre e grava um atributo ht em cada uma. O modelo continua compacto em memória; o formato do arquivo decide com que cara o arquivo fica

O que toda edição que renumera linhas deve agora aos runs?

Manutenção. Uma representação nova de metadados de linha só está correta se toda operação que muda números de linha a movimentar junto com as listas por linha ao lado das quais ela fica, e o commit toca 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 exclusão e readicionando o restante deslocado pelo delta — então um run que atravessa uma inserção vira dois runs com um vão, e um run que atravessa uma exclusão encolhe. TileRangeAxisMetadata limpa os runs por todo o trecho repetido e depois registra de novo cada run de origem uma vez por cópia, no deslocamento dela. TXLSXWorksheet.CopyFrom e TXLSXSheets.AddCopy pegam um Copy() do array em vez de atribuí-lo, e é por isso que o teste consegue limpar todas as alturas num clone e ainda encontrar a planilha original intacta na linha 1.048.576

var
  Sheet: TXLSXWorksheet;
begin
  Sheet := Workbook.Sheets[1];
  Sheet.RowHeight[500000] := 36;
  Sheet.ClearRowHeight(500001);
  // Insere duas linhas em 500000: o override vai para 500002, o buraco para 500003
  Sheet.InsertRows(500000, 2);
  Writeln(Sheet.RowHeight[500002]:0:2);   // 36.00
  Writeln(Sheet.HasRowHeight(500003));    // False
  // Exclui elas de novo: tudo volta ao lugar
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);   // 36.00
  // Repete as linhas 2..4 duas vezes planilha abaixo; as alturas 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. GetUsedRange sobe sua borda inferior até o FirstRow e o LastRow de cada run, e BuildRowMajorCellOrder estende sua linha máxima, incluindo metadados, por todo run, para que o writer de XLSX ainda visite linhas que só têm altura. Se você algum dia adicionar uma estrutura indexada por linha por cima do modelo de objetos do HotXLS, esta é a lista: inserir, excluir, repetir, copiar, intervalo usado e todo serializador. Erre um e a falha é silenciosa — as alturas deslocam pelo número de inserções, e nada levanta exceção

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

Flags de linha oculta, níveis de estrutura de tópicos e estado recolhido ainda são expandidos. O finalizador de linha faz laços de SetRowHidden e SetRowOutlineLevel uma vez por linha repetida, então uma planilha que oculta uma cauda de um milhão de linhas, ou a aninha dentro de um table:table-row-group, paga uma entrada por linha para cada um desses atributos. A mudança da 2.382.2 tem escopo nas duas coisas que o HXLS-005 realmente mediu — alturas e estilos herdados de células vazias — e a mesma técnica de run se aplicaria às outras se algum arquivo exigisse. O reader de ODS também não age sobre style:use-optimal-row-height; um estilo de linha que diz ideal e dá uma altura é importado com aquela altura

Contra o corpus, conditional-formatting.ods agora completa o ciclo de abrir, afirmar, salvar, reabrir e reafirmar em 0,178 segundos no Win32 e 0,158 segundos no Win64, com o estágio de abertura em si em 0,020 segundos, dentro de um orçamento de 60 segundos que ele antes esgotava. As interfaces no nível da pasta de trabalho por onde o formato passa estão descritas no passo a passo de abrir e salvar arquivos ODS, e o conjunto mais amplo de alavancas para arquivos grandes em desempenho com pastas de trabalho grandes; o próprio elemento de linha do ODF, com seus atributos de repetição e estilo, está especificado na ODF 1.3 Part 3 §9.1.4

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