HotXLS Delphi Componentは、table:number-rows-repeatedと行の高さを持つODSの行を、繰り返し行ごとに1つの高さエントリを持つ代わりに、単一のTXLSXRowHeightRunレコード——先頭行、最終行、1つの高さ——として格納し、それらの行が継承する空白セルのスタイルを単一の区間スタイルオーバーレイに畳み込みます。これが、末尾が104万8530行の空白行を繰り返すスプレッドシートを、2.382.1がタイムアウトしたところでHotXLS 2.382.2が0.02秒で開ける理由のすべてであり、同じファイルが100万行のリテラル行ではなく繰り返し回数を保ったままODSへ保存し戻される理由でもあります
問題のファイルはごく普通のものです。LibreOffice Calcは14列で45行のデータを持つシートを書き、その下のすべてを1つの要素で表現します。<table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>です。スタイルro1はstyle:row-height="0.452cm"を設定し、各<table:table-column>はtable:default-cell-style-nameを持ち、その範囲内のすべての空白セルがそれを継承します。content.xml全体は103 KBです。ファイルのどこにも「高コスト」とは書かれていません。コストは完全にこちら側にありました
繰り返し行1つがODSインポートをタイムアウトさせる理由
インポーターがそれを展開していたからです。2.382.1では行フィニッシャーが繰り返し行ごとにSetRowHeight(RowIndex + i, RowHeight)をループし、各高さを行番号をキーとするName=Value文字列リストへ書き込んでいました。そのリストへの挿入のたびに、すでに入っているすべてに対するIndexOfName検索が走るため、100万個の高さは100万回の線形走査を要しました。HXLS-005が報告された対象である二次的なリスト検索です。同時にOdsCommitRowは、スタイルを継承したすべての列について、繰り返されるすべての行でセルオブジェクトを実体化していました。スタイル付きの空白セルもセルとして数えられていたからです
保存側にも同じ問題の別バージョンがありました。LibreOfficeのファイルは大きな繰り返しの後にさらに1つro1の行で終わるため、最も下のスタイル付き行がシートの最下端に位置し、OdsBuildTableXmlはそこまでのすべての行をたどって<table:table-row>要素を1つずつ出力していました。安くインポートできたワークブックでも、保存は高くついたはずです。エクスポートを直さずにインポートだけ直しても、タイムアウトが移動するだけで消えはしません
HotXLSにおける行高ランとは何か
ランとは、「46行目から104万8575行目まではすべて高さ12.81ポイント」を104万8530回言わずに記述できる最小のものです。TXLSXRowHeightRunはFirstRow、LastRow、Heightのレコードで、TXLSXRowHeightRunsはその動的配列であり、各TXLSXWorksheetが既存の行ごとの高さリストの隣のFRowHeightRunsに1つ保持します。ODSインポートでは行フィニッシャーが繰り返し回数で分岐するようになりました。回数が1なら従来どおりSetRowHeightを呼び、それより大きければXlsxAssignRowHeightRunを範囲全体に対して1回呼びます。範囲はXlsxMaxRow、つまり1048576にクランプされるため、シートを超える繰り返し回数は拒否されず切り詰められます
XlsxAssignRowHeightRunはこの配列の唯一の書き手で、構造的にランを互いに素に保ちます。新しい区間を受け取ると、完全に外側にある既存のランをすべてコピーし、重なるランをその前の部分と後の部分に分割し、PresentがTrueなら新しい区間を追加します。PresentがFalseなら何も追加しません。これがClearRowHeightが1行の穴を開ける仕組みです。そこから2つのことが従います。配列が重複する区間を含むことは決してないため、検索は最初の一致で止められます。そして配列はその場で変更されることがなく、呼び出しごとに新しいコピーが作られます。この規模ではコストはゼロで、エイリアシングのバグを1つのクラスごと消し去ります
var
Workbook: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Workbook := TXLSXWorkbook.Create;
try
// 末尾の行が1つの行スタイルで104万8530回繰り返されるシート
Workbook.OpenODS('conditional-formatting.ods');
Sheet := Workbook.Sheets[1];
// どちらの読み取りも同じランで解決される。何も展開されていない
Writeln(Sheet.RowHeight[46]:0:2, ' pt');
Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
// 単一行のオーバーライドはランを分割せずに覆い隠す
Sheet.RowHeight[500000] := 36;
// ラン内の1行をクリアするとランが2つに切れる
Sheet.ClearRowHeight(500001);
Writeln(Sheet.HasRowHeight(500001)); // False
Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // 依然としてランの高さ
finally
Workbook.Free;
end;
end;
記憶しておく価値があるのは検索順序のほうです。TXLSXWorksheet.GetRowHeightは行ごとのリストを先に確認し、その行に明示的なエントリがないときにだけランを参照します。HasRowHeightも同じです。したがってSheet.RowHeight[500000] := 36はランに一切触れず、行ごとのリストにエントリを1つ追加するだけであり、そのエントリが先に検索されるため勝ちます。ClearRowHeightは逆で、行ごとのエントリを削除してからPresent = FalseでXlsxAssignRowHeightRunを呼びます。クリアされた行は、ランが覆っていても「高さなし」として読まれなければならないからです。ClearRowHeightsは両方の構造を一度に空にします
継承された空白セルのスタイルはどこへ行くのか
セルオブジェクトではなく、列ごとに1つの区間スタイルオーバーレイへです。OdsCommitRowは列の値ごとに、それがコンパクトな空白かどうかを判断します。行が複数回繰り返され、セルに値も数式もリッチテキストもない場合です。コンパクトな空白に対しては、ランの先頭行にだけ本物のセルを作り、継承したスタイルを適用し、次に同じ6つのスタイルインデックス——フォント、塗りつぶし、罫線、表示形式、配置、保護——を、その列の2行目からランの末尾までを覆うStyleOverlays.Addとして登録します。先頭行より後の行は、実体化ループで完全に飛ばされます
リグレッションテストがその形を具体的にします。2行目が太字の列既定スタイルのもとで104万8575回繰り返されるシートを開いた後、Sheet.Cells.Countは10未満であるとアサートされ、Sheet.Cells[700000, 1].FontIndexは依然として太字フォントに解決します。その座標に触れた瞬間にオーバーレイがスタイルを供給するからです。これはXLSX側で、書式付きだが空の列に100万個のセルを費やさないのと同じ仕組みです。行ブロックのセル格納と区間スタイルオーバーレイのメモで、オーバーレイがどう重なりどう解決されるかを扱っています。ここで新しいのは、ODSインポーターがアプリケーションの範囲書式設定を待つのではなく、繰り返し回数から自分でこれらを作ることです
SaveAsODSは繰り返し回数をどう書き戻すのか
シートの空の末尾を、実際に何かが変わる場所でだけ分割することでです。OdsBuildTableXmlは2つの境界を追跡するようになりました。値、数式、ハイパーリンク、手動の行区切りを持つ最後の行であるcontentMaxRowと、さらにスタイルだけの空白セル、単一行の高さ、各ランのLastRow、各オーバーレイの下端まで広がるmaxRowです。スタイルだけの空白セルはもはや内容として数えられません。TXLSXCells.IsStyleOnlyBlankがそれを除外します。そのためLibreOfficeのファイルの末尾のスタイル付き行が、内容境界をシートの最下端まで引きずることがなくなりました
contentMaxRowより上では、行は従来どおり1つずつ書かれます。それより下では、ライターがnextRowを次のうち最小のものとして計算します。次のランのFirstRow、現在のランのLastRow + 1、次の単一行の高さエントリ、次のオーバーレイの端、そして次に実体化されるセルです。現在の行からnextRow - 1までのすべては、その差分をtable:number-rows-repeatedに設定した1つの<table:table-row>として出力され、オーバーレイがその列を覆っていれば、オーバーレイで解決されたスタイル名を持つ<table:table-cell/>を列ごとに1つ持ちます。行スタイル自体はTOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec)から来ます。これは高さのテキスト——たとえば12.81pt——を、非表示と改ページのフラグとともに重複排除キーへ畳み込むようになったため、ラン内のすべての行が単一のstyle:row-heightプロパティを持つ1つのro<N>スタイルを共有します
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);
// 空の末尾は100万行ではなくわずかな繰り返し行として書かれる
Workbook.SaveAsODS(Saved);
Writeln('ODS size: ', Saved.Size, ' bytes');
Saved.Position := 0;
Reopened.Open(Saved);
// オーバーライド、穴、ランがすべてラウンドトリップを生き延びる
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); // ランの高さ
finally
Saved.Free;
Reopened.Free;
Workbook.Free;
end;
end;
これを固定するテストは、高さのランが104万8575行にわたり、途中にオーバーライドと穴が開けられたシートについて、保存されたストリームが64 KB未満であるとアサートします。その数値の隣には、正直な境界を2つ述べておくべきです。1つ目、データ検証を1つでも持つワークシートはcontentMaxRowをmaxRowに設定するため、検証はそのシートの末尾圧縮を無効にし、再び行ごとに書かれます。2つ目、XLSXには繰り返し属性がありません。SpreadsheetMLの<row>は1行を記述するため、ランで支えられたシートを.xlsxへエクスポートすると、ランが覆う行を列挙し、それぞれにht属性を書きます。モデルはメモリ上ではコンパクトなままで、ファイルの見た目を決めるのはファイル形式です
行の採番を変えるすべての編集がランに対して負うもの
メンテナンスです。行メタデータの新しい表現が正しいのは、行番号を変えるすべての操作が、隣に並ぶ行ごとのリストと一緒にそれを動かす場合だけであり、このコミットはそれらの操作のそれぞれに触れています。InsertRowsとDeleteRowsはXlsxShiftRowHeightRunsを通ります。これは各ランの編集点より前の部分を残し、削除ウィンドウ内に入るものを落とし、残りを差分だけずらして再追加することで配列を再構築します。そのため挿入にまたがるランは隙間を持つ2つのランになり、削除にまたがるランは縮みます。TileRangeAxisMetadataはタイリングされた範囲全体でランをクリアし、その後、各ソースランをコピーごとにそのオフセットで再登録します。TXLSXWorksheet.CopyFromとTXLSXSheets.AddCopyは配列を代入するのではなくCopy()を取ります。だからこそテストは、クローンの高さをすべてクリアしても元のシートが104万8576行目で無傷であることを確認できるのです
var
Sheet: TXLSXWorksheet;
begin
Sheet := Workbook.Sheets[1];
Sheet.RowHeight[500000] := 36;
Sheet.ClearRowHeight(500001);
// 500000に2行挿入:オーバーライドは500002へ、穴は500003へ移る
Sheet.InsertRows(500000, 2);
Writeln(Sheet.RowHeight[500002]:0:2); // 36.00
Writeln(Sheet.HasRowHeight(500003)); // False
// もう一度削除:すべてが元に戻る
Sheet.DeleteRows(500000, 2);
Writeln(Sheet.RowHeight[500000]:0:2); // 36.00
// 2..4行目をシートの下方へ2回タイリングする。ランの高さは各コピーに追随する
Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
Writeln(Sheet.RowHeight[7]:0:2); // ランの高さ
end;
読み取り側の境界にも同じ義務があります。GetUsedRangeは下端を各ランのFirstRowとLastRowまで押し上げ、BuildRowMajorCellOrderはメタデータを含む最大行をすべてのランを通して広げるため、XLSXライターは高さだけの行にも依然として訪れます。HotXLSのオブジェクトモデルの上に独自の行キー構造を追加するなら、これがチェックリストです。挿入、削除、タイリング、コピー、使用範囲、そしてすべてのシリアライザーです。1つでも漏らすと失敗は静かです。高さが挿入回数だけずれ、何も例外を投げません
行ごとのまま残るもの、そして今の数値
非表示フラグ、アウトラインレベル、折りたたみ状態は依然として展開されます。行フィニッシャーは繰り返し行ごとにSetRowHiddenとSetRowOutlineLevelをループするため、100万行の末尾を隠すシートや、それをtable:table-row-groupの入れ子にするシートは、それらの属性ごとに行単位のエントリを払います。2.382.2の変更は、HXLS-005が実際に測定した2つ——高さと継承された空白スタイル——に限定されており、ファイルが要求するなら同じランの手法を他にも適用できます。ODSリーダーはstyle:use-optimal-row-heightにも作用しません。「optimal」と書いて高さも与えている行スタイルは、その高さでインポートされます
コーパスに対して、conditional-formatting.odsはオープン、アサート、保存、再オープン、再アサートのサイクルを、Win32で0.178秒、Win64で0.158秒で完了するようになりました。オープン段階自体は0.020秒で、以前は使い切っていた60秒の予算の内側です。この形式が流れるワークブックレベルのインターフェースはODSファイルのオープンと保存の解説で、大きなファイル向けのより広いテクニックは大きなワークブックの性能で扱っています。ODFの行要素そのものは、繰り返し属性とスタイル属性を含め、ODF 1.3 Part 3 §9.1.4で規定されています
HotXLSはExcelやLibreOfficeをインストールせずに、ネイティブのDelphiとC++BuilderのコードからXLS、XLSX、ODSを読み書きします。100万行の繰り返しが外部プロセスに任せるものではなく、ライブラリがきちんとモデル化すべきものになるのはそのためです。HotXLS Delphiスプレッドシートコンポーネントのページに対応形式とRAD Studioのバージョンが掲載されています