技術文章

Excel 寫 1、LibreOffice 寫 true:XLSX 布林值

打開一份 LibreOffice Calc 存出來的活頁簿,某個寫成 hidden="false" 的欄位回來卻是隱藏的,而檔案本身一點錯都沒有。那個屬性背後的型別 xsd:boolean 允許 true、false、1 與 0,前後帶不帶空白都算;HotXLS for Delphi 只要 XLSX schema 宣告某處是布林值,這四種寫法全都讀得懂

xsd:boolean 裡 Excel 從不寫的那一半

W3C XML Schema Part 2 §3.2.2 定義 boolean 有四個合法字面值:true、false、1 與 0,並把它的 whiteSpace facet 定在 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 之間搬移活頁簿時那樣。這個 datatype 還有一個細節會影響解析器設計:字面值有分大小寫,所以 TRUE 根本不是合法的 xsd:boolean,等您在決定自己的讀取器要多寬鬆時,這點還會回來找您

Delphi 中 HotXLS 的 xsd:boolean 屬性解析:Excel 寫 customHeight="1",LibreOffice Calc 寫 customHeight="true",兩者都落在 true、false、1 與 0 四個字面值加空白塌陷的詞法空間內,由同一套 HotXLS 轉換讀取
Excel 只吐數字,LibreOffice Calc 吐單字,但兩種拼法都落在同一個四字面值 datatype 裡,讀取器必須完整接受

只比一個字元的寫法錯在哪裡

把屬性拿去跟單一數字相比的讀取器,一定會在其中一個方向上出錯,哪個方向取決於這個比對悄悄寫進去的預設值。下表列出在手工撰寫的 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 這種值算是畸形檔案——但為了一個壞掉的顯示旗標就拒絕開啟整本活頁簿,等於拿產生器的錯來罰使用者,所以讀取器保留每個屬性固定的後備值,繼續往下走

Delphi 中 HotXLS XLSX 布林屬性讀取與天真比法的對照:Value = '1'、Value <> '0'、Value = '0' 與 Copy(Raw, P + 5, 1) 各會誤讀哪一種單字拼法,涵蓋 customHeight、欄的 hidden、showGridLines 與 ChartML smooth
每種一字元比法對 1 與 0 都通過,只在一種單字拼法上失敗,這就是只用 Excel 語料測不出來的原因

用 HotXLS 讀 LibreOffice 風格的工作表

工作表層級的旗標會以 TXLSXWorksheet 上的普通屬性出現:CustomHeight、ThickTop 與 ThickBottom 來自 <sheetFormatPr>,而 ColHidden[]、RowHidden[]、RowThickTop[] 與 RowThickBottom[] 吃的是從 1 起算的欄號與列號。布林儲存格會以 Boolean variant 抵達 TXLSXCell.Value,不論 <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 的元素該怎麼讀?

ChartML 裡沒有 val 屬性的開關就是開著的。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 的預設值。公開的圖表屬性反映的就是這種讀法

Delphi 中 HotXLS 讀 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 裡嚴格的那個分支完全照 datatype 的字面走,您自己的讀取器跟 HotXLS 就是在此處稍有分歧:HotXLS 的轉換比對單字時不分大小寫,所以 TRUE 讀成 true。我們寧可接受 schema 禁止的拼法,也不願意因為某個產生器把它大寫就關掉一個旗標,畢竟沒有哪個產生器會用 TRUE 表示 false;反過來,驗證器就該保留嚴格分支並回報這個值。文字掃描器另外走了兩條捷徑,對 ChartML 開關安全,對一般 XML 則不成立。> 在屬性值裡是合法的,遇到第一個就停會截斷值裡真的含這個字元的標籤;而 XML 允許 = 周圍有空白,AttrName + '=' 的搜尋看不到這種寫法。ChartML 開關的值都是不含 > 的短 token,處理等號兩側空白也只是兩行的事,但只要超出一小把已知開關,用一個一次交出單一元素屬性的 XML 讀取器就能同時消掉這兩個問題,那也是 HotXLS 處理工作表部件時用的做法

當一份 Calc 活頁簿載入後,隱藏欄或列高跟它的 Excel 雙胞胎對不上,就把兩邊都解壓開,看看旗標怎麼拼;hidden="false" 對上 hidden="0" 通常一分鐘內就能解釋這個落差。把兩種拼法、帶空白或不帶空白,全都映射到同一組工作表、儲存格與圖表屬性上,就是 HotXLS Delphi 試算表元件每次載入時做的事