Technischer Artikel

Excel schreibt 1, LibreOffice schreibt true: XLSX-Booleans

Öffnet man eine Arbeitsmappe, die LibreOffice Calc gespeichert hat, kann eine Spalte, als hidden="false" geschrieben, versteckt zurückkommen, obwohl nichts in der Datei falsch ist. Unter xsd:boolean, dem Typ hinter diesem Attribut, sind true, false, 1 und 0 alle legal, mit oder ohne umgebende Leerzeichen; HotXLS für Delphi liest jede dieser Schreibweisen, wo immer ein XLSX-Schema ein Boolean deklariert

Die Hälfte von xsd:boolean, die Excel nie schreibt

W3C XML Schema Teil 2 §3.2.2 definiert boolean mit vier legalen Literalen, true, false, 1 und 0, und fixiert seine whiteSpace-Facette auf collapse, was bedeutet, dass customHeight=" 1 " genauso gültig ist wie customHeight="1". Derselbe Abschnitt nennt true und false als die kanonischen Formen. SpreadsheetML tut nichts, das einzuengen: sml.xsd deklariert customHeight, hidden, collapsed, showGridLines, wrapText, quotePrefix und Dutzende ihrer Nachbarn als plain xsd:boolean, die meisten mit explizitem Default false und einige, etwa showGridLines, mit Default true. Excel schreibt zufällig nur die nicht-kanonischen Ziffern. LibreOffice und manche andere Producer schreiben die Wörter, also trägt ein Paket von Calc ein <sheetFormatPr defaultRowHeight="12.8" customHeight="true"/>, wo Excel customHeight="1" geschrieben hätte

Diese Asymmetrie ist der Grund, warum Ein-Ziffern-Vergleiche jahrelang in OOXML-Readern überleben. Baut man ein Testkorpus aus Excel-Output, ist jeder Boolean, den man je sehen wird, ein 0 oder 1. Der Vergleich besteht jeden Test. Dann erreicht eine Calc-Datei einen Nutzer. Packen Sie mindestens ein paar von LibreOffice gespeicherte Pakete in jedes XLSX-Korpus, das Reader-Tests speist — seine Schreibweise von Booleans ist einer von mehreren Orten, an denen sich die beiden Producer unterscheiden, dieselbe Kluft, die in Formeln und Regeln auftaucht, wenn man Arbeitsmappen über ODS zwischen Excel und LibreOffice bewegt. Ein weiteres Detail des Datentyps zählt für das Parser-Design: Die Literale sind case sensitive, also ist TRUE überhaupt kein gültiges xsd:boolean, ein Punkt, der zurückkommt, wenn man entscheidet, wie nachsichtig der eigene Reader sein soll

HotXLS xsd:boolean-Attribut-Parsing in Delphi: Excel schreibt customHeight="1", während LibreOffice Calc customHeight="true" schreibt, beide innerhalb des Vier-Literal-lexikalischen Raums von true, false, 1 und 0 mit Whitespace-Collapse, gelesen durch eine einzige HotXLS-Konversion
Excel gibt nur je die Ziffern aus und LibreOffice Calc die Wörter, doch beide Schreibweisen sitzen in einem Vier-Literal-Datentyp, den ein Reader vollständig akzeptieren muss

Was ein Ein-Zeichen-Vergleich falsch macht

Ein Reader, der ein Attribut gegen eine einzelne Ziffer vergleicht, scheitert in die eine oder die andere Richtung. Welche Richtung, hängt vom Default ab, den der Vergleich stillschweigend einbaut. Die Tabelle listet die Gestalten, die in handgeschriebenem OOXML-Code auftauchen, was jede mit den Wort-Schreibweisen macht, und einige Attribute, an denen der Schaden sichtbar wird. Sie lässt showDropDown aus, dessen Problem sein Name ist und nicht seine Schreibweise: Der Schema-Default ist false, doch showDropDown="1" auf einer Listen-Validation versteckt den In-Cell-Pfeil, also exponiert TXLSXDataValidation.ShowDropDown die nutzerseitige Bedeutung (True zeigt den Pfeil) und speichert das invertierte OOXML-Flag

Naiver TestStillschweigender DefaultMissdeutetWo es sich zeigt
Value = '1'falsetrue wird falsesheetFormatPr customHeight, das <v> einer t="b"-Zelle
Value <> '0'true, sobald vorhandenfalse wird trueSpalten-hidden, definedName hidden, alignment wrapText
Value = '0'truefalse wird ignoriert, der Default bleibt ansheetView showGridLines, sheetProtection-Permission-Schalter
Copy(Raw, P + 5, 1) = '1'falsetrue wird auf t gekapptChartML-Datentabellen-Ränder, Serien-smooth

Die mittlere Zeile produziert den sichtbarsten Schaden, denn hidden="false" auf einem <col>-Element ist eine völlig normale Sache für einen Producer, der eine Breite für eine sichtbare Spalte aufzeichnet, und ein Reader, der jeden Nicht-null-Text als gesetzt behandelt, versteckt diese Spalte beim Laden. HotXLS führt jedes dieser Attribute durch eine einzige xsd:boolean-Konversion, die umgebende Leerzeichen trimmt, die Ziffern und die Wörter akzeptiert und für alles andere den eigenen Default des Attributs zurückgibt. Dieser letzte Zweig spiegelt eine Design-Entscheidung, die man aussprechen sollte: Ein Wert wie yes ist eine malformed Datei — aber eine Arbeitsmappe wegen eines schlechten Sichtbarkeits-Flags nicht zu öffnen würde den Nutzer für den Fehler des Producers bestrafen, also behält der Reader einen festen Fallback pro Attribut und macht weiter

HotXLS XLSX-Boolean-Attribut-Reader verglichen mit naiven Tests Value = '1', Value <> '0', Value = '0' und Copy(Raw, P + 5, 1) in Delphi, zeigt, welche Wort-Schreibweisen jeder für customHeight, Spalten-hidden, showGridLines und ChartML-smooth missdeutet
Jeder Ein-Zeichen-Test besteht auf 1 und 0 und scheitert an genau einer Wort-Schreibweise, weshalb ein nur-Excel-Testkorpus ihn nie ertappt

Ein LibreOffice-artiges Worksheet mit HotXLS lesen

Die Flags auf Worksheet-Ebene erscheinen als gewöhnliche Properties auf TXLSXWorksheet: CustomHeight, ThickTop und ThickBottom kommen aus <sheetFormatPr>, während ColHidden[], RowHidden[], RowThickTop[] und RowThickBottom[] 1-basierte Spalten- und Zeilennummern nehmen. Eine Boolean-Zelle kommt als Boolean-Variante in TXLSXCell.Value an, welche Schreibweise das <v>-Element auch benutzt hat. Die Routine unten druckt sie alle für das erste Sheet, und die Liste danach gibt die erwartete Ausgabe für Calc-artiges Markup

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];   // der Sheets-Indexer ist 1-basiert

    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 druckt TRUE
  • <col min="3" max="3" width="20" hidden="false"/>: Spalte 3 wird nicht gelistet
  • <row r="5" thickBot="true">: Zeile 5 meldet thickBot=TRUE
  • <c r="A2" t="b"><v>true</v></c>: A2 hält TRUE

Der Zeilen-Hinweis ist der Ort, an dem das Schema selbst unbeholfen wird. Eine Idee, zwei Schreibweisen — CT_SheetFormatPr nennt seine Attribute thickTop und thickBottom, während CT_Row thickTop und thickBot benutzt, also findet ein Reader, der auf einer Zeile nach thickBottom sucht, nichts. HotXLS schreibt jede Form auf ihrem eigenen Element zurück, und die Hinweise überleben ein Speichern zusammen mit dem Rest des Pakets, ganz wie unberührte Teile in einem verlustfreien XLSX-Round-Trip

Wie soll ein Chart ein Element ohne val lesen?

Ein ChartML-Switch ohne val-Attribut ist an. DrawingML Charts deklariert diese Switches mit CT_Boolean in dml-chart.xsd, dessen val ein optionales xsd:boolean mit Default true ist, also bedeuten <c:plotVisOnly/> und <c:showHorzBorder/> dasselbe wie ein val="1". Dieser Default ist das Gegenteil der meisten SpreadsheetML-Attribute, und er bricht den verbreiteten Shortcut, Chart-XML als Text zu lesen: das Element beim Namen finden, dann vorwärts nach dem nächsten val=" suchen. Lässt das Element val weg, läuft diese Suche über das Ende des Start-Tags hinaus und leiht sich den Wert von dem Element, das später im Part kommt, vielleicht ein val="0" zwei Geschwister weiter. Treffs nichts, gibt PosEx 0 zurück, und Code, der bei P + 5 schneidet, liest am Ende ab Zeichen 5 des Parts, was das l von <?xml ist. Keiner der Fehler wirft irgendetwas; die Chart-Property widerspricht nur der Datei

Quotes sind die andere Hälfte des Problems. XML 1.0 erlaubt, einen Attributwert mit doppelten oder einfachen Quotes zu begrenzen (die AttValue-Produktion in §2.3), also ist <c:majorTickMark val='out'/> genauso legitim wie die doppelt-quotierte Form, und eine Suche nach val=" überspringt sie komplett. HotXLS bewahrt das originale ChartML auf, damit es Excel-Charts bearbeiten kann, ohne Markup zu verlieren, das es nicht modelliert, und die aus diesem Text gelesenen Switches werden nur innerhalb des eigenen Start-Tags des Elements gesucht, mit beiden Quote-Stilen akzeptiert und einem fehlenden val, das als CT_Boolean-Default gelesen wird. Die öffentlichen Chart-Properties spiegeln dieses Lesen

HotXLS ChartML CT_Boolean-Switch-Lesen in Delphi: eine vorwärts gerichtete val="-Suche leiht sich ein späteres val="0", verpasst val='out' und schneidet <?xml, wenn PosEx 0 zurückgibt, während HotXLS für PlotVisibleOnly und ShowDataTableHorzBorder nur den Start-Tag liest
Die val-Suche auf den eigenen Start-Tag des Elements zu begrenzen behebt drei stille Chart-Missdeutungen auf einmal: einen geliehenen Wert, einen übersprungenen einfach-quotierten Wert und einen Schnitt ab der XML-Deklaration
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 ist 0-basiert
  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;

Erwartet für einen Chart-Part, der ein <c:plotVisOnly/> enthält, später gefolgt von einem Element mit val="0", einer Datentabelle mit <c:showHorzBorder/><c:showVertBorder val="0"/>, und einer Wertachse mit <c:majorTickMark val='out'/>: PlotVisibleOnly und ShowDataTableHorzBorder sind beide TRUE, und das Tick-Mark druckt out

Ein begrenzter xsd:boolean-Reader für Ihren eigenen OOXML-Code

Wenn Sie ein Paket selbst nachverarbeiten, etwa einen Custom-Part, den Ihre Anwendung hinzufügt, oder einen schnellen Scan des Chart-XMLs in einem Build-Schritt, gelten dieselben zwei Regeln: mit dem vollen lexikalischen Raum konvertieren, und eine Attributsuche nie das Start-Tag ihres Elements verlassen lassen. Die Funktionen unten tun beides. ChartSwitch erwartet, dass TagStart auf das < des Elements zeigt, und wendet den CT_Boolean-Default an, wenn val fehlt

uses
  SysUtils, StrUtils;

function TryParseXsdBoolean(const Literal: string; out Value: Boolean): Boolean;
var
  S: string;
begin
  S := Trim(Literal);            // whiteSpace=collapse: Padding ist legal
  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: kein 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;
  // der Name muss ein Token eröffnen, nicht einen anderen Attributnamen beenden
  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: ein fehlendes val bedeutet true
  Result := ReadXsdBoolean(V, True);
end;

Der strenge Zweig in TryParseXsdBoolean folgt dem Datentyp bis aufs Wort, und dort gehen Ihr Reader und HotXLS etwas auseinander: Die HotXLS-Konversion vergleicht die Wörter ohne Blick auf die Schreibweise, also liest TRUE als true. Wir akzeptieren lieber eine Schreibweise, die das Schema verbietet, als ein Flag abzuschalten, weil irgendein Producer es groß geschrieben hat, denn kein Producer schreibt TRUE, um false zu meinen; ein Validator dagegen sollte beim strengen Zweig bleiben und den Wert melden. Der Text-Scanner nimmt außerdem zwei Abkürzungen, die für ChartML-Switches sicher sind und für XML im Allgemeinen falsch. Ein > ist innerhalb eines Attributwerts legal, also kann das Anhalten am frühesten einen Tag kaputtschneiden, dessen Werte ihn enthalten, und XML erlaubt Whitespace um das =, was die Suche nach AttrName + '=' nicht sieht. ChartML-Switch-Werte sind kurze Tokens ohne > darin, und Leerzeichen um das Gleichheitszeichen zu überspringen ist eine Zwei-Zeilen-Erweiterung, aber für alles jenseits einer Handvoll bekannter Switches entfernt ein XML-Reader, der Ihnen die Attribute eines Elements nach dem anderen liefert, beide Probleme, und er ist es, was HotXLS für Worksheet-Parts benutzt

Lädt eine Calc-Arbeitsmappe mit anderen versteckten Spalten oder Zeilenhöhen als ihr Excel-Zwilling, entpacken Sie beide und sehen Sie nach, wie die Flags geschrieben sind; hidden="false" gegen hidden="0" erklärt die Kluft meist innerhalb einer Minute. Beide Schreibweisen, gepolstert oder nackt, auf einen Satz von Worksheet-, Zell- und Chart-Properties abzubilden ist die Arbeit, die die HotXLS Delphi Spreadsheet-Komponente bei jedem Laden leistet