一份帶著一個樞紐分析表的 ODS 活頁簿過了一趟函式庫,只編輯一個來源儲存格,存出來的檔案打開時每個儲存格值都完好,樞紐卻不見了:content.xml 進去時有一個 <table:data-pilot-table>,出來時是零個。直覺的修法是把樞紐 XML 當字串整段複製過去,元素計數是補回來了,接著看得懂命名空間的剖析器卻用「未綁定前置詞」的錯誤把存出來的部件整個退回。兩種失敗同出一個根源:一段 XML 只有在它依賴的命名空間宣告跟著一起走的時候,才能安全旅行
ODS 樞紐定義住在 content.xml 的哪裡
OpenDocument 的樞紐分析表——格式自己叫它 data pilot——是一份存在 content.xml 裡、排在工作表之後的定義;寫入器若從自己的物件模型重建這個部件,只有模型裡有地方放它,它才留得下來。ODF 1.3 Part 3 §9.6 描述了這個容器:<table:data-pilot-tables> 每個樞紐放一個 <table:data-pilot-table>,各自帶著 table:source-cell-range、一組 table:data-pilot-field 子元素、table:target-range-address 與一份 table:buttons 清單。樞紐顯示的結果儲存格則以普通儲存格的身分待在工作表裡。所以一個只重新產生儲存格、樣式、驗證、命名表示式與資料庫範圍的寫入器,會產出一份完全合法的試算表——螢幕上數字照舊,背後卻沒有樞紐
HotXLS Delphi Excel Component 在 OpenODS 期間、還沒剖析任何儲存格之前,就把整棵 <table:data-pilot-tables> 子樹從 content.xml 讀出來,然後嘗試把這個集合翻譯成自己的樞紐模型。形狀恰好是模型能表示的那種定義——來源範圍在本地、有列欄位、有彙總量值、儲存的結果與來源資料一致——會變成工作表 PivotTables 清單裡的 TXLSPivotTable,與 XLSX 樞紐得到的是同一種物件,可以用計算欄位擴充、從 Delphi 重新整理。形狀之外的任何東西都讓整個集合以捕獲 XML 的形式保留,由 SaveAsODS 寫回去。翻譯器不認得的命名空間裡出現一個屬性就足夠了:LibreOffice 存的樞紐可能帶 loext: 與 calcext: 屬性,一個 loext: 屬性就把定義送進捕獲這條路
這條後備路徑在活頁簿層級是全有或全無。三個樞紐裡只要有一個落在可翻譯形狀之外,三個就都以捕獲 XML 保留。把它們拆開,content.xml 就會對同一個容器有兩個事實來源:一半由物件重建、一半由文字重播,兩半任何不一致都會落進使用者的檔案。讓集合保持完整是比較安全的預設,這也正是 HotXLS 對XLSX 裡未知 extLst 區塊採用的同一條政策:模型帶不動的,照原樣留著
切片切出來的樞紐定義為什麼會弄壞存出來的檔案?
用字串搜尋從 content.xml 切出來的樞紐定義,會失去賦予其前置詞意義的命名空間宣告,而 Namespaces in XML 把沒有宣告的前置詞當成格式良好性錯誤。字串做法很短:把部件解碼成 WideString,用 Pos 找 <table:data-pilot-tables,往後找到配對的結尾標籤,把中間全部複製下來。切出來的文字看起來很完整:頁面欄位上有 loext:ignore-selected-page="true",每一層都有 calcext:repeat-item-labels="false",但綁定這些前置詞的 xmlns:loext 與 xmlns:calcext 宣告,坐在幾千個字元之外的文件根 <office:document-content> 上
Namespaces in XML 1.0 §6.1 讓宣告的作用域從帶著它的開頭標籤延伸到配對的結尾標籤,作用域內每一個帶前置詞的名稱都對它解析。把子樹從文件裡切出來,就把它切出了這個作用域。片段貼進新文件後,每個前置詞只有在湊巧被新根宣告過時才解析得到。HotXLS 寫自己的 <office:document-content> 時帶的是自家寫入器用到的命名空間,其中包括 office、table、text、style、fo、draw、calcext、tableooo 與 of。在切片版本裡,table: 解析得到,calcext: 解析得到,loext: 不行。一個未綁定前置詞就足以讓看得懂命名空間的剖析器退回整個部件,樞紐並不會優雅地降級——它周圍的工作表資料也一起變得讀不了
字串搜尋還有其他盲區,走讀取器的捕獲一個也碰不到。Pos 會比對到註解裡、CDATA 區段裡、或恰好引用了標籤文字的屬性值裡的字樣;它分不清同名的巢狀元素;而且它比對的是字面前置詞 table:,ODF 卻允許生產者把 table 命名空間綁到任何自己喜歡的前置詞上
HotXLS 怎麼把命名空間作用域帶到片段上
HotXLS 用一個串流式 XML 讀取器捕獲樞紐子樹:讀取器追蹤每一個命名空間宣告連同它出現的深度,目標元素一到,就把當時仍然生效的宣告複製到片段的開頭標籤上。宣告 xmlns 或 xmlns:前置詞 的每個開頭標籤都讓堆疊長高,宣告綁定的元素收尾時堆疊縮回。空元素也要同樣待遇:<x xmlns:p="..."/> 永遠不會產生單獨的結尾標籤事件,彈出動作必須在空的開頭標籤上也觸發,否則綁定會洩漏到它的兄弟元素裡
到了目標,捕獲從堆疊最內層的綁定往外走,每個前置詞只寫一次。由內往外走,實作的正是遮蔽:<office:spreadsheet> 若重新綁定根已宣告過的前置詞,較近的值就是樞紐處生效的那個,與 §6.1 的要求完全一致,而「已寫過的前置詞」集合擋住外層的值跟著進來。樞紐元素自己宣告的前置詞則跳過——在同一個標籤上寫兩次 xmlns:table 是另一種格式良好性錯誤
目標按命名空間比對,所以把 t 綁到 ODF table URI、寫出 <t:data-pilot-tables> 的生產者照樣被認得,而捕獲的標籤保留檔案裡的拼法。空白文字節點照寫入原樣回來。屬性的引號寫法與 <x/> 和 <x></x> 之間的選擇會被正規化,因為片段是從讀取器事件重新序列化的,不是逐位元組複製。content.xml 若在樞紐子樹還開著時就結束——被截斷的檔案正是如此——開檔就以例外失敗,而不是留下半份定義,等下次存檔把它寫回去
帶過去的集合刻意從寬。捕獲會複製作用域裡的每一個綁定,不管子樹用不用,所以片段根會重複 xmlns:table 與 xmlns:calcext,即使存出來的文件根也宣告了它們。在巢狀作用域裡重新宣告前置詞,Namespaces in XML 視為合法;根上宣告了幾十個命名空間的生產者檔案,會多出一兩 KB。修剪清單意味著掃描整棵子樹,找出元素名稱、屬性名稱與承載 QName 的屬性值裡的每一個前置詞,漏掉一個,未綁定前置詞的失敗立刻回來。幾個冗餘宣告的代價,遠低於這個風險
開檔、編輯、存檔:帶著樞紐的活頁簿
呼叫端沒有任何開關要打開:樞紐定義搭著普通的 OpenODS 與 SaveAsODS 一起走。工作表從 1 編號,兩個方法成功時都回傳 1:
uses
System.SysUtils, lxHandleX;
procedure EditPivotSource(const InFile, OutFile: string);
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
if Book.OpenODS(InFile) <> 1 then
raise Exception.CreateFmt('Cannot open %s', [InFile]);
// E2 位於 Sheet1.A2:E30 這個樞紐來源範圍內
Book.Sheets[1].Cells[2, 5].Value := 1250.0;
if Book.SaveAsODS(OutFile) <> 1 then
raise Exception.CreateFmt('Cannot save %s', [OutFile]);
finally
Book.Free;
end;
end;
存出來的 content.xml 裡,樞紐集合坐在 <office:spreadsheet> 內、寫入器產生的 <table:named-expressions> 正後方、<table:database-ranges> 之前。同時帶著 AutoFilter(HotXLS 把它寫成資料庫範圍)與樞紐的活頁簿,會讓這兩個元素緊挨在一起;如果您的管線會跑嚴格的 ODF schema 驗證器,這一對就是最先要看的地方
範例裡的編輯只改一個來源值,樞紐的結果儲存格維持原樣。捕獲的定義照原樣重播:HotXLS 不重算它,打開檔案的試算表應用程式會在下一次重新整理時重算樞紐。位址也一樣:Sheet1 改名或來源區塊搬家,存出來的定義仍指向 Sheet1.A2:E30,消費端應用程式重新整理時會回報這是個壞掉的範圍。樞紐走這條路的活頁簿,工作表名稱與來源區塊都要保持穩定
某個樞紐到底走了哪條路?
最快的檢查是工作表的 PivotTables 清單:翻譯過的定義會以 TXLSPivotTable 出現在裡面,捕獲的不會。轉換報告回答問題的另一半——樞紐換一種輸出格式會怎樣。GetConversionReport 檢查活頁簿時不存檔、不重算,回傳的報告歸呼叫端所有:
uses
System.SysUtils, lxHandleX, lxDiagnostics;
function DescribeOdsPivots(Book: TXLSXWorkbook): string;
var
I, Modeled: Integer;
Report: TXLSConversionReport;
begin
Modeled := 0;
for I := 1 to Book.Sheets.Count do // Sheets 從 1 算起
Inc(Modeled, Book.Sheets[I].PivotTables.Count);
Report := Book.GetConversionReport(xlsConversionOpenXmlWorkbook);
try
for I := 0 to Report.Count - 1 do
if (Report[I].Feature = 'OpenDocumentPivotTables') and
(Report[I].Fidelity = xlsConversionDropped) then
Exit('Captured ODF pivot XML: keep this workbook on .ods');
finally
Report.Free;
end;
if Modeled > 0 then
Result := Format('%d pivot table(s) in the pivot model', [Modeled])
else
Result := 'No pivot definitions';
end;
輸出路徑會有影響,因為捕獲 XML 是 ODF 詞彙,只有 ODS 寫入器輸得出它。存成 .xlsx 時,捕獲的集合沒有 SpreadsheetML 對應物,報告把它列在 OpenDocumentPivotTables 底下、標記 xlsConversionDropped。二進位的 .xlsb 寫入器會拒收還帶著它的活頁簿。翻譯過的樞紐是普通模型物件,改為遵循目標格式的規則。ODS 匯入與匯出路徑的差異在這裡與在其他地方一樣適用;帶樞紐的檔案,安全路線是 .ods 進、.ods 出
全有或全無的後備路徑還推出一條規則:活頁簿要嘛重播捕獲的集合,要嘛從模型寫出它的樞紐,絕不在同一份 content.xml 裡混用兩者。對已經持有捕獲樞紐 XML 的活頁簿用 AddPivotTable 加一個樞紐,ODS 存檔會以錯誤收場,而不是把產生的 XML 接在重播的 XML 旁邊
用第二個剖析器檢查存出來的部件
元素計數證明不了命名空間的事。在存出來的 content.xml 裡找 data-pilot-table 開頭標籤的正規表示式看到的是文字,第二節那份切片版本照樣通過那種測試。與寫出檔案的函式庫毫無關係、看得懂命名空間的剖析器是嚴得多的裁判,而 Delphi 本來就內建一個。下面的函式是一道檢查,請加進您自己的測試套件;TZipFile 與 TXMLDocument 來自 Delphi RTL,不是 HotXLS 的:
uses
System.SysUtils, System.Classes, System.Zip,
Xml.xmldom, Xml.XMLIntf, Xml.XMLDoc;
// 您自己的測試輔助函式:當 .ods 套件裡的 content.xml
// 能載進看得懂命名空間的 DOM 剖析器時回傳 True
function OdsContentParses(const OdsFile: string; out Error: string): Boolean;
var
Zip: TZipFile;
Bytes: TBytes;
Stream: TBytesStream;
Doc: IXMLDocument;
begin
Error := '';
Zip := TZipFile.Create;
try
Zip.Open(OdsFile, zmRead);
Zip.Read('content.xml', Bytes);
finally
Zip.Free;
end;
Stream := TBytesStream.Create(Bytes);
try
Doc := TXMLDocument.Create(nil);
try
Doc.LoadFromStream(Stream); // 未綁定的前置詞在這裡失敗
Result := True;
except
on E: EDOMParseError do
begin
Error := E.Message;
Result := False;
end;
end;
finally
Stream.Free;
end;
end;
您的程式碼存出的每個 .ods 都跑一遍;帶樞紐的檔案再進一步,剖析之後比對輸入與輸出的樞紐子樹:標籤、排序後的屬性、文字與子節點,遞迴進行,名稱展開成命名空間 URI 加本地名稱。這種比對放行改拼的前置詞、抓住未綁定的前置詞,恰恰畫出無害與壞掉之間那條線。使用者實際在用的每個生產者,都拿它的檔案來餵。把 table 命名空間綁到不同前置詞的文件、在樞紐元素本身上宣告前置詞的文件、或在 <office:spreadsheet> 裡重新綁定的文件,鍛鍊跳過與遮蔽規則的方式,單一份 LibreOffice 檔案給不了
ODS 讀取器與寫入器、XLSX 與 ODS 兩邊的樞紐模型,以及上面用到的轉換報告,都是 HotXLS Delphi Excel Component 的一部分;產品頁涵蓋 Delphi 與 C++Builder 完整的 XLSX、XLS 與 ODS 讀寫支援