技术文章

Excel 写 1,LibreOffice 写 true:XLSX 布尔值

打开一份 LibreOffice Calc 保存的工作簿,一个写着 hidden="false" 的列可能还是隐的,尽管文件里没有一点错。那个属性背后的类型 xsd:boolean 里,true、false、1 和 0 四个都合法,前后带不带空白也一样;只要 XLSX schema 声明了布尔,HotXLS for Delphi 就能读出这四种拼写中的任何一种

xsd:boolean 里 Excel 从不写的那一半

W3C XML Schema Part 2 §3.2.2 给 boolean 定义了四个合法字面量——true、false、1 和 0——并把它的 whiteSpace 面向定死为 collapse,意思是 customHeight=" 1 " 和 customHeight="1" 一样合法。同一节还指名 true 与 false 是规范形式。SpreadsheetML 一点也没收窄:sml.xsd 把 customHeight、hidden、collapsed、showGridLines、wrapText、quotePrefix 以及几十个邻居都声明成朴素的 xsd:boolean,大多显式默认 false,少数如 showGridLines 默认为 true。偏偏 Excel 只写那个非规范的数字形式。LibreOffice 和另一些生成器写的是单词,所以 Calc 出来的包里是 <sheetFormatPr defaultRowHeight="12.8" customHeight="true"/>,而 Excel 会写成 customHeight="1"

这种不对称,正是单字符比较能在 OOXML 阅读器里活好多年的原因。拿 Excel 的输出攒一份测试语料,你能见到的每个布尔都是 0 或 1,比较逻辑通过所有测试。然后某个用户递来一份 Calc 文件。给任何驱动阅读器测试的 XLSX 语料里都塞几份 LibreOffice 保存的包——它对布尔的拼写只是两家生成器分歧的若干处之一,同一个裂口也出现在公式和规则上,就是经由 ODS 在 Excel 与 LibreOffice 之间搬工作簿时撞到的那个。这个数据类型还有一个细节影响解析器设计:字面量区分大小写,所以 TRUE 压根不是合法的 xsd:boolean——等你决定自己的阅读器该宽松到什么程度时,这一点又会冒出来

HotXLS 在 Delphi 里解析 xsd:boolean 属性:Excel 写 customHeight="1",LibreOffice Calc 写 customHeight="true",两者都落在 true、false、1 与 0 这四个字面量构成的空间里、空白按 collapse 处理,由 HotXLS 同一套转换读取
Excel 只发数字,LibreOffice Calc 只发单词,可两种拼写都住在同一个四字面量数据类型里,读的人必须整套接受

单字符比较错在哪儿

拿单个数字去比属性的阅读器,不是往这一边错就是往那一边错,往哪边取决于那次比较悄悄编码进去的默认值。下表列出在手写 OOXML 代码里出没的几种写法、每种对单词拼写的后果,以及几个能看出损伤的属性。表里没放 showDropDown,它的问题出在名字而不是拼写上:schema 默认是 false,可列表验证上的 showDropDown="1" 会让单元格内的箭头消失,所以 TXLSXDataValidation.ShowDropDown 暴露的是面向使用者的含义(True 表示显示箭头),内部存的是反过来的 OOXML 标志

天真的写法隐含默认误读什么在哪儿显形
Value = '1'falsetrue 变成 falsesheetFormatPr customHeight、t="b" 单元格的 <v>
Value <> '0'只要属性存在就为 truefalse 变成 true列 hidden、definedName hidden、alignment wrapText
Value = '0'truefalse 被忽略,默认值继续生效sheetView showGridLines、sheetProtection 的权限开关
Copy(Raw, P + 5, 1) = '1'falsetrue 被截成 tChartML 数据表边框、系列的 smooth

中间那一行造成的损伤最显眼:当生成器为某个可见列记录宽度时,在 <col> 元素上写 hidden="false" 完全正常,而一个把任何非零文本都当“已设置”的阅读器,加载时就把这一列藏起来了。HotXLS 把这些属性统统送进同一次 xsd:boolean 转换:裁掉两端空白,接受数字也接受单词,遇到别的值则返回该属性自己的默认值。最后这条分支体现了一个值得说明的设计选择:yes 这样的值属于畸形文件——但因为一个坏掉的可见性标志就拒绝打开工作簿,等于拿生成器的错误去罚用户,所以阅读器保留一份按属性固定的回退值,接着往下走

HotXLS 的 XLSX 布尔属性读取器与天真的 Value = '1'、Value <> '0'、Value = '0' 和 Copy(Raw, P + 5, 1) 在 Delphi 里的对比,显示各自对 customHeight、列 hidden、showGridLines 与 ChartML smooth 会误读哪种单词拼写
每一个单字符测试在 1 和 0 上都过,并且恰好在一种单词拼写上失败,所以只用 Excel 攒的测试语料永远抓不到它

用 HotXLS 读一份 LibreOffice 风格的工作表

工作表层面的标志作为 TXLSXWorksheet 上的普通属性露出来:CustomHeight、ThickTop 和 ThickBottom 来自 <sheetFormatPr>,而 ColHidden[]、RowHidden[]、RowThickTop[] 和 RowThickBottom[] 取从 1 开始的列号与行号。布尔单元格在 TXLSXCell.Value 里以 Boolean 类型的 variant 到达,不管 <v> 元素当初用的是哪种拼写。下面这段例程把第一个表的这些全打出来,再后面的列表给出 Calc 风格标记的预期输出

uses
  SysUtils, Variants, lxHandleX;

procedure DumpSheetBooleans(const FileName: string);
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Flag: Variant;
  Col, Row: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.Open(FileName) <> 1 then
      raise Exception.CreateFmt('Cannot open %s', [FileName]);
    if Book.Sheets.Count = 0 then
      raise Exception.Create('Workbook has no sheets');
    Sheet := Book.Sheets[1];   // Sheets 索引器从 1 开始

    Writeln('Default row height is custom: ', Sheet.CustomHeight);
    Writeln('Rows default to thick top / bottom: ',
      Sheet.ThickTop, ' / ', Sheet.ThickBottom);

    for Col := 1 to 8 do
      if Sheet.ColHidden[Col] then
        Writeln('Column ', Col, ' is hidden');

    for Row := 1 to 20 do
      if Sheet.RowHidden[Row] or Sheet.RowThickTop[Row] or
         Sheet.RowThickBottom[Row] then
        Writeln('Row ', Row, ': hidden=', Sheet.RowHidden[Row],
          ' thickTop=', Sheet.RowThickTop[Row],
          ' thickBot=', Sheet.RowThickBottom[Row]);

    Flag := Sheet.Cells[2, 1].Value;
    if VarType(Flag) = varBoolean then
      Writeln('A2 holds the boolean ', Boolean(Flag));
  finally
    Book.Free;
  end;
end;
  • <sheetFormatPr defaultRowHeight="12.8" customHeight="true"/>:CustomHeight 打出 TRUE
  • <col min="3" max="3" width="20" hidden="false"/>:第 3 列不在列表里
  • <row r="5" thickBot="true">:第 5 行报告 thickBot=TRUE
  • <c r="A2" t="b"><v>true</v></c>:A2 装着 TRUE

行提示这一处,schema 自己就有点别扭。同一个想法两种拼写——CT_SheetFormatPr 把属性叫 thickTop 和 thickBottom,而 CT_Row 用的是 thickTop 和 thickBot,所以到行上找 thickBottom 的阅读器什么也找不到。HotXLS 把每种形式写回它自己的元素,这些提示连同包里的其余内容一起熬过一次保存,和无损 XLSX 往返里那些没被碰过的部件差不多

图表遇到没有 val 的元素该怎么读

没有 val 属性的 ChartML 开关就是打开的。DrawingML Charts 在 dml-chart.xsd 里用 CT_Boolean 声明这些开关,其 val 是可选的 xsd:boolean,默认 true,所以 <c:plotVisOnly/> 和 <c:showHorzBorder/> 与写明 val="1" 等价。这个默认值与大多数 SpreadsheetML 属性相反,也把“把图表 XML 当文本读”那条常见捷径打坏了:先按名字找到元素,再往后搜 val="。元素省略 val 时,这次搜索会冲过开始标签的末尾,借用这个部件里后面某个元素的值,说不定是两个兄弟之后那个 val="0"。什么都匹配不上时 PosEx 返回 0,于是按 P + 5 切片的代码就从部件的第 5 个字符开始读,那是 <?xml 里的 l。这两种失败都不抛异常,图表属性只是和文件说不到一块去

引号是问题的另一半。XML 1.0 允许属性值用双引号或单引号定界(§2.3 的 AttValue 产生式),所以 <c:majorTickMark val='out'/> 和双引号形式一样合法,而搜 val=" 会把它整个跳过。HotXLS 保留原始 ChartML,才能在不丢失它没有建模的标记的前提下修改 Excel 图表;从那段文本读出的开关只在元素自己的开始标签里查找,两种引号都接受,缺了 val 就按 CT_Boolean 的默认值读。公开的图表属性反映的就是这种读法

HotXLS 在 Delphi 里读 ChartML 的 CT_Boolean 开关:往后找 val=" 会借到后面某个 val="0",会漏掉 val='out',PosEx 返回 0 时还会从 <?xml 处切片;HotXLS 只为 PlotVisibleOnly 与 ShowDataTableHorzBorder 读开始标签
把 val 查找限制在元素自己的开始标签内,一次修掉三种静默的图表误读:借来的值、被跳过的单引号值,以及从 XML 声明处切出来的片段
procedure DumpChartSwitches(Sheet: TXLSXWorksheet);
const
  TickNames: array[TXLSXChartTickMark] of string =
    ('none', 'in', 'out', 'cross');
var
  I: Integer;
  Chart: TXLSXChart;
begin
  for I := 0 to Sheet.Charts.Count - 1 do   // Charts 从 0 开始
  begin
    Chart := Sheet.Charts[I];
    Writeln(Chart.Name, ': plotVisOnly=', Chart.PlotVisibleOnly,
      ' dTable showHorzBorder=', Chart.ShowDataTableHorzBorder);
    if Chart.ValueAxis <> nil then
      Writeln('  value axis majorTickMark=',
        TickNames[Chart.ValueAxis.MajorTickMark]);
    if Chart.SeriesCount > 0 then
      Writeln('  first series smooth=', Chart.Series[0].Smooth);
  end;
end;

对一个图表部件,其中 <c:plotVisOnly/> 后面跟一个带 val="0" 的元素,数据表是 <c:showHorzBorder/><c:showVertBorder val="0"/>,值轴是 <c:majorTickMark val='out'/>:预期 PlotVisibleOnly 与 ShowDataTableHorzBorder 都为 TRUE,刻度标记打出 out

给你自己的 OOXML 代码一个有边界的 xsd:boolean 读取器

你自己后处理包时——比如应用额外加的自定义部件,或构建脚本里对图表 XML 的快速扫描——同样两条规则:按完整字面量空间转换,并且绝不让属性查找跑出它所属元素的开始标签。下面几个函数都做到了。ChartSwitch 要求 TagStart 指向元素的 <,val 缺失时套用 CT_Boolean 的默认值

uses
  SysUtils, StrUtils;

function TryParseXsdBoolean(const Literal: string; out Value: Boolean): Boolean;
var
  S: string;
begin
  S := Trim(Literal);            // whiteSpace=collapse:两端留白合法
  Result := True;
  if (S = '1') or (S = 'true') then
    Value := True
  else if (S = '0') or (S = 'false') then
    Value := False
  else
    Result := False;             // TRUE、yes、on:不是 xsd:boolean
end;

function ReadXsdBoolean(const Literal: string; Default: Boolean): Boolean;
begin
  if not TryParseXsdBoolean(Literal, Result) then
    Result := Default;
end;

function AttrInStartTag(const Xml: string; TagStart: Integer;
  const AttrName: string; out Value: string): Boolean;
var
  TagEnd, P, Q: Integer;
  Quote: Char;
begin
  Result := False;
  Value := '';
  TagEnd := PosEx('>', Xml, TagStart);
  if TagEnd = 0 then
    Exit;
  // 名字必须是一个 token 的开头,不能是另一个属性名的结尾
  P := PosEx(AttrName + '=', Xml, TagStart);
  while (P > 0) and (P < TagEnd) and
        not CharInSet(Xml[P - 1], [' ', #9, #10, #13]) do
    P := PosEx(AttrName + '=', Xml, P + 1);
  if (P = 0) or (P >= TagEnd) then
    Exit;
  Q := P + Length(AttrName) + 1;
  Quote := Xml[Q];
  if (Quote <> '"') and (Quote <> '''') then
    Exit;
  P := PosEx(Quote, Xml, Q + 1);
  if (P = 0) or (P > TagEnd) then
    Exit;
  Value := Copy(Xml, Q + 1, P - Q - 1);
  Result := True;
end;

function ChartSwitch(const Xml: string; TagStart: Integer): Boolean;
var
  V: string;
begin
  if not AttrInStartTag(Xml, TagStart, 'val', V) then
    Exit(True);                  // CT_Boolean:val 缺失即 true
  Result := ReadXsdBoolean(V, True);
end;

TryParseXsdBoolean 里那条严格分支是照数据类型逐字执行的,你的阅读器和 HotXLS 就在这里略有分歧:HotXLS 的转换比较单词时不看大小写,所以 TRUE 读成 true。我们宁可接受一个 schema 禁止的拼写,也不愿因为某个生成器把它大写了就把标志关掉,毕竟没有哪个生成器会用 TRUE 表示假;而验证器相反,应当保留严格分支并把这个值报出来。这个文本扫描器还有两处走捷径,对 ChartML 开关安全,对一般 XML 则是错的。> 在属性值里是合法的,所以停在最早的那个可能截断一个值里含它的标签;XML 允许 = 两边有空白,而搜 AttrName + '=' 看不见。ChartML 开关的值都是不含 > 的短 token,跳过等号两边的空白也只是两行扩展;但超出少数几个已知开关之后,一个一次只交给你单个元素属性的 XML 阅读器能把两个问题一起消掉,HotXLS 处理工作表部件用的就是它

当一份 Calc 工作簿加载出来的隐藏列或行高和它的 Excel 孪生版不一样时,把两个都解压开,看看标志是怎么拼的;hidden="false" 对 hidden="0" 通常一分钟内就能解释差异。把两种拼写、带空白或不带空白,都映射到同一套工作表、单元格与图表属性上,就是HotXLS Delphi 电子表格组件每次加载都在干的活