技术文章

Delphi解析器中的XLSX OPC关系解析

一个合法的xlsx文件完全可以不包含xl/worksheets/sheet1.xml。HotXLS是面向Delphi和C++Builder的原生Excel电子表格组件,它通过OPC关系图来定位每一个部件,而不是靠猜测名称,因为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,而对于位于包根目录的部件,这个folder就是_rels/。关系标记条款规定,Target是一个按普通RFC 3986意义、相对于源部件URI解析的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;

下游的一切都依赖于同一套机制。共享字符串、样式、主题、位于微软命名空间类型http://schemas.microsoft.com/office/2006/relationships/vbaProject下的VBA工程、外部链接、工作簿范围的person部件、旧版批注、线程化批注、承载批注气泡几何信息的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的布局,依然能被打开;这个回退机制是一项兼容性功能,不是第二个可信来源,而且它可能会在测试阶段掩盖生产者那边的bug。还有三点边界值得了解。标记为TargetMode="External"的目标会被原样保存,而不会被解析,这对超链接、以及携带远程工作簿URL的externalLinkPath关系来说是正确的,但这也意味着你拿到的值就是生产者原样写下的东西。通过绘图关系部件发现的图表部件,是按位置而不是按标识符与绘图锚点配对的,所以异常的锚点顺序可能会导致图表绑定错位。而lxDirectRead.pas中的流式直读器保留着它自己更轻量的路径处理逻辑,直接以xl/为键,所以这里描述的完整解析器管的是TXLSXWorkbook.OpenGetSheetNames这两个入口,而不是Delphi流式直读器一文所记录的那条低分配扫描路径

如果你打算自己实现这一套,最简短的正确总结是:永远不要构造一个部件名,永远去解析一个部件名。读取_rels/.rels,沿着officeDocument走下去,把每一个Target相对于声明它的那个部件解析出来,并按r:id来路由工作表。如果你更愿意用一个已经针对重命名部件、不连续工作表编号和重复关系标识符都测试过的现成实现,这里描述的解析器就在HotXLSDelphi电子表格组件中提供,随附能让它不解析的那些部件保持原封不动的往返读写机制