Artículo técnico

Excel escribe 1, LibreOffice escribe true: booleanos XLSX

Abre un libro guardado por LibreOffice Calc y una columna escrita como hidden="false" puede volver oculta, aunque no hay nada malo en el fichero. Bajo xsd:boolean, el tipo detrás de ese atributo, true, false, 1 y 0 son todos legales, con o sin blancos alrededor; HotXLS para Delphi lee todas esas grafías dondequiera que un schema XLSX declare un boolean

La mitad de xsd:boolean que Excel nunca escribe

W3C XML Schema Part 2 §3.2.2 define boolean con cuatro literales legales, true, false, 1 y 0, y fija su faceta whiteSpace en collapse, lo que significa que customHeight=" 1 " es tan válido como customHeight="1". La misma sección nombra true y false como las formas canónicas. SpreadsheetML no hace nada por estrechar eso: sml.xsd declara customHeight, hidden, collapsed, showGridLines, wrapText, quotePrefix y docenas de sus vecinos como xsd:boolean plano, la mayoría con un valor por defecto explícito de false y unos pocos, como showGridLines, con por defecto true. Excel, por su lado, escribe solo los dígitos no canónicos. LibreOffice y algunos otros productores escriben las palabras, así que un paquete de Calc lleva <sheetFormatPr defaultRowHeight="12.8" customHeight="true"/> donde Excel habría escrito customHeight="1"

Esa asimetría es por lo que las comparaciones de un dígito sobreviven años en los lectores OOXML. Construye un corpus de pruebas con salida de Excel y todo boolean que veas jamás será 0 o 1. La comparación pasa todas las pruebas. Entonces un fichero de Calc llega a un usuario. Mete al menos unos pocos paquetes guardados por LibreOffice en cualquier corpus XLSX que alimente pruebas de lectores — su grafía de los booleanos es uno de varios sitios donde los dos productores difieren, la misma brecha que aparece en fórmulas y reglas cuando mueves libros entre Excel y LibreOffice vía ODS. Un detalle más del datatype importa para el diseño del parser: los literales distinguen mayúsculas, así que TRUE no es un xsd:boolean válido en absoluto, un punto que vuelve cuando decides lo indulgente que debe ser tu propio lector

Parseo de atributos xsd:boolean de HotXLS en Delphi: Excel escribe customHeight="1" mientras LibreOffice Calc escribe customHeight="true", ambos dentro del espacio léxico de cuatro literales true, false, 1 y 0 con colapso de blancos, leídos por una única conversión de HotXLS
Excel solo emite los dígitos y LibreOffice Calc emite las palabras, pero ambas grafías viven dentro de un mismo datatype de cuatro literales que un lector tiene que aceptar por completo

Lo que falla en una comparación de un solo carácter

Un lector que compara un atributo contra un único dígito falla en una dirección o en la otra, y la dirección depende del valor por defecto que la comparación codifica sin decirlo. La tabla lista las formas que aparecen en código OOXML escrito a mano, qué hace cada una con las grafías de palabras y algunos atributos donde el daño se ve. Deja fuera showDropDown, cuyo problema es su nombre y no su grafía: el valor por defecto del schema es false, pero un showDropDown="1" en una validación de lista oculta la flecha de la celda, así que TXLSXDataValidation.ShowDropDown expone el significado de cara al usuario (True muestra la flecha) y guarda el flag OOXML invertido

Prueba ingenuaPor defecto implícitoLectura erróneaDónde se ve
Value = '1'falsetrue se vuelve falsesheetFormatPr customHeight, el <v> de una celda t="b"
Value <> '0'true si está presentefalse se vuelve truecolumna hidden, definedName hidden, alignment wrapText
Value = '0'truefalse se ignora y el defecto sigue activosheetView showGridLines, conmutadores de permisos de sheetProtection
Copy(Raw, P + 5, 1) = '1'falsetrue se recorta a tbordes de la tabla de datos ChartML, smooth de series

La fila de en medio produce el daño más visible, porque un hidden="false" en un elemento <col> es algo perfectamente normal que un productor escribe cuando anota el ancho de una columna visible, y un lector que trate cualquier texto distinto de cero como activo oculta esa columna al cargar. HotXLS pasa cada uno de estos atributos por una única conversión xsd:boolean que recorta los blancos alrededor, acepta los dígitos y las palabras, y devuelve el valor por defecto del propio atributo para cualquier otra cosa. Esa última rama refleja una decisión de diseño que vale la pena enunciar: un valor como yes es un fichero malformado, pero negarse a abrir un libro por un flag de visibilidad malo castigaría al usuario por el error del productor, así que el lector mantiene un fallback fijo por atributo y sigue adelante

Lector de atributos booleanos XLSX de HotXLS comparado con las pruebas ingenuas Value = '1', Value <> '0', Value = '0' y Copy(Raw, P + 5, 1) en Delphi, mostrando qué grafías de palabra lee mal cada una para customHeight, column hidden, showGridLines y ChartML smooth
Cada prueba de un carácter pasa con 1 y 0 y falla con exactamente una grafía de palabra, que es por lo que un corpus de pruebas solo de Excel nunca la caza

Leer una hoja estilo LibreOffice con HotXLS

Los flags de nivel de hoja afloran como propiedades corrientes en TXLSXWorksheet: CustomHeight, ThickTop y ThickBottom vienen de <sheetFormatPr>, mientras que ColHidden[], RowHidden[], RowThickTop[] y RowThickBottom[] toman números de columna y fila base uno. Una celda booleana llega como variante Boolean en TXLSXCell.Value, sea cual sea la grafía que usó el elemento <v>. La rutina de abajo imprime todos para la primera hoja, y la lista posterior da la salida 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];   // el indexador de Sheets es base uno

    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"/>: la columna 3 no aparece en la lista
  • <row r="5" thickBot="true">: la fila 5 reporta thickBot=TRUE
  • <c r="A2" t="b"><v>true</v></c>: A2 guarda TRUE

La pista de fila es donde el schema en sí se pone incómodo. Una idea, dos grafías — CT_SheetFormatPr nombra sus atributos thickTop y thickBottom, mientras que CT_Row usa thickTop y thickBot, así que un lector que busque thickBottom en una fila no encuentra nada. HotXLS escribe cada forma de vuelta en su propio elemento, y las pistas sobreviven a un guardado junto con el resto del paquete, igual que las partes intactas en un round trip XLSX sin pérdidas

¿Cómo debe leer un gráfico un elemento sin val?

Un conmutador ChartML sin atributo val está activado. DrawingML Charts declara esos conmutadores con CT_Boolean en dml-chart.xsd, cuyo val es un xsd:boolean opcional con valor por defecto true, así que <c:plotVisOnly/> y <c:showHorzBorder/> significan lo mismo que escribir val="1". Ese valor por defecto es el inverso de la mayoría de los atributos de SpreadsheetML, y rompe el atajo habitual de leer el XML de gráficos como texto: encuentra el elemento por nombre y busca hacia delante el siguiente val=". Cuando el elemento omite val, esa búsqueda corre más allá del final del start tag y pide prestado el valor del elemento que venga después en la parte, quizá un val="0" dos hermanos más abajo. Cuando no hay ninguna coincidencia, PosEx devuelve 0, y el código que corta en P + 5 acaba leyendo desde el carácter 5 de la parte, que es la l de <?xml. Ninguno de los dos fallos lanza nada; la propiedad del gráfico simplemente discrepa del fichero

Las comillas son la otra mitad del problema. XML 1.0 permite delimitar el valor de un atributo con comillas dobles o simples (la producción AttValue de §2.3), así que <c:majorTickMark val='out'/> es tan legítimo como la forma con comillas dobles, y una búsqueda de val=" lo salta por completo. HotXLS conserva el ChartML original para poder editar gráficos de Excel sin perder el markup que no modela, y los conmutadores leídos de ese texto se buscan solo dentro del start tag del propio elemento, aceptando cualquiera de los dos estilos de comillas y leyendo un val ausente como el valor por defecto de CT_Boolean. Las propiedades públicas del gráfico reflejan esa lectura

Lectura de conmutadores CT_Boolean de ChartML de HotXLS en Delphi: una búsqueda hacia delante de val=" toma prestado un val="0" posterior, se pierde val='out' y corta desde <?xml cuando PosEx devuelve 0, mientras que HotXLS lee solo el start tag para PlotVisibleOnly y ShowDataTableHorzBorder
Acotar la búsqueda de val al start tag del propio elemento arregla de una vez tres lecturas erróneas silenciosas de gráficos: un valor prestado, un valor con comillas simples saltado y un corte desde la declaración 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 es base cero
  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 una parte de gráfico que contenga <c:plotVisOnly/> seguida más tarde de un elemento con val="0", una tabla de datos con <c:showHorzBorder/><c:showVertBorder val="0"/>, y un eje de valores con <c:majorTickMark val='out'/>: PlotVisibleOnly y ShowDataTableHorzBorder son ambos TRUE, y la marca de tick imprime out

Un lector xsd:boolean acotado para tu propio código OOXML

Cuando posprocesas un paquete tú mismo, por ejemplo una parte a medida que añade tu aplicación o un vistazo rápido al XML de gráficos en un paso de build, aplican las mismas dos reglas: convertir con el espacio léxico completo y no dejar nunca que la búsqueda de un atributo salga del start tag del elemento al que pertenece. Las funciones de abajo hacen ambas cosas. ChartSwitch espera que TagStart apunte al < del elemento y aplica el valor por defecto de CT_Boolean cuando falta val

uses
  SysUtils, StrUtils;

function TryParseXsdBoolean(const Literal: string; out Value: Boolean): Boolean;
var
  S: string;
begin
  S := Trim(Literal);            // whiteSpace=collapse: el relleno es 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: no es 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;
  // el nombre debe abrir un token, no cerrar el nombre de otro 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: un val ausente significa true
  Result := ReadXsdBoolean(V, True);
end;

La rama estricta de TryParseXsdBoolean sigue el datatype al pie de la letra, y ahí tu lector y HotXLS se separan un poco: la conversión de HotXLS compara las palabras sin fijarse en las mayúsculas, así que TRUE se lee como true. Preferimos aceptar una grafía que el schema prohíbe antes que apagar un flag porque a algún productor se le ocurrió escribirlo en mayúsculas, ya que ningún productor escribe TRUE para significar false; un validador, en cambio, debería quedarse con la rama estricta y reportar el valor. El scanner de texto toma además dos atajos que son seguros para los conmutadores de ChartML y erróneos para XML en general. Un > es legal dentro del valor de un atributo, así que pararse en el primero puede truncar un tag cuyos valores lo contengan, y XML permite blancos alrededor del =, que la búsqueda de AttrName + '=' no ve. Los valores de los conmutadores ChartML son tokens cortos sin ningún > dentro, y saltarse los blancos alrededor del igual es una extensión de dos líneas, pero para cualquier cosa más allá de un puñado de conmutadores conocidos, un lector XML que te entregue los atributos de un elemento cada vez elimina los dos problemas, y es lo que HotXLS usa para las partes de hojas de trabajo

Cuando un libro de Calc carga con columnas ocultas o altos de fila distintos que su gemelo de Excel, descomprime ambos y mira cómo están escritos los flags; hidden="false" frente a hidden="0" suele explicar la diferencia en un minuto. Mapear ambas grafías, con relleno o desnudas, sobre un único conjunto de propiedades de hoja, celda y gráfico es el trabajo que el componente de hojas de cálculo HotXLS para Delphi hace en cada carga