package ods
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"strconv"
"strings"
"time"
)
// Write produces an ODS file as []byte from a Workbook.
func Write(wb *Workbook) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// mimetype MUST be first entry, stored (not compressed)
mimeHeader := &zip.FileHeader{
Name: "mimetype",
Method: zip.Store,
}
mw, err := zw.CreateHeader(mimeHeader)
if err != nil {
return nil, fmt.Errorf("create mimetype: %w", err)
}
if _, err := mw.Write([]byte("application/vnd.oasis.opendocument.spreadsheet")); err != nil {
return nil, fmt.Errorf("write mimetype: %w", err)
}
// META-INF/manifest.xml
if err := writeManifest(zw); err != nil {
return nil, err
}
// meta.xml
if err := writeMeta(zw, wb.Meta); err != nil {
return nil, err
}
// styles.xml
if err := writeStyles(zw); err != nil {
return nil, err
}
// content.xml
if err := writeContent(zw, wb.Sheets); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("close zip: %w", err)
}
return buf.Bytes(), nil
}
// writeManifest writes META-INF/manifest.xml.
func writeManifest(zw *zip.Writer) error {
w, err := zw.Create("META-INF/manifest.xml")
if err != nil {
return fmt.Errorf("create manifest: %w", err)
}
const manifest = xml.Header + `
`
_, err = w.Write([]byte(manifest))
return err
}
// writeMeta writes meta.xml with custom properties.
func writeMeta(zw *zip.Writer, meta map[string]string) error {
w, err := zw.Create("meta.xml")
if err != nil {
return fmt.Errorf("create meta.xml: %w", err)
}
var b strings.Builder
b.WriteString(xml.Header)
b.WriteString(``)
b.WriteString(``)
b.WriteString(`Silo`)
b.WriteString(fmt.Sprintf(`%s`, time.Now().UTC().Format(time.RFC3339)))
if len(meta) > 0 {
b.WriteString(``)
// Encode all meta as key=value pairs separated by newlines
var pairs []string
for k, v := range meta {
pairs = append(pairs, xmlEscape(k)+"="+xmlEscape(v))
}
b.WriteString(xmlEscape(strings.Join(pairs, "\n")))
b.WriteString(``)
}
b.WriteString(``)
b.WriteString(``)
_, err = w.Write([]byte(b.String()))
return err
}
// writeStyles writes styles.xml with header, currency, and hidden column styles.
func writeStyles(zw *zip.Writer) error {
w, err := zw.Create("styles.xml")
if err != nil {
return fmt.Errorf("create styles.xml: %w", err)
}
const styles = xml.Header + `
`
_, err = w.Write([]byte(styles))
return err
}
// writeContent writes content.xml containing all sheet data.
func writeContent(zw *zip.Writer, sheets []Sheet) error {
w, err := zw.Create("content.xml")
if err != nil {
return fmt.Errorf("create content.xml: %w", err)
}
var b strings.Builder
b.WriteString(xml.Header)
b.WriteString(``)
// Automatic styles (defined in content.xml for cell/column styles)
b.WriteString(``)
// Currency data style
b.WriteString(``)
b.WriteString(`$`)
b.WriteString(``)
b.WriteString(``)
// Header cell style (bold)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
// Currency cell style
b.WriteString(``)
b.WriteString(``)
// Row status colors
writeColorStyle(&b, "ce-synced", "#c6efce")
writeColorStyle(&b, "ce-modified", "#ffeb9c")
writeColorStyle(&b, "ce-new", "#bdd7ee")
writeColorStyle(&b, "ce-error", "#ffc7ce")
writeColorStyle(&b, "ce-conflict", "#f4b084")
// Column styles
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
// Body
b.WriteString(``)
b.WriteString(``)
for _, sheet := range sheets {
writeSheet(&b, &sheet)
}
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
_, err = w.Write([]byte(b.String()))
return err
}
func writeColorStyle(b *strings.Builder, name, color string) {
b.WriteString(fmt.Sprintf(``, name))
b.WriteString(fmt.Sprintf(``, color))
b.WriteString(``)
}
func writeSheet(b *strings.Builder, sheet *Sheet) {
b.WriteString(fmt.Sprintf(``, xmlEscape(sheet.Name)))
// Column definitions
if len(sheet.Columns) > 0 {
for _, col := range sheet.Columns {
styleName := "co-default"
if col.Width != "" {
styleName = "co-wide"
}
if col.Hidden {
b.WriteString(fmt.Sprintf(``, styleName))
} else {
b.WriteString(fmt.Sprintf(``, styleName))
}
}
}
// Rows
for _, row := range sheet.Rows {
if row.IsBlank {
b.WriteString(``)
b.WriteString(``)
b.WriteString(``)
continue
}
b.WriteString(``)
for _, cell := range row.Cells {
writeCell(b, &cell)
}
b.WriteString(``)
}
b.WriteString(``)
}
func writeCell(b *strings.Builder, cell *Cell) {
switch cell.Type {
case CellEmpty:
b.WriteString(``)
case CellFormula:
// Formula cells: the formula attribute uses the of: namespace
b.WriteString(fmt.Sprintf(``, xmlEscape(cell.Formula)))
b.WriteString(``)
case CellFloat:
b.WriteString(fmt.Sprintf(``, xmlEscape(cell.Value)))
b.WriteString(fmt.Sprintf(`%s`, xmlEscape(cell.Value)))
b.WriteString(``)
case CellCurrency:
b.WriteString(fmt.Sprintf(``, xmlEscape(cell.Value)))
b.WriteString(fmt.Sprintf(`$%s`, xmlEscape(cell.Value)))
b.WriteString(``)
case CellDate:
b.WriteString(fmt.Sprintf(``, xmlEscape(cell.Value)))
b.WriteString(fmt.Sprintf(`%s`, xmlEscape(cell.Value)))
b.WriteString(``)
default: // CellString
b.WriteString(``)
b.WriteString(fmt.Sprintf(`%s`, xmlEscape(cell.Value)))
b.WriteString(``)
}
}
// xmlEscape escapes special XML characters.
func xmlEscape(s string) string {
var b strings.Builder
if err := xml.EscapeText(&b, []byte(s)); err != nil {
return s
}
return b.String()
}
// Helper functions for building cells
// StringCell creates a string cell.
func StringCell(value string) Cell {
return Cell{Value: value, Type: CellString}
}
// FloatCell creates a float cell.
func FloatCell(value float64) Cell {
return Cell{Value: strconv.FormatFloat(value, 'f', -1, 64), Type: CellFloat}
}
// CurrencyCell creates a currency (USD) cell.
func CurrencyCell(value float64) Cell {
return Cell{Value: fmt.Sprintf("%.2f", value), Type: CellCurrency}
}
// FormulaCell creates a formula cell.
func FormulaCell(formula string) Cell {
return Cell{Formula: formula, Type: CellFormula}
}
// EmptyCell creates an empty cell.
func EmptyCell() Cell {
return Cell{Type: CellEmpty}
}
// IntCell creates an integer cell stored as float.
func IntCell(value int) Cell {
return Cell{Value: strconv.Itoa(value), Type: CellFloat}
}
// HeaderCell creates a string cell intended for header rows.
// The header style is applied at the row level or by the caller.
func HeaderCell(value string) Cell {
return Cell{Value: value, Type: CellString}
}