oficina / sheets Public

XLSX import and export, sealed blobs, content-free errors

Import/Export (internal/sheets/xlsx) round-trip a tab through excelize,
confined to this package. Fill hex is retained verbatim alongside the
resolved OKLCH intent so a file we did not author re-exports byte-
identical in colour terms (TestImportedFillHexIsRetained, live-fire
checked). Formula cells are re-emitted from their exact original text
(Handle.RawInput), not flattened to a computed value — a gap the first
draft of the round-trip diff missed entirely until proven by a deliberate
break. Import errors carry only coordinates and codes (calc.A1), never
cell content — also live-fire checked against a canary. StoreSealed/
LoadSealed route generated/uploaded workbooks through the real
rastrillo/blobs.Sealed (verified against v0.20.0 source, not assumed).

store.Handle gained six small, additive methods (EnsureRow/EnsureCol,
SetFormat, RawInput, GridRows/GridCols, Cells) that XLSX import/export
needs and task 10's Handle didn't expose: growing a tab's rows/columns
by spreadsheet position, writing (not just reading) a cell's Format, and
enumerating a tab's actual populated cells without a quadratic rows x
cols walk.

Golden corpus (testdata/corpus/xlsx) generated by a skipped-by-default
test helper (gencorpus_test.go) rather than a tools/ command, keeping
excelize out of the rest of the tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
Paul Campbell pushed by paul@keymail.dev ab0acaf84acc2ff0cdd066146f8428a602b7ccfe parent 33441d2
22 files changed, +1944 −11
  • go.mod +8 −1
  • go.sum +29 −10
  • internal/sheets/store/handle.go +13 −0
  • internal/sheets/store/handle_grid.go +233 −0
  • internal/sheets/store/handle_grid_test.go +265 −0
  • internal/sheets/store/handle_open.go +4 −0
  • internal/sheets/xlsx/blob.go +59 −0
  • internal/sheets/xlsx/blob_test.go +97 −0
  • internal/sheets/xlsx/colors.go +168 −0
  • internal/sheets/xlsx/colors_test.go +66 −0
  • internal/sheets/xlsx/export.go +114 −0
  • internal/sheets/xlsx/format.go +112 −0
  • internal/sheets/xlsx/gencorpus_test.go +137 −0
  • internal/sheets/xlsx/helpers_test.go +292 −0
  • internal/sheets/xlsx/import.go +165 −0
  • internal/sheets/xlsx/plaintext_test.go +73 −0
  • internal/sheets/xlsx/roundtrip_test.go +109 −0
  • testdata/corpus/xlsx/blanks.xlsx binary
  • testdata/corpus/xlsx/fills.xlsx binary
  • testdata/corpus/xlsx/formulas.xlsx binary
  • testdata/corpus/xlsx/numberformats.xlsx binary
  • testdata/corpus/xlsx/numbers.xlsx binary
diff --git a/go.mod b/go.mod
index 70c0c7e..3df235d 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,8 @@ go 1.25.0
require (
github.com/carlosframework/rastrillo v0.20.0
github.com/go-chi/chi/v5 v5.3.2
+ github.com/xuri/excelize/v2 v2.11.0
+ golang.org/x/crypto v0.55.0
gorm.io/gorm v1.31.2
)
@@ -16,7 +18,12 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- golang.org/x/crypto v0.55.0 // indirect
+ github.com/richardlehane/mscfb v1.0.7 // indirect
+ github.com/richardlehane/msoleps v1.0.6 // indirect
+ github.com/tiendc/go-deepcopy v1.7.2 // indirect
+ github.com/xuri/efp v0.0.1 // indirect
+ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
+ golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
gorm.io/plugin/dbresolver v1.6.2 // indirect
diff --git a/go.sum b/go.sum
index d2fa6b8..4d8e3c3 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
github.com/carlosframework/rastrillo v0.20.0 h1:FrLru/36zxjf1+vPS/Jd5IVjgJy2OHsrMbvt38qvReU=
github.com/carlosframework/rastrillo v0.20.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
@@ -16,34 +18,51 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q=
+github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
+github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
+github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
+github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
+github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
+github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
+github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
+github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
+github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
-golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
-golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
+golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
-golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
-golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
-golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
-golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
-golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
-golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
+golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
diff --git a/internal/sheets/store/handle.go b/internal/sheets/store/handle.go
index 154f3f8..48d9bf0 100644
--- a/internal/sheets/store/handle.go
+++ b/internal/sheets/store/handle.go
@@ -54,6 +54,19 @@ type Handle struct {
rowPos map[uint64]int // real Row.ID -> Position
colPos map[uint64]int // real Col.ID -> Position
+ // rowByPos and colByPos are rowPos/colPos inverted — Position ->
+ // real ID — kept in lockstep with them (buildHandle populates both
+ // pairs together; EnsureRow/EnsureCol, handle_grid.go, are the only
+ // other writers). Row/Col records are never deleted or moved by
+ // anything in this package (setBlank deletes a Cell row, never a
+ // Row or Col — see handle_set.go), so a lookup here never goes
+ // stale within a handle's lifetime. They exist for callers, like
+ // xlsx import, that address cells by spreadsheet position (A1
+ // notation) rather than by stable id and need an O(1) "does a row
+ // already exist at position N" check instead of a linear scan.
+ rowByPos map[int]uint64
+ colByPos map[int]uint64
+
rawText map[calc.CellKey]string // decrypted input text; in-memory only, never persisted as-is
keyToIDs map[calc.CellKey]cellIDs // graph key -> the real ids that own it
diff --git a/internal/sheets/store/handle_grid.go b/internal/sheets/store/handle_grid.go
new file mode 100644
index 0000000..fb982cb
--- /dev/null
+++ b/internal/sheets/store/handle_grid.go
@@ -0,0 +1,233 @@
+// handle_grid.go is the grid-growth and format-write surface a bulk
+// importer needs and ordinary editing does not: EnsureRow/EnsureCol grow
+// a tab's row/col set on demand for a cell addressed by spreadsheet
+// position, SetFormat interns and assigns a cell's format (the write side
+// of Handle.Format, which is read-only — see handle.go), and RawInput
+// reads back the exact text a cell was last Set with, so an exporter can
+// tell a formula from a literal and re-emit the formula verbatim rather
+// than only its computed result.
+package store
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "gorm.io/gorm"
+
+ "amadan.net/oficina/sheets/internal/sheets"
+)
+
+// EnsureRow returns the id of the row at pos, creating it if this tab has
+// none there yet. Idempotent: a second call for the same pos returns the
+// same id without writing anything.
+func (h *Handle) EnsureRow(pos int) (uint64, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if id, ok := h.rowByPos[pos]; ok {
+ return id, nil
+ }
+ r := sheets.Row{TabID: h.tabID, Position: pos}
+ if err := h.db.Create(&r).Error; err != nil {
+ return 0, fmt.Errorf("store: create row at position %d: %w", pos, err)
+ }
+ h.rowPos[r.ID] = pos
+ h.rowByPos[pos] = r.ID
+ return r.ID, nil
+}
+
+// EnsureCol is EnsureRow's column counterpart.
+func (h *Handle) EnsureCol(pos int) (uint64, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if id, ok := h.colByPos[pos]; ok {
+ return id, nil
+ }
+ c := sheets.Col{TabID: h.tabID, Position: pos}
+ if err := h.db.Create(&c).Error; err != nil {
+ return 0, fmt.Errorf("store: create col at position %d: %w", pos, err)
+ }
+ h.colPos[c.ID] = pos
+ h.colByPos[pos] = c.ID
+ return c.ID, nil
+}
+
+// PosID pairs a row or column's spreadsheet position with its stable id.
+type PosID struct {
+ Pos int
+ ID uint64
+}
+
+// GridRows returns every row this tab has a Row record for, sorted by
+// position ascending. An exporter walks this (and GridCols) to know which
+// spreadsheet coordinates to visit — Handle otherwise only answers
+// questions about one cell at a time.
+func (h *Handle) GridRows() []PosID {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return sortedPosIDs(h.rowByPos)
+}
+
+// GridCols is GridRows' column counterpart.
+func (h *Handle) GridCols() []PosID {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return sortedPosIDs(h.colByPos)
+}
+
+func sortedPosIDs(byPos map[int]uint64) []PosID {
+ out := make([]PosID, 0, len(byPos))
+ for pos, id := range byPos {
+ out = append(out, PosID{Pos: pos, ID: id})
+ }
+ for i := 1; i < len(out); i++ {
+ for j := i; j > 0 && out[j].Pos < out[j-1].Pos; j-- {
+ out[j], out[j-1] = out[j-1], out[j]
+ }
+ }
+ return out
+}
+
+// CellCoord is a cell's stable, real identity — the (row, col) shape
+// Handle's own public methods (Set, Value, Format, RawInput, SetFormat)
+// all take.
+type CellCoord struct{ RowID, ColID uint64 }
+
+// Cells returns every cell this tab actually has a Cell row for — content,
+// a format, or both (models.go: Cell is sparse, so this is the complete
+// non-default set). An exporter walking GridRows x GridCols directly
+// would visit every row/col PAIR, which is quadratic in the wrong case
+// (many cells scattered across many distinct rows and columns, e.g. a
+// diagonal); this is the actual population, read straight from storage
+// the same way Format already does (unlike Value, which reads through
+// the in-memory graph — see Format's own doc comment on handle.go).
+func (h *Handle) Cells() ([]CellCoord, error) {
+ var cells []sheets.Cell
+ if err := h.db.Select("row_id", "col_id").
+ Where("tab_id = ?", h.tabID).Find(&cells).Error; err != nil {
+ return nil, fmt.Errorf("store: list cells: %w", err)
+ }
+ out := make([]CellCoord, len(cells))
+ for i, c := range cells {
+ out[i] = CellCoord{RowID: c.RowID, ColID: c.ColID}
+ }
+ return out, nil
+}
+
+// RawInput returns the exact text a cell was last Set with (or loaded
+// with, on Open) — never the computed value. false means this handle has
+// nothing recorded for the coordinate: either it is not part of the tab,
+// or it has always been blank.
+func (h *Handle) RawInput(rowID, colID uint64) (string, bool) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ key, ok := h.cellKey(rowID, colID)
+ if !ok {
+ return "", false
+ }
+ raw, ok := h.rawText[key]
+ return raw, ok
+}
+
+// SetFormat interns f (Format is deduplicated by content — models.go) and
+// assigns it to the cell at (rowID, colID), creating a Cell row with
+// empty sealed input if the cell is otherwise blank: FormatID lives on
+// Cell, so a formatted-but-empty cell (a coloured header nothing has been
+// typed into yet) still needs a row to carry it.
+func (h *Handle) SetFormat(rowID, colID uint64, f sheets.Format) error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if _, ok := h.cellKey(rowID, colID); !ok {
+ return fmt.Errorf("store: row %d col %d is not part of tab %d", rowID, colID, h.tabID)
+ }
+ formatID, err := h.internFormat(f)
+ if err != nil {
+ return err
+ }
+ return h.assignFormat(rowID, colID, formatID)
+}
+
+// internFormat finds the existing Format row matching f's content
+// (by digest) or creates one. A duplicate-key race against a concurrent
+// interner of the identical format is resolved by re-reading rather than
+// failing: the digest's uniqueIndex is what makes that safe.
+func (h *Handle) internFormat(f sheets.Format) (uint64, error) {
+ digest := formatDigest(f)
+
+ var existing sheets.Format
+ err := h.db.Where("digest = ?", digest).First(&existing).Error
+ if err == nil {
+ return existing.ID, nil
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return 0, fmt.Errorf("store: look up format: %w", err)
+ }
+
+ f.Digest = digest
+ if err := h.db.Create(&f).Error; err != nil {
+ // Another writer interned the same content between the lookup
+ // above and this Create — re-read rather than propagating what
+ // is, from the caller's point of view, not really a failure.
+ var again sheets.Format
+ if err2 := h.db.Where("digest = ?", digest).First(&again).Error; err2 == nil {
+ return again.ID, nil
+ }
+ return 0, fmt.Errorf("store: create format: %w", err)
+ }
+ return f.ID, nil
+}
+
+// formatDigest canonicalizes f's fields into the string Format.Digest
+// hashes — every field that participates in Format's own uniqueIndex
+// (models.go), and none that doesn't (ID, Digest itself).
+func formatDigest(f sheets.Format) string {
+ var sb strings.Builder
+ sb.WriteString(f.NumberFmt)
+ sb.WriteByte('|')
+ sb.WriteString(strconv.FormatBool(f.Bold))
+ sb.WriteByte('|')
+ sb.WriteString(strconv.FormatBool(f.Italic))
+ sb.WriteByte('|')
+ if f.FillIntentH != nil {
+ sb.WriteString(strconv.FormatFloat(*f.FillIntentH, 'g', -1, 64))
+ }
+ sb.WriteByte('|')
+ if f.FillIntentC != nil {
+ sb.WriteString(strconv.FormatFloat(*f.FillIntentC, 'g', -1, 64))
+ }
+ sb.WriteByte('|')
+ sb.WriteString(f.FillHex)
+ sb.WriteByte('|')
+ sb.WriteString(f.Align)
+ sum := sha256.Sum256([]byte(sb.String()))
+ return hex.EncodeToString(sum[:])
+}
+
+// assignFormat points the cell at (rowID, colID)'s FormatID at formatID,
+// creating the Cell row (with empty sealed input) if none exists yet.
+func (h *Handle) assignFormat(rowID, colID, formatID uint64) error {
+ var cell sheets.Cell
+ err := h.db.Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, rowID, colID).
+ First(&cell).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ sealedEmpty, serr := h.sealer.Seal("cell.input", []byte(""))
+ if serr != nil {
+ return fmt.Errorf("store: seal empty input for format-only cell: %w", serr)
+ }
+ id := formatID
+ return h.db.Create(&sheets.Cell{
+ TabID: h.tabID, RowID: rowID, ColID: colID,
+ InputSealed: sealedEmpty, FormatID: &id,
+ }).Error
+ }
+ if err != nil {
+ return fmt.Errorf("store: look up cell for format assignment: %w", err)
+ }
+ id := formatID
+ return h.db.Model(&sheets.Cell{}).
+ Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, rowID, colID).
+ Update("format_id", &id).Error
+}
diff --git a/internal/sheets/store/handle_grid_test.go b/internal/sheets/store/handle_grid_test.go
new file mode 100644
index 0000000..2462243
--- /dev/null
+++ b/internal/sheets/store/handle_grid_test.go
@@ -0,0 +1,265 @@
+package store_test
+
+import (
+ "path/filepath"
+ "testing"
+
+ "gorm.io/gorm"
+
+ "amadan.net/oficina/sheets/internal/sheets"
+ "amadan.net/oficina/sheets/internal/sheets/seal"
+ "amadan.net/oficina/sheets/internal/sheets/store"
+)
+
+// emptyTab builds a real database with an empty tab — no rows, no
+// columns, no cells — the shape a brand-new tab that XLSX import is
+// about to populate actually has. Unlike seeded() (handle_test.go), this
+// deliberately seeds nothing beyond the Tab row itself, because
+// EnsureRow/EnsureCol exist to grow exactly this.
+func emptyTab(t *testing.T) (*gorm.DB, *seal.Sealer, uint64) {
+ t.Helper()
+ dir := t.TempDir()
+ db := openTestDB(t, filepath.Join(dir, "test.db"))
+ if err := db.AutoMigrate(
+ &sheets.Sheet{}, &sheets.Tab{}, &sheets.Row{}, &sheets.Col{},
+ &sheets.Cell{}, &sheets.Format{},
+ ); err != nil {
+ t.Fatalf("migrate sheets models: %v", err)
+ }
+ s, err := seal.New(make32())
+ if err != nil {
+ t.Fatalf("seal.New: %v", err)
+ }
+ const emptyTabID uint64 = 55
+ if err := db.Create(&sheets.Tab{ID: emptyTabID, SheetID: 1}).Error; err != nil {
+ t.Fatalf("seed tab: %v", err)
+ }
+ return db, s, emptyTabID
+}
+
+// EnsureRow/EnsureCol are the grid-growth primitive XLSX import needs:
+// a tab starts with no rows or columns at all, and cells arrive addressed
+// by spreadsheet position, not by a pre-existing stable id.
+func TestEnsureRowColCreatesAndIsIdempotent(t *testing.T) {
+ db, sealer, tabID := emptyTab(t)
+ h, err := store.Open(db, sealer, tabID)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer h.Release()
+
+ r1, err := h.EnsureRow(4)
+ if err != nil {
+ t.Fatalf("EnsureRow(4): %v", err)
+ }
+ r2, err := h.EnsureRow(4)
+ if err != nil {
+ t.Fatalf("EnsureRow(4) again: %v", err)
+ }
+ if r1 != r2 {
+ t.Fatalf("EnsureRow(4) not idempotent: %d != %d", r1, r2)
+ }
+
+ c1, err := h.EnsureCol(2)
+ if err != nil {
+ t.Fatalf("EnsureCol(2): %v", err)
+ }
+ c2, err := h.EnsureCol(2)
+ if err != nil {
+ t.Fatalf("EnsureCol(2) again: %v", err)
+ }
+ if c1 != c2 {
+ t.Fatalf("EnsureCol(2) not idempotent: %d != %d", c1, c2)
+ }
+
+ // A cell can now be set at the row/col this just created — proving
+ // the new ids are wired into the handle's binder (cellKey), not just
+ // persisted rows nothing else knows about.
+ if err := h.Set(r1, c1, "42", actor); err != nil {
+ t.Fatalf("Set at newly-ensured coordinate: %v", err)
+ }
+ if got := h.Value(r1, c1); got.Kind != 1 || got.Num != 42 { // calc.KindNum == 1
+ t.Fatalf("Value at newly-ensured coordinate = %+v, want 42", got)
+ }
+}
+
+// A row created via EnsureRow survives a Release/re-Open — proving it
+// was actually persisted, not just cached in the live handle.
+func TestEnsureRowPersistsAcrossReopen(t *testing.T) {
+ db, sealer, tabID := emptyTab(t)
+ h, err := store.Open(db, sealer, tabID)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ rowID, err := h.EnsureRow(0)
+ if err != nil {
+ t.Fatalf("EnsureRow: %v", err)
+ }
+ h.Release()
+
+ h2, err := store.Open(db, sealer, tabID)
+ if err != nil {
+ t.Fatalf("re-Open: %v", err)
+ }
+ defer h2.Release()
+
+ rows := h2.GridRows()
+ if len(rows) != 1 || rows[0].ID != rowID || rows[0].Pos != 0 {
+ t.Fatalf("GridRows after reopen = %+v, want one row {Pos:0 ID:%d}", rows, rowID)
+ }
+}
+
+// RawInput is what XLSX export needs to tell a formula cell from a
+// literal one and to re-emit the exact formula text a person entered,
+// not just its computed result.
+func TestRawInputReturnsWhatWasSet(t *testing.T) {
+ db, sealer, tabID := seeded(t)
+ h, err := store.Open(db, sealer, tabID)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer h.Release()
+
+ if err := h.Set(rowB, colA, "=A1*2", actor); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ raw, ok := h.RawInput(rowB, colA)
+ if !ok || raw != "=A1*2" {
+ t.Fatalf("RawInput(B1) = %q, %v, want \"=A1*2\", true", raw, ok)
+ }
+
+ // seeded() itself put "5" at (rowA, colA).
+ raw, ok = h.RawInput(rowA, colA)
+ if !ok || raw != "5" {
+ t.Fatalf("RawInput(A1) = %q, %v, want \"5\", true", raw, ok)
+ }
+
+ // A cell nothing ever touched has no raw input.
+ if _, ok := h.RawInput(rowB, colA+1); ok {
+ t.Fatalf("RawInput on an unknown coordinate reported ok=true")
+ }
+}
+
+// SetFormat interns by content: two cells given field-for-field identical
+// Format values share one Format row, not two — the whole point of
+// interning (models.go's comment on Format).
+func TestSetFormatInterns(t *testing.T) {
+ db, sealer, tabID := seeded(t)
+ h, err := store.Open(db, sealer, tabID)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer h.Release()
+
+ rowC, err := h.EnsureRow(2)
+ if err != nil {
+ t.Fatalf("EnsureRow: %v", err)
+ }
+ if err := h.Set(rowC, colA, "7", actor); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+
+ want := sheets.Format{NumberFmt: "0.00", Bold: true}
+ if err := h.SetFormat(rowA, colA, want); err != nil {
+ t.Fatalf("SetFormat(A1): %v", err)
+ }
+ if err := h.SetFormat(rowC, colA, want); err != nil {
+ t.Fatalf("SetFormat(C1): %v", err)
+ }
+
+ fa := h.Format(rowA, colA)
+ fc := h.Format(rowC, colA)
+ if fa == nil || fc == nil {
+ t.Fatalf("Format returned nil: A1=%v C1=%v", fa, fc)
+ }
+ if fa.ID != fc.ID {
+ t.Fatalf("two identical formats got different ids: %d != %d — not interned", fa.ID, fc.ID)
+ }
+ if fa.NumberFmt != "0.00" || !fa.Bold {
+ t.Fatalf("Format fields did not round-trip: %+v", fa)
+ }
+}
+
+// A cell with no content at all can still carry a format — a formatted
+// but empty cell, e.g. a coloured header with nothing typed into it yet.
+// This is the one case models.go's "Cell is sparse: only non-empty cells
+// exist" comment does not cover: FormatID lives on Cell, so a format-only
+// cell needs a Cell row purely to carry it, sealed input empty.
+func TestSetFormatOnBlankCell(t *testing.T) {
+ db, sealer, tabID := emptyTab(t)
+ h, err := store.Open(db, sealer, tabID)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer h.Release()
+
+ rowID, err := h.EnsureRow(0)
+ if err != nil {
+ t.Fatalf("EnsureRow: %v", err)
+ }
+ colID, err := h.EnsureCol(0)
+ if err != nil {
+ t.Fatalf("EnsureCol: %v", err)
+ }
+
+ if err := h.SetFormat(rowID, colID, sheets.Format{FillHex: "FFFF0000"}); err != nil {
+ t.Fatalf("SetFormat on blank cell: %v", err)
+ }
+
+ f := h.Format(rowID, colID)
+ if f == nil || f.FillHex != "FFFF0000" {
+ t.Fatalf("Format(blank cell) = %+v, want FillHex FFFF0000", f)
+ }
+ // It is still blank — no value.
+ if v := h.Value(rowID, colID); v.Kind != 0 { // calc.KindEmpty == 0
+ t.Fatalf("Value(formatted-but-blank cell) = %+v, want KindEmpty", v)
+ }
+}
+
+// Cells is the enumeration an exporter needs: exactly the tab's
+// non-default cells (content or format), not the full rows x cols cross
+// product, which would be quadratic for a sparse, scattered tab.
+func TestCellsListsContentAndFormatOnlyCells(t *testing.T) {
+ db, sealer, tabID := seeded(t)
+ h, err := store.Open(db, sealer, tabID)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer h.Release()
+
+ // seeded() already put a cell at (rowA, colA).
+ rowFmt, err := h.EnsureRow(5)
+ if err != nil {
+ t.Fatalf("EnsureRow: %v", err)
+ }
+ colFmt, err := h.EnsureCol(5)
+ if err != nil {
+ t.Fatalf("EnsureCol: %v", err)
+ }
+ if err := h.SetFormat(rowFmt, colFmt, sheets.Format{Bold: true}); err != nil {
+ t.Fatalf("SetFormat: %v", err)
+ }
+
+ cells, err := h.Cells()
+ if err != nil {
+ t.Fatalf("Cells: %v", err)
+ }
+ want := map[store.CellCoord]bool{
+ {RowID: rowA, ColID: colA}: false,
+ {RowID: rowFmt, ColID: colFmt}: false,
+ }
+ if len(cells) != len(want) {
+ t.Fatalf("Cells() = %v, want exactly %d entries", cells, len(want))
+ }
+ for _, c := range cells {
+ if _, ok := want[c]; !ok {
+ t.Fatalf("Cells() returned unexpected coordinate %+v", c)
+ }
+ want[c] = true
+ }
+ for coord, seen := range want {
+ if !seen {
+ t.Fatalf("Cells() missing expected coordinate %+v", coord)
+ }
+ }
+}
diff --git a/internal/sheets/store/handle_open.go b/internal/sheets/store/handle_open.go
index 8560ba3..a6f2690 100644
--- a/internal/sheets/store/handle_open.go
+++ b/internal/sheets/store/handle_open.go
@@ -64,14 +64,18 @@ func buildHandle(db *gorm.DB, sealer *seal.Sealer, tabID uint64) (*Handle, error
graph: calc.NewGraph(),
rowPos: make(map[uint64]int, len(rows)),
colPos: make(map[uint64]int, len(cols)),
+ rowByPos: make(map[int]uint64, len(rows)),
+ colByPos: make(map[int]uint64, len(cols)),
rawText: make(map[calc.CellKey]string, len(cells)),
keyToIDs: make(map[calc.CellKey]cellIDs, len(cells)),
}
for _, r := range rows {
h.rowPos[r.ID] = r.Position
+ h.rowByPos[r.Position] = r.ID
}
for _, c := range cols {
h.colPos[c.ID] = c.Position
+ h.colByPos[c.Position] = c.ID
}
var stale []calc.CellKey
diff --git a/internal/sheets/xlsx/blob.go b/internal/sheets/xlsx/blob.go
new file mode 100644
index 0000000..8871115
--- /dev/null
+++ b/internal/sheets/xlsx/blob.go
@@ -0,0 +1,59 @@
+package xlsx
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+
+ "github.com/carlosframework/rastrillo/blobs"
+
+ "amadan.net/oficina/sheets/internal/sheets/store"
+)
+
+// blobContentType is deliberately opaque. blobs.Sealed encrypts the bytes
+// but passes ContentType through as visible metadata (verified directly
+// against rastrillo/blobs — see blobs.Sealed's own doc comment), so a
+// truthful spreadsheet content type would announce exactly what the
+// ciphertext is, in a bucket that is otherwise opaque. Spec §2.3, §11.
+const blobContentType = "application/octet-stream"
+
+// StoreSealed exports h's tab and writes it to underlying through
+// blobs.Sealed under key, never letting a plaintext spreadsheet reach
+// underlying — spec §0 and §2.3: unsealed on the S3 backend, a generated
+// XLSX file is a plaintext spreadsheet in a bucket, the one rule defeated
+// through a side door.
+//
+// This exists because the sealing step belongs next to the bytes it
+// seals, not because the rest of the plumbing does: the upload/download
+// HTTP surface and the background-job wrapper around Import/Export
+// (spec §11: "Import runs as a rastrillo background job") are plan 2 —
+// this repo has no job runner, no session-gated routes, and no app-row
+// shape for a stored blob's Ref yet. A caller with all of that wires it
+// to StoreSealed/LoadSealed rather than to underlying directly.
+//
+// The blob address returned is the ciphertext's hash (dedup is per key,
+// not per plaintext) and Ref.Size is the sealed size — both documented on
+// blobs.Sealed itself, restated here because a caller showing either to
+// a person (a quota, a progress bar) needs to know.
+func StoreSealed(ctx context.Context, underlying blobs.Store, key []byte, h *store.Handle) (blobs.Ref, error) {
+ var buf bytes.Buffer
+ if err := Export(h, &buf); err != nil {
+ return blobs.Ref{}, err
+ }
+ ref, err := blobs.Sealed(underlying, key).Put(ctx, &buf, blobContentType)
+ if err != nil {
+ return blobs.Ref{}, fmt.Errorf("xlsx: store sealed export: %w", err)
+ }
+ return ref, nil
+}
+
+// LoadSealed reads a blob written by StoreSealed (or any XLSX blob sealed
+// under key with blobs.Sealed) and imports it into h.
+func LoadSealed(ctx context.Context, underlying blobs.Store, key []byte, ref blobs.Ref, h *store.Handle) error {
+ rc, err := blobs.Sealed(underlying, key).Get(ctx, ref.Hash)
+ if err != nil {
+ return fmt.Errorf("xlsx: load sealed import: %w", err)
+ }
+ defer rc.Close()
+ return Import(rc, h)
+}
diff --git a/internal/sheets/xlsx/blob_test.go b/internal/sheets/xlsx/blob_test.go
new file mode 100644
index 0000000..2dfacab
--- /dev/null
+++ b/internal/sheets/xlsx/blob_test.go
@@ -0,0 +1,97 @@
+package xlsx_test
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/blobs"
+
+ "amadan.net/oficina/sheets/internal/sheets/xlsx"
+)
+
+// StoreSealed must never let a plaintext spreadsheet reach the
+// underlying blob backend — spec §0 and §2.3, and the task's own
+// constraint that an unsealed generated XLSX file in a bucket defeats
+// the one rule through a side door. This proves it against blobs.Dir
+// (content-addressed files on disk — the same shape blobs.S3 has,
+// minus network credentials this test environment doesn't have), by
+// grepping the raw stored bytes for a distinctive string only a cell in
+// the exported workbook could contain.
+func TestStoreSealedNeverWritesPlaintext(t *testing.T) {
+ const canary = "CANARY-XLSX-BLOB-PLAINTEXT-71ad"
+
+ db := openTestDB(t)
+ h := newTab(t, db, testSealer(t))
+ rowID, err := h.EnsureRow(0)
+ if err != nil {
+ t.Fatalf("EnsureRow: %v", err)
+ }
+ colID, err := h.EnsureCol(0)
+ if err != nil {
+ t.Fatalf("EnsureCol: %v", err)
+ }
+ if err := h.Set(rowID, colID, canary, 1); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+
+ blobDir := t.TempDir()
+ underlying, err := blobs.Dir(blobDir)
+ if err != nil {
+ t.Fatalf("blobs.Dir: %v", err)
+ }
+ key := make([]byte, 32)
+ for i := range key {
+ key[i] = byte(i)
+ }
+
+ ref, err := xlsx.StoreSealed(context.Background(), underlying, key, h)
+ if err != nil {
+ t.Fatalf("StoreSealed: %v", err)
+ }
+ if ref.ContentType != "application/octet-stream" {
+ t.Fatalf("ContentType = %q, want application/octet-stream", ref.ContentType)
+ }
+
+ entries, err := os.ReadDir(blobDir)
+ if err != nil {
+ t.Fatalf("read blob dir: %v", err)
+ }
+ if len(entries) == 0 {
+ t.Fatal("blobs.Dir wrote nothing")
+ }
+ for _, e := range entries {
+ raw, err := os.ReadFile(filepath.Join(blobDir, e.Name()))
+ if err != nil {
+ t.Fatalf("read %s: %v", e.Name(), err)
+ }
+ if bytes.Contains(raw, []byte(canary)) {
+ t.Fatalf("plaintext %q found in stored blob %s", canary, e.Name())
+ }
+ // Also unsealed: not even a bare, un-decrypted XLSX zip should
+ // be recoverable from the stored bytes (a sealed blob's magic
+ // bytes are AES-GCM ciphertext, never the "PK\x03\x04" zip
+ // signature every XLSX file starts with).
+ if bytes.HasPrefix(raw, []byte("PK\x03\x04")) {
+ t.Fatalf("stored blob %s starts with an unsealed XLSX zip signature", e.Name())
+ }
+ }
+
+ // And the round trip itself must still work: LoadSealed recovers
+ // exactly what was stored.
+ h2 := newTab(t, db, testSealer(t))
+ if err := xlsx.LoadSealed(context.Background(), underlying, key, ref, h2); err != nil {
+ t.Fatalf("LoadSealed: %v", err)
+ }
+ rowID2, colID2, ok := cellAt(h2, 0, 0)
+ if !ok {
+ t.Fatal("LoadSealed produced no cell at (0,0)")
+ }
+ raw2, ok := h2.RawInput(rowID2, colID2)
+ if !ok || !strings.Contains(raw2, "CANARY-XLSX-BLOB-PLAINTEXT") {
+ t.Fatalf("RawInput after LoadSealed = %q, %v, want the canary text back", raw2, ok)
+ }
+}
diff --git a/internal/sheets/xlsx/colors.go b/internal/sheets/xlsx/colors.go
new file mode 100644
index 0000000..143233b
--- /dev/null
+++ b/internal/sheets/xlsx/colors.go
@@ -0,0 +1,168 @@
+// colors.go converts between sRGB hex (what XLSX fills use) and OKLCH
+// (what Format.FillIntentH/FillIntentC store — models.go). The app's own
+// palette allocation — which offered swatch a hue/chroma pair snaps to —
+// lives in the design-system "ui" package (spec §10.3), which this repo
+// does not have: it is plan 2. What lives here is the underlying, palette-
+// independent color math: a hex an XLSX file wasn't necessarily authored
+// with one of our swatches gets its own true OKLCH hue and chroma computed
+// directly, rather than snapped to a fixed list this package cannot see.
+// FillHex (models.go) is what actually drives export when present, so
+// this conversion is never the lossy half of a round trip — see
+// TestImportedFillHexIsRetained.
+package xlsx
+
+import (
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+)
+
+// defaultFillLightness is the OKLCH lightness used to turn an intent
+// (hue/chroma only — Format has no lightness field, because in the app's
+// own color system lightness is chosen per-theme, not stored) back into a
+// concrete hex for export, for the one case that has no retained FillHex
+// to fall back on: a fill picked purely by intent, with no source hex,
+// which nothing in this backend-only repo can yet produce (there is no
+// palette-picker UI here — plan 2), but that Export must still handle
+// rather than assume can't happen. 0.7 sits in the middle of OKLab's
+// perceptual lightness range: neither so light a saturated hue clips at
+// white nor so dark it clips at black for the chromas cell fills use.
+const defaultFillLightness = 0.7
+
+// hexToOKLCH parses a fill hex (either 6-char RRGGBB or 8-char ARGB —
+// this package always retains and emits 8-char ARGB internally, see
+// argbHex, but import accepts either) and returns its OKLCH hue (degrees,
+// 0-360) and chroma. err is non-nil (and coordinate-free — callers attach
+// position) for a string that is not valid hex of one of those two
+// lengths.
+func hexToOKLCH(hex string) (hue, chroma float64, err error) {
+ r, g, b, err := parseHexRGB(hex)
+ if err != nil {
+ return 0, 0, err
+ }
+ l, c, h := srgbToOKLCH(r, g, b)
+ _ = l
+ return h, c, nil
+}
+
+// oklchToHex is hexToOKLCH's inverse for export, at a fixed lightness
+// (see defaultFillLightness) since Format carries no lightness of its
+// own. It always returns 8-char ARGB (alpha FF) — argbHex's shape.
+func oklchToHex(hue, chroma float64) string {
+ r, g, b := oklchToSRGB(defaultFillLightness, chroma, hue)
+ return argbHex(r, g, b)
+}
+
+// parseHexRGB accepts "RRGGBB" or "AARRGGBB" (case-insensitive, with or
+// without a leading '#') and returns 0-1 linear-light-free (still gamma
+// encoded) sRGB channel values in 0-255 form as float64, ignoring alpha.
+func parseHexRGB(hex string) (r, g, b float64, err error) {
+ s := strings.TrimPrefix(hex, "#")
+ switch len(s) {
+ case 8:
+ s = s[2:] // drop alpha
+ case 6:
+ // already RRGGBB
+ default:
+ return 0, 0, 0, fmt.Errorf("xlsx: fill hex has invalid length %d", len(s))
+ }
+ v, err := strconv.ParseUint(s, 16, 32)
+ if err != nil {
+ return 0, 0, 0, fmt.Errorf("xlsx: fill hex is not valid hexadecimal")
+ }
+ r = float64((v >> 16) & 0xFF)
+ g = float64((v >> 8) & 0xFF)
+ b = float64(v & 0xFF)
+ return r, g, b, nil
+}
+
+// argbHex renders 0-255 sRGB channels as this package's canonical 8-char
+// ARGB fill hex, alpha forced to FF: XLSX cell fills are opaque in every
+// case this app produces, and excelize's own Style.Fill.Color round trip
+// (verified against the library directly) always implies FF alpha too —
+// see import.go's readFillHex.
+func argbHex(r, g, b float64) string {
+ clamp := func(x float64) int {
+ if x < 0 {
+ return 0
+ }
+ if x > 255 {
+ return 255
+ }
+ return int(math.Round(x))
+ }
+ return fmt.Sprintf("FF%02X%02X%02X", clamp(r), clamp(g), clamp(b))
+}
+
+// --- OKLab/OKLCH <-> sRGB, Björn Ottosson's published transform ---
+// (https://bottosson.github.io/posts/oklab/). Standard, palette-
+// independent color science — not app-specific, which is exactly why it
+// belongs in this backend package rather than waiting on the "ui" one.
+
+func srgbChannelToLinear(c float64) float64 {
+ c /= 255
+ if c <= 0.04045 {
+ return c / 12.92
+ }
+ return math.Pow((c+0.055)/1.055, 2.4)
+}
+
+func linearToSRGBChannel(c float64) float64 {
+ if c <= 0.0031308 {
+ return c * 12.92 * 255
+ }
+ return (1.055*math.Pow(c, 1/2.4) - 0.055) * 255
+}
+
+// srgbToOKLCH converts 0-255 sRGB channels to OKLCH (L in 0-1, C >= 0,
+// H in degrees 0-360).
+func srgbToOKLCH(r255, g255, b255 float64) (l, c, h float64) {
+ r := srgbChannelToLinear(r255)
+ g := srgbChannelToLinear(g255)
+ b := srgbChannelToLinear(b255)
+
+ lc := 0.4122214708*r + 0.5363325363*g + 0.0514459929*b
+ m := 0.2119034982*r + 0.6806995451*g + 0.1073969566*b
+ s := 0.0883024619*r + 0.2817188376*g + 0.6299787005*b
+
+ lc, m, s = cbrt(lc), cbrt(m), cbrt(s)
+
+ L := 0.2104542553*lc + 0.7936177850*m - 0.0040720468*s
+ a := 1.9779984951*lc - 2.4285922050*m + 0.4505937099*s
+ bb := 0.0259040371*lc + 0.7827717662*m - 0.8086757660*s
+
+ C := math.Hypot(a, bb)
+ H := math.Atan2(bb, a) * 180 / math.Pi
+ if H < 0 {
+ H += 360
+ }
+ return L, C, H
+}
+
+// oklchToSRGB is srgbToOKLCH's inverse, returning 0-255 sRGB channels
+// (unclamped — argbHex clamps at render time).
+func oklchToSRGB(l, c, h float64) (r255, g255, b255 float64) {
+ hr := h * math.Pi / 180
+ a := c * math.Cos(hr)
+ b := c * math.Sin(hr)
+
+ lc := l + 0.3963377774*a + 0.2158037573*b
+ m := l - 0.1055613458*a - 0.0638541728*b
+ s := l - 0.0894841775*a - 1.2914855480*b
+
+ lc, m, s = lc*lc*lc, m*m*m, s*s*s
+
+ r := +4.0767416621*lc - 3.3077115913*m + 0.2309699292*s
+ g := -1.2684380046*lc + 2.6097574011*m - 0.3413193965*s
+ bl := -0.0041960863*lc - 0.7034186147*m + 1.7076147010*s
+
+ return linearToSRGBChannel(r), linearToSRGBChannel(g), linearToSRGBChannel(bl)
+}
+
+func cbrt(x float64) float64 {
+ if x < 0 {
+ return -math.Cbrt(-x)
+ }
+ return math.Cbrt(x)
+}
diff --git a/internal/sheets/xlsx/colors_test.go b/internal/sheets/xlsx/colors_test.go
new file mode 100644
index 0000000..6606c89
--- /dev/null
+++ b/internal/sheets/xlsx/colors_test.go
@@ -0,0 +1,66 @@
+package xlsx
+
+import (
+ "math"
+ "testing"
+)
+
+// The OKLCH round trip only needs to be good enough that snapping a hex
+// to hue/chroma and back doesn't visibly drift — FillHex, not this
+// conversion, is what actually drives lossless export (colors.go's own
+// doc comment). A few ULP of float error is fine; a wrong channel is not.
+func TestHexOKLCHRoundTrip(t *testing.T) {
+ cases := []string{"FF00B050", "FFFF0000", "FF0000FF", "FFFFFFFF", "FF000000", "FF808080"}
+ for _, hex := range cases {
+ hue, chroma, err := hexToOKLCH(hex)
+ if err != nil {
+ t.Fatalf("hexToOKLCH(%s): %v", hex, err)
+ }
+ got := oklchToHex2(hue, chroma, hex)
+ r1, g1, b1, _ := parseHexRGB(hex)
+ r2, g2, b2, _ := parseHexRGB(got)
+ if diff(r1, r2) > 2 || diff(g1, g2) > 2 || diff(b1, b2) > 2 {
+ t.Errorf("%s round-tripped to %s (hue=%.2f chroma=%.4f), channels drifted more than 2/255", hex, got, hue, chroma)
+ }
+ }
+}
+
+// oklchToHex2 mirrors oklchToHex but at the SOURCE lightness rather than
+// defaultFillLightness, isolating the hue/chroma conversion from the
+// lightness-loss that's expected (and documented) when re-deriving a hex
+// from a lightness-free intent.
+func oklchToHex2(hue, chroma float64, srcHex string) string {
+ r, g, b, _ := parseHexRGB(srcHex)
+ l, _, _ := srgbToOKLCH(r, g, b)
+ rr, gg, bb := oklchToSRGB(l, chroma, hue)
+ return argbHex(rr, gg, bb)
+}
+
+func diff(a, b float64) float64 { return math.Abs(a - b) }
+
+func TestParseHexRGBAcceptsSixAndEightChar(t *testing.T) {
+ r, g, b, err := parseHexRGB("FF00B050")
+ if err != nil || r != 0 || g != 176 || b != 80 {
+ t.Fatalf("8-char parse = %v %v %v %v", r, g, b, err)
+ }
+ r2, g2, b2, err := parseHexRGB("00B050")
+ if err != nil || r2 != r || g2 != g || b2 != b {
+ t.Fatalf("6-char parse mismatch: %v %v %v %v", r2, g2, b2, err)
+ }
+}
+
+func TestParseHexRGBRejectsGarbage(t *testing.T) {
+ if _, _, _, err := parseHexRGB("not-a-color"); err == nil {
+ t.Fatal("want error for invalid hex")
+ }
+ if _, _, _, err := parseHexRGB("ABCD"); err == nil {
+ t.Fatal("want error for wrong-length hex")
+ }
+}
+
+func TestArgbHexAlwaysOpaqueEightChar(t *testing.T) {
+ h := argbHex(0, 176, 80)
+ if h != "FF00B050" {
+ t.Fatalf("argbHex(0,176,80) = %s, want FF00B050", h)
+ }
+}
diff --git a/internal/sheets/xlsx/export.go b/internal/sheets/xlsx/export.go
new file mode 100644
index 0000000..91525d9
--- /dev/null
+++ b/internal/sheets/xlsx/export.go
@@ -0,0 +1,114 @@
+package xlsx
+
+import (
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/xuri/excelize/v2"
+
+ "amadan.net/oficina/sheets/internal/sheets/calc"
+ "amadan.net/oficina/sheets/internal/sheets/store"
+)
+
+// Export writes h's tab out as an XLSX workbook with one worksheet.
+//
+// A formula cell is re-emitted as a formula (its exact original text,
+// via Handle.RawInput), not flattened to its computed value — a person
+// who imported a workbook and re-exported it unchanged must still be
+// able to edit the formula, not just see a frozen number. No cached
+// result is written alongside it (excelize.SetCellFormula does not carry
+// one — verified directly against the library); Excel and LibreOffice
+// both recalculate a formula that has no cached value on open, which is
+// the same shape any programmatically generated formula file takes.
+func Export(h *store.Handle, w io.Writer) error {
+ f := excelize.NewFile()
+ defer f.Close()
+ sheetName := f.GetSheetList()[0]
+
+ rowPos := map[uint64]int{}
+ for _, r := range h.GridRows() {
+ rowPos[r.ID] = r.Pos
+ }
+ colPos := map[uint64]int{}
+ for _, c := range h.GridCols() {
+ colPos[c.ID] = c.Pos
+ }
+
+ cells, err := h.Cells()
+ if err != nil {
+ return fmt.Errorf("xlsx: %w", err)
+ }
+ for _, cell := range cells {
+ rp, ok := rowPos[cell.RowID]
+ if !ok {
+ continue // defensive: every cell's row was loaded by the same Open
+ }
+ cp, ok := colPos[cell.ColID]
+ if !ok {
+ continue
+ }
+ if err := exportCell(f, sheetName, h, cell.RowID, cell.ColID, rp, cp); err != nil {
+ return err
+ }
+ }
+
+ if err := f.Write(w); err != nil {
+ return fmt.Errorf("xlsx: write workbook: %w", err)
+ }
+ return nil
+}
+
+// exportCell writes one cell's content (if any) and format (if any) at
+// its spreadsheet position.
+func exportCell(f *excelize.File, sheetName string, h *store.Handle, rowID, colID uint64, rowPos, colPos int) error {
+ cellRef, err := excelize.CoordinatesToCellName(colPos+1, rowPos+1)
+ if err != nil {
+ return fmt.Errorf("xlsx: cell %s: %w", calc.A1(rowPos, colPos), err)
+ }
+
+ if raw, ok := h.RawInput(rowID, colID); ok && raw != "" {
+ if strings.HasPrefix(raw, "=") {
+ if err := f.SetCellFormula(sheetName, cellRef, raw[1:]); err != nil {
+ return fmt.Errorf("xlsx: write formula at %s: %w", calc.A1(rowPos, colPos), err)
+ }
+ } else if err := writeValue(f, sheetName, cellRef, h.Value(rowID, colID)); err != nil {
+ return fmt.Errorf("xlsx: write value at %s: %w", calc.A1(rowPos, colPos), err)
+ }
+ }
+
+ if format := h.Format(rowID, colID); format != nil {
+ if style, ok := styleFromFormat(format); ok {
+ styleID, err := f.NewStyle(&style)
+ if err != nil {
+ return fmt.Errorf("xlsx: build style at %s: %w", calc.A1(rowPos, colPos), err)
+ }
+ if err := f.SetCellStyle(sheetName, cellRef, cellRef, styleID); err != nil {
+ return fmt.Errorf("xlsx: apply style at %s: %w", calc.A1(rowPos, colPos), err)
+ }
+ }
+ }
+ return nil
+}
+
+// writeValue writes a literal cell's already-computed value. Cell values
+// are IEEE doubles end to end (spec §5): SetCellFloat's precision -1
+// means "the fewest digits that still round-trip exactly", the same
+// contract calc.Render leans on for the same reason.
+func writeValue(f *excelize.File, sheetName, cellRef string, v calc.Value) error {
+ switch v.Kind {
+ case calc.KindNum:
+ return f.SetCellFloat(sheetName, cellRef, v.Num, -1, 64)
+ case calc.KindStr:
+ return f.SetCellStr(sheetName, cellRef, v.Str)
+ case calc.KindBool:
+ return f.SetCellBool(sheetName, cellRef, v.Bool)
+ default:
+ // KindEmpty/KindErr never reach here for a literal (non-formula)
+ // cell: literalNode (store/handle_codec.go) never produces
+ // either from raw text. A formula cell's error result is
+ // preserved by re-emitting the formula above, not by writing a
+ // value at all.
+ return nil
+ }
+}
diff --git a/internal/sheets/xlsx/format.go b/internal/sheets/xlsx/format.go
new file mode 100644
index 0000000..a390d91
--- /dev/null
+++ b/internal/sheets/xlsx/format.go
@@ -0,0 +1,112 @@
+package xlsx
+
+import (
+ "strings"
+
+ "github.com/xuri/excelize/v2"
+
+ "amadan.net/oficina/sheets/internal/sheets"
+)
+
+// styleFromFormat builds the excelize style a Format should render as.
+// ok is false when f describes nothing worth a style at all (the zero
+// Format), so the caller can skip SetCellStyle entirely and leave a cell
+// on the workbook's default style rather than writing a no-op one.
+//
+// NumFmt always goes through CustomNumFmt, never the builtin NumFmt id:
+// excelize's own round trip (verified directly — see the package doc)
+// only reflects a written style's NumFmt id back as CustomNumFmt when it
+// isn't one of its ~50 builtins, so relying on the id for something as
+// common as "0.00" would silently stop being lossless the day this
+// package's number-format strings happen to collide with one.
+func styleFromFormat(f *sheets.Format) (excelize.Style, bool) {
+ if f == nil {
+ return excelize.Style{}, false
+ }
+ var st excelize.Style
+ used := false
+
+ if f.NumberFmt != "" {
+ nf := f.NumberFmt
+ st.CustomNumFmt = &nf
+ used = true
+ }
+ if f.Bold || f.Italic {
+ st.Font = &excelize.Font{Bold: f.Bold, Italic: f.Italic}
+ used = true
+ }
+ if f.Align != "" {
+ st.Alignment = &excelize.Alignment{Horizontal: f.Align}
+ used = true
+ }
+ if hex := exportFillHex(f); hex != "" {
+ // excelize's Fill.Color always takes 6-char RGB (styles.go's
+ // getPaletteColor prepends its own "FF" alpha) — see the
+ // package doc on colors.go for why this app's own FillHex is
+ // always the 8-char ARGB form.
+ st.Fill = excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{hex[2:]}}
+ used = true
+ }
+ return st, used
+}
+
+// exportFillHex resolves what hex a format's fill should render as: the
+// retained import hex when present (lossless re-export — the whole point
+// of FillHex, models.go), otherwise the intent's hue/chroma resolved at a
+// fixed lightness (colors.go), otherwise no fill at all.
+func exportFillHex(f *sheets.Format) string {
+ if f.FillHex != "" {
+ return f.FillHex
+ }
+ if f.FillIntentH != nil && f.FillIntentC != nil {
+ return oklchToHex(*f.FillIntentH, *f.FillIntentC)
+ }
+ return ""
+}
+
+// formatFromStyle is styleFromFormat's inverse for import: it reads back
+// what a cell's excelize style actually says and builds the Format this
+// app stores. ok is false when the style carries nothing this app
+// tracks (the workbook's default style, most commonly), so the caller
+// can skip writing a Format at all.
+func formatFromStyle(st *excelize.Style) (sheets.Format, bool) {
+ var f sheets.Format
+ used := false
+
+ if st.CustomNumFmt != nil && *st.CustomNumFmt != "" {
+ f.NumberFmt = *st.CustomNumFmt
+ used = true
+ }
+ if st.Font != nil && (st.Font.Bold || st.Font.Italic) {
+ f.Bold = st.Font.Bold
+ f.Italic = st.Font.Italic
+ used = true
+ }
+ if st.Alignment != nil && st.Alignment.Horizontal != "" {
+ f.Align = st.Alignment.Horizontal
+ used = true
+ }
+ if st.Fill.Type == "pattern" && len(st.Fill.Color) > 0 && st.Fill.Color[0] != "" {
+ hex := argbFromStyleColor(st.Fill.Color[0])
+ f.FillHex = hex
+ if hue, chroma, err := hexToOKLCH(hex); err == nil {
+ f.FillIntentH = &hue
+ f.FillIntentC = &chroma
+ }
+ used = true
+ }
+ return f, used
+}
+
+// argbFromStyleColor turns whatever excelize.Style.Fill.Color[0] hands
+// back (6-char RGB — see styleFromFormat's comment) into this package's
+// canonical 8-char ARGB form. Already-8-char input (defensive: some other
+// producer of *excelize.Style might not follow excelize's own round
+// trip) is upper-cased and returned as-is rather than double-prefixed.
+func argbFromStyleColor(color string) string {
+ c := strings.ToUpper(strings.TrimPrefix(color, "#"))
+ if len(c) == 8 {
+ return c
+ }
+ return "FF" + c
+}
diff --git a/internal/sheets/xlsx/gencorpus_test.go b/internal/sheets/xlsx/gencorpus_test.go
new file mode 100644
index 0000000..8000644
--- /dev/null
+++ b/internal/sheets/xlsx/gencorpus_test.go
@@ -0,0 +1,137 @@
+package xlsx
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/xuri/excelize/v2"
+)
+
+// TestGenerateCorpus (re)builds testdata/corpus/xlsx/*.xlsx — the golden
+// round-trip fixtures TestRoundTripIsStable (roundtrip_test.go) reads.
+// It is the "small tool or test helper" the task brief calls for rather
+// than a standalone command, specifically so excelize stays confined to
+// this package (import.go's doc comment) even for fixture generation.
+//
+// Skipped by default — it WRITES the committed fixtures, so an ordinary
+// `go test ./...` must not silently regenerate (and potentially drift)
+// them. Run deliberately after changing what the corpus should cover:
+//
+// GENCORPUS=1 go test ./internal/sheets/xlsx/ -run TestGenerateCorpus -v
+//
+// then review the diff and commit the result.
+func TestGenerateCorpus(t *testing.T) {
+ if os.Getenv("GENCORPUS") == "" {
+ t.Skip("set GENCORPUS=1 to regenerate testdata/corpus/xlsx fixtures")
+ }
+ dir := filepath.Join("..", "..", "..", "testdata", "corpus", "xlsx")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ for name, build := range map[string]func(*excelize.File, string){
+ "numbers.xlsx": buildNumbersFixture,
+ "formulas.xlsx": buildFormulasFixture,
+ "numberformats.xlsx": buildNumberFormatsFixture,
+ "fills.xlsx": buildFillsFixture,
+ "blanks.xlsx": buildBlanksFixture,
+ } {
+ f := excelize.NewFile()
+ sheet := f.GetSheetList()[0]
+ build(f, sheet)
+ path := filepath.Join(dir, name)
+ if err := f.SaveAs(path); err != nil {
+ t.Fatalf("save %s: %v", name, err)
+ }
+ if err := f.Close(); err != nil {
+ t.Fatalf("close %s: %v", name, err)
+ }
+ t.Logf("wrote %s", path)
+ }
+}
+
+// buildNumbersFixture covers plain numeric literals: integers, a
+// negative, a decimal, a very small and a very large magnitude, and
+// zero — the shapes a naive text-based codec is most likely to mangle.
+func buildNumbersFixture(f *excelize.File, sheet string) {
+ must(f.SetCellFloat(sheet, "A1", 42, -1, 64))
+ must(f.SetCellFloat(sheet, "A2", -17.5, -1, 64))
+ must(f.SetCellFloat(sheet, "A3", 0, -1, 64))
+ must(f.SetCellFloat(sheet, "A4", 0.0001, -1, 64))
+ must(f.SetCellFloat(sheet, "A5", 123456789.125, -1, 64))
+}
+
+// buildFormulasFixture covers formulas referencing other cells and a
+// function call — none volatile (no NOW/TODAY — calc's own golden corpus
+// excludes those for the same reason: a round trip must be deterministic
+// across two separate evaluations).
+func buildFormulasFixture(f *excelize.File, sheet string) {
+ must(f.SetCellFloat(sheet, "A1", 2, -1, 64))
+ must(f.SetCellFloat(sheet, "A2", 3, -1, 64))
+ must(f.SetCellFloat(sheet, "A3", 4, -1, 64))
+ must(f.SetCellFormula(sheet, "B1", "A1+A2"))
+ must(f.SetCellFormula(sheet, "B2", "SUM(A1:A3)"))
+ must(f.SetCellFormula(sheet, "B3", "B1*B2"))
+}
+
+// buildNumberFormatsFixture covers custom number-format codes attached to
+// a cell — percentages, thousands separators, currency-shaped codes
+// (currency is a number format, not a type — spec §5) — verifying the
+// format STRING round-trips, not any particular rendering of it (this
+// package never renders a display string; Export writes the raw double
+// plus the format code, same as a native XLSX file, and leaves rendering
+// to whatever opens it — see export.go's package doc).
+func buildNumberFormatsFixture(f *excelize.File, sheet string) {
+ setWithNumFmt(f, sheet, "A1", 1234.5, "#,##0.00")
+ setWithNumFmt(f, sheet, "A2", 0.256, "0.00%")
+ setWithNumFmt(f, sheet, "A3", 50000, `"$"#,##0.00`)
+}
+
+// buildFillsFixture covers a solid cell fill. A1's fill is deliberately
+// "00B050" (excelize's Style.Fill.Color takes 6-char RGB — the package
+// doc on colors.go explains why this app's own FillHex is always the
+// 8-char ARGB form "FF00B050") so this file also backs
+// TestImportedFillHexIsRetained.
+func buildFillsFixture(f *excelize.File, sheet string) {
+ must(f.SetCellFloat(sheet, "A1", 42, -1, 64))
+ styleID, err := f.NewStyle(&excelize.Style{
+ Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"00B050"}},
+ })
+ must(err)
+ must(f.SetCellStyle(sheet, "A1", "A1", styleID))
+
+ must(f.SetCellFloat(sheet, "B1", 7, -1, 64))
+ styleID2, err := f.NewStyle(&excelize.Style{
+ Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FF0000"}},
+ Font: &excelize.Font{Bold: true},
+ })
+ must(err)
+ must(f.SetCellStyle(sheet, "B1", "B1", styleID2))
+}
+
+// buildBlanksFixture covers an interior blank cell — B1 has neither a
+// value nor a formula, sitting between two cells that do — which is what
+// a real gap in a row of otherwise-populated data looks like at the XLSX
+// level (excelize.GetRows only trims TRAILING blank cells from a row, so
+// an interior one like this is exactly what Import must not lose or
+// shift out of position).
+func buildBlanksFixture(f *excelize.File, sheet string) {
+ must(f.SetCellFloat(sheet, "A1", 1, -1, 64))
+ must(f.SetCellFloat(sheet, "C1", 3, -1, 64))
+ must(f.SetCellStr(sheet, "A2", "left"))
+ must(f.SetCellStr(sheet, "C2", "right"))
+}
+
+func setWithNumFmt(f *excelize.File, sheet, cell string, value float64, numFmt string) {
+ must(f.SetCellFloat(sheet, cell, value, -1, 64))
+ styleID, err := f.NewStyle(&excelize.Style{CustomNumFmt: &numFmt})
+ must(err)
+ must(f.SetCellStyle(sheet, cell, cell, styleID))
+}
+
+func must(err error) {
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/sheets/xlsx/helpers_test.go b/internal/sheets/xlsx/helpers_test.go
new file mode 100644
index 0000000..16d1c88
--- /dev/null
+++ b/internal/sheets/xlsx/helpers_test.go
@@ -0,0 +1,292 @@
+package xlsx_test
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "testing"
+
+ rdb "github.com/carlosframework/rastrillo/db"
+ "github.com/xuri/excelize/v2"
+ "gorm.io/gorm"
+
+ "amadan.net/oficina/sheets/internal/sheets"
+ "amadan.net/oficina/sheets/internal/sheets/calc"
+ "amadan.net/oficina/sheets/internal/sheets/seal"
+ "amadan.net/oficina/sheets/internal/sheets/store"
+ "amadan.net/oficina/sheets/internal/sheets/xlsx"
+)
+
+// corpusDir is testdata/corpus/xlsx relative to this package — see the
+// task brief's file list. gencorpus_test.go (package xlsx, GENCORPUS=1)
+// is what writes it.
+func corpusDir(t *testing.T) string {
+ t.Helper()
+ dir := filepath.Join("..", "..", "..", "testdata", "corpus", "xlsx")
+ if _, err := os.Stat(dir); err != nil {
+ t.Fatalf("corpus dir %s: %v (run GENCORPUS=1 go test ./internal/sheets/xlsx/ -run TestGenerateCorpus)", dir, err)
+ }
+ return dir
+}
+
+// fixtureNames lists every .xlsx file in the committed corpus.
+func fixtureNames(t *testing.T) []string {
+ t.Helper()
+ entries, err := os.ReadDir(corpusDir(t))
+ if err != nil {
+ t.Fatalf("read corpus dir: %v", err)
+ }
+ var names []string
+ for _, e := range entries {
+ if !e.IsDir() && filepath.Ext(e.Name()) == ".xlsx" {
+ names = append(names, e.Name())
+ }
+ }
+ if len(names) == 0 {
+ t.Fatal("corpus dir has no .xlsx fixtures")
+ }
+ return names
+}
+
+// openTestDB mirrors internal/sheets/store's own helper of the same
+// name (helpers_test.go there) — the real cgo-free modernc driver, WAL
+// mode, the same runtime path — duplicated rather than imported because
+// it lives in that package's _test.go file and is not exported.
+func openTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ dir := t.TempDir()
+ d, err := rdb.Open(filepath.Join(dir, "test.db"), nil)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ t.Cleanup(func() {
+ if err := d.Close(); err != nil {
+ t.Errorf("close db: %v", err)
+ }
+ })
+ if err := d.G.AutoMigrate(
+ &sheets.Sheet{}, &sheets.Tab{}, &sheets.Row{}, &sheets.Col{},
+ &sheets.Cell{}, &sheets.Format{}, &store.Event{},
+ ); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ return d.G
+}
+
+// newTab creates an empty Tab (and its Sheet) in db and opens a Handle
+// on it — the shape XLSX import always starts from: a real tab with no
+// rows or columns yet, which Import's own EnsureRow/EnsureCol calls grow.
+func newTab(t *testing.T, db *gorm.DB, sealer *seal.Sealer) *store.Handle {
+ t.Helper()
+ sheet := sheets.Sheet{CreatedBy: 1}
+ if err := db.Create(&sheet).Error; err != nil {
+ t.Fatalf("create sheet: %v", err)
+ }
+ tab := sheets.Tab{SheetID: sheet.ID}
+ if err := db.Create(&tab).Error; err != nil {
+ t.Fatalf("create tab: %v", err)
+ }
+ h, err := store.Open(db, sealer, tab.ID)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ t.Cleanup(h.Release)
+ return h
+}
+
+// importFixture reads a named corpus file and imports it into a fresh
+// tab in a fresh database.
+func importFixture(t *testing.T, name string) *store.Handle {
+ t.Helper()
+ f, err := os.Open(filepath.Join(corpusDir(t), name))
+ if err != nil {
+ t.Fatalf("open fixture %s: %v", name, err)
+ }
+ defer f.Close()
+ return importReader(t, f)
+}
+
+// importReader imports r into a fresh tab in a fresh database — used
+// both for a fixture file and for the buffer TestRoundTripIsStable
+// exports and re-imports.
+func importReader(t *testing.T, r io.Reader) *store.Handle {
+ t.Helper()
+ db := openTestDB(t)
+ h := newTab(t, db, testSealer(t))
+ if err := xlsx.Import(r, h); err != nil {
+ t.Fatalf("Import: %v", err)
+ }
+ return h
+}
+
+// testSealer returns a Sealer over an all-zero stand-in DEK — sufficient
+// for tests that care about round-trip content, not key material (the
+// same convention store's own tests use — store/helpers_test.go's
+// make32).
+func testSealer(t *testing.T) *seal.Sealer {
+ t.Helper()
+ s, err := seal.New(make([]byte, 32))
+ if err != nil {
+ t.Fatalf("seal.New: %v", err)
+ }
+ return s
+}
+
+// badFormulaFixture builds a one-cell workbook whose formula is
+// syntactically unparseable — an unterminated CONCAT call — with canary
+// embedded as the string literal a formula like this would realistically
+// carry (the task brief's own example: `"salary 50000" at B7`).
+// TestImportErrorsContainNoCellContent proves canary never reaches the
+// error Import returns.
+func badFormulaFixture(t *testing.T, canary string) *bytes.Buffer {
+ t.Helper()
+ f := excelize.NewFile()
+ sheet := f.GetSheetList()[0]
+ formula := `CONCAT("` + canary + `", A1`
+ if err := f.SetCellFormula(sheet, "A1", formula); err != nil {
+ t.Fatalf("SetCellFormula: %v", err)
+ }
+ var buf bytes.Buffer
+ if err := f.Write(&buf); err != nil {
+ t.Fatalf("write fixture: %v", err)
+ }
+ if err := f.Close(); err != nil {
+ t.Fatalf("close fixture: %v", err)
+ }
+ return &buf
+}
+
+// cellSnapshot is one cell's full round-trippable state: its value and
+// its format, keyed by spreadsheet position rather than by stable id —
+// diffTabs compares two handles built from two independent imports, so
+// their real row/col ids never match even when their content does.
+type cellSnapshot struct {
+ pos posKey
+ value string // fmt.Sprintf("%+v", calc.Value) — good enough to diff and to print
+ raw string // Handle.RawInput — catches a formula silently flattened to its computed value, which comparing Value alone cannot: the recomputed result is identical either way.
+ hasFmt bool
+ numFmt string
+ bold bool
+ italic bool
+ align string
+ fillHex string
+}
+
+type posKey struct{ Row, Col int }
+
+// snapshot walks every cell h's tab actually has (store.Handle.Cells) and
+// captures its value and format by position.
+func snapshot(t *testing.T, h *store.Handle) map[posKey]cellSnapshot {
+ t.Helper()
+ rowPos := map[uint64]int{}
+ for _, r := range h.GridRows() {
+ rowPos[r.ID] = r.Pos
+ }
+ colPos := map[uint64]int{}
+ for _, c := range h.GridCols() {
+ colPos[c.ID] = c.Pos
+ }
+ cells, err := h.Cells()
+ if err != nil {
+ t.Fatalf("Cells: %v", err)
+ }
+ out := make(map[posKey]cellSnapshot, len(cells))
+ for _, c := range cells {
+ rp, ok := rowPos[c.RowID]
+ if !ok {
+ continue
+ }
+ cp, ok := colPos[c.ColID]
+ if !ok {
+ continue
+ }
+ key := posKey{Row: rp, Col: cp}
+ raw, _ := h.RawInput(c.RowID, c.ColID)
+ snap := cellSnapshot{pos: key, value: valueString(h.Value(c.RowID, c.ColID)), raw: raw}
+ if f := h.Format(c.RowID, c.ColID); f != nil {
+ snap.hasFmt = true
+ snap.numFmt = f.NumberFmt
+ snap.bold = f.Bold
+ snap.italic = f.Italic
+ snap.align = f.Align
+ snap.fillHex = f.FillHex
+ }
+ out[key] = snap
+ }
+ return out
+}
+
+// diffTabs reports every position whose value or format differs between
+// a and b, empty for an exact match.
+func diffTabs(t *testing.T, a, b *store.Handle) string {
+ t.Helper()
+ sa, sb := snapshot(t, a), snapshot(t, b)
+
+ var buf bytes.Buffer
+ seen := map[posKey]bool{}
+ for pos, av := range sa {
+ seen[pos] = true
+ bv, ok := sb[pos]
+ if !ok {
+ buf.WriteString(sprintPos(pos, "present in first, missing in second"))
+ continue
+ }
+ if av != bv {
+ buf.WriteString(sprintPos(pos, "differs"))
+ }
+ }
+ for pos := range sb {
+ if !seen[pos] {
+ buf.WriteString(sprintPos(pos, "present in second, missing in first"))
+ }
+ }
+ return buf.String()
+}
+
+func sprintPos(pos posKey, reason string) string {
+ return "(" + itoa(pos.Row) + "," + itoa(pos.Col) + "): " + reason + "\n"
+}
+
+func itoa(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ neg := n < 0
+ if neg {
+ n = -n
+ }
+ var b [20]byte
+ i := len(b)
+ for n > 0 {
+ i--
+ b[i] = byte('0' + n%10)
+ n /= 10
+ }
+ if neg {
+ i--
+ b[i] = '-'
+ }
+ return string(b[i:])
+}
+
+// valueString renders a calc.Value's meaningful field(s) only — Num for
+// KindNum, and so on — so two Values that differ only in a field Kind
+// doesn't select (e.g. a stale Str left over from reusing a struct) still
+// compare equal, matching calc.Value's own documented contract that only
+// the Kind-selected field is meaningful.
+func valueString(v calc.Value) string {
+ switch v.Kind {
+ case calc.KindNum:
+ return "Num:" + strconv.FormatFloat(v.Num, 'g', -1, 64)
+ case calc.KindStr:
+ return "Str:" + v.Str
+ case calc.KindBool:
+ return "Bool:" + strconv.FormatBool(v.Bool)
+ case calc.KindErr:
+ return "Err:" + v.Err.String()
+ default:
+ return "Empty"
+ }
+}
diff --git a/internal/sheets/xlsx/import.go b/internal/sheets/xlsx/import.go
new file mode 100644
index 0000000..d39675b
--- /dev/null
+++ b/internal/sheets/xlsx/import.go
@@ -0,0 +1,165 @@
+// Package xlsx converts between an XLSX workbook and one tab of this
+// app's own storage (internal/sheets/store). excelize is confined to
+// this package deliberately: its calc engine works against a file model
+// rather than our dependency graph (internal/sheets/calc), so letting any
+// other package import it would couple our storage to theirs.
+package xlsx
+
+import (
+ "errors"
+ "fmt"
+ "io"
+
+ "github.com/xuri/excelize/v2"
+
+ "amadan.net/oficina/sheets/internal/sheets/calc"
+ "amadan.net/oficina/sheets/internal/sheets/store"
+)
+
+// importActor is the actor id Import's own writes are attributed with.
+// Import's signature (fixed by the interface this package implements)
+// carries no caller identity, and the background-job wrapper that would
+// thread a real uploading user's id through (spec §11: "Import runs as a
+// rastrillo background job") is plan 2, not built in this repo yet. 0 is
+// not a real user id anywhere in this schema, so it reads unambiguously
+// as "the system, not a person" in the event log.
+const importActor uint64 = 0
+
+// Import reads an XLSX workbook's first worksheet into h, cell by cell:
+// literal values become literal input, formulas are parsed and
+// evaluated by this app's own engine (never excelize's — see the package
+// doc), and each cell's fill, number format, bold, italic and alignment
+// are carried over via SetFormat.
+//
+// Errors carry coordinates and codes, never cell content (spec §2.4):
+// import runs as a background job whose error text reaches the owner and
+// platform logs this app cannot grep, so "cannot parse cell B7" is what
+// gets said — never what B7 actually contained.
+func Import(r io.Reader, h *store.Handle) error {
+ f, err := excelize.OpenReader(r)
+ if err != nil {
+ return fmt.Errorf("xlsx: open workbook: %w", err)
+ }
+ defer f.Close()
+
+ sheetList := f.GetSheetList()
+ if len(sheetList) == 0 {
+ return errors.New("xlsx: workbook has no worksheets")
+ }
+ sheetName := sheetList[0]
+
+ rows, err := f.GetRows(sheetName, excelize.Options{RawCellValue: true})
+ if err != nil {
+ return fmt.Errorf("xlsx: read worksheet: %w", err)
+ }
+
+ // Fail before writing anything: a partial import left behind by
+ // hitting the cap mid-way through would be a confusing half state,
+ // and the bounding box is a cheap, conservative upper bound on the
+ // cell count to check it against up front.
+ total := 0
+ for _, row := range rows {
+ total += len(row)
+ }
+ if total > store.MaxCellsPerTab {
+ return store.ErrTabTooLarge
+ }
+
+ for rowPos, row := range rows {
+ for colPos, raw := range row {
+ if err := importCell(f, sheetName, h, rowPos, colPos, raw); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+// importCell imports one XLSX cell at (rowPos, colPos) (0-based). It
+// creates the backing Row/Col only if the cell actually has content or a
+// format worth recording — an interior blank cell with neither leaves no
+// trace, matching the sparse model (models.go's comment on Cell).
+func importCell(f *excelize.File, sheetName string, h *store.Handle, rowPos, colPos int, raw string) error {
+ cellRef, err := excelize.CoordinatesToCellName(colPos+1, rowPos+1)
+ if err != nil {
+ return fmt.Errorf("xlsx: cell %s: %w", calc.A1(rowPos, colPos), err)
+ }
+
+ input, err := cellInputText(f, sheetName, cellRef, rowPos, colPos, raw)
+ if err != nil {
+ return err
+ }
+
+ styleID, err := f.GetCellStyle(sheetName, cellRef)
+ if err != nil {
+ return fmt.Errorf("xlsx: read style at %s: %w", calc.A1(rowPos, colPos), err)
+ }
+ style, err := f.GetStyle(styleID)
+ if err != nil {
+ return fmt.Errorf("xlsx: read style at %s: %w", calc.A1(rowPos, colPos), err)
+ }
+ format, hasFormat := formatFromStyle(style)
+
+ if input == "" && !hasFormat {
+ return nil // nothing here worth a Row/Col at all
+ }
+
+ rowID, err := h.EnsureRow(rowPos)
+ if err != nil {
+ return fmt.Errorf("xlsx: cell %s: %w", calc.A1(rowPos, colPos), err)
+ }
+ colID, err := h.EnsureCol(colPos)
+ if err != nil {
+ return fmt.Errorf("xlsx: cell %s: %w", calc.A1(rowPos, colPos), err)
+ }
+
+ if input != "" {
+ if err := h.Set(rowID, colID, input, importActor); err != nil {
+ return fmt.Errorf("xlsx: cannot parse cell %s: %w", calc.A1(rowPos, colPos), err)
+ }
+ }
+ if hasFormat {
+ if err := h.SetFormat(rowID, colID, format); err != nil {
+ return fmt.Errorf("xlsx: cannot set format at %s: %w", calc.A1(rowPos, colPos), err)
+ }
+ }
+ return nil
+}
+
+// cellInputText decides what raw text (this app's own Set contract:
+// "" is blank, a leading "=" is a formula, anything else is a literal —
+// store/handle_codec.go) a cell becomes.
+//
+// A genuine XLSX formula is detected through excelize's own formula
+// reader, never by sniffing raw's leading character — but a plain TEXT
+// cell whose content happens to start with "=" is indistinguishable from
+// a formula once it becomes this app's raw text, because Set's contract
+// (predating this task — store/handle_codec.go, task 10) has exactly one
+// signal for "is this a formula": a leading "=" in the one text field a
+// cell has. That is a pre-existing modeling gap this task inherits, not
+// one introduced by XLSX import; it is called out in the task report
+// rather than worked around here.
+func cellInputText(f *excelize.File, sheetName, cellRef string, rowPos, colPos int, raw string) (string, error) {
+ formula, err := f.GetCellFormula(sheetName, cellRef)
+ if err != nil {
+ return "", fmt.Errorf("xlsx: read formula at %s: %w", calc.A1(rowPos, colPos), err)
+ }
+ if formula != "" {
+ return "=" + formula, nil
+ }
+ if raw == "" {
+ return "", nil
+ }
+
+ cellType, err := f.GetCellType(sheetName, cellRef)
+ if err != nil {
+ return "", fmt.Errorf("xlsx: read cell type at %s: %w", calc.A1(rowPos, colPos), err)
+ }
+ if cellType == excelize.CellTypeBool {
+ if raw == "1" {
+ return "TRUE", nil
+ }
+ return "FALSE", nil
+ }
+ return raw, nil
+}
diff --git a/internal/sheets/xlsx/plaintext_test.go b/internal/sheets/xlsx/plaintext_test.go
new file mode 100644
index 0000000..192ba75
--- /dev/null
+++ b/internal/sheets/xlsx/plaintext_test.go
@@ -0,0 +1,73 @@
+package xlsx_test
+
+import (
+ "bytes"
+ "path/filepath"
+ "testing"
+
+ rdb "github.com/carlosframework/rastrillo/db"
+
+ "amadan.net/oficina/sheets/internal/sheets"
+ "amadan.net/oficina/sheets/internal/sheets/sealtest"
+ "amadan.net/oficina/sheets/internal/sheets/store"
+ "amadan.net/oficina/sheets/internal/sheets/xlsx"
+)
+
+// XLSX import is where fresh, previously-untrusted content first enters
+// this app's storage. Everything it writes goes through Handle.Set,
+// which is already gated by store's own TestSetDoesNotLeakPlaintext —
+// this test re-proves the same guarantee specifically on the import
+// path, against a real cell value drawn from an actual XLSX file rather
+// than a hand-built raw string, closing the loop on "the one rule" at
+// the boundary where content most freshly enters the system.
+func TestImportDoesNotLeakPlaintext(t *testing.T) {
+ canary := string(sealtest.Canaries[0])
+
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "test.db")
+ d, err := rdb.Open(dbPath, nil)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer d.Close()
+ if err := d.G.AutoMigrate(
+ &sheets.Sheet{}, &sheets.Tab{}, &sheets.Row{}, &sheets.Col{},
+ &sheets.Cell{}, &sheets.Format{}, &store.Event{},
+ ); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ sealer := testSealer(t)
+
+ // A workbook whose one cell holds the canary text, built by hand
+ // through this app's own Set/Export rather than a raw excelize call,
+ // so the fixture itself carries the canary as ordinary cell content.
+ source := newTab(t, d.G, sealer)
+ rowID, err := source.EnsureRow(0)
+ if err != nil {
+ t.Fatalf("EnsureRow: %v", err)
+ }
+ colID, err := source.EnsureCol(0)
+ if err != nil {
+ t.Fatalf("EnsureCol: %v", err)
+ }
+ if err := source.Set(rowID, colID, canary, 1); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ var buf bytes.Buffer
+ if err := xlsx.Export(source, &buf); err != nil {
+ t.Fatalf("Export: %v", err)
+ }
+
+ // Import that workbook into a second tab in the SAME database, then
+ // checkpoint so the gate reads settled bytes (sealtest.AssertNoPlaintext
+ // also checks the WAL directly, but a checkpoint proves the main file
+ // itself is clean too).
+ target := newTab(t, d.G, sealer)
+ if err := xlsx.Import(&buf, target); err != nil {
+ t.Fatalf("Import: %v", err)
+ }
+ if err := d.G.Exec("PRAGMA wal_checkpoint(TRUNCATE)").Error; err != nil {
+ t.Fatalf("checkpoint: %v", err)
+ }
+ sealtest.AssertNoPlaintext(t, dbPath)
+}
diff --git a/internal/sheets/xlsx/roundtrip_test.go b/internal/sheets/xlsx/roundtrip_test.go
new file mode 100644
index 0000000..5ea3b59
--- /dev/null
+++ b/internal/sheets/xlsx/roundtrip_test.go
@@ -0,0 +1,109 @@
+package xlsx_test
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/sheets/internal/sheets"
+ "amadan.net/oficina/sheets/internal/sheets/store"
+ "amadan.net/oficina/sheets/internal/sheets/xlsx"
+)
+
+// import -> export -> import must be stable. A file we wrote must open in
+// Excel and LibreOffice; that half is checked by hand against the
+// fixtures and recorded, because CI cannot open Excel — see the task
+// report for what that check did and did not confirm in this
+// environment.
+func TestRoundTripIsStable(t *testing.T) {
+ for _, name := range fixtureNames(t) {
+ t.Run(name, func(t *testing.T) {
+ first := importFixture(t, name)
+ var buf bytes.Buffer
+ if err := xlsx.Export(first, &buf); err != nil {
+ t.Fatalf("export: %v", err)
+ }
+ second := importReader(t, bytes.NewReader(buf.Bytes()))
+ if diff := diffTabs(t, first, second); diff != "" {
+ t.Errorf("round trip changed the sheet:\n%s", diff)
+ }
+ })
+ }
+}
+
+// Fills imported from XLSX keep their original hex so a file we did not
+// author re-exports byte-identically in colour terms. The intent drives
+// display; the retained hex drives export until a user re-picks — see
+// models.go's Format.FillHex and colors.go's package doc.
+func TestImportedFillHexIsRetained(t *testing.T) {
+ h := importFixture(t, "fills.xlsx")
+ f := formatOf(t, h, 0, 0) // A1, gencorpus_test.go's buildFillsFixture
+ if f.FillHex != "FF00B050" {
+ t.Fatalf("FillHex = %q, want FF00B050", f.FillHex)
+ }
+ // Re-exporting must emit that exact hex back out, not the resolved
+ // OKLCH intent's approximation of it.
+ var buf bytes.Buffer
+ if err := xlsx.Export(h, &buf); err != nil {
+ t.Fatalf("export: %v", err)
+ }
+ reimported := importReader(t, bytes.NewReader(buf.Bytes()))
+ f2 := formatOf(t, reimported, 0, 0)
+ if f2.FillHex != "FF00B050" {
+ t.Fatalf("FillHex after re-export = %q, want FF00B050 (not byte-identical re-export)", f2.FillHex)
+ }
+}
+
+// formatOf resolves (rowPos, colPos) to the real ids h.GridRows/GridCols
+// hold for them and fetches the Format there, failing the test if either
+// the coordinate or the format is missing.
+func formatOf(t *testing.T, h *store.Handle, rowPos, colPos int) *sheets.Format {
+ t.Helper()
+ rowID, colID, ok := cellAt(h, rowPos, colPos)
+ if !ok {
+ t.Fatalf("no cell at position (%d,%d)", rowPos, colPos)
+ }
+ f := h.Format(rowID, colID)
+ if f == nil {
+ t.Fatalf("Format(%d,%d) at position (%d,%d) is nil", rowID, colID, rowPos, colPos)
+ }
+ return f
+}
+
+func cellAt(h *store.Handle, rowPos, colPos int) (rowID, colID uint64, ok bool) {
+ foundRow, foundCol := false, false
+ for _, r := range h.GridRows() {
+ if r.Pos == rowPos {
+ rowID, foundRow = r.ID, true
+ }
+ }
+ for _, c := range h.GridCols() {
+ if c.Pos == colPos {
+ colID, foundCol = c.ID, true
+ }
+ }
+ return rowID, colID, foundRow && foundCol
+}
+
+// Import runs as a background job (spec §11); its error text reaches the
+// owner and platform logs this app cannot grep (spec §2.4). A formula
+// that fails to parse must never leak into that error string — this pins
+// it against a formula built specifically to fail, carrying a value no
+// error message should ever contain.
+func TestImportErrorsContainNoCellContent(t *testing.T) {
+ const canary = "SECRET-PAYROLL-FIGURE-93f7"
+
+ buf := badFormulaFixture(t, canary)
+
+ db := openTestDB(t)
+ sealer := testSealer(t)
+ h := newTab(t, db, sealer)
+
+ err := xlsx.Import(buf, h)
+ if err == nil {
+ t.Fatal("want an error importing a deliberately unparseable formula, got nil")
+ }
+ if strings.Contains(err.Error(), canary) {
+ t.Fatalf("import error contains cell content: %q", err.Error())
+ }
+}
diff --git a/testdata/corpus/xlsx/blanks.xlsx b/testdata/corpus/xlsx/blanks.xlsx
new file mode 100644
index 0000000..bb17d95
Binary files /dev/null and b/testdata/corpus/xlsx/blanks.xlsx differ
diff --git a/testdata/corpus/xlsx/fills.xlsx b/testdata/corpus/xlsx/fills.xlsx
new file mode 100644
index 0000000..46bf861
Binary files /dev/null and b/testdata/corpus/xlsx/fills.xlsx differ
diff --git a/testdata/corpus/xlsx/formulas.xlsx b/testdata/corpus/xlsx/formulas.xlsx
new file mode 100644
index 0000000..8107afd
Binary files /dev/null and b/testdata/corpus/xlsx/formulas.xlsx differ
diff --git a/testdata/corpus/xlsx/numberformats.xlsx b/testdata/corpus/xlsx/numberformats.xlsx
new file mode 100644
index 0000000..0ccc88b
Binary files /dev/null and b/testdata/corpus/xlsx/numberformats.xlsx differ
diff --git a/testdata/corpus/xlsx/numbers.xlsx b/testdata/corpus/xlsx/numbers.xlsx
new file mode 100644
index 0000000..1280d78
Binary files /dev/null and b/testdata/corpus/xlsx/numbers.xlsx differ