技術文章

Delphi 解析器中的 XLSX OPC 關係解析

一個合法的 xlsx 檔案,並不一定要包含 xl/worksheets/sheet1.xml。HotXLS 是 Delphi 與 C++Builder 適用的原生 Excel 試算表元件,它透過 OPC 關係圖來定位每一個部件(part),而不是靠猜測名稱,因為 ISO/IEC 29500-2 只保證部件能從 _rels/.rels 抵達,從不保證它們會位於慣例路徑上

為何我的解析器會在一份合法的 xlsx 上失敗?

因為你記在腦子裡的那些部件名稱,只是某一個產生器的慣例,並不是格式本身的要求。每一個你曾經寫死過的路徑──xl/workbook.xmlxl/sharedStrings.xmlxl/styles.xmlxl/worksheets/sheetN.xml──都只是桌面版 Excel 寫入器恰好會輸出的樣子。一個合規的封裝,完全可以把活頁簿放在 office/book.xml,把第一張工作表放在 xl/custom/data-sheet.xml,只要關係指向那裡,它依然是合法的 SpreadsheetML。這正是自製讀取器,在一份 Excel、LibreOffice 與 Numbers 全都能毫無怨言開啟的檔案上,回報「找不到 sheet1.xml」的最常見單一原因

會這樣做的產生器一點也不罕見。伺服器端的報表產生器,會重複使用一個範本封裝,並保留它原本的版面配置。合併兩份活頁簿的匯出管線,會重新編號工作表並留下缺口,所以一份五張工作表的活頁簿,可能會是 sheet1sheet2sheet4sheet7sheet9。剝除某張工作表的工具,也不見得會為倖存的工作表重新編號。在以上每一種情況下,基於索引的猜測 xl/worksheets/sheet + IntToStr(i + 1) + .xml,都會悄悄讀到錯誤的工作表、或什麼都讀不到,這比拋出例外還要糟糕,因為活頁簿依然載入成功,數字卻是錯的。下方這個最小化封裝,把整個問題完整演練了一遍,也正是 HotXLS 回歸測試所依循的樣貌

<!-- _rels/.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
      Target="office/book.xml"/>
</Relationships>

<!-- office/_rels/book.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId42"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
      Target="../xl/custom/data-sheet.xml"/>
</Relationships>

<!-- xl/custom/_rels/data-sheet.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="note7"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
      Target="../notes/review.xml"/>
</Relationships>

ISO/IEC 29500-2 實際上保證了什麼?

它保證的是可達性,不是位置。ISO/IEC 29500-2 是該標準中屬於 Open Packaging Conventions 的部分,其關係條款只定義了唯一一個固定進入點:位於 _rels/.rels 的封裝關係部件。從那裡開始,你要沿著 Typehttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument 的關係走,才能抵達活頁簿部件,而其他每一個部件,都是透過讀取該部件自己的關係部件、並沿著具型別的邊向外走訪才能被發現的

同一份標準裡還有另外兩條規則,真正在做實際的工作。部件命名條款,固定了關係部件所在的位置:對於位於 <folder>/<name> 的部件,其關係位於 <folder>/_rels/<name>.rels;對於位於封裝根目錄的部件,這個資料夾就單純是 _rels/。關係標記條款則規定,Target 是一個 URI 參照,要依一般 RFC 3986 的意義,相對於來源部件的 URI 來解析,除非 TargetMode="External" 標明它指向封裝之外。相對於來源部件做解析,正是大家都會跳過的那一步,這也正是為何同樣的字面文字 ../notes/review.xml,在 xl/custom/_rels/data-sheet.xml.rels 裡是一個意思,在深一層資料夾中的 rels 檔案裡卻是完全不同的意思。邏輯模型與磁碟上的位元組之間,還有最後一個容易出錯的地方:邏輯模型中的部件名稱是絕對的,並以正斜線開頭,但 ZIP 實體映射條款規定,在把部件名稱轉成 ZIP 項目名稱時要去掉這個斜線,所以一個忘記這一點的解析器,會在封存檔中查找 /xl/sharedStrings.xml,結果什麼都找不到

深入 XlsxResolveRelationshipTarget

HotXLS 把整條解析規則,集中放在單一一個函式 XlsxResolveRelationshipTarget 裡,在 lxHandleX.pas 中宣告為 function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString。它接受來源部件的 ZIP 項目名稱,以及原始的 Target 屬性,回傳一個不帶前導斜線、可以直接交給封存檔使用的 ZIP 項目名稱。傳入一個空的 OwnerPartName,就會相對於封裝根目錄來解析,這正是封裝關係部件所需要的行為。操作的先後順序,比個別步驟本身還要重要:反斜線會先被正規化為正斜線,因為有些產生器會在 Target 裡寫入 Windows 式的分隔符;由 # 引入的任何片段,會在路徑處理之前先被切掉,所以 ../charts/chart1.xml#Sheet1 會解析成一個部件名稱,而不是解析成一個不存在的封存項目;只有到了這一步之後,函式才會區分絕對路徑與相對路徑

// Normalization core, as implemented in lxHandleX.pas.
combined := StringReplace(Target, '\', '/', [rfReplaceAll]);
p := Pos('#', combined);
if p > 0 then
  combined := Copy(combined, 1, p - 1);
if (combined <> '') and (combined[1] = '/') then
  Delete(combined, 1, 1)              // package-absolute: strip the slash only
else
begin
  p := LastDelimiter('/', String(OwnerPartName));
  if p > 0 then
    baseName := Copy(OwnerPartName, 1, p)
  else
    baseName := '';
  combined := baseName + combined;    // relative to the source part folder
end;

source.StrictDelimiter := True;       // '/' only, no quote or space handling
source.Delimiter := '/';
source.DelimitedText := String(combined);
for i := 0 to source.Count - 1 do
begin
  segment := WideString(source[i]);
  if (segment = '') or (segment = '.') then
    Continue;                         // empty and dot segments vanish
  if segment = '..' then
  begin
    if parts.Count > 0 then
      parts.Delete(parts.Count - 1);  // pop, and never below the root
  end
  else
    parts.Add(String(segment));
end;

這個路徑片段迴圈,就是一次單純的堆疊走訪:空片段與 . 會被捨棄,.. 會彈出一層,而一個會逸出封裝根目錄的 ..,則會被直接吸收掉,而不是產生一個負索引,或一個以 ../ 開頭的名稱。StrictDelimiter := True 這個賦值並不是裝飾用的。少了它,Delphi 的 TStringList 會把空格當成分隔符,並遵循引號字元的規則,這會弄亂任何名稱中含有空格的部件──而含有空格的部件名稱是合法的

走訪這張圖:活頁簿、工作表、繪圖

HotXLS 在 TXLSXWorkbook.Open 這條路徑上,會走訪三個層級的關係部件。封裝層由 XlsxFindOfficeDocumentPart 處理,它讀取 _rels/.rels 並回傳 officeDocument 目標。活頁簿層則讀取活頁簿的關係部件,並一次建立兩張對照表:一張供 r:id 查找用的識別碼對照表,以及一張供單例部件用的型別對照表。工作表層與繪圖層,則以 ParseWorksheetRelsXmlParseDrawingRelsXml 重複同樣的模式,各自把自己的部件名稱當作解析基準,所以一張參照了 ../media/image3.png 的繪圖,就能落在正確的二進位資料上

// Tier 1: the only fixed name in the whole format.
WorkbookPartName := XlsxFindOfficeDocumentPart(zip);
if WorkbookPartName = '' then
  WorkbookPartName := 'xl/workbook.xml';        // legacy fallback
if not zip.Exists(WorkbookPartName) then
  Exit;

// Tier 2: <folder>/_rels/<name>.rels for the workbook part itself.
relsName := XlsxRelationshipPartName(WorkbookPartName);
if zip.Exists(relsName) then
begin
  relsStream := zip.OpenFile(relsName);
  try
    ParsePartRelationshipsXml(relsStream, WorkbookPartName,
      WorkbookTargetById, WorkbookTargetsByType);
  finally
    relsStream.Free;
  end;
end;

// Typed singletons resolve by relationship type URI.
PartName := WorkbookTargetsByType.Values[XlsxRtSharedStrings];
if PartName = '' then
  PartName := 'xl/sharedStrings.xml';

工作表這一類部件,特別必須經由識別碼對照表,而不是型別對照表。活頁簿部件中的 <sheet> 元素帶有 r:id 屬性,而這個識別碼,正是把工作表名稱綁定到某個部件的唯一依據。HotXLS 會在 ParseWorkbookXml 期間收集這些識別碼,並依序對照活頁簿關係對照表來解析,只有在識別碼缺失或無法解析時,才會退回使用慣例的編號名稱

// Tier 2b: r:id -> worksheet part, per sheet, in workbook order.
PartName := '';
if (i < SheetRelIds.Count) and (SheetRelIds[i] <> '') then
  PartName := WorkbookTargetById.Values[SheetRelIds[i]];
if PartName = '' then
  PartName := 'xl/worksheets/sheet' + IntToStr(i + 1) + '.xml';
SheetPartNames.Add(String(PartName));

// Tier 3: each worksheet resolves its own satellites against its own name.
relsName := XlsxRelationshipPartName(PartName);
if zip.Exists(relsName) then
begin
  relsStream := zip.OpenFile(relsName);
  try
    ParseWorksheetRelsXml(relsStream, PartName,
      FParRels[i], ParTableTargets[i], ParPartTargets[i]);
  finally
    relsStream.Free;
  end;
end;

下游的一切,都仰賴同一套機制運作。共用字串、樣式、佈景主題、位於 Microsoft 命名空間型別 http://schemas.microsoft.com/office/2006/relationships/vbaProject 之下的 VBA 專案、外部連結、活頁簿範圍的人員部件、舊式註解、討論串註解、承載註解泡泡幾何形狀的 VML 繪圖,以及繪圖、影像、圖表、表格與樞紐分析表,全都是透過解析後的目標,才能取得它們的位元組資料。佈景主題部件尤其必須被正確定位,否則一次來回讀寫,就會悄悄地把客戶的品牌調色盤,覆寫成 Office 內建的佈景主題,這正是 佈景主題、extLst 與 calcChain 的無損 XLSX 來回讀寫一文 所涵蓋的失敗模式之一。關係讀取也是載入流程之所以要這樣分階段進行的原因:所有的封存檔存取,都會在解析工作表 XML 之前、於單一執行緒上完成,因為一個 ZIP 封存檔的解壓縮狀態並非執行緒安全,這項限制在 平行 XLSX 解析與記憶體配置器一文 中有解釋

為何重複的 rId 會破壞以型別為基礎的路由?

因為稍後出現的一個格式錯誤條目,可能會覆寫掉先前一個有效的條目,進而劫持查找結果。關係識別碼理應在一個關係部件內是唯一的,但格式錯誤的封裝,卻會重複使用它們,而一個天真的 Values[Id] := 賦值,遵循的是「後寫的贏」原則。如果第一個 rId3 一開始指向一張真正的工作表,而第二個 rId3 卻指向一個不受支援或空白的目標,「後寫的贏」就會讓那張工作表消失。因此,ParsePartRelationshipsXml 採用的是一條「先贏」規則,並附帶兩個條件:解析出來的目標必須非空,且該識別碼必須尚未存在。這兩個條件合在一起,才是真正安全的關鍵,因為非空檢查,能阻止一個 Target 缺失的關係,在一個可用的關係抵達之前,就先搶占那個位置

if (TargetById <> nil) and (Id <> '') and (resolvedTarget <> '') and
  (TargetById.IndexOfName(String(Id)) < 0) then
  TargetById.Values[String(Id)] := String(resolvedTarget);
if (TargetsByType <> nil) and (relType <> '') and (resolvedTarget <> '') then
  TargetsByType.Add(String(relType + '=' + resolvedTarget));

請留意這段程式碼片段裡刻意設計的不對稱性。識別碼對照表是一張帶有「先贏」防護機制的真正對照表,而型別收集則是一份只能附加、由 type=target 配對組成的清單。這個區別是有實質意義的:一份活頁簿恰好只有一個共用字串關係,卻可能有許多工作表關係與外部連結關係,所以透過 Values[] 做型別查找,對單例部件會回傳第一個相符項,而像 externalLink 這種多值型別,則是靠走訪整份清單來列舉的

關係走訪在哪裡止步

誠實面對邊界,比說一個漂亮的故事更重要。每當關係缺失時,HotXLS 就會退回使用慣例名稱,所以一個關係部件已損毀或遺失的封裝,只要它剛好遵循 Excel 的版面配置,依然能開啟;這個退回機制是一項相容性功能,而不是第二個真實來源,而且它在測試期間,可能會把產生器本身的錯誤給掩蓋掉。還有三個限制值得了解。標記為 TargetMode="External" 的目標,會原封不動地被儲存、而不是被解析,這對超連結、以及攜帶遠端活頁簿 URL 的 externalLinkPath 關係而言是正確的做法,但這也意味著你拿回來的值,就是產生器當初寫下的原樣。透過繪圖關係部件所發現的圖表部件,是依位置、而不是依識別碼,與繪圖錨點配對的,所以不尋常的錨點順序,可能會導致圖表繫結錯位。而 lxDirectRead.pas 裡的串流直讀器,維持著自己一套以 xl/ 為鍵的較輕量路徑處理邏輯,所以本文所描述的完整解析器,管轄的是 TXLSXWorkbook.OpenGetSheetNames 這兩個進入點,而不是 Delphi 串流直讀器一文 所記載的那條低配置量掃描路徑

如果你打算自己動手實作,最簡短而正確的總結就是:絕不要自行建構部件名稱,永遠要去解析出一個部件名稱。讀取 _rels/.rels、沿著 officeDocument 走、把每一個 Target 相對於宣告它的那個部件來解析,並依 r:id 來路由工作表。如果你寧可直接使用一套已經針對重新命名的部件、不連續的工作表編號,以及重複的關係識別碼測試過的做法,本文所描述的解析器,就隨附在 HotXLS Delphi 試算表元件 之中,連同讓它不解析的部件保持原封不動的來回讀寫機制一併出貨