一份十四欄、45 列資料的試算表存成 ODS,結尾通常多出一個你沒預期到的元素:<table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>。這一行描述了第 46 列一直到網格底部的每一列。若照字面展開——同一列寫 1,048,530 遍——小小的 content.xml 就會變成一百萬筆列記錄、一百萬筆高度項目,欄位再帶個預設儲存格樣式的話,還有近一千五百萬個帶樣式的空白儲存格
一個重複列為什麼會讓匯入變貴
成本來自展開,而展開恰是寫匯入器最直覺的方式。樣式 ro1 設定 style:row-height="0.452cm",即 12.81 點。收列邏輯若對每個重複列呼叫一次 SetRowHeight(RowIndex + i, RowHeight),就會往物件模型的逐列高度表裡寫進 1,048,530 筆。那張表若是一個每次寫入都先搜尋既有鍵值的鍵值字串清單,匯入成本就對重複次數是二次方;一百萬的重複次數,恰恰是二次方不再是理論問題的那個輸入
空白儲存格讓事情更糟。這種檔案裡的每個 <table:table-column> 通常帶一個 table:default-cell-style-name,重複列裡的每個空儲存格都繼承它。對大多數物件模型而言,帶樣式的空白仍然是個儲存格;每個重複列的每一欄都具體化一個,記憶體帳單立刻把高度表比下去
存檔那一側有鏡像的問題。模型一旦說第 1,048,575 列有高度,從第 1 列走到最後一個有東西的列的寫入器,就會輸出一百萬個 <table:table-row> 元素。便宜的匯入配上昂貴的存檔,只是把成本搬到另一顆按鈕上
HotXLS 怎麼儲存重複的列高
HotXLS Delphi Excel Component 把重複的 ODS 列高存成一個區段(run):一筆持有 FirstRow、LastRow 與單一 Height 的 TXLSXRowHeightRun 記錄。TXLSXWorkbook.OpenODS 收完一個重複次數為 1 的列時,照一般程式碼那樣呼叫 SetRowHeight;次數更大時,整段範圍記成一個區段,範例裡的尾段因此成為第 46 到 1,048,575 列、12.81 點。衝出網格末端的重複次數會在第 1,048,576 列處截斷,而不是被拒收
匯入器裡有一條規則,會讓拿結果與來源檔案做 diff 的人意外。寫 ODS 的試算表應用程式傾向給每個列樣式都帶上 style:row-height,包括沒人調整過的列,並標上 style:use-optimal-row-height="true"。對完全不存儲存格的列,自動高度其實就是預設高度,HotXLS 因此不為它記錄高度。真的存了東西的列,即使標了 optimal 也保留高度——靠換行撐高的列,若把高度丟了,活頁簿重開成 XLSX 時就會縮回去。這個取捨是對的:丟掉真實高度造成看得見的版面變化,留著冗餘高度只多花一個區段
把高度讀回來走的是普通的 RowHeight 屬性:先查逐列清單,該列沒有明確項目時才退回區段;HasRowHeight 同一順序。區段彼此不重疊,所以後備查找停在第一個包含該列的區段
var
Workbook: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
Spans: TXLSXAxisSpans;
I: Integer;
begin
Workbook := TXLSXWorkbook.Create;
try
// 尾段列元素重複 1,048,530 次的工作表
Workbook.OpenODS('tail-repeat.ods');
Sheet := Workbook.Sheets[1]; // Sheets 從 1 算起
// 儲存的列中介資料:先逐列項目,再區段
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;
// 清掉區段內的一列,會在區段裡切出一個單列的洞
Sheet.ClearRowHeight(500001);
Writeln(Sheet.HasRowHeight(500001)); // FALSE
Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // 仍是區段高度
finally
Workbook.Free;
end;
end;
範例裡的兩個編輯刻意行為不同。Sheet.RowHeight[500000] := 36 不碰區段,只往逐列清單加一筆,因為先被查到而蓋住區段。ClearRowHeight 不能這樣做——被清的列必須回報沒有高度,即使仍有區段蓋著它。所以它刪掉任何逐列項目,然後重建區段清單:被清列之外的每個區段原樣保留,包含被清列的區段拆成之前與之後兩段,第 500,001 列本身什麼都不存。GetStoredAxisSpans(True) 此時列出 46..500000 與 500002..1048575 兩個獨立區段,旁邊還有第 500,000 列的單列項目。ClearRowHeights 一次呼叫清空逐列清單與區段
繼承來的空白儲存格樣式去了哪裡?
繼承來的空白樣式變成樣式覆疊:工作表上攜帶樣式的矩形,背後沒有儲存格物件。讀列的當下,匯入器把重複的儲存格按相鄰欄位、且解析到同一樣式的跨度來走。樣式看起來與 Default 完全一樣的跨度什麼都不存;帶真實格式的跨度——比如置中或粗體的欄預設——成為一筆涵蓋其全部欄位的列值
那筆列值提交而列又重複時,HotXLS 在跨度的左上角具體化單一儲存格,把繼承樣式套上去,並透過 Sheet.StyleOverlays.Add 為整個矩形——跨度裡的每一欄、從第一個重複列到最後一個——註冊六個樣式索引(字型、填色、框線、數字格式、對齊、保護)。其餘 1,048,529 列什麼都不建立。Sheet.Cells.Count 保持在真正持有資料的儲存格數量附近
矩形裡的儲存格,在程式碼碰到它的那一刻就拿起覆疊。開啟一張尾段被粗體欄預設格式化的工作表後,Sheet.Cells[700000, 1].FontIndex 解析到粗體字型,雖然一行程式之前那個座標上根本沒有儲存格。帶格式但空白的欄的 XLSX 檔案走同一套機制,列區塊儲存格儲存與區間樣式覆疊一文講的是多層覆疊怎麼疊、怎麼解析。ODS 匯入器的差別只在覆疊的來源:它自己從重複次數推導出來
SaveAsODS 怎麼把重複次數寫回去
TXLSXWorkbook.SaveAsODS 把每一段完全相同的列寫成一個帶 table:number-rows-repeated 的 <table:table-row>,只在列的某個性質改變處結束一段。表格向下延伸到最低的、攜帶任何東西的列:一個值、一個只有樣式的空白儲存格、一筆單列高度、一個區段的結尾、一個覆疊的下緣、隱藏或大綱項目,或一塊驗證或條件格式區域
從第 1 列起,寫入器向前看下一個邊界。候選有:下一個具體化儲存格、區段的起點或終點、任何單列項目(高度、隱藏旗標、大綱層級、摺疊狀態、手動分頁線)、覆疊邊緣、合併範圍、超連結、註解、圖片,以及每個驗證與條件格式矩形的邊緣。到邊界為止的一切就是一個元素。元素內部同樣的邏輯沿欄位跑,所以空的一段變成一個帶 table:number-columns-repeated 的 <table:table-cell>,樣式名稱取覆疊解析的結果
列樣式是共用的。列高格式化成文字——範例裡是 12.81pt——這段文字連同隱藏旗標與分頁旗標,構成自動列樣式的鍵。三個值都相同的每一列指向同一個 ro<N> 樣式,樣式裡只有一條 style:row-height 屬性。沒有高度、可見、又沒有分頁的列,完全不帶列樣式
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);
// 尾段寫成少數幾個重複列元素
Workbook.SaveAsODS(Saved);
Writeln('ODS stream: ', Saved.Size, ' bytes');
Saved.Position := 0;
Reopened.OpenODS(Saved);
// 預期:覆寫、洞與兩段區段都走完 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); // 區段高度
finally
Saved.Free;
Reopened.Free;
Workbook.Free;
end;
end;
對上面那張工作表,尾段切成四塊:第 46 到 499,999 列在區段高度、第 500,000 列 36 點、第 500,001 列無列樣式、第 500,002 到 1,048,575 列回到區段高度。預設樣式留下覆疊的任何欄位,可以在覆疊結束處再加一道切分。尾段上方 45 列資料逐列各寫一個元素,因為每一列都持有儲存格
XLSX 是另一回事。SpreadsheetML 沒有重複屬性,一個 <row> 元素恰恰描述一列,所以把區段支撐的工作表存成 .xlsx 時,會列舉區段涵蓋的每一列,逐列寫上 ht 與 customHeight="1"。模型在記憶體裡保持精簡;檔案多大由目標格式決定。活頁簿以 ODS 進、也只需以 ODS 出,就永遠不必付這筆逐列成本
會重編列號的編輯
列高的第二種表示法要成立,前提是每個會搬動列的操作都搬動它。TXLSXWorksheet.InsertRows 與 DeleteRows 讓區段跟著逐列清單一起位移:編輯點之前的區段部分原地不動,刪除視窗內的列被丟棄,其餘按插入或刪除數量位移。橫跨插入點的區段因此變成兩個區段、中間隔著一段無高度的列;橫跨刪除區的區段則變短
procedure ShiftAroundTheRun(Sheet: TXLSXWorksheet);
begin
Sheet.RowHeight[500000] := 36;
Sheet.ClearRowHeight(500001);
// 在 500000 插入兩列:覆寫移到 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;
這趟往返不會讓尾段精準回到原位。插入把尾段區段從 500,002..1,048,575 推到 500,004..1,048,577,而網格在第 1,048,576 列結束,超出部分被切掉;刪除再把剩下的上移兩列,區段在第 1,048,574 列收尾——第 1,048,575 列不再有儲存的高度。網格最後幾列對你要緊的話,插入加刪除一輪之後把它們的高度再設一次。插進區段中間的列也不會繼承高度:它們打開一段無高度的缺口、不繼承區段高度,這正是上面 HasRowHeight(500000) 印出 FALSE 的原因
其餘搬列的操作遵循同一模式。TileRangeAxisMetadata 清掉整個平鋪跨度上的區段,再按位移為每份複本重新註冊每個來源區段一次。TXLSXWorksheet.CopyFrom 與 TXLSXSheets.AddCopy 給新工作表自己的區段清單副本,對複製品呼叫 ClearRowHeights 不會動到原件的高度。讀取這一側,UsedRange 延伸到每個區段的第一列與最後一列,XLSX 寫入器的列走訪也穿過區段,只有高度的列照樣被寫出
隱藏列與大綱層級仍逐列保存
列高與繼承的空白樣式,是收攏成區段與覆疊的兩樣東西。隱藏旗標、大綱層級與摺疊狀態則每列記一次。被隱藏的重複列、或坐在 <table:table-row-group> 裡的重複列,每個屬性都為每個重複列產生一筆項目;存檔時每筆這樣的項目都是自己的邊界,所以被隱藏的那段按每列一個 <table:table-row> 寫出
自己掌控的範本,請把工作表被隱藏與群組的部分維持在資料真正需要的緊湊度。在報表下面藏二十列是免費的;把第 46 列到網格底部全部藏起來,載入時是一百萬筆項目、存檔時是一百萬個列元素,而唯一看得見的差別是沒有人能再捲過報表
活頁簿層級的格式側——哪些 ODS 功能映射到物件模型的哪些部分——見開啟與儲存 ODS 檔案的逐步解說;大檔案的其他槓桿收集在大型活頁簿效能底下。列元素本身連同其重複與樣式屬性,規定在 ODF 1.3 Part 3 的 table 章
百萬列的重複是原生讀取器必須好好建模的格式細節,因為背景裡沒有辦公室套件幫它吸收。XLS、XLSX 與 ODS 的讀寫功能,以及支援的 Delphi 與 C++Builder 版本,列在 HotXLS Delphi spreadsheet component 產品頁