Skip to content
Open
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
22 changes: 22 additions & 0 deletions lib/sheet/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,24 @@ func (w *Workbook) Apply(op Op) error {
case OpSetFreeze:
s.FrozenRows = op.FrozenRows
s.FrozenCols = op.FrozenCols
case OpMergeCells:
if op.EndRow == op.Row && op.EndCol == op.Col {
return nil // degenerate 1x1 (e.g. collapsed by a rebase): no-op
}
// Excel semantics: merging over existing merges absorbs them. Cell
// content is kept (the view hides non-anchor cells; unmerge reveals it).
for a, sp := range s.Merges {
if intersects(a, sp, op.Row, op.Col, op.EndRow, op.EndCol) {
delete(s.Merges, a)
}
}
s.Merges[CellRef{op.Row, op.Col}] = Span{Rows: op.EndRow - op.Row + 1, Cols: op.EndCol - op.Col + 1}
case OpUnmergeCells:
for a, sp := range s.Merges {
if intersects(a, sp, op.Row, op.Col, op.EndRow, op.EndCol) {
delete(s.Merges, a)
}
}
case OpInsertRows:
s.remap(func(r CellRef) (CellRef, bool) {
if r.Row >= op.Index {
Expand All @@ -105,6 +123,7 @@ func (w *Workbook) Apply(op Op) error {
return r, true
})
s.RowHeights = shiftDims(s.RowHeights, op.Index, op.Count)
s.Merges = shiftMerges(s.Merges, "row", op.Index, op.Count)
case OpDeleteRows:
s.remap(func(r CellRef) (CellRef, bool) {
if r.Row >= op.Index && r.Row < op.Index+op.Count {
Expand All @@ -116,6 +135,7 @@ func (w *Workbook) Apply(op Op) error {
return r, true
})
s.RowHeights = shiftDims(s.RowHeights, op.Index, -op.Count)
s.Merges = shiftMerges(s.Merges, "row", op.Index, -op.Count)
case OpInsertCols:
s.remap(func(r CellRef) (CellRef, bool) {
if r.Col >= op.Index {
Expand All @@ -124,6 +144,7 @@ func (w *Workbook) Apply(op Op) error {
return r, true
})
s.ColWidths = shiftDims(s.ColWidths, op.Index, op.Count)
s.Merges = shiftMerges(s.Merges, "col", op.Index, op.Count)
case OpDeleteCols:
s.remap(func(r CellRef) (CellRef, bool) {
if r.Col >= op.Index && r.Col < op.Index+op.Count {
Expand All @@ -135,6 +156,7 @@ func (w *Workbook) Apply(op Op) error {
return r, true
})
s.ColWidths = shiftDims(s.ColWidths, op.Index, -op.Count)
s.Merges = shiftMerges(s.Merges, "col", op.Index, -op.Count)
default:
return fmt.Errorf("apply: unhandled op type %q", op.Type)
}
Expand Down
141 changes: 141 additions & 0 deletions lib/sheet/merge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package sheet

import "testing"

func mergeOp(sheet string, r0, c0, r1, c1 int) Op {
return Op{Type: OpMergeCells, Sheet: sheet, Row: r0, Col: c0, EndRow: r1, EndCol: c1}
}

func TestMergeUnmergeApply(t *testing.T) {
wb := NewWorkbook()
wb.AddSheet("s1", "Sheet1")

if err := wb.Apply(mergeOp("s1", 1, 1, 3, 2)); err != nil {
t.Fatalf("merge: %v", err)
}
s := wb.SheetByID("s1")
if sp := s.Merges[CellRef{1, 1}]; sp != (Span{Rows: 3, Cols: 2}) {
t.Fatalf("merge span = %+v", sp)
}

// Overlapping merge absorbs the existing one (Excel semantics).
if err := wb.Apply(mergeOp("s1", 2, 2, 5, 5)); err != nil {
t.Fatalf("merge 2: %v", err)
}
if len(s.Merges) != 1 {
t.Fatalf("expected absorb, merges = %v", s.Merges)
}
if sp := s.Merges[CellRef{2, 2}]; sp != (Span{Rows: 4, Cols: 4}) {
t.Fatalf("absorbed span = %+v", sp)
}

// Unmerge by any intersecting rectangle.
if err := wb.Apply(Op{Type: OpUnmergeCells, Sheet: "s1", Row: 3, Col: 3, EndRow: 3, EndCol: 3}); err != nil {
t.Fatalf("unmerge: %v", err)
}
if len(s.Merges) != 0 {
t.Fatalf("merges left after unmerge: %v", s.Merges)
}

// Degenerate 1x1 merge (possible after rebase) is a no-op, not an error.
if err := wb.Apply(mergeOp("s1", 0, 0, 0, 0)); err != nil {
t.Fatalf("1x1 merge: %v", err)
}
if len(s.Merges) != 0 {
t.Fatalf("1x1 merge stored: %v", s.Merges)
}
}

func TestMergeStructuralShifts(t *testing.T) {
newWb := func() *Workbook {
wb := NewWorkbook()
wb.AddSheet("s1", "Sheet1")
// rows 2-4, cols 1-2
if err := wb.Apply(mergeOp("s1", 2, 1, 4, 2)); err != nil {
t.Fatalf("seed merge: %v", err)
}
return wb
}

// Insert above: merge moves down.
wb := newWb()
_ = wb.Apply(Op{Type: OpInsertRows, Sheet: "s1", Index: 0, Count: 2})
if sp, ok := wb.SheetByID("s1").Merges[CellRef{4, 1}]; !ok || sp != (Span{3, 2}) {
t.Fatalf("insert above: %v", wb.SheetByID("s1").Merges)
}

// Insert exactly at the trailing edge (row 5, right after rows 2-4):
// the merge must NOT grow (qodo finding on the exclusive-end shift).
wb = newWb()
_ = wb.Apply(Op{Type: OpInsertRows, Sheet: "s1", Index: 5, Count: 2})
if sp, ok := wb.SheetByID("s1").Merges[CellRef{2, 1}]; !ok || sp != (Span{3, 2}) {
t.Fatalf("insert at trailing edge: %v", wb.SheetByID("s1").Merges)
}

// Insert inside: merge grows.
wb = newWb()
_ = wb.Apply(Op{Type: OpInsertRows, Sheet: "s1", Index: 3, Count: 1})
if sp, ok := wb.SheetByID("s1").Merges[CellRef{2, 1}]; !ok || sp != (Span{4, 2}) {
t.Fatalf("insert inside: %v", wb.SheetByID("s1").Merges)
}

// Delete a band overlapping the bottom: merge shrinks.
wb = newWb()
_ = wb.Apply(Op{Type: OpDeleteRows, Sheet: "s1", Index: 4, Count: 3})
if sp, ok := wb.SheetByID("s1").Merges[CellRef{2, 1}]; !ok || sp != (Span{2, 2}) {
t.Fatalf("delete bottom: %v", wb.SheetByID("s1").Merges)
}

// Delete the whole row range: merge is dropped.
wb = newWb()
_ = wb.Apply(Op{Type: OpDeleteRows, Sheet: "s1", Index: 2, Count: 3})
if len(wb.SheetByID("s1").Merges) != 0 {
t.Fatalf("delete all rows: %v", wb.SheetByID("s1").Merges)
}

// Delete cols so the merge collapses to a single column AND single... no:
// rows stay 3, cols 2->1: still a 3x1 merge (kept). Deleting both cols drops it.
wb = newWb()
_ = wb.Apply(Op{Type: OpDeleteCols, Sheet: "s1", Index: 1, Count: 1})
if sp, ok := wb.SheetByID("s1").Merges[CellRef{2, 1}]; !ok || sp != (Span{3, 1}) {
t.Fatalf("delete one col: %v", wb.SheetByID("s1").Merges)
}
_ = wb.Apply(Op{Type: OpDeleteCols, Sheet: "s1", Index: 1, Count: 1})
if len(wb.SheetByID("s1").Merges) != 0 {
t.Fatalf("delete both cols: %v", wb.SheetByID("s1").Merges)
}
}

func TestMergeTransform(t *testing.T) {
in := mergeOp("s1", 2, 1, 4, 2)
out := Transform(in, Op{Type: OpInsertRows, Sheet: "s1", Index: 3, Count: 2})
if out.Row != 2 || out.EndRow != 6 {
t.Fatalf("transform insert: %+v", out)
}
out = Transform(in, Op{Type: OpDeleteRows, Sheet: "s1", Index: 0, Count: 2})
if out.Row != 0 || out.EndRow != 2 {
t.Fatalf("transform delete: %+v", out)
}
// Concurrent delete of the entire range collapses the merge to 1x1 in one
// dimension; Submit must not error (Apply no-ops when fully degenerate).
out = Transform(in, Op{Type: OpDeleteRows, Sheet: "s1", Index: 2, Count: 3})
if out.Row != 2 || out.EndRow != 2 {
t.Fatalf("transform collapse: %+v", out)
}
if err := out.Validate(); err != nil {
t.Fatalf("collapsed merge must stay valid: %v", err)
}
}

func TestMergeSnapshotRoundTrip(t *testing.T) {
wb := NewWorkbook()
wb.AddSheet("s1", "Sheet1")
_ = wb.Apply(mergeOp("s1", 0, 0, 1, 1))
_ = wb.Apply(mergeOp("s1", 5, 5, 5, 7))

got := WorkbookFromSnapshot(wb.Snapshot())
s := got.SheetByID("s1")
if len(s.Merges) != 2 || s.Merges[CellRef{0, 0}] != (Span{2, 2}) || s.Merges[CellRef{5, 5}] != (Span{1, 3}) {
t.Fatalf("snapshot roundtrip merges = %v", s.Merges)
}
}
9 changes: 7 additions & 2 deletions lib/sheet/op.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const (
// Grid metadata ops.
OpSetDimension OpType = "setDimension"
OpSetFreeze OpType = "setFreeze"
// Merged cells: both carry the Row/Col..EndRow/EndCol rectangle.
OpMergeCells OpType = "mergeCells"
OpUnmergeCells OpType = "unmergeCells"
)

// Op is one cell-based operation. BaseRev is the workbook revision the client
Expand Down Expand Up @@ -103,9 +106,11 @@ func (o Op) Validate() error {
if err := ValidateProps(o.Props); err != nil {
return err
}
case OpClearRange:
// mergeCells allows a degenerate 1x1 rectangle (Apply no-ops it): rebasing
// past a concurrent row/col delete can collapse a valid merge to one cell.
case OpClearRange, OpMergeCells, OpUnmergeCells:
if o.Row < 0 || o.Col < 0 || o.EndRow < o.Row || o.EndCol < o.Col {
return fmt.Errorf("clearRange invalid bounds")
return fmt.Errorf("%s invalid bounds", o.Type)
}
case OpInsertRows, OpDeleteRows, OpInsertCols, OpDeleteCols:
if o.Index < 0 {
Expand Down
66 changes: 65 additions & 1 deletion lib/sheet/sheet.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ package sheet

import "maps"

// Span is the extent of a merged-cell range, keyed by its top-left anchor.
// Rows/Cols >= 1; a 1x1 span is never stored.
type Span struct {
Rows int `json:"rows"`
Cols int `json:"cols"`
}

// Sheet is a single tab: sparse cells plus structural metadata.
type Sheet struct {
Id string `json:"id"`
Expand All @@ -13,10 +20,13 @@ type Sheet struct {
// 0 or 1 each: freeze the first row / first col (position: sticky in the view).
FrozenRows int `json:"-"`
FrozenCols int `json:"-"`
// Merged-cell ranges keyed by top-left anchor. Non-anchor cells keep their
// content (hidden by the view); unmerge reveals it again.
Merges map[CellRef]Span `json:"-"`
}

func NewSheet(id, name string) *Sheet {
return &Sheet{Id: id, Name: name, Cells: map[CellRef]Cell{}, ColWidths: map[int]int{}, RowHeights: map[int]int{}}
return &Sheet{Id: id, Name: name, Cells: map[CellRef]Cell{}, ColWidths: map[int]int{}, RowHeights: map[int]int{}, Merges: map[CellRef]Span{}}
}

// SetCell stores a cell, dropping it from storage if empty (keeps it sparse).
Expand All @@ -38,6 +48,7 @@ func (s *Sheet) clone() *Sheet {
Id: s.Id, Name: s.Name, Cells: make(map[CellRef]Cell, len(s.Cells)),
ColWidths: maps.Clone(s.ColWidths), RowHeights: maps.Clone(s.RowHeights),
FrozenRows: s.FrozenRows, FrozenCols: s.FrozenCols,
Merges: maps.Clone(s.Merges),
}
maps.Copy(cp.Cells, s.Cells)
return cp
Expand All @@ -60,6 +71,59 @@ func shiftDims(m map[int]int, index, delta int) map[int]int {
return next
}

// intersects reports whether the merge at anchor overlaps the inclusive
// rectangle [r0..r1] x [c0..c1].
func intersects(anchor CellRef, sp Span, r0, c0, r1, c1 int) bool {
return anchor.Row <= r1 && anchor.Row+sp.Rows-1 >= r0 &&
anchor.Col <= c1 && anchor.Col+sp.Cols-1 >= c0
}

// shiftMerges rebuilds the merge map after a row (axis "row") or col insert
// (delta > 0) / delete of -delta indices at index. Endpoints shift like cell
// coordinates (an insert inside a merge grows it, a delete shrinks it);
// merges that collapse to a single cell are dropped.
func shiftMerges(m map[CellRef]Span, axis string, index, delta int) map[CellRef]Span {
if len(m) == 0 {
return m
}
next := make(map[CellRef]Span, len(m))
for a, sp := range m {
lo, span := a.Row, sp.Rows
if axis == "col" {
lo, span = a.Col, sp.Cols
}
// Shift the anchor and the EXCLUSIVE end. The end needs shiftEnd: on
// insert an exclusive bound only moves for index STRICTLY inside
// (coord > index), or an insert at the merge's trailing edge would
// grow it; on delete shiftCoord's in-band clamp is exactly right.
nlo := shiftCoord(lo, index, delta)
nspan := shiftEnd(lo+span, index, delta) - nlo
if nspan <= 0 {
continue // merge entirely inside a deleted band
}
na, nsp := a, sp
if axis == "col" {
na.Col, nsp.Cols = nlo, nspan
} else {
na.Row, nsp.Rows = nlo, nspan
}
if nsp.Rows <= 1 && nsp.Cols <= 1 {
continue // collapsed to a single cell
}
next[na] = nsp
}
return next
}

// shiftEnd shifts an EXCLUSIVE upper bound: like shiftCoord, except an insert
// exactly at the bound (coord == index) does not move it.
func shiftEnd(coord, index, delta int) int {
if delta >= 0 && coord == index {
return coord
}
return shiftCoord(coord, index, delta)
}

// remap rebuilds the sparse cell map by transforming each ref. The fn returns
// the new ref and whether to keep the cell. Used by structural row/col ops.
func (s *Sheet) remap(fn func(CellRef) (CellRef, bool)) {
Expand Down
29 changes: 25 additions & 4 deletions lib/sheet/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,24 @@ type CellSnapshot struct {
StyleId int `json:"styleId,omitempty"`
}

// MergeSnapshot is one merged range: top-left anchor plus its span.
type MergeSnapshot struct {
Row int `json:"row"`
Col int `json:"col"`
Rows int `json:"rows"`
Cols int `json:"cols"`
}

type SheetSnapshot struct {
Id string `json:"id"`
Name string `json:"name"`
Cells []CellSnapshot `json:"cells"`
// Sparse dimension overrides; JSON object keys are stringified indices.
ColWidths map[int]int `json:"colWidths,omitempty"`
RowHeights map[int]int `json:"rowHeights,omitempty"`
FrozenRows int `json:"frozenRows,omitempty"`
FrozenCols int `json:"frozenCols,omitempty"`
ColWidths map[int]int `json:"colWidths,omitempty"`
RowHeights map[int]int `json:"rowHeights,omitempty"`
FrozenRows int `json:"frozenRows,omitempty"`
FrozenCols int `json:"frozenCols,omitempty"`
Merges []MergeSnapshot `json:"merges,omitempty"`
}

// WorkbookSnapshot is the JSON-serializable form of a Workbook for persistence.
Expand Down Expand Up @@ -57,6 +66,15 @@ func (w *Workbook) Snapshot() WorkbookSnapshot {
if len(s.RowHeights) > 0 {
ss.RowHeights = maps.Clone(s.RowHeights)
}
for a, sp := range s.Merges {
ss.Merges = append(ss.Merges, MergeSnapshot{a.Row, a.Col, sp.Rows, sp.Cols})
}
sort.Slice(ss.Merges, func(a, b int) bool {
if ss.Merges[a].Row != ss.Merges[b].Row {
return ss.Merges[a].Row < ss.Merges[b].Row
}
return ss.Merges[a].Col < ss.Merges[b].Col
})
out.Sheets[i] = ss
}
return out
Expand Down Expand Up @@ -86,6 +104,9 @@ func WorkbookFromSnapshot(snap WorkbookSnapshot) *Workbook {
maps.Copy(sh.ColWidths, ss.ColWidths)
maps.Copy(sh.RowHeights, ss.RowHeights)
sh.FrozenRows, sh.FrozenCols = ss.FrozenRows, ss.FrozenCols
for _, m := range ss.Merges {
sh.Merges[CellRef{m.Row, m.Col}] = Span{Rows: m.Rows, Cols: m.Cols}
}
w.Sheets[i] = sh
}
return w
Expand Down
9 changes: 7 additions & 2 deletions lib/sheet/transform.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ func Transform(in, applied Op) Op {

// shiftRows moves row coordinates of `in` by delta for rows at/after index.
// delta > 0 is an insert; delta < 0 is a delete (band [index, index-delta)).
// hasRange reports whether the op carries an EndRow/EndCol rectangle.
func hasRange(t OpType) bool {
return t == OpClearRange || t == OpMergeCells || t == OpUnmergeCells
}

func shiftRows(in Op, index, delta int) Op {
in.Row = shiftCoord(in.Row, index, delta)
if in.Type == OpClearRange {
if hasRange(in.Type) {
in.EndRow = shiftCoord(in.EndRow, index, delta)
}
if in.Type == OpInsertRows || in.Type == OpDeleteRows {
Expand All @@ -39,7 +44,7 @@ func shiftRows(in Op, index, delta int) Op {

func shiftCols(in Op, index, delta int) Op {
in.Col = shiftCoord(in.Col, index, delta)
if in.Type == OpClearRange {
if hasRange(in.Type) {
in.EndCol = shiftCoord(in.EndCol, index, delta)
}
if in.Type == OpInsertCols || in.Type == OpDeleteCols {
Expand Down
Loading
Loading