LibreOffice 和所有自研阅读器都能毫无怨言打开的 XLSX,Excel 却弹出「我们发现某些内容有问题」,因为 Excel 强制执行了那些阅读器忽略的两件事:schema 要求的属性和 Open Packaging Conventions 的唯一性规则。HotXLS 是 Delphi 和 C++Builder 上的原生 Excel 电子表格组件,在 v2.382.5 里,它的输出第一次经过真正的 Excel COM 实例时正好撞上了这一点;三个原因分别是少了 fontId 的 <phoneticPr>、[Content_Types].xml 里重复的 Override,以及两条根关系共用 rId4
为什么别的阅读器都接受的包,Excel 却拒绝?
因为修复提示来自 schema 与包校验,而不是解析器失败。HotXLS 的语料库好几周来一直让一个 4805 条公式的贷款模板在库、LibreOffice 和测试套件里的 XML 校验器之间往返。保存出来的文件在 XLSX OPC 关系解析那篇所说的 OPC 意义上结构完好:每个部件都可到达,每个目标都可解析。然后一台装着 Excel 16.0 build 20326 的 Windows 机器可用了,语料运行器在关闭 DisplayAlerts 的隔离 COM 实例里通过 Workbooks.Open 打开保存好的模板,调用直接失败。手动操作时同一个文件会弹出那个熟悉的、提议修复的对话框,而修复日志(如果 Excel 愿意写的话)只会指出部件,不会指出规则。那一个提示背后藏着三个互不相关的缺陷,Excel 不会一个个报;它直接拒绝工作簿,剩下的靠你自己找到并分析。下面依次讲每条规则、HotXLS 违反它的那段代码,以及交付的修复——因为每一条都是任何 Delphi XLSX 写入器都可能绊倒的规则
规则一:phoneticPr fontId 是必填,即使值为零
<phoneticPr> 元素带一个 fontId 属性,ECMA-376 Part 1 §18.4.3 声明它是 use="required";而值 0 是合法的字体索引,不是「没有值」。旧的 HotXLS worksheet 写入器把零当成「未设置」,只在 Sheet.PhoneticFontId > 0 时才输出该属性。这对 Delphi 程序员是很自然的条件反射,因为整数字段默认为零——但对任何注音字体恰好是 styles.xml 里第一个字体的工作簿,它就产出 <phoneticPr type="noConversion"/>,而 HotXLS 语料里的贷款模板正是这样。于是 Excel 在回读时拒绝了一个它自己写出去的值
// lxHandleX.pas,worksheet 写入器——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 没有
规则二:[Content_Types].xml 里每个部件名只能有一个 Override
内容类型流里每个部件名最多只能声明一次,而 Excel 把同一 PartName 的第二个 Override 当作损坏,哪怕两条条目的 ContentType 完全一样。HotXLS 里有两个写入器往这个流里写东西。BuildContentTypesXml 声明对象模型生成的每个部件:workbook、styles、shared strings、theme、worksheets,以及当 TXLSXWorkbook.CustomProperties.Count > 0 时的 /docProps/custom.xml。当 PreserveUnsupportedParts 打开时,TXLSXOpaquePackage 又会为它从源包里逐字捕获的每个部件追加一条 Override,好让这些字节在写出去时仍被声明。冲突就出在同时属于两边的部件上。自定义文档属性会被解析进模型,但源包的 docProps/custom.xml 也被不透明地捕获了一份,于是合并后的流把它声明了两次;当模型重新生成某个部件、而不透明层也留着它时,图表和透视缓存部件也会落到同一个位置上。在 v2.382.5 之前,ContentTypeOverridesXml 根本看不到模型已经写了什么,所以无从知晓
<!-- 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;
规则三:关系 Id 在同一个关系部件内必须唯一
.rels 部件里的每条 Relationship 都需要一个在该部件内唯一的 Id,两条共用同一个时 Excel 会拒绝整个包。HotXLS 用固定标识符写包级的 _rels/.rels:workbook 用 rId1,核心与扩展文档属性用 rId2 和 rId3,模型里有自定义属性时用 rId4。随后不透明包会把它从源里保留下来的根关系追加进去,对已经出现在 UsedIds 列表里的标识符重新编号。那个列表知道 rId1 到 rId3,却不知道 rId4,也不知道模型马上要输出自己的自定义属性关系;于是当源包里自定义属性关系也叫 rId4(Excel 默认就是这么写的)时,结果就出现两条指向同一目标的 rId4。调用方 BuildRootRelsXml 现在把 Workbook.FCustomProps.Count > 0 当作第二个参数传进去,于是预留和跳过由同一个条件驱动——正是决定模型到底要不要写 rId4 的那个条件。在包根上重新编号是安全的,因为工作簿内部没有任何东西按名字引用根关系的标识符;同样的手法放到下一层就错了——workbook.xml 里的 r:id 属性绑定的是 workbook 关系部件里的标识符,这也是 MergeWorkbookRelationshipsXml 单独维护一份标识符映射的原因
// 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;
这三个失败有什么共同点?
三个都是同一种病的症状:写入器有两个来源,而包的各项不变式没有单一负责人。对象模型生成它理解的部件;不透明层重放它不理解的部件,好让往返保住图表、透视缓存、自定义 XML,以及主题、extLst 和 calcChain 的无损往返那些笔记里描述的一切。两边各自都是自洽的。OPC 加在整个包上的约束——Override 部件名唯一、每个部件内关系标识符唯一——只存在于两者被拼接起来的那道缝上,而在 v2.382.5 之前没人检查这道缝。fontId 那个 bug 是同一种形状下沉一层:写入器知道自己想省略什么,却从不去查那个说它不能省略的 schema。HotXLS 最终定下的是固定优先级而不是合并启发式:模型先写,不透明层看到已经写了什么,遇到冲突就让步;语料运行器现在从外部用 verify_opc_uniqueness 强制这些不变式——它读取保存后包里的 [Content_Types].xml 和每个 .rels 条目,只要出现重复的 PartName、Extension 或 Id 就让用例失败。这项检查开销很小,不需要 Excel,而且本来能在第一次语料运行时就抓住三个缺陷中的两个
同一批里还有:打印区域既是公式又不是区域
这一轮 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 电子表格组件里交付,连同那个让这道缝值得守护的不透明部件往返机制