技術記事

DelphiのHotXLSにおけるODS繰り返し行と行高ラン

14列45行のデータを持つスプレッドシートをODSで保存すると、たいてい期待より1つ多い要素で終わります。<table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>という1行です。この行は、46行目からグリッドの底までのすべての行を記述します。これを文字どおり104万8530回書き出すと、小さなcontent.xmlが100万の行レコードと100万の高さエントリになり、列がデフォルトのセルスタイルを持っていれば、約1500万のスタイル付き空セルになります

HotXLSが1つのODS繰り返し行をどうコンパクトな状態に変えるか:table:number-rows-repeated 1048530とスタイルro1を持つcontent.xml要素は、同じ要素を100万のSetRowHeightエントリとセルオブジェクトに展開する代わりに、12.81 ptで46行から1048575行に渡る1つのTXLSXRowHeightRunレコードと、同スタイルの列スパンごとのStyleOverlaysエントリ1つに写像される
繰り返し回数、ro1の行高、列デフォルトスタイルは45行目以下のすべての空行を記述します。だからインポーターは1つのランレコードと少数の列スパンオーバーレイを作るだけで、100万の座標には触れません

1つの繰り返し行のインポートが高くつく理由

コストは展開から来て、展開はインポーターの書き方としていちばん素直なものです。スタイルro1はstyle:row-height="0.452cm"、つまり12.81ポイントを設定します。繰り返し行ごとにSetRowHeight(RowIndex + i, RowHeight)を1回呼ぶ行フィニッシャーは、オブジェクトモデルが持つ行単位の高さテーブルに104万8530個のエントリを書き込みます。それがキー付き文字列リストで、各書き込みが既存キーの検索から始まるなら、インポートは繰り返し回数に対して2次関数になります。繰り返し回数100万は、2次関数が理論でなくなるまさにその入力です

空セルが追い打ちをかけます。そういうファイルの各<table:table-column>はたいていtable:default-cell-style-nameを名指しし、繰り返し行のすべての空セルがそれを継承します。スタイル付きの空白は、たいていのオブジェクトモデルにとってまだセルです。繰り返し行と列のすべての組み合わせで実体化すると、メモリの請求額は高さテーブルをはるかに凌駕します

保存側には鏡像の問題があります。モデルが「行1048575は高さを持つ」と言った瞬間、行1から何かを持つ最終行まで歩くライターは100万個の<table:table-row>要素を出力します。安いインポートと高い保存は、コストを別のボタンに移すだけです

HotXLSは繰り返し行の高さをどう格納するのか

HotXLS Delphi Excel Componentは繰り返しODS行の高さをランとして格納します。FirstRow、LastRow、1つのHeightを保持するTXLSXRowHeightRunレコードです。TXLSXWorkbook.OpenODSは、繰り返し回数1の行を終えるときはほかのコードと同じくSetRowHeightを呼びます。それより大きい回数では区間全体を1つのランとして記録するので、例の末尾は12.81 ptの46〜1048575行になります。グリッドの末尾を越える繰り返し回数は拒否されず、行1048576で切り詰められます

インポーターの1つのルールは、結果をソースファイルとdiffする人を驚かせます。ODSを書く表計算アプリケーションは、誰もリサイズしたことのない行を含めてすべての行スタイルにstyle:row-heightを付け、style:use-optimal-row-height="true"で印を付ける傾向があります。セルをまったく格納しない行にとって、自動高とは単にデフォルト高のことなので、HotXLSはその行の高さを記録しません。何かを格納する行は、optimalの印があっても高さを保持します。内容に合わせて伸びた折り返しテキストの行が、XLSXとして再オープンしたとき縮んで戻ってしまうからです。このトレードオフは正しい選択です。本物の高さを落とすと目に見えるレイアウト変更になり、冗長な高さを保持するコストはラン1つです

高さの読み戻しは普通のRowHeightプロパティを通ります。まず行単位のリストを調べ、明示的なエントリがない行だけランにフォールバックします。HasRowHeightも同じ順です。ランは重ならないので、フォールバックはその行を含む最初のランで止められます

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Spans: TXLSXAxisSpans;
  I: Integer;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // 末尾の行が1つの行スタイルで104万8530回繰り返されるシート
    Workbook.OpenODS('tail-repeat.ods');
    Sheet := Workbook.Sheets[1];  // Sheets is 1-based
    // 保存済みの行メタデータ:まず単一行エントリ、次にラン
    Spans := Sheet.GetStoredAxisSpans(True);
    for I := 0 to High(Spans) do
      Writeln(Spans[I].FirstIndex, '..', Spans[I].LastIndex);
    // どちらの読み取りも同じランで解決される。何も展開されていない
    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;

この例の2つの編集は、意図的に振る舞いが違います。Sheet.RowHeight[500000] := 36はランに触れず、行単位のリストに1エントリ追加します。リストが先に調べられるので、これがランを覆い隠します。ClearRowHeightにはその方法が使えません。クリアされた行は、ランがまだ跨いていても「高さなし」と報告する必要があるからです。そこでこのメソッドは行単位のエントリをすべて削除し、それからランリストを作り直します。クリア行の外側のランはそのまま保持され、それを含んでいたランは前の部分と後の部分に分割され、行500001自体には何も格納されません。GetStoredAxisSpans(True)は今や、500000の単一行エントリの隣に、46..500000と500002..1048575を別々の2つのランとして列挙します。ClearRowHeightsは行単位リストとランを1回の呼び出しで空にします

HotXLSでの行高ランの手術:OpenODS後は1つのランが12.81 ptで46行から1048575行を覆い、行単位のエントリが500000行を36 ptに設定して、RowHeightが行単位リストを先に調べるためルックアップで勝つ。500001行のClearRowHeightは、穴を挟んでランを離れた2片に分割する
ClearRowHeightはクリア行の外側のランの断片を保持し、行自体には何も格納しません。だからランは決して重ならず、ルックアップは最初のヒットで止められ、500000行のオーバーライドは無傷のままです

継承された空白セルのスタイルはどこへ行くのか

継承された空スタイルはスタイルオーバーレイになります。スタイルを運ぶ、ワークシート上の矩形で、裏にセルオブジェクトはありません。行を読んでいる間、インポーターは繰り返しセルを、同じスタイルに解決される隣接列のスパン単位で歩きます。Defaultとまったく同じに見えるスタイルのスパンは何も格納しません。センタリングや太字の列デフォルトのような本物の書式を持つスパンは、その全列を覆う1つの行値になります

その行値がコミットされ、行が繰り返されるとき、HotXLSはスパンの左上に1つのセルを実体化し、そこに継承スタイルを適用し、6つのスタイルインデックス(フォント、塗りつぶし、罫線、表示形式、配置、保護)を、スパンの全列を最初の繰り返し行から最後まで覆う矩形全体についてSheet.StyleOverlays.Addで登録します。残りの104万8529行のために作られるものは何もありません。Sheet.Cells.Countは、実際にデータを保持するセル数の近くに留まります

矩形の中のセルは、コードが触れた瞬間にオーバーレイを拾います。末尾が太字の列デフォルトで整形されたシートを開いたあと、Sheet.Cells[700000, 1].FontIndexは太字フォントに解決します。1行前にはその座標にセルは存在しなかったのにです。整形されているが空の列を持つXLSXファイルも同じ仕組みを通ります。複数のオーバーレイがどう積み重なり解決されるかは、行ブロックのセル格納と区間スタイルオーバーレイの記事が扱います。ODSインポーターが違うのはオーバーレイの出所だけです。繰り返し回数から自力で導出します

SaveAsODSが繰り返し回数を書き戻す仕組み

TXLSXWorkbook.SaveAsODSは、同一の行の区間を1つの<table:table-row>としてtable:number-rows-repeated付きで書き、行について何かが変わる場所でのみ区間を終えます。テーブルは何かを運ぶ最も低い行まで伸びます。値、スタイルのみの空セル、単一行の高さ、高さランの終端、オーバーレイの下端、非表示やアウトラインのエントリ、入力規則や条件付き書式の領域です

行1から、ライターは次の境界を先読みします。候補は、次に実体化されたセル、高さランの始端と終端、すべての単一行エントリ(高さ、非表示フラグ、アウトラインレベル、折りたたみ状態、手動改ページ)、オーバーレイの縁、結合範囲、ハイパーリンク、コメント、画像、そしてすべての入力規則と条件付き書式矩形の縁です。境界までのすべてが1つの要素になります。要素の中では同じ論理が列方向に走るので、空の区間はオーバーレイが解決するスタイル名を持つ1つの<table:table-cell>にtable:number-columns-repeated付きで変わります

行スタイルは共有されます。行高はテキストとして整形され、例では12.81ptです。このテキストが非表示フラグと改ページフラグとともに、自動行スタイルのキーを形成します。同じ3つの値を持つ行はすべて同じro<N>スタイルを指し、そこにはstyle:row-heightプロパティが1つだけあります。高さがなく可視で改ページもない行は、行スタイルをまったく得ません

ランが支えるシートにSaveAsODSが書くもの:1から45行目は値を持ち行ごとに1要素で書かれ、1048575行までの末尾はランの縁と行単位のオーバーライドとオーバーレイの縁で分割される繰り返しtable-row要素として出力される。同じ高さの行は1つの自動行スタイルを共有し、セルスタイルはオーバーレイ経由で解決される
各繰り返し要素は1つの均質な区間を覆い、次のランの縁、高さエントリ、オーバーレイの縁、ルール領域、実体化されたセルで止まります。空の末尾は少数の要素として保存され、XLSXエクスポートは行ごとに1つの行要素を書きます
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);
    // 空の末尾は100万行ではなくわずかな繰り返し行として書かれる
    Workbook.SaveAsODS(Saved);
    Writeln('ODS stream: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.OpenODS(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;

上のシートでは、末尾は4片に分かれます。ラン高さの46〜499999行、36 ptの500000行、行スタイルなしの500001行、再びラン高さの500002〜1048575行です。デフォルトスタイルがオーバーレイを残した列は、そのオーバーレイが終わる場所に分割を追加できます。末尾の上にある45のデータ行は、それぞれがセルを保持するため、行ごとに1要素で書かれます

XLSXは話が別です。SpreadsheetMLには繰り返し属性がなく、<row>要素はちょうど1行を記述します。だからランが支えるシートを.xlsxで保存すると、ランが覆う行を列挙し、それぞれにhtとcustomHeight="1"を書きます。モデルはメモリ上ではコンパクトなままです。ファイルがどれだけ大きくなるかはターゲットフォーマット次第です。ODSとして届いてODSとして出るだけのワークブックなら、行単位のコストを払うことは決してありません

行番号を振り直す編集

行高の第2の表現が正しいのは、行を動かすすべての操作がそれも動かすときだけです。TXLSXWorksheet.InsertRowsとDeleteRowsは、行単位のリストとともにランもシフトします。編集点の前のラン部分はその場所に留まり、削除ウィンドウの中の行は落ち、残りは挿入・削除数だけ動きます。だから挿入を跨ぐランは、間に高さ未設定の行の隙間を挟んだ2つのランになり、削除を跨ぐランは短くなります

procedure ShiftAroundTheRun(Sheet: TXLSXWorksheet);
begin
  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
  Writeln(Sheet.HasRowHeight(500000));   // FALSE:挿入された行には高さがない
  // もう一度削除:すべてが元に戻る
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);  // 36.00
  Writeln(Sheet.HasRowHeight(500001));   // FALSE
  Writeln(Sheet.UsedRange.Row2);         // シフト後のランの終端も含まれる
end;

往返は末尾を元の場所にぴったり戻しはしません。挿入は末尾ランを500002..1048575から500004..1048577へ押しやり、グリッドは行1048576で終わるので、それより先の部分は切り捨てられます。削除は残りを2つ上へ動かし、ランは行1048574で終わります。行1048575にはもう格納された高さがありません。グリッドの最終行が大事なら、挿入と削除のペアのあとで高さを設定し直してください。ランの途中に挿入された行もランの高さを拾いません。高さ未設定の行の隙間を開き、ラン高さを継承しないので、上でHasRowHeight(500000)がFALSEと出たのです

その他の行を動かす操作も同じパターンに従います。TileRangeAxisMetadataはタイル状の区間全体のランをクリアし、それから各ソースランをコピーごとにそのオフセットで1回登録します。TXLSXWorksheet.CopyFromとTXLSXSheets.AddCopyは新しいシートにランリストの独立したコピーを渡すので、クローンに対するClearRowHeightsは元の高さに触れません。読み取り側では、UsedRangeはすべてのランの最初と最後の行まで伸び、XLSXライターの行ウォークもランを貫通するので、高さのみの行も書かれます

非表示行とアウトラインレベルは行ごとに保持される

ランとオーバーレイに潰されるのは、行高と継承された空スタイルの2つだけです。非表示フラグ、アウトラインレベル、折りたたみ状態は行ごとに1回記録されます。非表示の繰り返し行や<table:table-row-group>の中に座っている繰り返し行は、それらの属性ごとに繰り返し行単位のエントリを1つ生み出し、保存時にはそのようなエントリがそれぞれ独自の境界になるので、非表示区間は行ごとに1つの<table:table-row>として書かれます

自分で管理するテンプレートでは、シートの非表示・グループ化部分をデータが必要な分だけタイトに保ってください。レポートの下の20行を隠すのは無料です。行46からグリッドの底までをすべて隠すと、読み込みで100万のエントリ、保存で100万の行要素となり、目に見える違いは、誰もレポートの先へスクロールできなくなったことだけです

ODSのどの機能がオブジェクトモデルのどの部分に写像されるかといったフォーマットのワークブックレベルの側面は、ODSファイルのオープンと保存のウォークスルーにあり、大きなファイルのためのその他のレバーはラージワークブックのパフォーマンスの下に集めてあります。繰り返しとスタイル属性を持つ行要素自体は、ODF 1.3 Part 3のtable章で定義されています

100万行の繰り返しは、ネイティブリーダーがきちんとモデル化しなければならないフォーマットの細部です。後ろで吸収してくれるオフィススイートがいないのですから。XLS、XLSX、ODSで読み書きできる機能と対応するDelphi、C++Builderのバージョンは、HotXLS Delphi spreadsheet componentページに一覧があります