Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions lib/sheet/op.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func (o Op) Validate() error {
if o.Row < 0 || o.Col < 0 {
return fmt.Errorf("setCell negative coord")
}
if err := validateProps(o.Props); err != nil {
if err := ValidateProps(o.Props); err != nil {
return err
}
case OpSetStyle:
Expand All @@ -100,7 +100,7 @@ func (o Op) Validate() error {
if o.Row < 0 || o.Col < 0 {
return fmt.Errorf("setStyle negative coord")
}
if err := validateProps(o.Props); err != nil {
if err := ValidateProps(o.Props); err != nil {
return err
}
case OpClearRange:
Expand Down Expand Up @@ -161,10 +161,10 @@ var fontFamilies = map[string]bool{
"Courier New": true, "Georgia": true, "Verdana": true,
}

// validateProps allowlists style prop keys and values. Props come from
// ValidateProps allowlists style prop keys and values. Props come from
// arbitrary collaborators and end up as inline CSS on every viewer's DOM, so
// anything outside the known vocabulary is rejected (e.g. bg: "url(...)").
func validateProps(props map[string]string) error {
func ValidateProps(props map[string]string) error {
for k, v := range props {
ok := false
switch k {
Expand Down
70 changes: 64 additions & 6 deletions lib/xlsx/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,33 @@ import (

// Export renders a workbook to .xlsx bytes. Cells whose raw starts with '='
// become formulas; numeric-looking raw is written as a number, the rest as a
// string. Styles and merges are out of scope for v1 (the sheet model does not
// store them yet).
// string. Cell styles, column widths / row heights and freeze panes are
// carried over; merges are not (the sheet model does not store them).
func Export(wb *sheet.Workbook) ([]byte, error) {
f := excelize.NewFile()
defer f.Close()

const defaultSheet = "Sheet1"

// Lazily translate pool style ids to excelize style ids (shared per file).
styleIds := map[int]int{}
styleFor := func(id int) (int, error) {
if xid, ok := styleIds[id]; ok {
return xid, nil
}
st, ok := wb.Styles.Get(id)
if !ok || len(st.Props) == 0 {
styleIds[id] = 0
return 0, nil
}
xid, err := f.NewStyle(propsToStyle(st.Props))
if err != nil {
return 0, err
}
styleIds[id] = xid
return xid, nil
}

for i, s := range wb.Sheets {
name := s.Name
if name == "" {
Expand All @@ -44,13 +63,52 @@ func Export(wb *sheet.Workbook) ([]byte, error) {
if err := f.SetCellFormula(name, axis, cell.Raw[1:]); err != nil {
return nil, err
}
continue
}
if n, err := strconv.ParseFloat(cell.Raw, 64); err == nil {
} else if n, err := strconv.ParseFloat(cell.Raw, 64); err == nil && cell.Raw != "" {
if err := f.SetCellValue(name, axis, n); err != nil {
return nil, err
}
} else if err := f.SetCellValue(name, axis, cell.Raw); err != nil {
} else if cell.Raw != "" {
if err := f.SetCellValue(name, axis, cell.Raw); err != nil {
return nil, err
}
}
if cell.StyleId != 0 {
xid, err := styleFor(cell.StyleId)
if err != nil {
return nil, err
}
if xid != 0 {
if err := f.SetCellStyle(name, axis, axis, xid); err != nil {
return nil, err
}
}
}
}

for col, px := range s.ColWidths {
cn, err := excelize.ColumnNumberToName(col + 1)
if err != nil {
return nil, err
}
if err := f.SetColWidth(name, cn, cn, pxToColWidth(px)); err != nil {
return nil, err
}
}
for row, px := range s.RowHeights {
if err := f.SetRowHeight(name, row+1, pxToRowHeight(px)); err != nil {
return nil, err
}
}

if s.FrozenRows > 0 || s.FrozenCols > 0 {
topLeft, err := excelize.CoordinatesToCellName(s.FrozenCols+1, s.FrozenRows+1)
if err != nil {
return nil, err
}
if err := f.SetPanes(name, &excelize.Panes{
Freeze: true, XSplit: s.FrozenCols, YSplit: s.FrozenRows,
TopLeftCell: topLeft, ActivePane: "bottomRight",
}); err != nil {
return nil, err
}
}
Expand Down
83 changes: 79 additions & 4 deletions lib/xlsx/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@ package xlsx

import (
"io"
"math"

"github.com/ether/etherpad-go/lib/sheet"
"github.com/xuri/excelize/v2"
)

// The client grid is fixed at 200x52; dimension scans stop there.
const (
maxRows = 200
maxCols = 52
)

// Import parses an .xlsx into a WorkbookSnapshot. Sheet id == sheet name. Cells
// carry their raw value, or "=<formula>" when a formula is present. Styles and
// merges are skipped in v1 (no error; the sheet model does not store them yet).
// carry their raw value, or "=<formula>" when a formula is present. Cell styles
// (allowlisted props only), column widths / row heights and freeze panes are
// imported; merges are skipped (the sheet model does not store them).
func Import(r io.Reader) (sheet.WorkbookSnapshot, error) {
f, err := excelize.OpenReader(r)
if err != nil {
Expand All @@ -18,6 +26,23 @@ func Import(r io.Reader) (sheet.WorkbookSnapshot, error) {
defer f.Close()

wb := sheet.NewWorkbook()
// excelize style idx -> pool id (0 = nothing representable), shared across
// sheets since styles are file-global.
poolIds := map[int]int{}
poolIdFor := func(xid int) int {
if id, ok := poolIds[xid]; ok {
return id
}
id := 0
if st, err := f.GetStyle(xid); err == nil && st != nil {
if props := styleToProps(st); len(props) > 0 {
id = wb.Styles.Put(sheet.Style{Props: props})
}
}
poolIds[xid] = id
return id
}

for _, name := range f.GetSheetList() {
sh := wb.AddSheet(name, name)
rows, err := f.GetRows(name)
Expand All @@ -34,10 +59,60 @@ func Import(r io.Reader) (sheet.WorkbookSnapshot, error) {
if formula, ferr := f.GetCellFormula(name, axis); ferr == nil && formula != "" {
raw = "=" + formula
}
if raw == "" {
styleId := 0
if xid, serr := f.GetCellStyle(name, axis); serr == nil && xid != 0 {
styleId = poolIdFor(xid)
}
if raw == "" && styleId == 0 {
continue
}
sh.SetCell(sheet.CellRef{Row: rIdx, Col: cIdx}, sheet.Cell{Raw: raw})
sh.SetCell(sheet.CellRef{Row: rIdx, Col: cIdx}, sheet.Cell{Raw: raw, StyleId: styleId})
}
}

// Styled-but-empty cells never show up in GetRows, and excelize does
// not maintain the sheet dimension attribute, so sweep the whole grid
// for styles on cells not yet seen. ponytail: 200x52 lookups per
// sheet, fine for an import endpoint.
for r := range maxRows {
for c := range maxCols {
ref := sheet.CellRef{Row: r, Col: c}
if _, seen := sh.Cells[ref]; seen {
continue
}
axis, _ := excelize.CoordinatesToCellName(c+1, r+1)
if xid, serr := f.GetCellStyle(name, axis); serr == nil && xid != 0 {
if id := poolIdFor(xid); id != 0 {
sh.SetCell(ref, sheet.Cell{StyleId: id})
}
}
}
}

// Dimensions: excelize returns the sheet default for untouched
// indices, so probe a far-away column/row as the baseline and store
// only deviations. ponytail: scans the fixed grid extent (52/200).
baseW, _ := f.GetColWidth(name, "XFD")
for c := range maxCols {
cn, _ := excelize.ColumnNumberToName(c + 1)
if w, err := f.GetColWidth(name, cn); err == nil && math.Abs(w-baseW) > 0.01 {
sh.ColWidths[c] = colWidthToPx(w)
}
}
baseH, _ := f.GetRowHeight(name, excelize.TotalRows)
for r := range maxRows {
if h, err := f.GetRowHeight(name, r+1); err == nil && math.Abs(h-baseH) > 0.01 {
sh.RowHeights[r] = rowHeightToPx(h)
}
}

// Freeze panes: the model only supports freezing the first row/col.
if panes, err := f.GetPanes(name); err == nil && panes.Freeze {
if panes.YSplit > 0 {
sh.FrozenRows = 1
}
if panes.XSplit > 0 {
sh.FrozenCols = 1
}
}
}
Expand Down
115 changes: 115 additions & 0 deletions lib/xlsx/import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

"github.com/ether/etherpad-go/lib/sheet"
"github.com/xuri/excelize/v2"
)

func TestImportExportRoundTrip(t *testing.T) {
Expand Down Expand Up @@ -38,3 +39,117 @@ func TestImportExportRoundTrip(t *testing.T) {
t.Fatalf("B1 = %q", sh.GetCell(sheet.CellRef{Row: 0, Col: 1}).Raw)
}
}

func TestRoundTripStylesDimsAndFreeze(t *testing.T) {
wb := sheet.NewWorkbook()
s := wb.AddSheet("Data", "Data")
props := map[string]string{
"bold": "1", "italic": "1", "underline": "1",
"color": "#ff0000", "bg": "#00ff00", "align": "center",
"border": "all", "wrap": "1",
"fontFamily": "Arial", "fontSize": "14",
"numFmt": "currency:2",
}
styleId := wb.Styles.Put(sheet.Style{Props: props})
s.SetCell(sheet.CellRef{Row: 0, Col: 0}, sheet.Cell{Raw: "42", StyleId: styleId})
// styled but empty cell must survive too
s.SetCell(sheet.CellRef{Row: 1, Col: 1}, sheet.Cell{StyleId: styleId})
s.ColWidths[2] = 150
s.RowHeights[3] = 40
s.FrozenRows, s.FrozenCols = 1, 1

data, err := Export(wb)
if err != nil {
t.Fatalf("Export: %v", err)
}
snap, err := Import(bytes.NewReader(data))
if err != nil {
t.Fatalf("Import: %v", err)
}
got := sheet.WorkbookFromSnapshot(snap)
sh := got.SheetByID("Data")
if sh == nil {
t.Fatal("sheet Data missing")
}

for _, ref := range []sheet.CellRef{{Row: 0, Col: 0}, {Row: 1, Col: 1}} {
c := sh.GetCell(ref)
if c.StyleId == 0 {
t.Fatalf("cell %v lost its style", ref)
}
st, _ := got.Styles.Get(c.StyleId)
for k, want := range props {
if st.Props[k] != want {
t.Errorf("cell %v prop %s = %q, want %q", ref, k, st.Props[k], want)
}
}
}
if px := sh.ColWidths[2]; px < 148 || px > 152 { // unit conversion rounds
t.Errorf("col 2 width = %d, want ~150", px)
}
if px := sh.RowHeights[3]; px < 38 || px > 42 {
t.Errorf("row 3 height = %d, want ~40", px)
}
if sh.FrozenRows != 1 || sh.FrozenCols != 1 {
t.Errorf("freeze = %d/%d, want 1/1", sh.FrozenRows, sh.FrozenCols)
}
if len(sh.ColWidths) != 1 || len(sh.RowHeights) != 1 {
t.Errorf("dims not sparse: cols=%v rows=%v", sh.ColWidths, sh.RowHeights)
}
}

// TestImportForeignFile builds a file the way Excel would (builtin numFmt ids,
// style on a value cell) rather than via our own Export, so mapping gaps can't
// hide behind a symmetric round-trip.
func TestImportForeignFile(t *testing.T) {
f := excelize.NewFile()
styleId, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FF0000"},
NumFmt: 10, // builtin "0.00%"
})
if err != nil {
t.Fatalf("NewStyle: %v", err)
}
if err := f.SetCellValue("Sheet1", "A1", 0.5); err != nil {
t.Fatalf("SetCellValue: %v", err)
}
if err := f.SetCellStyle("Sheet1", "A1", "A1", styleId); err != nil {
t.Fatalf("SetCellStyle: %v", err)
}
buf, err := f.WriteToBuffer()
if err != nil {
t.Fatalf("WriteToBuffer: %v", err)
}

snap, err := Import(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("Import: %v", err)
}
wb := sheet.WorkbookFromSnapshot(snap)
c := wb.SheetByID("Sheet1").GetCell(sheet.CellRef{Row: 0, Col: 0})
if c.StyleId == 0 {
t.Fatal("A1 has no style")
}
st, _ := wb.Styles.Get(c.StyleId)
if st.Props["bold"] != "1" || st.Props["color"] != "#ff0000" || st.Props["numFmt"] != "percent:2" {
t.Fatalf("props = %v", st.Props)
}
}

func TestNumFmtCodeMapping(t *testing.T) {
cases := map[string]string{
"@": "text", "m/d/yyyy": "date", "0.00%": "percent:2",
"$#,##0.00": "currency:2", "#,##0": "number:0", "General": "",
}
for code, want := range cases {
if got := codeToNumFmt(code); got != want {
t.Errorf("codeToNumFmt(%q) = %q, want %q", code, got, want)
}
}
// symbolic -> code -> symbolic is stable
for _, nf := range []string{"text", "date", "number:2", "currency:1", "percent:0"} {
if got := codeToNumFmt(numFmtToCode(nf)); got != nf {
t.Errorf("roundtrip %q -> %q", nf, got)
}
}
}
Loading
Loading