技術記事

Excel は 1、LibreOffice は true:XLSX の真偽値

LibreOffice Calc が保存したブックを開くと、hidden="false" と書かれた列が隠れて返ってくることがあります。ファイルのどこも間違っていないのに、です。この属性の背後にある型 xsd:boolean では、true、false、1、0 がすべて合法で、前後の空白があってもなくても構いません。Delphi 用 HotXLS は、XLSX スキーマが真偽値を宣言している場所では、どの綴りも読みます

Excel が決して書かない xsd:boolean の半分

W3C XML Schema Part 2 の §3.2.2 は boolean を 4 つの合法リテラル、true、false、1、0 で定義し、whiteSpace ファセットを collapse に固定します。つまり customHeight=" 1 " は customHeight="1" と同じく正当です。同じ節は true と false を canonical form と名指します。SpreadsheetML はこれを狭めません。sml.xsd は customHeight、hidden、collapsed、showGridLines、wrapText、quotePrefix とその仲間数十個をただの xsd:boolean として宣言し、大半は false の明示的既定を持ち、showGridLines のような少数は既定が true です。Excel はたまたま非 canonical の数字しか書きません。LibreOffice と他のいくつかの作成者は単語を書きます。だから Calc 由来のパッケージは、Excel なら customHeight="1" と書いた場所に <sheetFormatPr defaultRowHeight="12.8" customHeight="true"/> を運びます

この非対称性ゆえに、1 文字の比較が OOXML リーダーの中で何年も生き延びます。テストコーパスを Excel の出力から作れば、目にする真偽値はすべて 0 か 1 で、比較は全試験を通ります。そして Calc のファイルがユーザーの手に届きます。リーダーの試験を支えるどんな XLSX コーパスにも、LibreOffice 保存のパッケージを最低数個は混ぜましょう。真偽値の綴りは 2 つの作成者が違う場所の 1 つで、ODS 経由で Excel と LibreOffice の間をブックが行き来するときに式や規則で顔を出すのと同じ溝です。データ型のもう 1 つの細部がパーサ設計に効きます。リテラルは大文字小文字を区別するので、TRUE はそもそも正当な xsd:boolean ではありません。自分のリーダーをどれだけ寛容にするかを決めるときに戻ってくる論点です

HotXLS の Delphi での xsd:boolean 属性パース。Excel は customHeight="1"、LibreOffice Calc は customHeight="true" と書き、どちらも true、false、1、0 の 4 リテラル語彙空間と whitespace collapse の内側。HotXLS の 1 つの変換で読む
Excel は数字しか出さず、LibreOffice Calc は単語を出します。それでも両方の綴りは、リーダーが丸ごと受け入れるべき 1 つの 4 リテラル型の中に座っています

1 文字の比較が取り違えるもの

属性を 1 つの数字と比較するリーダーは、どちらかの向きに失敗します。どちらの向きかは、比較がこっそり組み込んだ既定で決まります。表は手書きの OOXML コードに現れる形、それぞれが単語の綴りに対して何をするか、被害が見える属性をいくつか列挙します。ただし showDropDown は外してあります。この問題は綴りではなく名前のほうにあります。スキーマ既定は false なのに、リスト入力規則の showDropDown="1" はセル内矢印を隠すのです。そこで TXLSXDataValidation.ShowDropDown はユーザーから見た意味(True で矢印を表示)を出し、反転した OOXML フラグを格納します

素朴な試験暗黙の既定取り違え現れる場所
Value = '1'falsetrue が false になるsheetFormatPr 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 が t に切り詰められるChartML のデータテーブル罫線、系列の smooth

真ん中の行が最も見ていて痛い被害を作ります。可視列の幅を記録するとき、作成者が <col> 要素に hidden="false" と書くのはまったく普通のことで、ゼロ以外のテキストをすべて「設定あり」と扱うリーダーは、読み込みの時点でその列を隠してしまうからです。HotXLS はこれらの属性をすべて 1 つの xsd:boolean 変換へ回します。前後の空白を切り落とし、数字と単語の両方を受け入れ、それ以外には属性自身の既定を返します。最後の分岐には、明言する価値のある設計判断が込められています。yes のような値は不正形式のファイルです。それでも、1 つの壊れた表示フラグのためにブックを開くのを拒めば、ユーザーは作成者の過ちを罰せられます。リーダーは属性ごとの固定フォールバックを保って先へ進みます

HotXLS の Delphi での XLSX 真偽値属性リーダーと素朴な試験 Value = '1'、Value <> '0'、Value = '0'、Copy(Raw, P + 5, 1) の比較。customHeight、列 hidden、showGridLines、ChartML smooth についてそれぞれがどの単語綴りを取り違えるかを示す
1 文字の試験はどれも 1 と 0 には通りますが、単語の綴りのうちちょうど 1 つで失敗します。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 を保持

行のヒントは、スキーマ自身が不器用になる場所です。1 つの概念に 2 つの綴り。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 を省いていると、この探索は開始タグの終端を走り抜け、パート内で後から来る要素の値を借用します。2 つ隣の 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 の探索を要素自身の開始タグに閉じ込めれば、サイレントなチャート誤読が 3 つ一度に直ります。借用された値、見過ごされた単一引用符の値、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 の手早い走査など——には同じ 2 つの規則が効きます。語彙空間の全部で変換する、そして属性の探索を、その属性の属する要素の開始タグから外へ出さない。下の関数は両方をやります。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;
  // 名前はトークンの先頭でなければならない。別の属性名の末尾ではいけない
  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 として読めます。ある作成者が大文字で書いたからといってフラグを切るより、スキーマが禁じる綴りを受け入れるほうがましです。TRUE を false の意味で書く作成者はいないので。対照的にバリデータは厳密な分岐を保ち、値を報告すべきです。テキストスキャナは、ChartML スイッチには安全で、XML 一般には間違った手抜きを 2 つします。> は属性値の中でも合法なので、最初の 1 個で止まると、値にそれを含むタグを切り詰めかねません。また XML は = の前後の空白を許すのに、AttrName + '=' の探索はそれを見ません。ChartML のスイッチ値は > を含まない短いトークンで、等号の周りの空白を飛ばすのは 2 行の拡張です。しかしそれ以外の用途には、要素の属性を 1 個ずつ渡してくれる XML リーダーが両方の問題を取り除きます。HotXLS がワークシートパートに使っているのはそちらです

Calc のブックが、同じ Excel 版と違う隠し列や行の高さで読み込まれるときは、両方を展開してフラグの綴りを見ましょう。hidden="false" 対 hidden="0" は、たいてい 1 分で溝を説明してくれます。パディングあっても素でも、両方の綴りを 1 組のワークシート、セル、チャートのプロパティへ写像するのが、HotXLS Delphi spreadsheet component が読み込みのたびに行う仕事です