技術文章

Delphi 的 ODS 樞紐分析表 round trip:XML 命名空間作用域

HotXLS Delphi Excel Component 從 v2.382.0 起,會在開檔時逐字捕獲 content.xml 裡的 <table:data-pilot-tables> 子樹並在存檔時重播,讓 OpenDocument 的資料樞紐表走完一次 ODS 開檔存檔循環。從 v2.382.1 起,這個片段還會帶著它所有祖先宣告過的每一個 XML 命名空間綁定,所以存下來的樞紐定義對任何消費端都是格式良好的,不只有 HotXLS 看得懂

逼出這兩項改動的 bug 出自一趟嚴格的 corpus 執行。範例 official-pivot.ods 由一個 LibreOffice 6.1 開發版寫出,裡面有一個名為 DataPilot1 的樞紐,讀取 Sheet1.A2:E30,把結果落在 Sheet1.G6:J18。用 HotXLS 打開、原封不動存回、數一數輸出裡的 <table:data-pilot-table> 元素:進去一個,出來零個,Win32 與 Win64 都一樣。測試裡沒有任何東西碰過那個樞紐。第一輪探查只比對了儲存格常數,而且通過了;是結構斷言才揭露出這個遺失,這提醒我們「值有對上」是對 round trip 保真度很弱的定義

為什麼 ODS 樞紐分析表在函式庫存檔之後就消失了?

它會消失,是因為 HotXLS 對 OpenDocument 的資料樞紐表沒有記憶體內的模型,而 ODS 寫入器完全從模型建出 content.xml。寫入器組出自動樣式、每張工作表一個 <table:table>、<table:content-validations>、<table:named-expressions> 與 <table:database-ranges>,每一項都由活頁簿真正持有的物件產生。而一份樞紐定義——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——沒有任何物件可以棲身,所以重新產生的部件就只是把它省略掉

與 XLSX 的對比是刻意的。HotXLS 把 SpreadsheetML 的樞紐快取與樞紐表剖析成真正的模型,讓您可以從 Delphi 建立、用計算欄位擴充並重新整理,所以那些東西存檔後還在,因為它們是被重寫、不是被複製。ODS 樞紐是稀有得多的需求,而只為了 round trip 去把 ODF 的資料樞紐詞彙建成模型,會是一大堆沒有人會去編輯的程式碼。務實的答案就是 HotXLS 早就用在XLSX 裡未知 extLst 區塊上的那個:不懂的就留著,能逐位元組就逐位元組,不能就逐事件

第一版用 Pos 做的捕獲錯在哪裡?

v2.382.0 的捕獲把樞紐定義當成純字串從 content.xml 切出來,而切出來的那一段少了讓它有意義的命名空間宣告。實作就短得像它聽起來那樣:把部件解碼成 WideString、用 Pos 找到開頭標籤、在它後面找到結尾標籤、把這段複製到活頁簿上的 FRawOdsDataPilotTablesXml:

// HotXLS v2.382.0 -- 一個版本後就被取代
function OdsCaptureDataPilotTablesXml(Stream: TStream): WideString;
const
  OpenTag: WideString = '<table:data-pilot-tables';
  CloseTag: WideString = '</table:data-pilot-tables>';
var
  Text: WideString;
  StartPos, ClosePos: Integer;
begin
  Result := '';
  Text := LoadPartAsWideString(Stream);   // 整份 content.xml 載進記憶體
  StartPos := Pos(OpenTag, Text);
  if StartPos = 0 then Exit;
  ClosePos := Pos(CloseTag, Copy(Text, StartPos, MaxInt));
  if ClosePos = 0 then Exit;
  Result := Copy(Text, StartPos, ClosePos + Length(CloseTag) - 1);
end;

計數斷言轉綠,修正也出貨了。抓到它的是同一天加上的第二道更嚴格的檢查:已儲存套件的每一個 XML 部件都會被送進 HotXLS 之外一個獨立的、看得懂命名空間的剖析器,而那個剖析器以「未綁定的前置詞」錯誤拒收了新的 content.xml。來自 LibreOffice 的那個樞紐帶著生產者的擴充屬性——頁面欄位上的 loext:ignore-selected-page="true"、每一層上的 calcext:repeat-item-labels="false"——而被切出來的字串含有那些屬性,卻不含綁定它們的 xmlns:loext 與 xmlns:calcext 宣告。那些宣告坐在來源檔的 <office:document-content> 根上,一共三十五個,離樞紐兩千個字元遠

W3C Namespaces in XML 1.0 §6.1 定義的那條規則,讓這件事變成硬性失敗而不是美觀問題:一個命名空間宣告的作用域,從它所在元素的開頭標籤延伸到該元素的結尾標籤,而該作用域內每一個帶前置詞的名稱都對它解析。把一棵子樹從文件裡切出來,就等於把它從作用域裡切出來。HotXLS 自己寫的 <office:document-content> 根帶十一個宣告——office、table、text、style、number、fo、draw、svg、xlink、calcext、tableooo——所以 calcext: 剛好解析得出來、table: 剛好解析得出來,而 loext: 不行。看得懂命名空間的剖析器會把未綁定的前置詞當成格式良好性的違規,那意味著整個部件讀不了,不只是某一個屬性

HotXLS 用 Pos 捕獲 official-pivot.ods 時漏掉了什麼:樞紐子樹帶著 loext 與 calcext 擴充屬性,而綁定它們的 xmlns 宣告坐在三十五個綁定之外的 office:document-content 根上,所以切出來的片段讓它用到的每個前置詞都未綁定,看得懂命名空間的剖析器於是拒收了整份 content.xml
命名空間宣告的作用域從開頭標籤到結尾標籤,把子樹從文件裡切出來就等於把它從作用域裡切出來,於是區區一個屬性就讓整個部件讀不了

HotXLS 怎麼把祖先的 xmlns 綁定帶到片段上?

HotXLS v2.382.1 把字串切片換成用自家串流式 TXMLReader 走一趟 content.xml,維護一個命名空間綁定的堆疊,並標記每一個是在哪個深度宣告的;一抵達目標,就把當時仍然生效的綁定複製到片段的根元素上。讀取器以啟用 PreserveWhitespaceText 的方式執行,所以文字節點會照原樣回來,而重建的標籤用的是 TXMLReader.RawName 與 TXMLReader.Attribute[I].RawName——檔案裡的前置詞拼法——而不是讀取器平常交給部件剖析器的正規名稱。以下是迴圈的核心:

HotXLS v2.382.1 如何連同命名空間作用域一起捕獲資料樞紐子樹:串流式 TXMLReader 走訪維護一個標著宣告深度的 xmlns 綁定堆疊,在 table:data-pilot-tables 目標上由最內層往外走一遍,用 Seen 集合尊重遮蔽關係,跳過元素自己宣告的前置詞,並且在結尾標籤與空元素上都照樣彈出綁定
用讀取器的正規名稱比對目標,可讓把 table 前置詞改拼的生產者繼續運作,而永遠沒收尾的子樹會丟出例外,而不是在存檔時把半個片段寫回去
// Namespaces:一個 'xmlns:p=uri' 的 TStringList,宣告深度放在 Objects[] 裡
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // 元素、文字、CDATA、註解
  if Reader.NodeType = xmlntElement then
  begin
    for I := 0 to Reader.AttributeCount - 1 do
    begin
      AttrName := Reader.Attribute[I].RawName;
      if (AttrName = 'xmlns') or (Pos(WideString('xmlns:'), AttrName) = 1) then
        Namespaces.AddObject(String(AttrName) + '=' + String(Reader.Attribute[I].Value),
          TObject(NativeInt(Depth)));
    end;
    if (CaptureDepth < 0) and (Reader.Name = 'table:data-pilot-tables') then
    begin
      Opening := XlsxRawXmlReaderOpenTag(Reader);   // 先剝掉結尾的 '>' 或 '/>'
      ...
      // 把仍然生效的祖先綁定帶到片段根元素上。
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // 最內層的綁定勝出
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // 這裡已經宣告過了?跳過
          Opening := Opening + ' ' + AttrName + '="' +
            XlsxEscapeAttr(WideString(Namespaces.ValueFromIndex[I])) + '"';
      end;
      ...
      CaptureDepth := Depth;
    end;
    if not Reader.IsEmptyElement then Inc(Depth);
  end
  else if Reader.NodeType = xmlntEndElement then
  begin
    Dec(Depth);
    if Depth = CaptureDepth then Exit;                           // 子樹收尾
  end;
  if (Reader.NodeType = xmlntEndElement) or
     ((Reader.NodeType = xmlntElement) and Reader.IsEmptyElement) then
    while (Namespaces.Count > 0) and
          (NativeInt(Namespaces.Objects[Namespaces.Count - 1]) >= Depth) do
      Namespaces.Delete(Namespaces.Count - 1);                   // 離開作用域
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

那個迴圈裡有三個細節撐起正確性。從最內層的綁定往外走堆疊、並在 Seen 裡記住每個前置詞,實作了遮蔽:若有更近的祖先重新綁定 xmlns:table,較近的值勝出,完全是 §6.1 規定的樣子。跳過元素自己已經宣告的前置詞,避免同一個屬性被輸出兩次——那會是另一種格式良好性錯誤。而彈出的規則在結尾標籤 以及空元素上都會觸發,因為 <x/> 永遠不會產生 EndElement 事件——這正是 XLSX 的 extLst 捕獲也得學會的同一個自閉合陷阱。用 Reader.Name 而不是 RawName 來比對目標,是個比較安靜的收穫:讀取器會把 ODF 的 table 命名空間 URI 正規化成 table 前置詞,所以把它拼成 t:data-pilot-tables 的生產者照樣比對得到,而輸出的片段保留的是生產者自己用的那個前置詞

這個迴圈也拒絕猜測。如果部件在捕獲還開著的時候就結束——一份被截斷或格式不良的 content.xml——OdsCaptureDataPilotTablesXml 會丟出例外,而不是回傳半個片段,因為半個片段會在存檔時被寫回去,把一份壞掉的輸入變成一份掛著函式庫名字的壞掉輸出

片段落在存出來的 content.xml 哪個位置?

HotXLS 把捕獲到的片段寫進 <office:spreadsheet>,緊接在它產生的 <table:named-expressions> 之後、排在 <table:database-ranges> 之前。ODF 1.3 Part 3 對 <office:spreadsheet> 的內容模型替這些結尾子元素規定了固定順序,所以逐字區塊不能隨便附加在寫入器剛好走到的地方;它必須被放進特定的格子。從呼叫端來看,沒有 API、也沒有東西要設定;定義就搭著一次普通的開檔與存檔一起走:

捕獲到的樞紐定義在 HotXLS 的 ODS 存檔裡落在哪裡:office:spreadsheet 的子元素照固定 ODF 順序排列,從產生的 table 元素經 table:content-validations 與 table:named-expressions,逐字的 table:data-pilot-tables 片段插在 table:database-ranges 之前;沒有 API,因為定義就是搭著 OpenODS 與 SaveAsODS 一起走
逐字區塊不能隨便附加在寫入器剛好走到的地方,而它帶著的那幾份祖先綁定無害,因為 Namespaces in XML 允許在巢狀作用域裡重新宣告前置詞
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.OpenODS('official-pivot.ods') <> 1 then
      raise Exception.Create('open failed');
    Book.Sheets[0].Cells[2, 5].Value := 1250.0;   // 在樞紐來源範圍內編輯
    Book.SaveAsODS('official-pivot-out.ods');
    // 輸出裡的 content.xml 仍然帶著 DataPilot1,連同它的
    // 來源範圍、欄位、目標範圍、按鈕與 loext:/calcext: 屬性
  finally
    Book.Free;
  end;
end;

這份冗餘是刻意的,也值得知道。片段根現在重複了 xmlns:table 與 xmlns:calcext,即使存出來的文件根也宣告了它們;Namespaces in XML 允許在巢狀作用域裡重新宣告前置詞,所以這些重複無害。對那個 LibreOffice 範例而言,帶過去的集合就是全部三十五個根宣告,在 8,357 字元的定義之外多出大約兩 KB,因為捕獲並不會分析這棵子樹實際用到哪些前置詞。做一輪已用前置詞掃描可以把這裁掉,那或許之後再來;先正確,再精簡

把子樹從 XML 切出來逐字重播的一條規則

一般的教訓是:子樹只有在您把它弄成自足之後才自足,而您一忘掉,第一個壞掉的就是命名空間作用域。HotXLS 現在對任何「留著我們沒有建模的東西」的捕獲都套用這份檢查清單:

  • 用真正的讀取器走訪文件,並追蹤作用域內的綁定。用 Pos 做字串搜尋根本看不到作用域,而且它還會在同名的巢狀元素上、在註解或 CDATA 區段裡剛好相符的字串上、以及在屬性值剛好含有標籤文字時比對錯誤
  • 把仍然生效的綁定複製到片段根上,由最內層往外,每個前置詞一次,跳過根本來就宣告過的
  • 輸出的標籤保留原始的前置詞拼法;比對目標時看解析後的命名空間,不是看字面前置詞
  • 保留空白文字節點,並記住空元素不靠結尾標籤事件就自己收掉了作用域
  • 用一個不是被測函式庫的剖析器來驗證存出來的部件。函式庫會很樂意用寫出它的那條寬鬆程式路徑,把自己產出的東西再讀一遍

最後那一點,才是第二次真正抓到 HXLS-003 的東西。v2.382.0 的驗收檢查是一個正規表示式,數存出來的 content.xml 裡有幾個 data-pilot-table 開頭標籤,而正規表示式看到的是標籤、不是文件——它看不出那個標籤上的前置詞有沒有被綁定。v2.382.1 加上的嚴格 corpus 執行器,會用看得懂命名空間的剖析器剖析已儲存套件的每一個 XML 與 .rels 部件,然後把樞紐樹——標籤、排序後的屬性、文字、子節點,遞迴地——與原檔比對。那個比對是展開命名空間後做的,所以改拼前置詞還是會過,而未綁定的前置詞過不了

逐字保證到哪裡為止

逐字重播保存的是一份定義;它並不理解這份定義,而界線就是從這裡來的。HotXLS 不提供任何 API 可以讀取、編輯或重新整理 ODS 樞紐,所以 FRawOdsDataPilotTablesXml 是內部欄位,唯一可觀察的行為就是那份定義活了下來。片段是從讀取器事件重新序列化出來的,不是逐位元組複製:屬性的引號寫法與自閉合形式會被正規化,而文字與空白則保留。捕獲到的 XML 只由 ODS 內容寫入器輸出,所以從 .ods 開檔、存成 .xlsx 的活頁簿會弄丟樞紐,而從 .xlsx 開檔的活頁簿則沒有東西可以重播進 .ods 存檔——ODS 匯入與匯出路徑的不對稱在這裡跟其他地方一樣適用。而且因為那份定義是不透明的,它跟不上您的編輯:在 HotXLS 裡把 Sheet1 改名或搬動來源資料,存下來的樞紐仍然指向 Sheet1.A2:E30,只能留給消費端在下次重新整理時回報範圍壞掉。這裡還要補一個順序上的但書:HotXLS 把 AutoFilter 範圍當成 <table:database-ranges> 輸出在樞紐片段之後,而 corpus 範例沒有帶 database range,所以一份同時有篩選與樞紐的活頁簿,在您依賴那兩個元素的相對順序之前,應該先過一次 ODF schema 驗證器

要拿您自己生產者的檔案來測,不要只用 corpus 範例。命名空間帶過去的機制能處理生產者宣告在祖先上的任何前置詞,但一份把前置詞宣告在樞紐元素本身上的文件,或是對 table 詞彙使用預設命名空間的文件,才會走到那個跳過分支與遮蔽分支,而 LibreOffice 範例碰不到它們。兩者都實作了;兩者在 corpus 裡都還沒有範例,而這種區別正是 changelog 條目最容易含糊帶過的東西

v2.382.0 的逐字資料樞紐捕獲與 v2.382.1 的命名空間作用域修正,都隨目前的 HotXLS Delphi Excel Component 出貨,產品頁列出 Delphi 與 C++Builder 完整的 ODS、XLSX 與 XLS 讀寫涵蓋範圍