package main
import (
"archive/zip"
"fmt"
"hash/crc32"
"os"
"regexp"
"strconv"
"strings"
)
// odsMimetype is the ODF package media type, written as the archive's
// first, uncompressed entry.
const odsMimetype = "application/vnd.oasis.opendocument.spreadsheet"
// writeODS writes a minimal OpenDocument spreadsheet at path with one
// formula per row in column D, plus the same small fixed lookup table
// calc's own corpusEnv provides in columns A:B, rows 1-3 (see
// lookupTableRows), with C1 left blank. See main.go's package doc
// comment for the overall mechanism.
func writeODS(path string, cases []corpusCase) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
zw := zip.NewWriter(f)
// mimetype must be the first entry, stored uncompressed, per the ODF
// package format spec. It is written with CreateRaw, not
// CreateHeader, on purpose: CreateHeader streams the entry and
// therefore sets the "sizes follow in a data descriptor" flag bit
// even for a Store entry, and LibreOffice's ODF package reader
// rejects that outright -- the whole document fails to open with
// "source file could not be loaded", not with a per-cell error.
// Verified against LibreOffice 26.8.0.3: CreateHeader here produces
// an unloadable file, CreateRaw with the sizes and CRC filled in
// produces a loadable one.
mh := &zip.FileHeader{Name: "mimetype", Method: zip.Store}
mh.CRC32 = crc32.ChecksumIEEE([]byte(odsMimetype))
mh.CompressedSize64 = uint64(len(odsMimetype))
mh.UncompressedSize64 = uint64(len(odsMimetype))
mw, err := zw.CreateRaw(mh)
if err != nil {
return err
}
if _, err := mw.Write([]byte(odsMimetype)); err != nil {
return err
}
cw, err := zw.Create("content.xml")
if err != nil {
return err
}
if _, err := cw.Write([]byte(contentXML(cases))); err != nil {
return err
}
mfw, err := zw.Create("META-INF/manifest.xml")
if err != nil {
return err
}
if _, err := mfw.Write([]byte(manifestXML)); err != nil {
return err
}
return zw.Close()
}
const manifestXML = `
`
// lookupTableRows is the fixed A:B data VLOOKUP/INDEX/MATCH cases
// reference as "A1:B3" (both columns) or "A1:A3" (the number column
// alone) -- byte-identical to the table
// internal/sheets/calc/corpus_test.go's corpusEnv and
// internal/sheets/xlsx/oracle_excelize_test.go's newOracleWorkbook both
// set up, so a generated corpus entry's Expr can be evaluated unchanged
// by both of those harnesses. C1 is deliberately left blank so it reads
// as a genuinely empty cell for the COUNT(A1,C1)/COUNTA(A1,C1) cases;
// each row's own formula therefore goes in column D, clear of the
// table.
var lookupTableRows = []struct {
num float64
text string
}{
{10, "ten"},
{20, "twenty"},
{30, "thirty"},
}
// formulaColumnIndex is the zero-based CSV field the formula cell lands
// in: columns A, B and C are the support table (and its deliberately
// blank C1), so column D is index 3. readResults reads this field, not
// field 0.
const formulaColumnIndex = 3
func contentXML(cases []corpusCase) string {
var rows strings.Builder
for i, c := range cases {
rows.WriteString(" ")
if i < len(lookupTableRows) {
rows.WriteString(numberCell(lookupTableRows[i].num))
rows.WriteString(stringCell(lookupTableRows[i].text))
rows.WriteString(``)
} else {
rows.WriteString(``)
}
// office:value is deliberately poisoned with a value no case can
// produce, so a run in which LibreOffice failed to recalculate
// would come back as 999999 everywhere rather than as plausible
// answers. See main's poisonedCacheValue check.
rows.WriteString(fmt.Sprintf(
`%s`,
escapeXML(toODFFormula(c.Expr)), poisonedCacheValue, poisonedCacheValue))
rows.WriteString("\n")
}
// xmlns:of is load-bearing, not decoration: the "of:" in each cell's
// table:formula="of:=..." is an XML namespace prefix, and without
// this declaration LibreOffice does not recognise the formula
// grammar and every single cell comes back as Err:510 rather than a
// value. Verified against LibreOffice 26.8.0.3 by running the same
// document with and without it.
return `
` + rows.String() + `
`
}
// numberCell/stringCell write a literal (not formula) value cell -- the
// lookup table's own data, which must be reflected in the file's actual
// value/type attributes, not just visible text, or LibreOffice would not
// see it as a number/string for VLOOKUP/INDEX/MATCH to compare against.
func numberCell(n float64) string {
s := strconv.FormatFloat(n, 'g', -1, 64)
return fmt.Sprintf(`%s`, s, s)
}
func stringCell(s string) string {
return fmt.Sprintf(`%s`, s)
}
// escapeXML escapes a formula for use inside an XML attribute value.
// Every case with a string argument contains double quotes, which would
// otherwise close the table:formula attribute early.
func escapeXML(s string) string {
s = strings.ReplaceAll(s, "&", "&")
s = strings.ReplaceAll(s, "<", "<")
s = strings.ReplaceAll(s, ">", ">")
return strings.ReplaceAll(s, `"`, """)
}
// odfFunctionNames maps a function's Excel/English spelling to the name
// LibreOffice actually answers to inside an ODF of: formula. ODF's own
// function set is a standard; functions Microsoft added after it are
// vendor-namespaced, and LibreOffice writes and reads them under the
// COM.MICROSOFT. prefix. Calling one by its bare Excel name in an of:
// formula is not an error LibreOffice reports as such -- it is simply an
// unknown name, and the cell comes back #NAME?.
//
// This was found the hard way on this tool's first real run: IFS and
// CONCAT both came back #NAME?, which classify would have happily
// recorded as the corpus's expected answer, turning "LibreOffice has
// never heard of this ODF spelling" into "a real spreadsheet says these
// functions are errors". checkResults (soffice.go) now refuses a #NAME?
// result outright for exactly that reason.
//
// Add an entry here whenever a case uses a post-ODF Microsoft function.
// The symptom is always the same: a #NAME? that fails the run.
var odfFunctionNames = map[string]string{
"IFS": "COM.MICROSOFT.IFS",
"CONCAT": "COM.MICROSOFT.CONCAT",
}
// funcCallPattern matches an identifier immediately followed by "(",
// i.e. a function call's name. Like the comma substitution below it does
// not skip quoted spans; no case has a bare word followed by "(" inside
// a string literal.
var funcCallPattern = regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_.]*\(`)
// toODFFormula translates a comma-argument-separated Excel-style formula
// (this tool's own case list, and this project's own formula syntax --
// see internal/sheets/calc/parse.go) into ODF's own formula syntax, which
// uses semicolons between arguments. The translation is required, not
// cosmetic: verified against LibreOffice 26.8.0.3, an of:= formula with
// comma separators evaluates to #NAME?, not to a value.
//
// Two things this deliberately does NOT do, both checked by measurement
// rather than assumed:
// - It does not rewrite A1-style references into ODF's bracketed
// [.A1:.B3] form. LibreOffice accepts the plain form and resolves it
// identically (VLOOKUP(20;A1:B3;2;FALSE()) and
// VLOOKUP(20;[.A1:.B3];2;FALSE()) both return "twenty").
// - It does not rewrite bare TRUE/FALSE into TRUE()/FALSE().
// LibreOffice accepts the bare form (IF(TRUE;"yes";"no") returns
// "yes").
//
// It DOES rewrite function names that ODF vendor-namespaces -- see
// odfFunctionNames.
//
// It is a blunt, comma-to-semicolon substitution -- wrong for any
// formula whose comma appears inside a quoted string argument (a text
// literal that itself contains a comma). None of this tool's own cases
// do; a human adding one that does needs to rewrite this function to
// skip quoted spans, or avoid the comma.
func toODFFormula(expr string) string {
s := strings.ReplaceAll(expr, ",", ";")
return funcCallPattern.ReplaceAllStringFunc(s, func(call string) string {
name := strings.ToUpper(call[:len(call)-1])
if odf, ok := odfFunctionNames[name]; ok {
return odf + "("
}
return call
})
}