Техническая статья

Excel пишет 1, LibreOffice пишет true: булевы XLSX

Откройте книгу, сохранённую LibreOffice Calc, и колонка, записанная как hidden="false", может вернуться скрытой, хотя в файле всё правильно. Под xsd:boolean — типом за этим атрибутом — легальны true, false, 1 и 0, с окружающими пробелами или без; HotXLS для Delphi читает каждое из этих написаний везде, где схема XLSX объявляет булево

Половина xsd:boolean, которую Excel никогда не пишет

W3C XML Schema Part 2 §3.2.2 определяет boolean четырьмя легальными литералами — true, false, 1 и 0 — и фиксирует фасетку whiteSpace в 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. Кладите хотя бы несколько пакетов, сохранённых LibreOffice, в любой корпус XLSX, на котором гоняются тесты читателя, — его написание булевых одно из нескольких мест, где производители расходятся, та же щель, что вылезает в формулах и правилах, когда вы переносите книги между Excel и LibreOffice через ODS. Для дизайна парсера важна ещё одна деталь типа: литералы чувствительны к регистру, так что TRUE вообще не валидный xsd:boolean, — это вспомните, когда будете решать, насколько снисходительным быть вашему собственному читателю

Разбор атрибутов xsd:boolean в HotXLS на Delphi: Excel пишет customHeight="1", а LibreOffice Calc — customHeight="true", оба внутри четырёхлитерального лексического пространства true, false, 1 и 0 со свёртыванием пробелов, читаются одним преобразованием HotXLS
Excel испускает только цифры, LibreOffice Calc — только слова, но оба написания сидят внутри одного четырёхлитерального типа, который читатель обязан принимать целиком

Что не так у сравнения в один символ

Читатель, сравнивающий атрибут с одной цифрой, проваливается в ту или другую сторону. В какую — зависит от дефолта, который сравнение молча в себе несёт. Таблица перечисляет формы, встречающиеся в рукописном OOXML-коде, что каждая делает с написаниями-словами и несколько атрибутов, где ущерб виден. За кадром остался showDropDown, чья беда в имени, а не в написании: дефолт схемы — false, но showDropDown="1" на валидации списка прячет стрелку в ячейке, поэтому TXLSXDataValidation.ShowDropDown наружу выставляет человеческий смысл (True показывает стрелку) и хранит инвертированный флаг OOXML

Наивная проверкаПодразумеваемый дефолтОшибочно читаетГде проявляется
Value = '1'falsetrue становится falsesheetFormatPr customHeight, <v> ячейки t="b"
Value <> '0'true при наличииfalse становится trueколонка hidden, definedName hidden, alignment wrapText
Value = '0'truefalse игнорируется, дефолт остаётся включённымsheetView showGridLines, переключатели разрешений sheetProtection
Copy(Raw, P + 5, 1) = '1'falsetrue обрезается до tграницы data-table ChartML, smooth серии

Средняя строка даёт самый заметный ущерб: hidden="false" на элементе <col> — совершенно нормальная вещь для производителя, который записывает ширину видимой колонки, а читатель, считающий любой ненулевой текст установленным, прячет эту колонку при загрузке. HotXLS проводит все эти атрибуты через одно преобразование xsd:boolean, которое обрезает окружающие пробелы, принимает цифры и слова и для всего остального возвращает собственный дефолт атрибута. Последняя ветка отражает решение о дизайне, которое стоит проговорить: значение вроде yes — признак битого файла, но отказ открывать книгу из-за одного испорченного флага видимости наказал бы пользователя за ошибку производителя, поэтому читатель держит фиксированный запасной вариант на каждый атрибут и идёт дальше

Читатель булевых атрибутов XLSX в HotXLS в сравнении с наивными проверками Value = '1', Value <> '0', Value = '0' и Copy(Raw, P + 5, 1) на Delphi: что каждое ошибочно читает в написаниях-словах для customHeight, колонки hidden, showGridLines и smooth ChartML
Каждая односимвольная проверка проходит на 1 и 0 и проваливается ровно на одном написании-слове, поэтому корпус тестов на чистом Excel её никогда не ловит

Читаем лист в стиле LibreOffice через HotXLS

Флаги уровня листа выходят наружу как обычные свойства TXLSXWorksheet: CustomHeight, ThickTop и ThickBottom приходят из <sheetFormatPr>, а ColHidden[], RowHidden[], RowThickTop[] и RowThickBottom[] принимают номера колонок и строк с единицы. Булева ячейка приезжает вариантом Boolean в 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 считается с единицы

    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

Строчная подсказка — то место, где спотыкается сама схема. Одна идея, два написания: CT_SheetFormatPr зовёт свои атрибуты thickTop и thickBottom, а CT_Row использует thickTop и thickBot, так что читатель, ищущий thickBottom на строке, не найдёт ничего. HotXLS пишет каждую форму обратно на её собственный элемент, и подсказки переживают сохранение вместе с остальной частью пакета — как нетронутые части при круговом прогоне XLSX без потерь

Как диаграмме читать элемент без val?

Переключатель ChartML без атрибута val — включён. DrawingML Charts объявляет такие переключатели через CT_Boolean в dml-chart.xsd, чей val — необязательный xsd:boolean с дефолтом true, так что <c:plotVisOnly/> и <c:showHorzBorder/> означают то же, что записанный val="1". Этот дефолт обратен большинству атрибутов SpreadsheetML и ломает привычный трюк чтения XML диаграммы как текста: найти элемент по имени, затем искать вперёд ближайший val=". Когда элемент опускает val, поиск убегает за конец стартового тега и одалживает значение у какого-нибудь элемента дальше по части — возможно, у val="0" двумя сиблингами ниже. Когда не совпадает ничего, PosEx возвращает 0, и код, режущий по P + 5, оказывается читающим с пятого символа части — с буквы l из <?xml. Ни одна из этих неудач ничего не поднимает; свойство диаграммы просто расходится с файлом

Кавычки — вторая половина проблемы. XML 1.0 разрешает ограничивать значение атрибута как двойными, так и одинарными кавычками (продукция AttValue в §2.3), так что <c:majorTickMark val='out'/> так же легитимен, как форма с двойными, и поиск val=" проскочит его целиком. HotXLS хранит исходный ChartML, чтобы править диаграммы Excel, не теряя разметку, которую не моделирует, и переключатели, читаемые из этого текста, ищутся только внутри стартового тега самого элемента — с приёмом любого стиля кавычек и чтением отсутствующего val как дефолта CT_Boolean. Публичные свойства диаграмм отражают именно такое чтение

Чтение переключателей CT_Boolean ChartML в HotXLS на Delphi: поиск вперёд val=" одалживает более поздний val="0", проскакивает val='out' и режет от <?xml, когда PosEx возвращает 0, тогда как 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 нуль-базированный
  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

Ограниченный читатель xsd:boolean для вашего собственного OOXML-кода

Если вы сами пост-обрабатываете пакет — скажем, кастомную часть, которую добавляет приложение, или беглый скан XML диаграмм на шаге сборки, — те же два правила в силе: конвертировать с полным лексическим пространством и никогда не давать поиску атрибута выйти за стартовый тег его элемента. Функции ниже делают и то и другое. ChartSwitch ждёт, что TagStart указывает на < элемента, и применяет дефолт CT_Boolean, когда val отсутствует

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 вообще. > легален внутри значения атрибута, поэтому остановка на самом раннем может обрезать тег, чьи значения его содержат, а XML разрешает пробелы вокруг =, которых поиск AttrName + '=' не видит. Значения переключателей ChartML — короткие токены без > внутри, а пропуск пробелов вокруг знака равенства — расширение на две строки, но для чего-либо сверх горстки известных переключателей XML-читатель, выдающий атрибуты по одному элементу за раз, снимает обе проблемы — именно такой HotXLS использует для частей листов

Когда книга Calc грузится с другими скрытыми колонками или высотами строк, чем её двойник из Excel, распакуйте обе и посмотрите, как написаны флаги; hidden="false" против hidden="0" обычно объясняет расхождение за минуту. Сопоставление обоих написаний, с набивкой или голых, одному набору свойств листов, ячеек и диаграмм — работа, которую компонент электронных таблиц HotXLS для Delphi делает при каждой загрузке