技術文章

為什麼 Excel 要修復合法的 XLSX:Delphi 的 OPC 套件規則

Excel 會在一份 LibreOffice 與各種自製讀取器都開得好好的 XLSX 上跳出「我們發現部分內容有問題」,因為 Excel 強制執行那些讀取器視而不見的兩件事:schema 標為必填的屬性,以及 Open Packaging Conventions 的唯一性規則。HotXLS 是給 Delphi 與 C++Builder 用的原生 Excel 試算表元件,它的輸出第一次經過真正的 Excel COM 實例時就在 v2.382.5 正好撞上這件事,三個原因是:一個沒有 fontId 的 <phoneticPr>、[Content_Types].xml 裡重複的 Override,以及兩條共用 rId4 的根關聯

為什麼其他讀取器都接受的套件,Excel 卻拒收?

因為那個修復提示是一道 schema 與套件驗證器,不是剖析失敗。HotXLS 的 corpus 幾個星期以來一直讓一份 4805 條公式的貸款範本在函式庫、LibreOffice 以及測試套件裡的 XML 驗證器之間來回走完 round trip。存出來的檔案在 XLSX OPC 關聯解析一文所談的 OPC 意義下結構健全:每個部件都到得了,每個目標都解析得出來。然後一台裝著 Excel 16.0 build 20326 的 Windows 機器到位了,corpus 執行器在一個隔離的 COM 實例裡、把 DisplayAlerts 關掉,用 Workbooks.Open 打開存出來的範本,呼叫直接失敗。互動操作下同一個檔案會跳出那個熟悉的對話框問您要不要修復,而 Excel 真的願意寫下修復記錄時,它會指出部件、卻不指出規則。那一個提示底下躲著三個彼此獨立的缺陷,而 Excel 不會一次報一個;它直接拒收整本活頁簿,讓您自己去把它們找出來並加以分析。接下來談每一條規則、HotXLS 違反它的那一行、以及出貨的修正,因為這三條都是任何 Delphi XLSX 寫入器都可能踩到的規則

規則 1:phoneticPr 的 fontId 是必填,即使是零

<phoneticPr> 元素帶著一個 fontId 屬性,在 ECMA-376 Part 1 §18.4.3 裡宣告為 use="required",而值為 0 是一個合法的字型索引,不是「沒有」。HotXLS 舊的工作表寫入器把零當成「未設定」,只有在 Sheet.PhoneticFontId > 0 時才輸出這個屬性。那是很自然的 Delphi 反射動作,因為整數欄位預設就是零,但這樣一來,任何拼音字型剛好是 styles.xml 裡第一個字型的活頁簿,都會產生 <phoneticPr type="noConversion"/>——而 HotXLS corpus 裡的貸款範本正是這樣。於是 Excel 在它自己寫進去的值上,回過頭來把檔案拒收了

為什麼 Excel 對 HotXLS 的工作表部件要求修復:phoneticPr 元素在 ECMA-376 Part 1 裡把 fontId 宣告為必填,字型索引 0 是合法值,而舊寫入器在 PhoneticFontId 為零時省略該屬性,於是產生 phoneticPr type noConversion;schema 對 type 與 alignment 有預設值,對 fontId 沒有
只有在 schema 宣告了預設值時,省略等於預設值的屬性才安全,而那份貸款範本的拼音字型正好是 styles.xml 裡的第一筆
// lxHandleX.pas,工作表寫入器 — v2.382.5 之前
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// v2.382.5 — 這個屬性是必填,零也算
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
  phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';

HotXLS 仍然只在 TXLSXWorksheet.PhoneticType 非空時才輸出這個元素,所以從來沒有帶拼音設定的活頁簿不受影響。迴歸測試 PhoneticSettings_DefaultFontIsExplicit 在一張全新的工作表上把 PhoneticFontId 設為零、存檔,然後斷言 xl/worksheets/sheet1.xml 裡存在 <phoneticPr fontId="0"。更大的教訓是:「等於預設值就省略」只有在 schema 宣告了預設值時才安全;那個元素裡 type 與 alignment 有預設值,fontId 沒有

規則 2:[Content_Types].xml 裡每個部件名稱只能有一個 Override

內容類型串流對每個部件名稱最多只能宣告一次,而 Excel 把同一個 PartName 的第二個 Override 當成毀損,即使兩筆帶著相同的 ContentType 也一樣。HotXLS 有兩個寫入器在餵這條串流。BuildContentTypesXml 宣告物件模型產生的每一個部件:活頁簿、樣式、共用字串、主題、工作表,以及當 TXLSXWorkbook.CustomProperties.Count > 0 時的 /docProps/custom.xml。當 PreserveUnsupportedParts 打開時,TXLSXOpaquePackage 接著會為每一個它從來源套件逐字捕獲的部件補上一個 Override,好讓那些位元組在輸出時仍被宣告。衝突點就是兩邊都住的部件。自訂文件屬性是剖析進模型裡的,但來源套件的 docProps/custom.xml 也被不透明層捕獲了,所以合併後的串流把它宣告了兩次;而當模型重新產生一個不透明層也保留著的部件時,圖表與樞紐快取部件也會落到同樣的處境。在 v2.382.5 之前,ContentTypeOverridesXml 看不到模型已經寫了什麼,所以它無從得知

兩個 HotXLS 寫入器如何在 [Content_Types].xml 裡撞在一起:BuildContentTypesXml 從物件模型宣告 docProps/custom.xml,而 TXLSXOpaquePackage 為同一個逐字捕獲的部件又補上一個 Override;從 v2.382.5 起,不透明層會先剖析產生出來的串流,用 OpcLowerPartName 正規化名稱,並讓模型在每一次衝突中勝出
兩個寫入器各自都是一致的,而「每個部件名稱只能出現一次」這條約束只存在於兩者輸出被串接起來的接縫上,這就是修正要把模型串流傳進去的原因
<!-- v2.382.5 之前 Excel 看到的內容 -->
<Override PartName="/docProps/custom.xml"
    ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>
...
<Override PartName="/docProps/custom.xml"
    ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>

修正把產生出來的 XML 傳進 ContentTypeOverridesXml,讓不透明寫入器在輸出任何東西之前先剖析它。有兩個細節撐起正確性。OpcLowerPartName 在比對前會轉小寫、把反斜線換成正斜線、剝掉開頭的斜線,因為 OPC 部件名稱是不分大小寫比對的,而模型寫的名稱帶開頭斜線,不透明層存的 ZIP 項目名稱不帶。另外,BuildContentTypesXml 裡的呼叫端傳的是 Result + '</Types>',把還沒建完的文件收尾,好讓 TXMLReader 看到的是格式良好的輸入,而不是被截斷的串流。浮現出來的規則是先到先贏、模型排前面:物件模型宣告什麼就是權威,不透明層的重播只負責補洞

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // 剖析模型產生的串流,收集每一個已宣告的 PartName。
    while Reader.Read do
      if (Reader.NodeType= xmlntElement)and (Reader.Name= 'Override') then
      begin
        Index:= Reader.AttributeIndex('PartName');
        if Index>= 0 then
          UsedNames.Add(String(OpcLowerPartName(Reader.Attribute[Index].Value)));
      end;
  for i:= 0 to FParts.Count- 1 do
  begin
    Part:= TXLSXOpaquePart(FParts[i]);
    if (Part.ContentType= '')or (LowerCase(ExtractFileExt(String(Part.PartName)))= '.rels')or
      (UsedNames.IndexOf(String(OpcLowerPartName(Part.PartName)))>= 0) then
      Continue;                       // 已宣告過,或是 rels 部件
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

規則 3:關聯 Id 在一個關聯部件內必須唯一

.rels 部件裡的每個 Relationship 都需要一個在該部件內唯一的 Id,只要兩個共用一個,Excel 就拒收整個套件。HotXLS 寫套件層級的 _rels/.rels 時用固定識別碼:活頁簿是 rId1,核心與擴充文件屬性用 rId2 與 rId3,而當模型有自訂屬性時,自訂屬性用 rId4。不透明套件接著會把它從來源保留下來的根關聯全部補上去,並把任何已在 UsedIds 清單裡的識別碼重新編號。那份清單知道 rId1 到 rId3,卻不知道 rId4,也不知道模型正準備輸出它自己的自訂屬性關聯,所以一個自訂屬性關聯也叫 rId4 的來源套件(Excel 預設就是這樣寫的),出來就帶著兩筆指向同一個目標的 rId4。呼叫端 BuildRootRelsXml 現在把 Workbook.FCustomProps.Count > 0 當第二個參數傳進去,於是保留與跳過都由「模型到底會不會輸出 rId4」這同一個條件驅動。重新編號在套件根層級是安全的,因為活頁簿裡沒有任何東西用名稱參照根關聯的識別碼;同樣的手法挪到下一層就是錯的,那裡 workbook.xml 的 r:id 屬性綁的是活頁簿關聯部件裡的識別碼,這也是 MergeWorkbookRelationshipsXml 要另外維護一份識別碼對映的原因

HotXLS 套件根層級的關聯識別碼衝突:模型寫出 rId1 到 rId4,其中 rId4 保留給自訂屬性;由於 UsedIds 只知道 rId1 到 rId3,不透明層就重播了一條也叫 rId4 的來源關聯;修正後只要 EmitCustomProps 成立就事先保留 rId4,其餘的再重新編號
重新編號在套件根層級安全,因為活頁簿裡沒有任何東西用名稱參照根識別碼,而同樣的手法挪到下一層會弄壞 workbook.xml 裡每一個 r:id 綁定
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // 由模型寫入器保留
if EmitDocProps then
begin
  UsedIds.Add('rId2');
  UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
  Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
  // 自訂屬性現在歸模型所有;不要重播來源那一份。
  if EmitCustomProps and OpcEndsWith(LowerCase(Rel.RelType), '/custom-properties') then
    Continue;
  Id:= Rel.Id;
  if (Id= '')or (UsedIds.IndexOf(String(Id))>= 0) then
    Id:= AllocateRelationshipId(UsedIds);      // 最小的可用 rIdN
  UsedIds.Add(String(Id));
  ...
end;

這三個失敗有什麼共同點?

三個都是同一個症狀:一個有兩個來源、卻沒有單一擁有者負責套件不變式的寫入器。物件模型產生它懂的部件;不透明層重播它不懂的部件,好讓 round trip 保住圖表、樞紐快取、自訂 XML,以及主題、extLst 與 calcChain 的無損 round trip一文談到的其他一切。兩邊各自都是一致的。OPC 對整個套件設下的約束——唯一的 Override 部件名稱,以及每個部件內唯一的關聯識別碼——只存在於兩者被串接起來的那道接縫上,而在 v2.382.5 之前,沒有人檢查那道接縫。fontId 那個 bug 是同一種形狀、只是低了一層:寫入器知道自己想省略什麼,卻從來沒去查那個說它不准省略的 schema。HotXLS 最後採用的修法是固定優先序,不是合併啟發式。模型先寫,不透明層看到已經寫了什麼,遇到任何衝突就讓步,而 corpus 執行器現在從外面用 verify_opc_uniqueness 強制檢查這些不變式:它讀取已儲存套件裡的 [Content_Types].xml 與每一個 .rels 項目,只要出現重複的 PartName、Extension 或 Id 就把該案例判為失敗。這個檢查很便宜、不需要 Excel,而且第一次跑 corpus 就會抓到三個缺陷裡的兩個

同一批:不是範圍,而是公式的列印區域

那一趟 Excel 驗證也標出了貸款範本的 _xlnm.Print_Area,Excel 在原檔上回報的是 $A$1:$J$29,而在存回的副本上必須回報一模一樣的值。那一個斷言背後躲著兩個彼此獨立的 bug。匯入時,XlsxStripSheetPrefix 會把第一個沒有被引號包住的 ! 之前全部切掉,所以像 OFFSET('Print Data'!$A$1,0,0,2,2) 這樣的動態列印區域回來變成 $A$1,0,0,2,2),而像 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 這樣的具名聯集只有第一段掉了前置詞。匯出時,寫入器把工作表名稱加在整份存下來的 PrintArea 前面一次,所以一個單純的聯集 $A$1:$B$2,$D$1:$E$2 離開函式庫時第一段帶著限定、第二段裸著,而根據 ECMA-376 Part 1 §18.2.5,Excel 不接受這種東西當 _xlnm.Print_Area 定義

// 匯入:只有在剩下的東西是單純 sqref 時才剝掉前置詞
function XlsxPrintAreaFromDefinition(const Formula: WideString): WideString;
begin
  Result:= Formula;
  if not XlsxReadFormulaSheetPrefixAt(Formula, 1, Prefix, SheetPart, Start) then
    Exit;
  Area:= Copy(Formula, Start, Length(Formula));
  if XlsxParseSqrefPart(Area, R1, C1, R2, C2) then
    Result:= Area;                    // 'Sheet'!$A$1:$J$29 -> $A$1:$J$29
end;                                  // OFFSET(...) 原樣回傳,不做處理

// 匯出:每個以逗號分隔的段落都要加限定,否則就全部不加
function XlsxPrintAreaDefinition(const SheetName, Area: WideString): WideString;
begin
  Result:= Area;
  ... split Area on ',' with StrictDelimiter ...
  for I:= 0 to Parts.Count- 1 do
    if not XlsxParseSqrefPart(WideString(Trim(Parts[I])), R1, C1, R2, C2) then
      Exit;                           // 是一條公式:逐字輸出
  Result:= '';
  for I:= 0 to Parts.Count- 1 do
  begin
    if I> 0 then Result:= Result+ ',';
    Result:= Result+ XlsxQuoteSheetName(SheetName)+ '!'+ WideString(Trim(Parts[I]));
  end;
end;

兩邊的配對規則相同:列印區域只有在每一段都解析成單純範圍時才算單純範圍,否則它就是一條公式、逐字傳遞。PrintArea_FormulaDefinitionSurvivesRoundTrip 涵蓋具名基底、帶工作表限定的基底,以及經過兩次存檔再重開循環的聯集。列印區域與頁面設定、以及列印模型其餘部分怎麼互動,見工作表保護、頁面設定與列印一文

怎麼找出 Excel 到底在抗議哪一條規則?

先假設您自己的驗證器是錯的,因為它放行了。Open XML SDK 的驗證器會指出像缺少 fontId 這樣的 schema 違規,連部件與 XPath 一起報,而它底下的封裝層根本拒絕打開任何帶著重複內容類型項目的套件,所以先跑它,別做別的。當它一聲不吭而 Excel 還是要修復時,就把套件切半:解壓、刪掉一個部件連同它的關聯與 Override、重新壓縮、重開,每次都把候選集合砍一半,直到提示消失為止。這裡的三個缺陷就是照這個順序掉出來的,而且它們都不會出現在 Excel 願意讓您存下的那份修復檔裡,因為修復會無聲地丟掉或重編那些有問題的項目。v2.382.5 修正的界線也值得一樣直白地講清楚。去重是先到先贏、模型排前面,所以如果來源套件對某個模型也會產生的部件宣告了不同的內容類型,模型那一份宣告勝出、來源那份被丟掉,這對 HotXLS 會重新產生的部件是對的,但它不是通用的合併策略。verify_opc_uniqueness 只檢查唯一性;它不驗 schema,所以未來某個新的必填屬性還是得靠 Excel 或 schema 驗證器才會浮現。而對產生出來的內容類型串流多做的那一趟 TXMLReader,在每次啟用 PreserveUnsupportedParts 的儲存時都會跑,這點小成本換來的是一條很少超過幾 KB 的串流。有了這些,貸款範本的 Win32 與 Win64 版本現在都能在 Excel 裡無提示開啟,把 4805 條已驗證公式全部重算、零筆不符,並回報與原檔相同的列印區域

如果您自己要用 Delphi 寫 XLSX,檢查清單很短:schema 標為必填的屬性一律輸出,不管值是什麼;每個部件名稱只宣告一次;而每個關聯部件都要在所有會動到它的寫入器之間共用同一份已用識別碼清單。如果您希望那份清單已經存在、而且是對著 Excel 測過、而不是只對著自己的讀取器測過,本文描述的套件寫入器隨 HotXLS Delphi 試算表元件一起出貨,連同那個讓這道接縫值得守護的不透明部件 round trip