diff --git a/lib/sheet/op.go b/lib/sheet/op.go index f5da0cf3..c430bf85 100644 --- a/lib/sheet/op.go +++ b/lib/sheet/op.go @@ -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: @@ -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: @@ -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 { diff --git a/lib/xlsx/export.go b/lib/xlsx/export.go index fe778181..21284483 100644 --- a/lib/xlsx/export.go +++ b/lib/xlsx/export.go @@ -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 == "" { @@ -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 } } diff --git a/lib/xlsx/import.go b/lib/xlsx/import.go index 7839e3a2..bfb4a2ec 100644 --- a/lib/xlsx/import.go +++ b/lib/xlsx/import.go @@ -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 "=" 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 "=" 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 { @@ -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) @@ -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 } } } diff --git a/lib/xlsx/import_test.go b/lib/xlsx/import_test.go index cafb02da..c74d292c 100644 --- a/lib/xlsx/import_test.go +++ b/lib/xlsx/import_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/ether/etherpad-go/lib/sheet" + "github.com/xuri/excelize/v2" ) func TestImportExportRoundTrip(t *testing.T) { @@ -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) + } + } +} diff --git a/lib/xlsx/style.go b/lib/xlsx/style.go new file mode 100644 index 00000000..92052772 --- /dev/null +++ b/lib/xlsx/style.go @@ -0,0 +1,238 @@ +package xlsx + +import ( + "fmt" + "math" + "regexp" + "strings" + + "github.com/ether/etherpad-go/lib/sheet" + "github.com/xuri/excelize/v2" +) + +// Mapping between the sheet model's allowlisted style props (see +// sheet.ValidateProps) and excelize styles / xlsx dimensions. + +// px <-> xlsx unit conversions. Column width is in "character" units +// (px ≈ w*7+5 for the default Calibri 11); row height is in points. +func colWidthToPx(w float64) int { return int(math.Round(w*7 + 5)) } +func pxToColWidth(px int) float64 { return float64(px-5) / 7 } +func rowHeightToPx(h float64) int { return int(math.Round(h * 96 / 72)) } +func pxToRowHeight(px int) float64 { return float64(px) * 72 / 96 } + +// numFmtToCode maps the model's symbolic numFmt (general|text|date| +// number[:d]|currency[:d]|percent[:d]) to an xlsx format code. +func numFmtToCode(numFmt string) string { + kind, dec, hasDec := strings.Cut(numFmt, ":") + frac := "" + if hasDec { + if n := atoiSafe(dec); n > 0 { + frac = "." + strings.Repeat("0", n) + } + } + switch kind { + case "text": + return "@" + case "date": + return "m/d/yyyy" // matches the client's en-US display (format.ts) + case "number": + return "#,##0" + frac + case "currency": + if !hasDec { + frac = ".00" // Intl currency default + } + return "$#,##0" + frac + case "percent": + return "0" + frac + "%" + } + return "" // general +} + +var fracRe = regexp.MustCompile(`\.(0+)`) + +// builtin xlsx numFmt ids -> format codes for the ones we can represent. +var builtinNumFmt = map[int]string{ + 1: "0", 2: "0.00", 3: "#,##0", 4: "#,##0.00", + 5: "$#,##0", 6: "$#,##0", 7: "$#,##0.00", 8: "$#,##0.00", + 9: "0%", 10: "0.00%", + 14: "m/d/yyyy", 15: "d-mmm-yy", 16: "d-mmm", 17: "mmm-yy", 22: "m/d/yy h:mm", + 37: "#,##0", 38: "#,##0", 39: "#,##0.00", 40: "#,##0.00", + 44: "$#,##0.00", 49: "@", +} + +// codeToNumFmt classifies an xlsx format code back into the symbolic model +// vocabulary; "" means general / unrepresentable (prop omitted). +func codeToNumFmt(code string) string { + if code == "" || strings.EqualFold(code, "general") { + return "" + } + if code == "@" { + return "text" + } + dec := 0 + if m := fracRe.FindStringSubmatch(code); m != nil { + dec = len(m[1]) + } + if dec > 99 { + dec = 99 // validator caps at 2 digits + } + low := strings.ToLower(code) + switch { + case strings.ContainsAny(low, "ymd") && !strings.Contains(low, "red"): // date letters; "[Red]" is a color, not a date + return "date" + case strings.Contains(code, "%"): + return fmt.Sprintf("percent:%d", dec) + case strings.ContainsAny(code, "$€£") || strings.Contains(code, "[$"): + return fmt.Sprintf("currency:%d", dec) + case strings.ContainsAny(code, "0#"): + return fmt.Sprintf("number:%d", dec) + } + return "" +} + +func atoiSafe(s string) int { + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + return 0 + } + n = n*10 + int(r-'0') + } + return n +} + +// normalizeHex converts an excelize color ("FF0000", "FFFF0000" ARGB, with or +// without '#') to "#rrggbb", or "" if it isn't a plain hex color. +func normalizeHex(c string) string { + c = strings.TrimPrefix(c, "#") + if len(c) == 8 { + c = c[2:] // drop ARGB alpha + } + if len(c) != 6 { + return "" + } + for _, r := range c { + if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F') { + return "" + } + } + return "#" + strings.ToLower(c) +} + +// expandHex turns a model color ("#abc" or "#aabbcc") into excelize's +// six-digit form without '#'. +func expandHex(c string) string { + c = strings.TrimPrefix(c, "#") + if len(c) == 3 { + c = string([]byte{c[0], c[0], c[1], c[1], c[2], c[2]}) + } + return c +} + +// propsToStyle builds an excelize style from model props. +func propsToStyle(props map[string]string) *excelize.Style { + st := &excelize.Style{} + font := &excelize.Font{} + hasFont := false + if props["bold"] == "1" { + font.Bold, hasFont = true, true + } + if props["italic"] == "1" { + font.Italic, hasFont = true, true + } + if props["underline"] == "1" { + font.Underline, hasFont = "single", true + } + if c := props["color"]; c != "" { + font.Color, hasFont = expandHex(c), true + } + if fam := props["fontFamily"]; fam != "" { + font.Family, hasFont = fam, true + } + if sz := atoiSafe(props["fontSize"]); sz > 0 { + font.Size, hasFont = float64(sz), true + } + if hasFont { + st.Font = font + } + if bg := props["bg"]; bg != "" { + st.Fill = excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{expandHex(bg)}} + } + if props["align"] != "" || props["wrap"] == "1" { + st.Alignment = &excelize.Alignment{Horizontal: props["align"], WrapText: props["wrap"] == "1"} + } + if props["border"] == "all" { + for _, side := range []string{"left", "right", "top", "bottom"} { + st.Border = append(st.Border, excelize.Border{Type: side, Style: 1, Color: "000000"}) + } + } + if code := numFmtToCode(props["numFmt"]); code != "" { + st.CustomNumFmt = &code + } + return st +} + +// styleToProps converts an excelize style back to model props. Only values the +// allowlist accepts are emitted; everything else is dropped silently. +func styleToProps(st *excelize.Style) map[string]string { + props := map[string]string{} + if f := st.Font; f != nil { + if f.Bold { + props["bold"] = "1" + } + if f.Italic { + props["italic"] = "1" + } + if f.Underline != "" && f.Underline != "none" { + props["underline"] = "1" + } + if c := normalizeHex(f.Color); c != "" { + props["color"] = c + } + if f.Family != "" { + props["fontFamily"] = f.Family + } + if sz := int(math.Round(f.Size)); sz >= 6 && sz <= 96 { + props["fontSize"] = fmt.Sprintf("%d", sz) + } + } + if st.Fill.Type == "pattern" && st.Fill.Pattern > 0 && len(st.Fill.Color) > 0 { + if c := normalizeHex(st.Fill.Color[0]); c != "" { + props["bg"] = c + } + } + if a := st.Alignment; a != nil { + if a.Horizontal == "left" || a.Horizontal == "center" || a.Horizontal == "right" { + props["align"] = a.Horizontal + } + if a.WrapText { + props["wrap"] = "1" + } + } + sides := 0 + for _, b := range st.Border { + if b.Style > 0 && (b.Type == "left" || b.Type == "right" || b.Type == "top" || b.Type == "bottom") { + sides++ + } + } + if sides == 4 { + props["border"] = "all" + } + code := "" + if st.CustomNumFmt != nil { + code = *st.CustomNumFmt + } else if st.NumFmt > 0 { + code = builtinNumFmt[st.NumFmt] + } + if nf := codeToNumFmt(code); nf != "" { + props["numFmt"] = nf + } + // Final gate: uploaded files are untrusted and props become inline CSS on + // every viewer's DOM, so re-check against the same allowlist ops go through. + for k, v := range props { + if sheet.ValidateProps(map[string]string{k: v}) != nil { + delete(props, k) + } + } + return props +}