Article technique

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

Une feuille de quatorze colonnes avec 45 rangées de données, sauvegardée en ODS, se termine d’habitude par un élément de plus qu’on n’attend : <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>. Cette ligne unique décrit chaque rangée de la 46 jusqu’au bas de la grille. Étendez-la littéralement, cette rangée écrite 1 048 530 fois, et un petit content.xml devient un million d’enregistrements de rangées, un million d’entrées de hauteur et, si les colonnes portent un style de cellule par défaut, près de quinze millions de cellules vides stylées

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 étendue de colonnes de même style, au lieu d’étendre 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 quelques calques par étendue de colonnes sans toucher un million de coordonnées

Pourquoi une seule rangée répétée coûte cher à l’import

Le coût vient de l’extension, et l’extension est la façon évidente d’écrire un importateur. Le style ro1 règle style:row-height="0.452cm", soit 12,81 points. Un finaliseur de rangée qui l’applique avec un appel SetRowHeight(RowIndex + i, RowHeight) par rangée répétée écrit 1 048 530 entrées dans la table de hauteurs par rangée que le modèle d’objets garde. Si cette table est une liste de chaînes indexée où chaque écriture cherche d’abord une clé existante, l’import est quadratique dans le nombre de répétitions, et un million est précisément l’entrée où le quadratique cesse d’être théorique

Les cellules vides empirent les choses. Chaque <table:table-column> d’un tel fichier nomme d’habitude un table:default-cell-style-name, et chaque cellule vide de la rangée répétée en hérite. Un vide stylé reste une cellule pour la plupart des modèles d’objets. Matérialisez-en une par colonne par rangée répétée et la facture mémoire écrase la table de hauteurs

Le côté sauvegarde a le problème en miroir. Dès que le modèle dit que la rangée 1 048 575 a une hauteur, un écrivain qui marche de la rangée 1 à la dernière rangée portant quelque chose émet un million d’éléments <table:table-row>. Un import bon marché suivi d’une sauvegarde chère ne fait que déplacer le coût vers un autre bouton

Comment HotXLS stocke une hauteur de rangée répétée

Le HotXLS Delphi Excel Component stocke une hauteur de rangée ODS répétée comme un run : un enregistrement TXLSXRowHeightRun portant FirstRow, LastRow et une Height. Quand TXLSXWorkbook.OpenODS termine une rangée dont le nombre de répétitions vaut 1, il appelle SetRowHeight comme n’importe quel autre code. Pour tout compte plus grand, il enregistre toute l’étendue comme un seul run, donc la queue de l’exemple devient les rangées 46 à 1 048 575 à 12,81 pt. Un nombre de répétitions qui dépasse le bas de la grille est coupé à la rangée 1 048 576 au lieu d’être rejeté

Une règle de l’importateur surprend ceux qui comparent le résultat au fichier source. Les applications tableur qui écrivent de l’ODS ont tendance à donner à chaque style de rangée un style:row-height, y compris pour des rangées que personne n’a jamais redimensionnées, et à les marquer style:use-optimal-row-height="true". Pour une rangée qui ne stocke aucune cellule, une hauteur automatique est simplement la hauteur par défaut, donc HotXLS n’en enregistre aucune. Les rangées qui stockent quelque chose gardent leur hauteur même marquée optimale, car une rangée de texte enveloppé qui a grandi pour épouser son contenu rétrécirait sinon à la réouverture en XLSX. Ce compromis est le bon : perdre une vraie hauteur produit un changement de mise en page visible, garder une redondante ne coûte qu’un run

La relecture des hauteurs passe par la propriété RowHeight ordinaire. Elle consulte d’abord la liste par rangée et ne retombe sur les runs que quand la rangée n’a pas d’entrée explicite ; HasRowHeight suit le même ordre. Les runs ne se chevauchent jamais, donc le repli s’arrête au premier run qui contient la rangée

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Spans: TXLSXAxisSpans;
  I: Integer;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // Une feuille dont le dernier élément de rangée se répète 1 048 530 fois
    Workbook.OpenODS('tail-repeat.ods');
    Sheet := Workbook.Sheets[1];  // Sheets est en base 1
    // Métadonnées de rangée stockées : entrées mono-rangée d’abord, puis runs
    Spans := Sheet.GetStoredAxisSpans(True);
    for I := 0 to High(Spans) do
      Writeln(Spans[I].FirstIndex, '..', Spans[I].LastIndex);
    // Les deux lectures viennent du même run
    Writeln(Sheet.RowHeight[46]:0:2, ' pt');
    Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
    // Une entrée par rangée est consultée avant le run, donc elle gagne
    Sheet.RowHeight[500000] := 36;
    // Effacer une rangée à l’intérieur du run y perce un trou d’une rangée
    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;

Les deux modifications de cet exemple se comportent différemment à dessein. Sheet.RowHeight[500000] := 36 laisse le run tranquille et ajoute une entrée à la liste par rangée, qui masque le run parce qu’elle est consultée en premier. ClearRowHeight ne peut pas faire ainsi, puisqu’une rangée effacée doit ne rapporter aucune hauteur même si un run la couvre encore. Il supprime donc toute entrée par rangée puis reconstruit la liste de runs : chaque run à l’extérieur de la rangée effacée est gardé tel quel, le run qui la contenait est scindé en la partie avant et la partie après, et rien n’est stocké pour la rangée 500 001 elle-même. GetStoredAxisSpans(True) liste maintenant 46..500000 et 500002..1048575 comme deux runs distincts, à côté de l’entrée mono-rangée pour 500 000. ClearRowHeights vide la liste par rangée et les runs en un appel

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 RowHeight consulte d’abord la liste par rangée, et ClearRowHeight de la rangée 500001 scinde le run en deux morceaux disjoints autour du trou
ClearRowHeight garde les morceaux du run à l’extérieur de la rangée effacée et ne stocke rien pour la rangée elle-même, donc les runs ne se chevauchent jamais, une recherche peut s’arrêter au premier résultat, et la dérogation à la rangée 500000 reste intacte

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

Les styles vides hérités deviennent des calques de style : des rectangles sur la feuille qui portent un style, sans objets cellule derrière. Pendant la lecture d’une rangée, l’importateur parcourt les cellules répétées en étendues de colonnes adjacentes qui se résolvent vers le même style. Une étendue dont le style ressemble exactement à Default ne stocke rien. Une étendue avec un vrai formatage, disons un défaut de colonne centré ou gras, devient une valeur de rangée couvrant toutes ses colonnes

Quand cette valeur de rangée est validée et que la rangée se répète, HotXLS matérialise une seule cellule en haut à gauche de l’étendue, lui applique le style hérité, et enregistre les six index de style (police, remplissage, bordure, format de nombre, alignement, protection) via Sheet.StyleOverlays.Add pour tout le rectangle : chaque colonne de l’étendue, de la première rangée répétée à la dernière. Rien d’autre n’est créé pour les 1 048 529 rangées restantes. Sheet.Cells.Count reste proche du nombre de cellules qui portent réellement des données

Une cellule à l’intérieur du rectangle ramasse le calque dès que votre code la touche. Après ouverture d’une feuille dont la queue est formatée par un défaut de colonne gras, Sheet.Cells[700000, 1].FontIndex se résout vers la police grasse même si aucune cellule n’existait à cette coordonnée une ligne plus tôt. Les fichiers XLSX avec une colonne formatée mais vide passent par la même machinerie, et l’article sur le stockage de cellules par blocs de rangées et les calques de style d’intervalle couvre la façon dont plusieurs calques se superposent et se résolvent. L’importateur ODS ne diffère que par la provenance des calques : il les dérive lui-même des nombres de répétitions

Comment SaveAsODS réécrit le nombre de répétitions

TXLSXWorkbook.SaveAsODS écrit chaque étendue de rangées identiques comme un seul <table:table-row> avec table:number-rows-repeated, et ne termine une étendue que là où quelque chose change dans les rangées. La table descend jusqu’à la rangée la plus basse qui porte quelque chose : une valeur, une cellule vide uniquement stylée, une hauteur mono-rangée, la fin d’un run de hauteur, le bord inférieur d’un calque, une entrée masquée ou de plan, ou une zone de validation ou de mise en forme conditionnelle

À partir de la rangée 1, l’écrivain regarde en avant pour la prochaine frontière. Les candidates sont la prochaine cellule matérialisée, le début ou la fin d’un run de hauteur, toute entrée mono-rangée (hauteur, drapeau masqué, niveau de plan, état replié, saut de page manuel), les bords de calques, les plages fusionnées, les hyperliens, les commentaires, les images, et les bords de chaque rectangle de validation et de mise en forme conditionnelle. Tout ce qui va jusqu’à cette frontière est un élément. À l’intérieur de chaque élément, la même logique tourne en colonnes, donc une étendue vide devient un seul <table:table-cell> avec table:number-columns-repeated et le nom de style vers lequel le calque se résout

Les styles de rangée sont partagés. La hauteur de rangée est formatée en texte, 12.81pt pour l’exemple, et ce texte avec le drapeau masqué et le drapeau de saut de page forme la clé du style de rangée automatique. Chaque rangée avec les trois mêmes valeurs pointe vers le même style ro<N>, qui porte une seule propriété style:row-height. Une rangée sans hauteur, visible et sans saut ne reçoit aucun style de rangée

Ce que SaveAsODS écrit pour une feuille adossée à des runs : les rangées 1 à 45 portent des valeurs et sont écrites un élément par rangée, la queue jusqu’à la rangée 1048575 est émise en éléments table-row répétés qui se scindent aux bords de runs, aux dérogations par rangée et aux bords de calques, les rangées de même hauteur partagent un style de rangée automatique, et les styles de cellule se résolvent via les calques
Chaque élément répété couvre une étendue uniforme et s’arrête au prochain bord de run, entrée de hauteur, bord de calque, zone de règle ou cellule matérialisée, donc une queue vide se sauvegarde en une poignée d’éléments tandis qu’un export XLSX écrit un élément de rangée par rangée couverte
var
  Workbook, Reopened: TXLSXWorkbook;
  Saved: TMemoryStream;
begin
  Workbook := TXLSXWorkbook.Create;
  Reopened := TXLSXWorkbook.Create;
  Saved := TMemoryStream.Create;
  try
    Workbook.OpenODS('tail-repeat.ods');
    Workbook.Sheets[1].RowHeight[500000] := 36;
    Workbook.Sheets[1].ClearRowHeight(500001);
    // La queue est écrite en quelques éléments de rangées répétés
    Workbook.SaveAsODS(Saved);
    Writeln('ODS stream: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.OpenODS(Saved);
    // Attendu : dérogation, trou et les deux morceaux du run survivent à 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);   // la hauteur du run
  finally
    Saved.Free;
    Reopened.Free;
    Workbook.Free;
  end;
end;

Pour la feuille ci-dessus, la queue se scinde en quatre morceaux : les rangées 46 à 499 999 à la hauteur du run, la rangée 500 000 à 36 pt, la rangée 500 001 sans style de rangée, et les rangées 500 002 à 1 048 575 de nouveau à la hauteur du run. Toute colonne dont le style par défaut a laissé un calque peut ajouter une scission là où ce calque se termine. Les 45 rangées de données au-dessus de la queue sont écrites un élément par rangée parce que chacune porte des cellules

XLSX est une autre histoire. SpreadsheetML n’a pas d’attribut de répétition, et un élément <row> décrit exactement une rangée, donc sauvegarder une feuille adossée à des runs en .xlsx énumère les rangées qu’un run couvre et écrit ht et customHeight="1" sur chacune. Le modèle reste compact en mémoire ; le format cible décide de la taille du fichier. Si un classeur arrive en ODS et ne doit repartir qu’en ODS, il ne paie jamais ce coût par rangée

Les éditions qui renumérotent des rangées

Une seconde représentation des hauteurs de rangée n’est correcte que si chaque opération qui déplace des rangées la déplace aussi. TXLSXWorksheet.InsertRows et DeleteRows décalent les runs avec les listes par rangée : la partie d’un run avant le point d’édition reste où elle est, les rangées à l’intérieur d’une fenêtre supprimée sont abandonnées, et le reste se déplace du nombre inséré ou supprimé. Un run qui enjambe une insertion devient donc deux runs avec un trou de rangées sans hauteur entre eux, et un run qui enjambe une suppression rétrécit

procedure ShiftAroundTheRun(Sheet: TXLSXWorksheet);
begin
  Sheet.RowHeight[500000] := 36;
  Sheet.ClearRowHeight(500001);
  // Deux nouvelles 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
  Writeln(Sheet.HasRowHeight(500000));   // FALSE : les rangées insérées n’ont pas de hauteur
  // Les supprimer à nouveau : la dérogation et le trou reviennent à leurs anciens numéros
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);  // 36.00
  Writeln(Sheet.HasRowHeight(500001));   // FALSE
  Writeln(Sheet.UsedRange.Row2);         // inclut la fin du run décalé
end;

L’aller-retour ne laisse pas la queue exactement où elle était. L’insertion pousse le run de queue de 500 002..1 048 575 à 500 004..1 048 577, et la grille se termine à la rangée 1 048 576, donc la partie au-delà est coupée. La suppression remonte ensuite ce qui reste de deux, et le run se termine à la rangée 1 048 574 : la rangée 1 048 575 n’a plus de hauteur stockée. Si les dernières rangées de la grille vous importent, redonnez-leur une hauteur après une paire insertion-suppression. Les rangées insérées au milieu d’un run ne ramassent pas sa hauteur non plus : elles ouvrent un trou de rangées sans hauteur et n’héritent pas de la hauteur du run, voilà pourquoi HasRowHeight(500000) a affiché FALSE plus haut

Les autres opérations qui déplacent des rangées suivent le même schéma. TileRangeAxisMetadata vide les runs sur toute l’étendue tuilée puis enregistre chaque run source une fois par copie à son décalage. TXLSXWorksheet.CopyFrom et TXLSXSheets.AddCopy donnent à la nouvelle feuille sa propre copie de la liste de runs, donc ClearRowHeights sur un clone laisse les hauteurs de l’original en place. Côté lecture, UsedRange s’étend à la première et à la dernière rangée de chaque run, et la marche de rangées de l’écrivain XLSX s’étend à travers les runs pour que les rangées à hauteur seule soient encore écrites

Rangées masquées et niveaux de plan restent par rangée

Les hauteurs de rangée et les styles vides hérités sont les deux choses qui se replient en runs et calques. Les drapeaux masqués, les niveaux de plan et l’état replié sont enregistrés une fois par rangée. Une rangée répétée qui est masquée, ou qui siège dans un <table:table-row-group>, produit une entrée par rangée répétée pour chacun de ces attributs, et à la sauvegarde chaque entrée est une frontière à elle seule, donc l’étendue masquée est écrite un <table:table-row> par rangée

Pour les modèles que vous contrôlez, gardez la partie masquée et groupée d’une feuille aussi serrée que les données l’exigent. Masquer vingt rangées sous un rapport ne coûte rien. Masquer tout de la rangée 46 au bas de la grille fait un million d’entrées au chargement et un million d’éléments de rangées à la sauvegarde, et la seule différence visible est que personne ne peut plus faire défiler au-delà du rapport

Le côté format au niveau classeur, y compris quelles fonctionnalités ODS se projettent sur quelles parties du modèle d’objets, est dans la visite guidée de l’ouverture et la sauvegarde de fichiers ODS, et d’autres leviers pour les gros fichiers sont réunis sous les performances des gros classeurs. L’élément de rangée lui-même, avec ses attributs de répétition et de style, est défini dans le chapitre table d’ODF 1.3 Partie 3

Une répétition d’un million de rangées est un détail du format qu’un lecteur natif doit bien modéliser, parce qu’aucune suite bureautique ne tourne en arrière-plan pour l’absorber. La page du composant tableur HotXLS pour Delphi liste les fonctionnalités XLS, XLSX et ODS lues et écrites, ainsi que les versions Delphi et C++Builder prises en charge