打開一份 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,等您在決定自己的讀取器要多寬鬆時,這點還會回來找您
只比一個字元的寫法錯在哪裡
把屬性拿去跟單一數字相比的讀取器,一定會在其中一個方向上出錯,哪個方向取決於這個比對悄悄寫進去的預設值。下表列出在手工撰寫的 OOXML 程式碼裡會出現的幾種寫法、各自對單字拼法做了什麼,以及幾個會露出損害的屬性。表裡沒放 showDropDown,它的問題在名字而不是拼法:schema 的預設是 false,可是清單驗證上的 showDropDown="1" 會把儲存格內的下拉箭頭藏起來,所以 TXLSXDataValidation.ShowDropDown 暴露的是使用者看得到的語意(True 代表顯示箭頭),存的則是反轉後的 OOXML 旗標
| 天真的比法 | 隱含的預設值 | 誤讀成 | 出錯的地方 |
|---|---|---|---|
Value = '1' | false | true 變成 false | sheetFormatPr customHeight,以及 t="b" 儲存格的 <v> |
Value <> '0' | 一旦出現就當 true | false 變成 true | 欄的 hidden、definedName hidden、alignment wrapText |
Value = '0' | true | false 被忽略,預設值繼續生效 | sheetView showGridLines、sheetProtection 的權限開關 |
Copy(Raw, P + 5, 1) = '1' | false | true 被截成 t | ChartML 資料表框線、數列的 smooth |
中間那一列造成的損害最明顯,因為產生器在為可見欄記錄寬度時,在 <col> 元素上寫 hidden="false" 是很正常的事,而把任何非零文字都當成已設定的讀取器,載入時就把它藏起來了。HotXLS 讓這些屬性全都走同一個 xsd:boolean 轉換:去掉前後空白、接受數字與單字,其餘一律回傳該屬性自己的預設值。最後那個分支反映的是一個值得說清楚的設計選擇:yes 這種值算是畸形檔案——但為了一個壞掉的顯示旗標就拒絕開啟整本活頁簿,等於拿產生器的錯來罰使用者,所以讀取器保留每個屬性固定的後備值,繼續往下走
用 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 的預設值。公開的圖表屬性反映的就是這種讀法
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 試算表元件每次載入時做的事