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
22 files changed,
+1944
−11
go.mod+8 −1go.sum+29 −10internal/sheets/store/handle.go+13 −0internal/sheets/store/handle_grid.go+233 −0internal/sheets/store/handle_grid_test.go+265 −0internal/sheets/store/handle_open.go+4 −0internal/sheets/xlsx/blob.go+59 −0internal/sheets/xlsx/blob_test.go+97 −0internal/sheets/xlsx/colors.go+168 −0internal/sheets/xlsx/colors_test.go+66 −0internal/sheets/xlsx/export.go+114 −0internal/sheets/xlsx/format.go+112 −0internal/sheets/xlsx/gencorpus_test.go+137 −0internal/sheets/xlsx/helpers_test.go+292 −0internal/sheets/xlsx/import.go+165 −0internal/sheets/xlsx/plaintext_test.go+73 −0internal/sheets/xlsx/roundtrip_test.go+109 −0testdata/corpus/xlsx/blanks.xlsxbinarytestdata/corpus/xlsx/fills.xlsxbinarytestdata/corpus/xlsx/formulas.xlsxbinarytestdata/corpus/xlsx/numberformats.xlsxbinarytestdata/corpus/xlsx/numbers.xlsxbinary
Intent
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 |