Artykuł techniczny

Excel pisze 1, LibreOffice pisze true: booleany w XLSX

Otwórz skoroszyt zapisany przez LibreOffice Calc, a kolumna zapisana jako hidden="false" może wrócić ukryta, choć w pliku nie ma nic złego. Pod xsd:boolean — typem stojącym za tym atrybutem — true, false, 1 i 0 są wszystkie legalne, z otaczającymi białymi znakami albo bez; HotXLS dla Delphi czyta każdą z tych pisowni wszędzie tam, gdzie schemat XLSX deklaruje boolean

Ta połowa xsd:boolean, której Excel nigdy nie zapisuje

W3C XML Schema Part 2 §3.2.2 definiuje boolean z czterema legalnymi literałami — true, false, 1 i 0 — i przybija jego fasetę whiteSpace na collapse, czyli customHeight=" 1 " jest równie poprawne jak customHeight="1". Ta sama sekcja nazywa true i false formami kanonicznymi. SpreadsheetML nic tego nie zawęża: sml.xsd deklaruje customHeight, hidden, collapsed, showGridLines, wrapText, quotePrefix i dziesiątki ich sąsiadów jako gołe xsd:boolean, większość z jawnym domyślnym false, a kilka — jak showGridLines — z domyślnym true. Excel z przypadku zapisuje wyłącznie cyfry niekanoniczne. LibreOffice i niektórzy inni producenci zapisują słowa, więc pakiet z Calca niesie <sheetFormatPr defaultRowHeight="12.8" customHeight="true"/> tam, gdzie Excel napisałby customHeight="1"

Ta asymetria jest powodem, dla którego porównania po jednej cyfrze przeżywają latami w czytnikach OOXML. Zbuduj korpus testowy z wyjścia Excela, a każdy boolean, jakiego kiedykolwiek zobaczysz, to 0 albo 1. Porównanie przechodzi każdy test. Potem plik Calca trafia do użytkownika. Włóż przynajmniej kilka pakietów zapisanych przez LibreOffice do każdego korpusu XLSX, który napędza testy czytnika — jego pisownia booleanów to jedno z kilku miejsc, gdzie dwaj producenci się różnią, ta sama przepaść, co wychodzi w formułach i regułach, gdy przenosisz skoroszyty między Excel a LibreOffice przez ODS. Jeden szczegół tego typu danych ma znaczenie dla projektu parsera: literały rozróżniają wielkość liter, więc TRUE w ogóle nie jest poprawnym xsd:boolean — punkt, który wraca, gdy decydujesz, jak pobłażliwy ma być twój własny czytnik

Parsowanie atrybutów xsd:boolean w Delphi z HotXLS: Excel zapisuje customHeight="1", a LibreOffice Calc zapisuje customHeight="true", oba wewnątrz czteroliterałowej przestrzeni leksykalnej true, false, 1 i 0 z zapadem białych znaków, czytane przez jedną konwersję HotXLS
Excel wysyła wyłącznie cyfry, a LibreOffice Calc wysyła słowa, i obie pisownie siedzą wewnątrz jednego czteroliterałowego typu danych, który czytnik musi przyjmować w całości

Na czym myli się porównanie po jednym znaku

Czytnik porównujący atrybut z pojedynczą cyfrą myli się w jedną albo drugą stronę. W którą — zależy od domyślnej wartości, którą porównanie po cichu koduje. Tabela wypisuje kształty, które wyrastają w ręcznie pisanym kodzie OOXML, co każdy robi z pisowniami słownymi i kilka atrybutów, gdzie szkody wychodzą na jaw. Pomija showDropDown, którego problemem jest nazwa, a nie pisownia: domyślna schematu to false, ale showDropDown="1" na walidacji listy chowa strzałkę w komórce, więc TXLSXDataValidation.ShowDropDown wystawia znaczenie widziane przez użytkownika (True pokazuje strzałkę) i przechowuje odwróconą flagę OOXML

Naiwny testImplikowany domyślnyŹle czytaGdzie wychodzi
Value = '1'falsetrue staje się falsesheetFormatPr customHeight, <v> komórki t="b"
Value <> '0'true, gdy obecnyfalse staje się truekolumna hidden, definedName hidden, alignment wrapText
Value = '0'truefalse jest ignorowane, domyślna zostaje włączonasheetView showGridLines, przełączniki uprawnień sheetProtection
Copy(Raw, P + 5, 1) = '1'falsetrue jest ucinane do tramki tabeli danych ChartML, smooth serii

Środkowy wiersz produkuje najbardziej widoczne szkody, bo hidden="false" na elemencie <col> to zupełnie normalna rzecz, jaką producent zapisuje, gdy odnotowuje szerokość widocznej kolumny, a czytnik traktujący każdy niezerowy napis jako ustawiony chowa tę kolumnę przy wczytaniu. HotXLS prowadzi każdy z tych atrybutów przez jedną konwersję xsd:boolean, która ścina otaczające białe znaki, przyjmuje cyfry i słowa, a dla czegokolwiek innego zwraca własną domyślną atrybutu. Ta ostatnia gałąź odzwierciedla wybór projektu wart wypowiedzenia: wartość typu yes to uszkodzony plik — ale odmowa otwarcia skoroszytu przez jedną zepsutą flagę widoczności karałaby użytkownika za błąd producenta, więc czytnik trzyma stały fallback per atrybut i jedzie dalej

Czytnik atrybutów boolean XLSX w HotXLS na tle naiwnych testów Value = '1', Value <> '0', Value = '0' i Copy(Raw, P + 5, 1) w Delphi, pokazujący, które pisownie słowne każdy źle czyta dla customHeight, kolumny hidden, showGridLines i ChartML smooth
Każdy test po jednym znaku przechodzi na 1 i 0 i pada na dokładnie jednej pisowni słownej, dlatego korpus testowy tylko z Excela nigdy go nie złapie

Czytanie arkusza w stylu LibreOffice z HotXLS

Flagi poziomu arkusza wychodzą jako zwykłe własności na TXLSXWorksheet: CustomHeight, ThickTop i ThickBottom pochodzą z <sheetFormatPr>, podczas gdy ColHidden[], RowHidden[], RowThickTop[] i RowThickBottom[] biorą numery kolumn i wierszy liczone od 1. Komórka boolean przychodzi jako wariant Boolean w TXLSXCell.Value, niezależnie od pisowni użytej przez element <v>. Procedura poniżej wypisuje wszystkie dla pierwszego arkusza, a lista za nią daje oczekiwane wyjście dla znaczników w stylu Calca

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];   // indeksator Sheets jest liczony od 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 wypisuje TRUE
  • <col min="3" max="3" width="20" hidden="false"/>: kolumna 3 nie zostaje wypisana
  • <row r="5" thickBot="true">: wiersz 5 zgłasza thickBot=TRUE
  • <c r="A2" t="b"><v>true</v></c>: A2 trzyma TRUE

Podpowiedź wierszowa to miejsce, gdzie sam schemat robi się niezdarne. Jedna idea, dwie pisownie — CT_SheetFormatPr nazywa swoje atrybuty thickTop i thickBottom, podczas gdy CT_Row używa thickTop i thickBot, więc czytnik szukający thickBottom na wierszu nie znajduje niczego. HotXLS zapisuje każdą formę z powrotem na jej własnym elemencie, a podpowiedzi przeżywają zapis razem z resztą pakietu — podobnie jak nietknięte części w bezstratnym round trippie XLSX

Jak wykres ma czytać element bez val?

Przełącznik ChartML bez atrybutu val jest włączony. DrawingML Charts deklaruje te przełączniki przez CT_Boolean w dml-chart.xsd, którego val jest opcjonalnym xsd:boolean domyślnym do true, więc <c:plotVisOnly/> i <c:showHorzBorder/> znaczą to samo co zapisanie val="1". Ta domyślna jest odwrotnością większości atrybutów SpreadsheetML i łamie pospolity skrót czytania XML wykresu jako tekstu: znajdź element po nazwie, potem szukaj w przód następnego val=". Gdy element pomija val, to szukanie przebiega za koniec znacznika otwierającego i pożycza wartość dowolnego elementu, który przyjdzie później w części — może val="0" dwa rodzeństwa dalej. Gdy nie pasuje nic, PosEx zwraca 0, a kod krojący przy P + 5 kończy z czytaniem od znaku 5 części, czyli od l w <?xml. Żadna z tych awarii niczego nie podnosi; własność wykresu po prostu rozmija się z plikiem

Cudzysłowy to druga połowa problemu. XML 1.0 pozwala ograniczać wartość atrybutu cudzysłowem podwójnym albo pojedynczym (produkcja AttValue w §2.3), więc <c:majorTickMark val='out'/> jest równie legalny jak forma w podwójnych, a szukanie val=" pomija go w całości. HotXLS zachowuje oryginalny ChartML, żeby umieć edytować wykresy Excela bez gubienia znaczników, których nie modeluje, a przełączniki czytane z tego tekstu są wyszukiwane wyłącznie wewnątrz własnego znacznika otwierającego elementu, z przyjmowaniem obu stylów cudzysłowów i brakującym val czytanym jako domyślna CT_Boolean. Publiczne własności wykresu odzwierciedlają to czytanie

Czytanie przełączników CT_Boolean ChartML w Delphi z HotXLS: szukanie w przód val=" pożycza późniejsze val="0", gubi val='out' i kroi <?xml, gdy PosEx zwraca 0, podczas gdy HotXLS czyta wyłącznie znacznik otwierający dla PlotVisibleOnly i ShowDataTableHorzBorder
Ograniczenie wyszukiwania val do własnego znacznika otwierającego elementu naprawia naraz trzy ciche błędne odczyty wykresu: pożyczoną wartość, pominiętą wartość w pojedynczych cudzysłowach i krojenie od deklaracji 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 jest liczone od 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;

Oczekiwane dla części wykresu zawierającej <c:plotVisOnly/>, dalej element z val="0", tabelę danych z <c:showHorzBorder/><c:showVertBorder val="0"/> i oś wartości z <c:majorTickMark val='out'/>: PlotVisibleOnly i ShowDataTableHorzBorder oba TRUE, a znacznik wypisuje out

Ograniczony czytnik xsd:boolean do własnego kodu OOXML

Gdy sam post-processujesz pakiet — powiedzmy własną część dodawaną przez aplikację albo szybki przegląd XML wykresu w kroku budowania — obowiązują te same dwie reguły: konwertuj z pełną przestrzenią leksykalną i nigdy nie pozwalaj wyszukiwaniu atrybutu opuścić znacznika otwierającego elementu, do którego należy. Funkcje poniżej robią oba. ChartSwitch oczekuje, że TagStart wskazuje < elementu, i stosuje domyślną CT_Boolean, gdy val nie ma

uses
  SysUtils, StrUtils;

function TryParseXsdBoolean(const Literal: string; out Value: Boolean): Boolean;
var
  S: string;
begin
  S := Trim(Literal);            // whiteSpace=collapse: wypełnienie jest legalne
  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: nie 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;
  // nazwa musi zaczynać token, a nie kończyć inną nazwę atrybutu
  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: brak val znaczy true
  Result := ReadXsdBoolean(V, True);
end;

Ścisła gałąź w TryParseXsdBoolean idzie za typem danych co do litery i tu twój czytnik z HotXLS trochę się rozchodzą: konwersja HotXLS porównuje słowa bez względu na wielkość liter, więc TRUE czyta się jako true. Wolimy przyjąć pisownię, której schemat zabrania, niż wyłączyć flagę, bo jakiś producent ją skapitalizował — żaden producent nie zapisuje TRUE w znaczeniu false; walidator przeciwnie, powinien trzymać ścisłą gałąź i zgłosić wartość. Skaner tekstowy bierze też dwa skróty bezpieczne dla przełączników ChartML i złe dla XML w ogólności. > jest legalny wewnątrz wartości atrybutu, więc zatrzymanie na najwcześniejszym może uciąć znacznik, którego wartości go zawierają, a XML pozwala biel wokół =, czego szukanie AttrName + '=' nie widzi. Wartości przełączników ChartML to krótkie tokeny bez > w środku, a pomijanie bieli wokół znaku równości to rozszerzenie na dwa wiersze, ale dla czegokolwiek ponad garstkę znanych przełączników czytnik XML podający atrybuty po jednym elemencie naraz usuwa oba problemy — i to właśnie używa HotXLS dla części arkuszy

Gdy skoroszyt Calca wczytuje się z innymi ukrytymi kolumnami albo wysokościami wierszy niż jego bliźniak z Excela, rozpakuj oba i zobacz, jak flagi są zapisane; hidden="false" kontra hidden="0" zwykle tłumaczy przepaść w ciągu minuty. Mapowanie obu pisowni, wypchanych i gołych, na jeden zestaw własności arkusza, komórek i wykresu to robota, którą HotXLS, komponent arkuszy dla Delphi wykonuje przy każdym wczytaniu