技術文章

Delphi 中 HotXLS 把 ODS 重複列化為列高區段

一條 ODS 列若同時帶著 table:number-rows-repeated 與列高,HotXLS Delphi Component 會把它存成單一筆 TXLSXRowHeightRun 記錄——第一列、最後一列、一個高度——而不是每一個重複列一筆高度項目,並把那些列繼承到的空白儲存格樣式收攏成一個區間樣式覆疊。這就是 HotXLS 2.382.2 能在 0.02 秒內開啟一份尾段重複 1,048,530 列空白的試算表、而 2.382.1 會逾時的全部原因,也是為什麼同一個檔案存回 ODS 時重複次數原封不動,而不是變成一百萬列實體列

那個檔案本身很普通。LibreOffice Calc 寫出一張十四欄、45 列資料的工作表,然後用一個元素描述它們以下的一切:<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。這個檔案沒有任何地方寫著「很貴」;貴的地方完全是我們自己

HotXLS 如何把一個 ODS 重複列變成精簡狀態:content.xml 裡 table:number-rows-repeated 為 1048530、樣式為 ro1 的元素,對映到單一筆 TXLSXRowHeightRun 記錄,以 12.81 點橫跨第 46 到 1048575 列,外加每欄一筆 StyleOverlays 項目;而 2.382.1 版把同一個元素展開成一百萬筆 SetRowHeight 項目與儲存格物件
重複次數、ro1 的列高與各欄的預設樣式一起描述了第 45 列以下每一列空白,所以匯入器可以只建一筆區段記錄與每欄的覆疊,不必去碰一百萬個座標

為什麼一個重複列就讓 ODS 匯入逾時?

因為匯入器以前會把它展開。在 2.382.1 裡,收列邏輯對每個重複列跑一次 SetRowHeight(RowIndex + i, RowHeight),把每個高度寫進一個以列號為鍵的 Name=Value 字串清單。每一次插入都會對清單裡已存在的所有東西跑一遍 IndexOfName 查找,所以一百萬個高度就是一百萬次線性掃描——HXLS-005 當初就是針對這個二次方清單搜尋開的單。同時,OdsCommitRow 會在每一個重複列上,為每一欄繼承到樣式的欄位具體造出一個儲存格物件,因為有帶樣式的空白儲存格仍然算是一個儲存格

存檔那一側有它自己的版本。那個 LibreOffice 檔案在大量重複之後又接了最後一列 ro1,所以帶樣式的最高列就坐在工作表的最底端,而 OdsBuildTableXml 會一路走到那裡,逐列輸出 <table:table-row> 元素。即使某本活頁簿匯入得很便宜,寫出去照樣貴。只修匯入不修匯出,只是把逾時搬家,不是把它移掉

HotXLS 裡的列高區段是什麼?

區段是能描述「第 46 列到第 1,048,575 列都是 12.81 點高」而不用講 1,048,530 遍的最小東西。TXLSXRowHeightRun 是一筆含 FirstRow、LastRow 與 Height 的記錄;TXLSXRowHeightRuns 是它們的動態陣列,而每個 TXLSXWorksheet 在既有的逐列高度清單旁邊,於 FRowHeightRuns 裡保有一份。ODS 匯入時,收列邏輯現在依重複次數分支:次數為 1 仍呼叫 SetRowHeight,更大的次數則對整個範圍呼叫一次 XlsxAssignRowHeightRun。範圍會被夾到 XlsxMaxRow,也就是 1,048,576,所以衝出工作表之外的重複次數會被截掉,而不是被拒收

XlsxAssignRowHeightRun 是這個陣列唯一的寫入者,而且它在結構上就保證區段彼此不相交。給定一個新區間,它會複製每一筆完全落在它之外的既有區段,把任何與它重疊的區段拆成前面那塊與後面那塊,然後在 Present 為 true 時附加新區間——或在 Present 為 false 時什麼都不附加,ClearRowHeight 就是這樣打穿出一個單列的洞。由此得到兩件事。陣列裡永遠不會有重疊的區間,所以查找命中第一個就能停。而且陣列從不就地改動;每次呼叫都建一份全新的副本,在這個規模下不花什麼成本,卻整類移除了別名共用的 bug

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // 一張工作表,尾段列在同一個列樣式下重複 1,048,530 次
    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;
    // 清掉區段內的一列,會把區段切成兩塊
    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 完全不碰區段——它只是往逐列清單加一筆,而那一筆因為先被查到所以勝出。ClearRowHeight 剛好相反:它先移除任何逐列項目,然後以 Present = False 呼叫 XlsxAssignRowHeightRun,因為被清掉的列必須讀成「沒有高度」,即使有區段蓋住它。ClearRowHeights 則一次清空兩個結構

HotXLS 裡的列高區段手術:OpenODS 之後,一個區段以 12.81 點蓋住第 46 到 1048575 列,而一筆逐列項目把第 500000 列設成 36 點,並因為 GetRowHeight 先查逐列清單而在查找中勝出;對第 500001 列呼叫 ClearRowHeight 則把區段沿著那個洞拆成兩塊互不相交的片段
XlsxAssignRowHeightRun 複製被清區間之外的片段,對區間本身則不附加任何東西,所以區段在結構上保持互不相交,查找命中第一個就能停,而第 500000 列的覆寫完全沒被動到

繼承來的空白儲存格樣式去了哪裡?

進到每一欄一個的區間樣式覆疊,而不是進到儲存格物件。OdsCommitRow 逐欄值判斷它是不是一個精簡空白:該列重複不只一次、儲存格沒有值、沒有公式、也沒有富文字。對精簡空白,它只在區段的第一列造出真正的儲存格、把繼承來的樣式套上去,然後把那六個樣式索引——字型、填色、框線、數字格式、對齊、保護——註冊成一個 StyleOverlays.Add,涵蓋該欄從第二列到區段結尾。第一列之後的列在具體化迴圈裡整個被跳過

迴歸測試把這個形狀講具體。開啟一張第二列在某個粗體欄預設樣式下重複 1,048,575 次的工作表之後,斷言 Sheet.Cells.Count 小於 10,而 Sheet.Cells[700000, 1].FontIndex 仍然解析到那個粗體字型——覆疊在那個座標被碰到的當下就供上樣式。這跟 XLSX 那一側讓一個有格式卻是空的欄不至於花掉一百萬個儲存格的是同一套機制;列區塊儲存格儲存與區間樣式覆疊一文談覆疊怎麼分層與解析。這裡新的是 ODS 匯入器自己從重複次數把它們造出來,而不是等應用程式去格式化某個範圍

SaveAsODS 怎麼把重複次數寫回去?

只在真正有東西改變的地方把工作表的空白尾段切開。OdsBuildTableXml 現在追蹤兩個邊界:contentMaxRow,最後一個持有值、公式、超連結或手動分頁的列;以及 maxRow,它還會延伸到只有樣式的空白儲存格、單列高度、每個區段的 LastRow,以及每個覆疊的下緣。只有樣式的空白儲存格不再算內容——把它排除掉的是 TXLSXCells.IsStyleOnlyBlank——所以那個 LibreOffice 檔案尾端帶樣式的列,不再把內容邊界拖到工作表最底端

在 contentMaxRow 之上,列照舊一列一列寫。在它之下,寫入器把 nextRow 算成下列幾個裡最小的:下一個區段的 FirstRow、當前區段的 LastRow + 1、下一筆單列高度項目、下一道覆疊邊緣,以及下一個具體化儲存格。接著從當前列到 nextRow - 1 的全部內容,會被輸出成單一個 <table:table-row>,table:number-rows-repeated 設為其差值,每一欄帶一個 <table:table-cell/>,而當某個覆疊蓋住該欄時就帶上覆疊解析後的樣式名稱。列樣式本身來自 TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec),它現在把高度文字——比如 12.81pt——連同隱藏與分頁旗標一起折進它的去重鍵,所以區段裡的每一列共用同一個 ro<N> 樣式,只帶一個 style:row-height 屬性

SaveAsODS 對一張由區段支撐的工作表寫出什麼:contentMaxRow 停在數值結束的第 45 列,而 maxRow 延伸到列高區段與它的覆寫之外;邊界之上的列逐列寫出,尾段則輸出成重複的 table-row 元素,其列樣式來自 RowStyleFor,儲存格樣式則經由覆疊解析
每個重複元素涵蓋一段均勻的區段,並在下一個區段邊緣、高度項目、覆疊邊緣或具體化儲存格處停下,所以沒有驗證規則的工作表只存成少數幾個元素,而有驗證規則或匯出 XLSX 時就得逐列付帳
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);
    // 空白尾段寫成少數幾個重複列,不是一百萬列
    Workbook.SaveAsODS(Saved);
    Writeln('ODS size: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.Open(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;

釘住這件事的測試斷言:對一張列高區段橫跨 1,048,575 列、中間還被打了一個覆寫與一個洞的工作表,存出來的串流小於 64 KB。那個數字旁邊該擺兩個誠實的界線。第一,任何帶資料驗證的工作表都會把 contentMaxRow 設成 maxRow,所以驗證規則會停掉該工作表的尾段壓縮,它又會被逐列寫出。第二,XLSX 沒有重複屬性——一個 SpreadsheetML 的 <row> 描述一列——所以把由區段支撐的工作表匯出成 .xlsx 時,會列舉區段涵蓋的每一列,並在每一列上寫一個 ht 屬性。模型在記憶體裡保持精簡;檔案格式決定檔案長什麼樣

每個會重編列號的編輯,現在欠區段什麼?

維護。列中介資料換了新表示法之後,唯有每一個會改變列號的操作都帶著它、連同它旁邊那些逐列清單一起搬,它才會是對的,而這次提交碰到的正是那些操作中的每一個。InsertRows 與 DeleteRows 走 XlsxShiftRowHeightRuns,它重建陣列的方式是:保留每個區段落在編輯點之前的部分、丟掉落在刪除視窗裡的東西、再把剩下的部分按差值位移後重新加回去——所以一個橫跨插入點的區段會變成兩段、中間留一個缺口,而一個橫跨刪除區段的區段會縮短。TileRangeAxisMetadata 會先清掉整個被磚貼範圍上的區段,再按照位移為每一份複本重新註冊每個來源區段一次。TXLSXWorksheet.CopyFrom 與 TXLSXSheets.AddCopy 取的是陣列的 Copy() 而不是直接指派,這就是為什麼測試可以在一個複製品上清光所有高度,而原工作表在第 1,048,576 列依然完好

var
  Sheet: TXLSXWorksheet;
begin
  Sheet := Workbook.Sheets[1];
  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
  // 再把它們刪掉:一切移回原位
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);   // 36.00
  // 把第 2..4 列在工作表上向下磚貼兩次;區段高度跟著每一份複本
  Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
  Writeln(Sheet.RowHeight[7]:0:2);        // 區段高度
end;

讀取端的邊界有同樣的義務。GetUsedRange 會把下緣頂到每個區段的 FirstRow 與 LastRow,而 BuildRowMajorCellOrder 則把含中介資料的最大列延伸過每一個區段,好讓 XLSX 寫入器仍然會走到只有高度的列。如果您哪天在 HotXLS 物件模型之上加了屬於自己的、以列為鍵的結構,這就是檢查清單:插入、刪除、磚貼、複製、已用範圍,以及每一個序列化器。漏掉一個,失敗是無聲的——高度按插入次數偏移,而且什麼都不會丟出例外

什麼仍然逐列處理,而現在的數字長什麼樣

隱藏旗標、大綱層級與摺疊狀態仍然會展開。收列邏輯對每個重複列跑一次 SetRowHidden 與 SetRowOutlineLevel,所以一張把一百萬列尾段藏起來、或把它嵌進 table:table-row-group 的工作表,每一個屬性都還是要付一筆逐列項目。2.382.2 的改動範圍只限於 HXLS-005 真正量到的兩件事——高度與繼承來的空白樣式——而同樣的區段技巧哪天有檔案逼著要,也可以套到其他屬性上。ODS 讀取器也不理會 style:use-optimal-row-height;一個寫著「最佳」又給了高度的列樣式,匯入時就用那個高度

對著 corpus,conditional-formatting.ods 現在能在 Win32 上以 0.178 秒、Win64 上以 0.158 秒跑完開檔、斷言、存檔、重開與再斷言的循環,其中開檔階段本身是 0.020 秒,而它以前會把 60 秒的預算用完。這個格式流經的活頁簿層級介面,見開啟與儲存 ODS 檔案的逐步解說,大檔案的更多可用槓桿見大型活頁簿效能;ODF 的列元素本身連同它的重複與樣式屬性,規定在 ODF 1.3 Part 3 §9.1.4

HotXLS 用原生 Delphi 與 C++Builder 程式碼讀寫 XLS、XLSX 與 ODS,不需安裝 Excel 或 LibreOffice,這就是為什麼一百萬列的重複是函式庫得好好建模、而不是丟給外部行程的東西——HotXLS Delphi 試算表元件產品頁列出支援的格式與 RAD Studio 版本