Il componente HotXLS per Delphi memorizza una riga ODS che porta table:number-rows-repeated e un'altezza di riga come un unico record TXLSXRowHeightRun — prima riga, ultima riga, una sola altezza — invece di una voce di altezza per ogni riga ripetuta, e fonde lo stile delle celle vuote che quelle righe ereditano in un unico overlay di stile a intervallo. È tutto qui il motivo per cui HotXLS 2.382.2 apre in 0.02 secondi un foglio di calcolo la cui coda ripete 1,048,530 righe vuote, dove la 2.382.1 andava in timeout, e per cui lo stesso file si risalva in ODS con il conteggio di ripetizione intatto invece che come un milione di righe letterali
Il file in questione è del tutto ordinario. LibreOffice Calc scrive un foglio a quattordici colonne con 45 righe di dati, poi descrive tutto ciò che sta sotto 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>. Lo stile ro1 imposta style:row-height="0.452cm", e ogni <table:table-column> porta un table:default-cell-style-name che ogni cella vuota del run eredita. L'intero content.xml pesa 103 KB. Niente nel file dice "costoso"; il costo era tutto nostro
Perché una sola riga ripetuta manda in timeout un'importazione ODS?
Perché l'importer la espandeva. Nella 2.382.1 il finisher di riga ciclava SetRowHeight(RowIndex + i, RowHeight) una volta per ogni riga ripetuta, scrivendo ogni altezza in una lista di stringhe Name=Value indicizzata per numero di riga. Ogni inserimento in quella lista eseguiva una ricerca IndexOfName su tutto ciò che conteneva già, quindi un milione di altezze costava un milione di scansioni lineari: la ricerca quadratica nella lista contro cui è stato aperto HXLS-005. Allo stesso tempo OdsCommitRow materializzava un oggetto cella per ogni colonna che ereditava uno stile, su ognuna delle righe ripetute, perché una cella vuota con stile contava comunque come cella
Il lato del salvataggio aveva la sua versione del problema. Il file di LibreOffice termina con un'ulteriore riga ro1 dopo la grande ripetizione, quindi la riga con stile più alta stava in fondo al foglio, e OdsBuildTableXml percorreva ogni riga fino a lì emettendo elementi <table:table-row> uno alla volta. Anche un workbook importato a buon mercato avrebbe scritto in modo costoso. Correggere l'importazione senza correggere l'esportazione avrebbe spostato il timeout, non eliminato
Che cos'è un run di altezza di riga in HotXLS?
Un run è la cosa più piccola che sappia descrivere "le righe da 46 a 1,048,575 sono tutte alte 12.81 punti" senza dirlo 1,048,530 volte. TXLSXRowHeightRun è un record con FirstRow, LastRow e Height; TXLSXRowHeightRuns è un array dinamico di quei record, e ogni TXLSXWorksheet ne tiene uno in FRowHeightRuns accanto alla lista di altezze per riga già esistente. In importazione ODS il finisher di riga ora dirama sul conteggio di ripetizione: un conteggio di 1 chiama ancora SetRowHeight, qualsiasi conteggio più grande chiama una sola volta XlsxAssignRowHeightRun per l'intero intervallo. L'intervallo viene limitato a XlsxMaxRow, che è 1,048,576, quindi un conteggio di ripetizione che sfora il foglio viene troncato invece che rifiutato
XlsxAssignRowHeightRun è l'unico scrittore dell'array, e mantiene i run disgiunti per costruzione. Dato un nuovo intervallo copia ogni run esistente che sta interamente fuori da esso, spezza ogni run che lo sovrappone nel pezzo prima e nel pezzo dopo, poi aggiunge il nuovo intervallo quando Present è true — o non aggiunge nulla quando Present è false, ed è così che ClearRowHeight apre un buco di una riga. Ne derivano due cose. L'array non contiene mai intervalli sovrapposti, quindi una ricerca può fermarsi al primo riscontro. E l'array non viene mai mutato sul posto; a ogni chiamata se ne costruisce una copia nuova, cosa che alle dimensioni in gioco non costa nulla e rimuove un'intera classe di bug di aliasing
var
Workbook: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Workbook := TXLSXWorkbook.Create;
try
// Un foglio la cui riga di coda si ripete 1,048,530 volte sotto un unico stile di riga
Workbook.OpenODS('conditional-formatting.ods');
Sheet := Workbook.Sheets[1];
// Entrambe le letture passano dallo stesso run; nulla è stato espanso
Writeln(Sheet.RowHeight[46]:0:2, ' pt');
Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
// Un override su una sola riga copre il run senza spezzarlo
Sheet.RowHeight[500000] := 36;
// Azzerare una riga dentro il run lo taglia in due pezzi
Sheet.ClearRowHeight(500001);
Writeln(Sheet.HasRowHeight(500001)); // False
Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // ancora l'altezza del run
finally
Workbook.Free;
end;
end;
L'ordine di ricerca è la parte che vale la pena memorizzare. TXLSXWorksheet.GetRowHeight controlla prima la lista per riga e consulta i run solo quando la riga non ha una voce esplicita, e HasRowHeight fa lo stesso. Quindi Sheet.RowHeight[500000] := 36 non tocca affatto il run: aggiunge una voce alla lista per riga, e quella voce vince perché viene cercata per prima. ClearRowHeight fa l'opposto: rimuove qualsiasi voce per riga e poi chiama XlsxAssignRowHeightRun con Present = False, perché una riga azzerata deve leggersi come "nessuna altezza" anche se un run la copre. ClearRowHeights svuota entrambe le strutture in un colpo solo
Dove finiscono gli stili ereditati delle celle vuote?
In un solo overlay di stile a intervallo per colonna, non in oggetti cella. OdsCommitRow decide per ogni valore di colonna se si tratta di un vuoto compatto: la riga si ripete più di una volta, la cella non ha valore, né formula, né testo formattato. Per un vuoto compatto crea una cella vera solo sulla prima riga del run, vi applica lo stile ereditato, poi registra gli stessi sei indici di stile — font, riempimento, bordo, formato numerico, allineamento, protezione — come StyleOverlays.Add che copre dalla seconda riga fino alla fine del run in quella colonna. Le righe dopo la prima vengono saltate del tutto nel ciclo di materializzazione
Il test di regressione rende concreta la forma. Dopo aver aperto un foglio la cui seconda riga si ripete 1,048,575 volte sotto uno stile di default di colonna in grassetto, si verifica che Sheet.Cells.Count sia sotto 10, e che Sheet.Cells[700000, 1].FontIndex si risolva ancora nel font in grassetto: l'overlay fornisce lo stile nel momento in cui quella coordinata viene toccata. È lo stesso meccanismo che evita a una colonna formattata ma vuota di costare un milione di celle sul lato XLSX; le note su memorizzazione delle celle a blocchi di righe e overlay di stile a intervallo spiegano come gli overlay si stratificano e si risolvono. La novità qui è che l'importer ODS li crea da solo, a partire dal conteggio di ripetizione, invece di aspettare che un'applicazione formatti un intervallo
Come fa SaveAsODS a riscrivere il conteggio di ripetizione?
Spezzando la coda vuota del foglio solo dove qualcosa cambia davvero. OdsBuildTableXml ora tiene traccia di due limiti: contentMaxRow, l'ultima riga che contiene un valore, una formula, un collegamento o un'interruzione di riga manuale, e maxRow, che si estende anche attraverso le celle vuote solo-stile, le altezze su singola riga, il LastRow di ogni run e il bordo inferiore di ogni overlay. Una cella vuota solo-stile non conta più come contenuto — è TXLSXCells.IsStyleOnlyBlank a escluderla — quindi la riga con stile in coda nel file di LibreOffice smette di trascinare il limite del contenuto fino in fondo al foglio
Sopra contentMaxRow le righe vengono scritte una alla volta esattamente come prima. Sotto, il writer calcola nextRow come il minore tra: il FirstRow del run successivo, il LastRow + 1 del run corrente, la prossima voce di altezza su singola riga, il bordo del prossimo overlay e la prossima cella materializzata. Tutto ciò che va dalla riga corrente fino a nextRow - 1 viene poi emesso come un unico <table:table-row> con table:number-rows-repeated impostato alla differenza, portando un <table:table-cell/> per colonna con il nome di stile risolto dall'overlay quando un overlay copre quella colonna. Lo stile di riga vero e proprio arriva da TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec), che ora fonde il testo dell'altezza — 12.81pt, per dire — nella propria chiave di deduplicazione insieme ai flag hidden e di interruzione di pagina, così ogni riga del run condivide un unico stile ro<N> con una sola proprietà 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 coda vuota viene scritta come una manciata di righe ripetute, non un milione
Workbook.SaveAsODS(Saved);
Writeln('ODS size: ', Saved.Size, ' bytes');
Saved.Position := 0;
Reopened.Open(Saved);
// Override, buco e run sopravvivono tutti al 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); // altezza del run
finally
Saved.Free;
Reopened.Free;
Workbook.Free;
end;
end;
Il test che fissa questo comportamento verifica che lo stream salvato sia sotto i 64 KB per un foglio il cui run di altezza copre 1.048.575 righe con un override e un buco scavato nel mezzo. Accanto a quel numero stanno due confini dichiarati onestamente. Primo, un foglio di lavoro con qualsiasi validazione dati porta contentMaxRow a maxRow, quindi le validazioni disattivano la compattazione della coda su quel foglio e lo fanno riscrivere riga per riga. Secondo, XLSX non ha alcun attributo di ripetizione — una <row> SpreadsheetML descrive una riga — quindi esportare in .xlsx un foglio basato su run enumera le righe che il run copre e scrive un attributo ht su ciascuna. Il modello resta compatto in memoria; è il formato del file a decidere che aspetto ha il file
Che cosa deve ora ogni modifica che rinumera le righe ai run?
Manutenzione. Una nuova rappresentazione dei metadati di riga è corretta solo se ogni operazione che cambia i numeri di riga la sposta insieme alle liste per riga che le stanno accanto, e il commit tocca ognuna di quelle operazioni. InsertRows e DeleteRows passano da XlsxShiftRowHeightRuns, che ricostruisce l'array tenendo la parte di ogni run che sta prima del punto di modifica, scartando ciò che cade dentro una finestra di cancellazione e riaggiungendo il resto spostato del delta — così un run che scavalca un inserimento diventa due run con un buco, e un run che scavalca una cancellazione si accorcia. TileRangeAxisMetadata azzera i run su tutto l'intervallo replicato, poi riregistra ogni run sorgente una volta per copia al suo offset. TXLSXWorksheet.CopyFrom e TXLSXSheets.AddCopy prendono una Copy() dell'array invece di assegnarlo, ed è per questo che il test può azzerare tutte le altezze su un clone e trovare comunque il foglio originale intatto alla riga 1,048,576
var
Sheet: TXLSXWorksheet;
begin
Sheet := Workbook.Sheets[1];
Sheet.RowHeight[500000] := 36;
Sheet.ClearRowHeight(500001);
// Inserisci due righe a 500000: l'override va a 500002, il buco a 500003
Sheet.InsertRows(500000, 2);
Writeln(Sheet.RowHeight[500002]:0:2); // 36.00
Writeln(Sheet.HasRowHeight(500003)); // False
// Cancellale di nuovo: tutto torna indietro
Sheet.DeleteRows(500000, 2);
Writeln(Sheet.RowHeight[500000]:0:2); // 36.00
// Replica le righe da 2 a 4 due volte nel foglio; le altezze dei run seguono ogni copia
Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
Writeln(Sheet.RowHeight[7]:0:2); // l'altezza del run
end;
I limiti sul lato lettura hanno lo stesso obbligo. GetUsedRange alza il proprio bordo inferiore al FirstRow e al LastRow di ogni run, e BuildRowMajorCellOrder estende la propria riga massima, metadati inclusi, attraverso ogni run, così il writer XLSX visita comunque le righe che hanno solo altezza. Se un giorno aggiungi una struttura indicizzata per riga sopra il modello a oggetti di HotXLS, questa è la checklist: inserimento, cancellazione, replica, copia, intervallo usato e ogni serializer. Se ne salti uno il guasto è silenzioso: le altezze derivano del numero di righe inserite e nulla solleva eccezioni
Che cosa resta per riga, e come sono ora i numeri
I flag hidden, i livelli di struttura e lo stato compresso si espandono ancora. Il finisher di riga cicla SetRowHidden e SetRowOutlineLevel una volta per ogni riga ripetuta, quindi un foglio che nasconde una coda da un milione di righe, o la annida dentro un table:table-row-group, paga una voce per riga per ognuno di quegli attributi. La modifica della 2.382.2 è limitata alle due cose che HXLS-005 ha davvero misurato — altezze e stili ereditati delle celle vuote — e la stessa tecnica dei run si applicherebbe alle altre se un file lo richiedesse. Il reader ODS inoltre non agisce su style:use-optimal-row-height; uno stile di riga che dice "optimal" e fornisce un'altezza viene importato con quell'altezza
Sul corpus, conditional-formatting.ods completa ora il ciclo di apertura, asserzione, salvataggio, riapertura e ri-asserzione in 0.178 secondi su Win32 e 0.158 secondi su Win64, con la sola fase di apertura a 0.020 secondi, dentro un budget di 60 secondi che prima esauriva. Le interfacce a livello di workbook su cui passa il formato sono descritte nella panoramica su apertura e salvataggio dei file ODS, e l'insieme più ampio di leve per i file grandi in prestazioni sui workbook di grandi dimensioni; l'elemento row di ODF, con i suoi attributi di ripetizione e di stile, è specificato in ODF 1.3 Part 3 §9.1.4
HotXLS legge e scrive XLS, XLSX e ODS da codice nativo Delphi e C++Builder senza Excel né LibreOffice installati, ed è per questo che una ripetizione da un milione di righe è qualcosa che la libreria deve modellare bene invece di passare a un processo esterno: la pagina del componente spreadsheet HotXLS per Delphi elenca i formati supportati e le versioni di RAD Studio