Artigo Técnico

Excel grava 1, LibreOffice grava true: booleans do XLSX

Abra um workbook salvo pelo LibreOffice Calc e uma coluna gravada como hidden="false" pode voltar oculta, embora nada no arquivo esteja errado. Sob xsd:boolean, o tipo por trás desse atributo, true, false, 1 e 0 são todos legais, com ou sem brancos ao redor; o HotXLS para Delphi lê cada uma dessas grafias onde quer que um schema XLSX declare um boolean

A metade do xsd:boolean que o Excel nunca grava

A W3C XML Schema Part 2 §3.2.2 define boolean com quatro literais legais, true, false, 1 e 0, e fixa a faceta whiteSpace dele em collapse, o que significa que customHeight=" 1 " é tão válido quanto customHeight="1". A mesma seção nomeia true e false como as formas canônicas. O SpreadsheetML não faz nada para estreitar isso: o sml.xsd declara customHeight, hidden, collapsed, showGridLines, wrapText, quotePrefix e dezenas de vizinhos deles como xsd:boolean puro, a maioria com default explícito de false e alguns, como showGridLines, com default true. O Excel por acaso grava só os dígitos não canônicos. LibreOffice e alguns outros produtores gravam as palavras, então um pacote do Calc carrega <sheetFormatPr defaultRowHeight="12.8" customHeight="true"/> onde o Excel teria gravado customHeight="1"

Essa assimetria é por que comparações de um dígito sobrevivem por anos em readers OOXML. Monte um corpus de teste a partir da saída do Excel e todo boolean que você jamais verá é 0 ou 1. A comparação passa em todo teste. Então um arquivo do Calc chega a um usuário. Ponha pelo menos alguns pacotes salvos pelo LibreOffice em qualquer corpus XLSX que mova testes de reader — a grafia de booleans dele é um dos vários lugares em que os dois produtores divergem, a mesma lacuna que aparece em fórmulas e regras quando você move workbooks entre Excel e LibreOffice via ODS. Mais um detalhe do datatype importa para o design do parser: os literais são case sensitive, então TRUE não é um xsd:boolean válido de forma alguma, um ponto que volta quando você decide quão tolerante o seu próprio reader deve ser

Parse de atributos xsd:boolean no Delphi com HotXLS: o Excel grava customHeight="1" enquanto o LibreOffice Calc grava customHeight="true", ambos dentro do espaço lexical de quatro literais true, false, 1 e 0 com collapse de whitespace, lidos por uma única conversão do HotXLS
o Excel só emite os dígitos e o LibreOffice Calc emite as palavras, mas as duas grafias moram dentro de um datatype de quatro literais que um reader precisa aceitar por completo

O que uma comparação de um caractere erra

Um reader que compara um atributo contra um único dígito falha em uma direção ou na outra. Qual direção depende do default que a comparação embute às escuras. A tabela lista as formas que aparecem em código OOXML escrito à mão, o que cada uma faz com as grafias de palavras e alguns atributos onde o dano aparece. Ela deixa de fora o showDropDown, cujo problema é o nome dele e não a grafia: o default do schema é false, mas showDropDown="1" numa validação de lista esconde a seta na célula, então o TXLSXDataValidation.ShowDropDown expõe o significado voltado ao usuário (True mostra a seta) e armazena a flag OOXML invertida

Teste ingênuoDefault implícitoLê erradoOnde aparece
Value = '1'falsetrue vira falsesheetFormatPr customHeight, o <v> de uma célula t="b"
Value <> '0'true quando presentefalse vira truecoluna hidden, definedName hidden, alignment wrapText
Value = '0'truefalse é ignorado, o default segue ligadosheetView showGridLines, switches de permissão do sheetProtection
Copy(Raw, P + 5, 1) = '1'falsetrue é cortado para tbordas de data table no ChartML, smooth de série

A linha do meio produz o dano mais visível, porque hidden="false" num elemento <col> é coisa perfeitamente normal de um produtor gravar quando ele registra uma largura para uma coluna visível, e um reader que trata qualquer texto não zero como definido oculta essa coluna no load. O HotXLS encaminha cada um desses atributos por uma única conversão xsd:boolean que corta brancos ao redor, aceita os dígitos e as palavras, e devolve o default do próprio atributo para qualquer outra coisa. Esse último ramo reflete uma escolha de design que vale enunciar: um valor como yes é arquivo malformado — mas recusar abrir um workbook por causa de uma flag de visibilidade ruim puniria o usuário pelo erro do produtor, então o reader mantém um fallback fixo por atributo e segue em frente

Reader de atributos boolean XLSX do HotXLS comparado com testes ingênuos Value = '1', Value <> '0', Value = '0' e Copy(Raw, P + 5, 1) no Delphi, mostrando qual grafia de palavra cada um lê errado para customHeight, coluna hidden, showGridLines e smooth do ChartML
todo teste de um caractere passa em 1 e 0 e falha em exatamente uma grafia de palavra, por isso um corpus de teste só do Excel nunca o pega

Lendo uma planilha estilo LibreOffice com o HotXLS

As flags de nível de planilha aparecem como propriedades comuns no TXLSXWorksheet: CustomHeight, ThickTop e ThickBottom vêm do <sheetFormatPr>, enquanto ColHidden[], RowHidden[], RowThickTop[] e RowThickBottom[] recebem números de coluna e linha de base um. Uma célula boolean chega como variante Boolean no TXLSXCell.Value, seja qual grafia o elemento <v> tenha usado. A rotina abaixo imprime todas para a primeira planilha, e a lista depois dela dá a saída esperada para markup estilo 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];   // o indexador Sheets é 1-based

    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 imprime TRUE
  • <col min="3" max="3" width="20" hidden="false"/>: a coluna 3 não é listada
  • <row r="5" thickBot="true">: a linha 5 reporta thickBot=TRUE
  • <c r="A2" t="b"><v>true</v></c>: A2 guarda TRUE

A dica de linha é onde o próprio schema fica esquisito. Uma ideia, duas grafias — o CT_SheetFormatPr nomeia os atributos dele thickTop e thickBottom, enquanto o CT_Row usa thickTop e thickBot, então um reader que procura thickBottom numa linha não encontra nada. O HotXLS grava cada forma de volta no próprio elemento dela, e as dicas sobrevivem a um save junto com o resto do pacote, muito como as partes intocadas fazem em uma ida e volta XLSX sem perdas

Como um gráfico deve ler um elemento sem val?

Um switch ChartML sem atributo val está ligado. O DrawingML Charts declara esses switches com CT_Boolean no dml-chart.xsd, cujo val é um xsd:boolean opcional com default true, então <c:plotVisOnly/> e <c:showHorzBorder/> significam o mesmo que gravar val="1". Esse default é o inverso da maioria dos atributos SpreadsheetML, e ele quebra o atalho comum de ler XML de gráfico como texto: achar o elemento pelo nome, depois procurar adiante pelo próximo val=". Quando o elemento omite o val, essa busca corre além do fim da start tag e toma emprestado o valor de qualquer elemento que venha depois na part, talvez um val="0" dois irmãos adiante. Quando nada bate, o PosEx retorna 0, e código que corta em P + 5 acaba lendo a partir do caractere 5 da part, que é o l de <?xml. Nenhuma das falhas levanta exceção; a propriedade do gráfico só diverge do arquivo

Aspas são a outra metade do problema. O XML 1.0 permite que um valor de atributo seja delimitado por aspas duplas ou simples (a produção AttValue em §2.3), então <c:majorTickMark val='out'/> é tão legítimo quanto a forma com aspas duplas, e uma busca por val=" o ignora por completo. O HotXLS preserva o ChartML original para que possa editar gráficos do Excel sem perder markup que ele não modela, e os switches lidos desse texto são procurados dentro da própria start tag do elemento apenas, com qualquer estilo de aspas aceito e um val ausente lido como o default do CT_Boolean. As propriedades públicas de gráfico refletem essa leitura

Leitura de switches CT_Boolean de ChartML no Delphi com HotXLS: uma busca adiante por val=" toma emprestado um val="0" posterior, não vê val='out' e corta <?xml quando o PosEx retorna 0, enquanto o HotXLS lê só a start tag para PlotVisibleOnly e ShowDataTableHorzBorder
limitar a busca por val à própria start tag do elemento conserta três leituras erradas silenciosas de gráfico de uma vez: um valor emprestado, um valor com aspas simples ignorado e um corte na declaração 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-based
  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;

Esperado para uma part de gráfico contendo <c:plotVisOnly/> seguida mais adiante por um elemento com val="0", um data table com <c:showHorzBorder/><c:showVertBorder val="0"/>, e um eixo de valores com <c:majorTickMark val='out'/>: PlotVisibleOnly e ShowDataTableHorzBorder são ambos TRUE, e o tick mark imprime out

Um reader xsd:boolean com limites para o seu próprio código OOXML

Quando você faz pós-processamento de um pacote por conta própria, digamos uma part customizada que a sua aplicação acrescenta ou um scan rápido do XML de gráfico numa etapa de build, as mesmas duas regras valem: converter com o espaço lexical completo, e nunca deixar uma busca por atributo sair da start tag do elemento a que ela pertence. As funções abaixo fazem as duas coisas. O ChartSwitch espera que o TagStart aponte para o < do elemento e aplica o default do CT_Boolean quando o val está ausente

uses
  SysUtils, StrUtils;

function TryParseXsdBoolean(const Literal: string; out Value: Boolean): Boolean;
var
  S: string;
begin
  S := Trim(Literal);            // whiteSpace=collapse: padding é 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: não é 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;
  // o nome precisa começar um token, não terminar outro nome de atributo
  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 ausente significa true
  Result := ReadXsdBoolean(V, True);
end;

O ramo estrito no TryParseXsdBoolean segue o datatype à risca, e é aí que o seu reader e o HotXLS divergem um pouco: a conversão do HotXLS compara as palavras sem considerar a caixa, então TRUE lê como true. Nós preferimos aceitar uma grafia que o schema proíbe a desligar uma flag porque algum produtor a escreveu em maiúsculas, já que nenhum produtor grava TRUE para significar false; um validator, ao contrário, deve manter o ramo estrito e reportar o valor. O scanner de texto também toma dois atalhos que são seguros para switches ChartML e errados para XML em geral. Um > é legal dentro de um valor de atributo, então parar no primeiro pode truncar uma tag cujos valores o contenham, e o XML permite whitespace em volta do =, que a busca por AttrName + '=' não vê. Valores de switch ChartML são tokens curtos sem > dentro, e pular brancos em volta do sinal de igual é uma extensão de duas linhas, mas para qualquer coisa além de um punhado de switches conhecidos um reader XML que entrega os atributos de um elemento por vez elimina os dois problemas, e é o que o HotXLS usa para parts de planilha

Quando um workbook do Calc carrega com colunas ocultas ou alturas de linha diferentes das do seu gêmeo do Excel, descompacte os dois e veja como as flags estão grafadas; hidden="false" contra hidden="0" normalmente explica a diferença em um minuto. Mapear as duas grafias, com padding ou sem, para um único conjunto de propriedades de planilha, célula e gráfico é o trabalho que o componente de planilhas Delphi HotXLS faz em todo load