Article technique

Rangées ODS répétées : runs de hauteur HotXLS Delphi

Le HotXLS Delphi Component stocke une rangée ODS qui porte table:number-rows-repeated et une hauteur de rangée sous la forme d'un seul enregistrement TXLSXRowHeightRun — première rangée, dernière rangée, une hauteur — au lieu d'une entrée de hauteur par rangée répétée, et replie le style des cellules vides que ces rangées héritent dans un seul calque de style d'intervalle. C'est toute la raison pour laquelle HotXLS 2.382.2 ouvre en 0,02 seconde un classeur dont la queue répète 1 048 530 rangées vides là où la 2.382.1 expirait, et pour laquelle le même fichier se sauvegarde en ODS avec le nombre de répétitions intact plutôt qu'en un million de rangées littérales

Le fichier en question est banal. LibreOffice Calc écrit une feuille de quatorze colonnes avec 45 rangées de données, puis décrit tout ce qui se trouve dessous avec un seul élément : <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>. Le style ro1 définit style:row-height="0.452cm", et chaque <table:table-column> porte un table:default-cell-style-name que chaque cellule vide du run hérite. Tout le content.xml fait 103 Ko. Rien dans ce fichier ne dit « coûteux » ; la dépense était entièrement la nôtre

Comment HotXLS transforme une rangée ODS répétée en état compact : l'élément content.xml avec table:number-rows-repeated 1048530 et le style ro1 se traduit par un seul enregistrement TXLSXRowHeightRun couvrant les rangées 46 à 1048575 à 12,81 pt plus une entrée StyleOverlays par colonne, alors que la version 2.382.1 étendait le même élément en un million d'entrées SetRowHeight et d'objets cellule
Le nombre de répétitions, la hauteur de rangée ro1 et les styles par défaut des colonnes décrivent chaque rangée vide sous la rangée 45, donc l'importateur peut construire un seul enregistrement de run et des calques par colonne sans toucher un million de coordonnées

Pourquoi une seule rangée répétée fait-elle expirer un import ODS ?

Parce que l'importateur l'étendait. En 2.382.1, le finaliseur de rangée bouclait SetRowHeight(RowIndex + i, RowHeight) une fois par rangée répétée, en écrivant chaque hauteur dans une liste de chaînes Name=Value indexée par numéro de rangée. Chaque insertion dans cette liste exécutait une recherche IndexOfName sur tout ce qu'elle contenait déjà, donc un million de hauteurs coûtait un million de balayages linéaires — la recherche quadratique dans une liste contre laquelle HXLS-005 a été ouvert. Au même moment, OdsCommitRow matérialisait un objet cellule pour chaque colonne héritant d'un style, sur chacune des rangées répétées, parce qu'une cellule vide stylée comptait quand même comme une cellule

Le côté sauvegarde avait sa propre version du problème. Le fichier LibreOffice se termine par une rangée ro1 de plus après la grande répétition, donc la rangée stylée la plus haute se trouvait tout en bas de la feuille, et OdsBuildTableXml parcourait chaque rangée jusqu'à elle en émettant les éléments <table:table-row> un par un. Même un classeur importé à bon marché aurait été écrit cher. Corriger l'import sans corriger l'export aurait déplacé le délai d'expiration, pas supprimé

Qu'est-ce qu'un run de hauteur de rangée dans HotXLS ?

Un run est la plus petite chose capable de décrire « les rangées 46 à 1 048 575 font toutes 12,81 points de haut » sans le dire 1 048 530 fois. TXLSXRowHeightRun est un enregistrement de FirstRow, LastRow et Height ; TXLSXRowHeightRuns est un tableau dynamique de ces enregistrements, et chaque TXLSXWorksheet en garde un dans FRowHeightRuns à côté de la liste de hauteurs par rangée existante. À l'import ODS, le finaliseur de rangée bifurque désormais sur le nombre de répétitions : un compte de 1 appelle toujours SetRowHeight, tout ce qui est plus grand appelle XlsxAssignRowHeightRun une fois pour toute l'étendue. L'étendue est bornée à XlsxMaxRow, qui vaut 1 048 576, donc un nombre de répétitions qui dépasse la feuille est tronqué plutôt que rejeté

XlsxAssignRowHeightRun est le seul écrivain du tableau, et il garde les runs disjoints par construction. Recevant un nouvel intervalle, il recopie chaque run existant entièrement à l'extérieur de celui-ci, découpe tout run qui le chevauche en la partie avant et la partie après, puis ajoute le nouvel intervalle quand Present vaut true — ou n'ajoute rien quand Present vaut false, ce qui est la façon dont ClearRowHeight perce un trou d'une rangée. Deux conséquences. Le tableau ne contient jamais d'intervalles qui se chevauchent, donc une recherche peut s'arrêter au premier résultat. Et le tableau n'est jamais modifié sur place ; une copie neuve est construite à chaque appel, ce qui ne coûte rien aux tailles concernées et élimine toute une classe de bugs d'aliasing

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // Une feuille dont la rangée de queue est répétée 1 048 530 fois sous un seul style
    Workbook.OpenODS('conditional-formatting.ods');
    Sheet := Workbook.Sheets[1];
    // Les deux lectures se résolvent via le même run ; rien n'a été étendu
    Writeln(Sheet.RowHeight[46]:0:2, ' pt');
    Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
    // Une dérogation d'une seule rangée masque le run sans le découper
    Sheet.RowHeight[500000] := 36;
    // Effacer une rangée à l'intérieur du run le coupe en deux morceaux
    Sheet.ClearRowHeight(500001);
    Writeln(Sheet.HasRowHeight(500001)); // False
    Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // toujours la hauteur du run
  finally
    Workbook.Free;
  end;
end;

L'ordre de recherche est la partie à mémoriser. TXLSXWorksheet.GetRowHeight consulte d'abord la liste par rangée et n'interroge les runs que lorsque la rangée n'a pas d'entrée explicite, et HasRowHeight fait de même. Donc Sheet.RowHeight[500000] := 36 ne touche pas du tout au run — il ajoute une entrée à la liste par rangée, et cette entrée gagne parce qu'elle est cherchée en premier. ClearRowHeight fait l'inverse : il retire toute entrée par rangée puis appelle XlsxAssignRowHeightRun avec Present = False, parce qu'une rangée effacée doit se lire comme « aucune hauteur » même si un run la couvre. ClearRowHeights vide les deux structures d'un coup

La chirurgie des runs de hauteur dans HotXLS : après OpenODS, un run couvre les rangées 46 à 1048575 à 12,81 pt tandis qu'une entrée par rangée met la rangée 500000 à 36 pt et gagne la recherche parce que GetRowHeight consulte d'abord la liste par rangée, et ClearRowHeight de la rangée 500001 découpe le run en deux morceaux disjoints autour du trou
XlsxAssignRowHeightRun recopie les morceaux à l'extérieur de l'intervalle effacé et n'ajoute rien pour l'intervalle lui-même, donc les runs restent disjoints par construction et une recherche peut s'arrêter au premier résultat, tandis que la dérogation de la rangée 500000 reste intacte

Où vont les styles hérités des cellules vides ?

Dans un seul calque de style d'intervalle par colonne, pas dans des objets cellule. OdsCommitRow décide colonne par colonne si une valeur est un vide compact : la rangée se répète plus d'une fois, la cellule n'a ni valeur, ni formule, ni texte enrichi. Pour un vide compact, il ne crée une vraie cellule que sur la première rangée du run, y applique le style hérité, puis enregistre les six mêmes index de style — police, remplissage, bordure, format de nombre, alignement, protection — via un StyleOverlays.Add couvrant les rangées deux à la fin du run dans cette colonne. Les rangées après la première sont entièrement sautées dans la boucle de matérialisation

Le test de régression rend la forme concrète. Après ouverture d'une feuille dont la deuxième rangée se répète 1 048 575 fois sous un style de colonne par défaut en gras, Sheet.Cells.Count est vérifié comme inférieur à 10, et Sheet.Cells[700000, 1].FontIndex se résout toujours vers la police grasse — le calque fournit le style à l'instant où cette coordonnée est touchée. C'est le même mécanisme qui évite qu'une colonne formatée mais vide coûte un million de cellules côté XLSX ; les notes sur le stockage de cellules par blocs de rangées et les calques de style d'intervalle expliquent comment les calques se superposent et se résolvent. Ce qui est nouveau ici, c'est que l'importateur ODS les crée de lui-même, à partir du nombre de répétitions, au lieu d'attendre qu'une application formate une plage

Comment SaveAsODS réécrit-il le nombre de répétitions ?

En découpant la queue vide de la feuille uniquement là où quelque chose change réellement. OdsBuildTableXml suit désormais deux bornes : contentMaxRow, la dernière rangée qui porte une valeur, une formule, un lien hypertexte ou un saut de page manuel, et maxRow, qui s'étend en plus sur les cellules vides uniquement stylées, les hauteurs d'une seule rangée, le LastRow de chaque run et le bord inférieur de chaque calque. Une cellule vide uniquement stylée ne compte plus comme du contenu — c'est TXLSXCells.IsStyleOnlyBlank qui l'exclut — donc la rangée stylée finale du fichier LibreOffice cesse de tirer la borne de contenu jusqu'au bas de la feuille

Au-dessus de contentMaxRow, les rangées sont écrites une par une exactement comme avant. En dessous, l'écrivain calcule nextRow comme le plus petit de : la FirstRow du prochain run, la LastRow + 1 du run courant, la prochaine entrée de hauteur d'une seule rangée, le prochain bord de calque, et la prochaine cellule matérialisée. Tout ce qui va de la rangée courante jusqu'à nextRow - 1 est alors émis comme un seul <table:table-row> avec table:number-rows-repeated réglé à la différence, portant un <table:table-cell/> par colonne avec le nom de style résolu par le calque quand un calque couvre cette colonne. Le style de rangée lui-même vient de TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec), qui replie désormais le texte de hauteur — 12.81pt, par exemple — dans sa clé de déduplication aux côtés des drapeaux hidden et de saut de page, si bien que chaque rangée du run partage un seul style ro<N> avec une seule propriété style:row-height

Ce que SaveAsODS écrit pour une feuille adossée à des runs : contentMaxRow s'arrête à la rangée 45 là où les valeurs finissent tandis que maxRow s'étend à travers le run de hauteur et ses dérogations, les rangées au-dessus de la borne sont écrites une par une, et la queue est émise sous forme d'éléments table-row répétés dont le style de rangée vient de RowStyleFor et dont les styles de cellule se résolvent via les calques
Chaque élément répété couvre une portion uniforme et s'arrête au prochain bord de run, entrée de hauteur, bord de calque ou cellule matérialisée, donc une feuille sans validation se sauvegarde en une poignée d'éléments tandis que les validations ou un export XLSX paient à la rangée
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 queue vide est écrite en une poignée de rangées répétées, pas un million
    Workbook.SaveAsODS(Saved);
    Writeln('ODS size: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.Open(Saved);
    // Dérogation, trou et run survivent tous à l'aller-retour
    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);   // hauteur du run
  finally
    Saved.Free;
    Reopened.Free;
    Workbook.Free;
  end;
end;

Le test qui verrouille cela vérifie que le flux sauvegardé fait moins de 64 Ko pour une feuille dont le run de hauteur couvre 1 048 575 rangées avec une dérogation et un trou percé au milieu. Deux limites honnêtes vont à côté de ce chiffre. D'abord, une feuille de calcul avec la moindre validation de données met contentMaxRow à maxRow, donc les validations désactivent la compaction de queue sur cette feuille et elle est réécrite rangée par rangée. Ensuite, XLSX n'a pas d'attribut de répétition — un <row> SpreadsheetML décrit une seule rangée — donc exporter une feuille adossée à un run vers .xlsx énumère les rangées que le run couvre et écrit un attribut ht sur chacune. Le modèle reste compact en mémoire ; le format de fichier décide de l'aspect du fichier

Que doit désormais chaque édition qui renumérote des rangées aux runs ?

De l'entretien. Une nouvelle représentation des métadonnées de rangée n'est correcte que si chaque opération qui change les numéros de rangée la déplace en même temps que les listes par rangée à côté desquelles elle se trouve, et le commit touche chacune de ces opérations. InsertRows et DeleteRows passent par XlsxShiftRowHeightRuns, qui reconstruit le tableau en conservant la partie de chaque run située avant le point d'édition, en abandonnant ce qui tombe dans une fenêtre de suppression, et en réajoutant le reste décalé du delta — donc un run qui chevauche une insertion devient deux runs avec un trou, et un run qui chevauche une suppression rétrécit. TileRangeAxisMetadata vide les runs sur toute l'étendue tuilée, puis réenregistre chaque run source une fois par copie à son décalage. TXLSXWorksheet.CopyFrom et TXLSXSheets.AddCopy prennent un Copy() du tableau plutôt que de l'affecter, ce qui explique pourquoi le test peut effacer toutes les hauteurs sur un clone et retrouver la feuille d'origine intacte à la rangée 1 048 576

var
  Sheet: TXLSXWorksheet;
begin
  Sheet := Workbook.Sheets[1];
  Sheet.RowHeight[500000] := 36;
  Sheet.ClearRowHeight(500001);
  // Insérer deux rangées à 500000 : la dérogation passe à 500002, le trou à 500003
  Sheet.InsertRows(500000, 2);
  Writeln(Sheet.RowHeight[500002]:0:2);   // 36.00
  Writeln(Sheet.HasRowHeight(500003));    // False
  // Les supprimer à nouveau : tout revient en place
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);   // 36.00
  // Tuiler les rangées 2..4 deux fois vers le bas ; les hauteurs de run suivent chaque copie
  Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
  Writeln(Sheet.RowHeight[7]:0:2);        // la hauteur du run
end;

Les bornes côté lecture ont la même obligation. GetUsedRange remonte son bord inférieur à la FirstRow et à la LastRow de chaque run, et BuildRowMajorCellOrder étend sa rangée maximale incluant les métadonnées à travers chaque run pour que l'écrivain XLSX visite encore les rangées qui n'ont qu'une hauteur. Si vous ajoutez un jour votre propre structure indexée par rangée au-dessus du modèle objet HotXLS, voici la liste de contrôle : insertion, suppression, tuilage, copie, plage utilisée et chaque sérialiseur. Ratez-en un et la défaillance est silencieuse — les hauteurs dérivent du nombre d'insertions, et rien ne lève d'exception

Ce qui reste par rangée, et à quoi ressemblent les chiffres maintenant

Les drapeaux de masquage, les niveaux de plan et l'état replié s'étendent toujours. Le finaliseur de rangée boucle SetRowHidden et SetRowOutlineLevel une fois par rangée répétée, donc une feuille qui masque une queue d'un million de rangées, ou l'imbrique dans un table:table-row-group, paie une entrée par rangée pour chacun de ces attributs. Le changement 2.382.2 se limite aux deux choses que HXLS-005 a réellement mesurées — les hauteurs et les styles hérités des cellules vides — et la même technique de run s'appliquerait aux autres si un fichier l'exigeait un jour. Le lecteur ODS n'agit pas non plus sur style:use-optimal-row-height ; un style de rangée qui dit « optimal » et donne une hauteur est importé avec cette hauteur

Face au corpus, conditional-formatting.ods boucle maintenant le cycle ouverture, assertion, sauvegarde, réouverture et re-assertion en 0,178 seconde sur Win32 et 0,158 seconde sur Win64, l'étape d'ouverture elle-même prenant 0,020 seconde, dans un budget de 60 secondes qu'il épuisait auparavant. Les interfaces au niveau classeur par lesquelles le format passe sont décrites dans la visite guidée de l'ouverture et la sauvegarde de fichiers ODS, et l'ensemble plus large de leviers pour les gros fichiers dans les performances des gros classeurs ; l'élément de rangée ODF lui-même, avec ses attributs de répétition et de style, est spécifié dans ODF 1.3 Partie 3 §9.1.4

HotXLS lit et écrit XLS, XLSX et ODS depuis du code Delphi et C++Builder natif, sans Excel ni LibreOffice installés, et c'est pourquoi un million de répétitions de rangées est quelque chose que la bibliothèque doit bien modéliser plutôt que de le confier à un processus externe — la page du composant tableur HotXLS pour Delphi liste les formats pris en charge et les versions de RAD Studio