A sheet Excel can't take under its own name (spaces at its ends, or a clash but for them and case) is written as, say, Plan (2). Formulas naming it used to go out as values under the missing-sheet note; now their references name the written sheet, quoted, and so do the named ranges on it. Co-Authored-By: Claude <noreply@anthropic.com>
- commit
- 93e2fc4fd9c8f07b53bdcac455da87d4d1c4c15f
- parent
- 8bd3c19c262923e348adbc6f7d79d49e66568ae1
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | +- |
| M | internal/ | 36 | +++++++++++--- |
| M | internal/ | 13 | ++++- |
| M | internal/ | 27 | ++++++++-- |
| M | internal/ | 26 | +--------- |
| A | internal/ | 79 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 7 | ++- |
@@ -19,7 +19,7 @@ File > Import asks where the data goes, as Sheets' Import location does
1919 | Format | Import | Download |2020 |---|---|---|2121 | CSV, TSV | Delimiter (`,` `;` tab `\|`), UTF-8 BOM, UTF-16 and Windows-1252 detected; entries become numbers, dates, currency and percentages as if typed; formulas stay text | Values as shown, as Sheets' Download does |22-| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths, column and row styles, frozen panes, filters (see [Filters in XLSX](#filters-in-xlsx)), shared formulas, dates in the 1904 system; sheets Excel hid stay hidden (unless it's the one Excel showed). Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed. Files past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) are refused with a message saying which | Every sheet, the same, with formulas in Excel's syntax and their results cached, column and row formats as column and row styles, frozen rows and columns as frozen panes, and a filter as Excel's with the rows it hides hidden. JEV functions and `#AND#` save as values, and so do formulas naming a sheet that doesn't exist (their `#REF!`; Excel would refuse the reference) or a sheet whose name Excel can't take as is (renamed in the file, e.g. `Plan (2)`), and pivot tables: Excel gets the results, not a pivot. The download's result counts the formulas saved as values, with an example |22+| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths, column and row styles, frozen panes, filters (see [Filters in XLSX](#filters-in-xlsx)), shared formulas, dates in the 1904 system; sheets Excel hid stay hidden (unless it's the one Excel showed). Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed. Files past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) are refused with a message saying which | Every sheet, the same, with formulas in Excel's syntax and their results cached, column and row formats as column and row styles, frozen rows and columns as frozen panes, and a filter as Excel's with the rows it hides hidden. A sheet whose name Excel can't take as is (spaces at its ends, or the same as another's but for them and case) gets one it can (without the spaces, with a number when two would clash, e.g. `Plan (2)`), and the formulas and named ranges naming it name that. JEV functions and `#AND#` save as values, and so do formulas naming a sheet that doesn't exist (their `#REF!`; Excel would refuse the reference), and pivot tables: Excel gets the results, not a pivot. The download's result counts the formulas saved as values, with an example |2323 | SQLite | Pick a table or view, or type a query; a header row names the columns | The sheet shown or the selection as a table, first row as column names; a table of that name is replaced |2424 | Parquet | Every column, with dates and timestamps; lists joined with commas | |2525 | Lotus 1-2-3 `.wk1`, `.wks` | Numbers, labels with their alignment, formats, column widths, formulas translated (references, operators, `@SUM`, `@AVG`, `@IF`, `@ROUND`, `@PMT` and 60 more) or kept as values | |@@ -2,6 +2,8 @@ package fileio
22 33 import (44 "strings"5+6+ "github.com/FelineStateMachine/012/internal/formula"57 )68 79 // 012 reads Sheets' formula syntax and some of 1-2-3's (@SUM, A1..B3,@@ -17,11 +19,14 @@ var xlfn = map[string]bool{
1719 // toExcelFormula translates a formula entry to Excel's syntax, or1820 // reports false when Excel has no equivalent (JEV functions, 1-2-3's1921 // #AND# operators), in which case the value is exported instead.20-func toExcelFormula(input string) (string, bool) {22+//23+// renamed maps the key (formula.SheetKey) of each sheet written under24+// another name to that name: references to it are rewritten to name it.25+func toExcelFormula(input string, renamed map[string]string) (string, bool) {2126 src := strings.TrimPrefix(input, "=")2227 var b strings.Builder2328 for i := 0; i < len(src); {24- next, ok := excelToken(&b, src, i)29+ next, ok := excelToken(&b, src, i, renamed)2530 if !ok {2631 return "", false2732 }@@ -32,13 +37,15 @@ func toExcelFormula(input string) (string, bool) {
3237 3338 // excelToken writes the token starting at src[i] in Excel's syntax and3439 // returns the index just past it, or false when Excel has no equivalent.35-func excelToken(b *strings.Builder, src string, i int) (int, bool) {40+func excelToken(b *strings.Builder, src string, i int, renamed map[string]string) (int, bool) {3641 c := src[i]3742 switch {3843 case c == '\'':39- // A quoted sheet name, 'Q3 plan'!A1, is the same in Excel.44+ // A quoted sheet name, 'Q3 plan'!A1, is the same in Excel, unless45+ // the sheet is written under another name.4046 j := quoteEnd(src, i)41- b.WriteString(src[i:j])47+ name := strings.ReplaceAll(strings.TrimSuffix(src[i+1:j], "'"), "''", "'")48+ b.WriteString(excelSheetRef(src[i:j], name, j < len(src) && src[j] == '!', renamed))4249 return j, true4350 case c == '"':4451 j := stringEnd(src, i)@@ -57,7 +64,7 @@ func excelToken(b *strings.Builder, src string, i int) (int, bool) {
5764 b.WriteByte(':') // 1-2-3's A1..B35865 return i + 2, true5966 case c == '@' || isIdentByte(c) && !isDigitByte(c) && c != '.':60- return excelName(b, src, i)67+ return excelName(b, src, i, renamed)6168 }6269 b.WriteByte(c)6370 return i + 1, true@@ -81,7 +88,7 @@ func stringEnd(s string, i int) int {
8188 // excelName writes the name starting at src[i]: a function name is8289 // upper-cased and given Excel's _xlfn. prefix where it needs one, and8390 // 1-2-3's @PI becomes PI(); references and named ranges stay as they are.84-func excelName(b *strings.Builder, src string, i int) (int, bool) {91+func excelName(b *strings.Builder, src string, i int, renamed map[string]string) (int, bool) {8592 at := src[i] == '@'8693 j := i8794 if at {@@ -109,10 +116,23 @@ func excelName(b *strings.Builder, src string, i int) (int, bool) {
109116 name += "()" // @PI110117 }111118 }112- b.WriteString(name)119+ b.WriteString(excelSheetRef(name, name, !call && !at && j < len(src) && src[j] == '!', renamed))113120 return j, true114121 }115122 123+// excelSheetRef is text, a sheet name as written (name, unquoted) when124+// sheet says it is followed by !, with the name the sheet is written125+// under when that differs: always quoted, as Excel accepts.126+func excelSheetRef(text, name string, sheet bool, renamed map[string]string) string {127+ if !sheet {128+ return text129+ }130+ if to, ok := renamed[formula.SheetKey(name)]; ok {131+ return "'" + strings.ReplaceAll(to, "'", "''") + "'"132+ }133+ return text134+}135+116136 // fromExcelFormula translates an Excel formula (without its =) to a 012117137 // entry. The result may still fail to parse, e.g. with references to118138 // other sheets; the importer then keeps the cell's value.@@ -25,9 +25,9 @@ type Snapshot struct {
2525 // Sheets are every sheet of the workbook, in order, for formats that2626 // hold several (XLSX); the snapshot itself is one of them, the one2727 // shown. Nil exports just this snapshot. Names are the workbook's28- // named ranges as name and Excel reference, e.g. Q3!$B$2:$B$9.28+ // named ranges.2929 Sheets []*Snapshot30- Names [][2]string30+ Names []SnapName3131 Hidden bool // a hidden sheet of Sheets, written hidden3232 3333 // FrozenRows and FrozenCols are the frozen panes; Filter is the@@ -38,6 +38,13 @@ type Snapshot struct {
3838 HiddenRows map[int]bool3939 }4040 41+// SnapName is a named range: its name, and the range on the sheet42+// named Sheet.43+type SnapName struct {44+ Name, Sheet string45+ Range sheet.Rect46+}47+4148 // SnapCell is one non-blank cell of a snapshot.4249 type SnapCell struct {4350 Input string@@ -112,7 +119,7 @@ func SnapBook(s *sheet.Sheet) *Snapshot {
112119 out.Sheets = all113120 for _, n := range s.Book().Names() {114121 if !n.Gone() {115- out.Names = append(out.Names, [2]string{n.Name, excelRange(n.Sheet.Name(), n.Range)})122+ out.Names = append(out.Names, SnapName{Name: n.Name, Sheet: n.Sheet.Name(), Range: n.Range})116123 }117124 }118125 return out@@ -41,7 +41,7 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
4141 if len(sheets) == 0 {4242 sheets = []*Snapshot{snap}4343 }44- w := &xlsxWriter{styles: newXLSXStyleTable(), multi: len(sheets) > 1, known: map[string]bool{}}44+ w := &xlsxWriter{styles: newXLSXStyleTable(), multi: len(sheets) > 1, known: map[string]bool{}, renamed: map[string]string{}}4545 res := &ExportResult{}4646 names, hidden, active := make([]string, len(sheets)), make([]bool, len(sheets)), 04747 used := map[string]bool{}@@ -51,10 +51,11 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
5151 active = i5252 }5353 hidden[i] = sn.Hidden54- // A formula can name only a sheet written under its own name:55- // one renamed to suit Excel goes out as values, as a missing one.56- if names[i] == sn.Name {57- w.known[formula.SheetKey(sn.Name)] = true54+ // A sheet renamed to suit Excel is named so in formulas too.55+ key := formula.SheetKey(sn.Name)56+ w.known[key] = true57+ if names[i] != sn.Name {58+ w.renamed[key] = names[i]5859 }5960 }6061 hidden[active] = false@@ -70,7 +71,7 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
7071 if err := writePart(zw, "xl/styles.xml", w.styles.xml()); err != nil {7172 return err7273 }73- if err := writePackage(zw, names, hidden, active, snap.Names, filterRanges(sheets, names)); err != nil {74+ if err := writePackage(zw, names, hidden, active, w.definedNames(snap.Names), filterRanges(sheets, names)); err != nil {7475 return err7576 }7677 return zw.Close()@@ -89,6 +90,20 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
8990 return res, nil9091 }9192 93+// definedNames are the named ranges as Excel's defined names hold them,94+// with the names their sheets are written under: Q3!$B$2:$B$9.95+func (w *xlsxWriter) definedNames(names []SnapName) [][2]string {96+ out := make([][2]string, len(names))97+ for i, n := range names {98+ ws := n.Sheet99+ if to, ok := w.renamed[formula.SheetKey(ws)]; ok {100+ ws = to101+ }102+ out[i] = [2]string{n.Name, excelRange(ws, n.Range)}103+ }104+ return out105+}106+92107 // uniqueSheetName is name, or name with a number when another sheet93108 // already took it (Excel's names ignore case).94109 func uniqueSheetName(name string, used map[string]bool) string {@@ -233,8 +233,10 @@ func TestExcelFormulas(t *testing.T) {
233233 {"=#REF!+1", "#REF!+1", true},234234 {"='Q3 #1'!A1..B2", "'Q3 #1'!A1:B2", true},235235 {"=SUM('Bob''s'!A1)", "SUM('Bob''s'!A1)", true},236+ {"='q3/q4'!A1+plan!B2:B3+Plan+'Plan'", "'Q3_Q4'!A1+'Plan (2)'!B2:B3+Plan+'Plan'", true},237+ {`=PLAN(1)&"plan!"&Other!A1`, `PLAN(1)&"plan!"&Other!A1`, true},236238 } {237- got, ok := toExcelFormula(tc.in)239+ got, ok := toExcelFormula(tc.in, map[string]string{"Q3/Q4": "Q3_Q4", "PLAN": "Plan (2)"})238240 if ok != tc.ok || ok && got != tc.want {239241 t.Errorf("toExcelFormula(%q) = %q, %v, want %q, %v", tc.in, got, ok, tc.want, tc.ok)240242 }@@ -375,28 +377,6 @@ func TestXLSXWriteText(t *testing.T) {
375377 }376378 }377379 378-// A sheet whose name Excel can't take as is gets another; formulas379-// naming it go out as values, like formulas naming a missing sheet.380-func TestXLSXRenamedSheetAsValue(t *testing.T) {381- src := build(t, map[string]string{"A1": "1"})382- book := src.Book()383- book.RenameSheet(src, "Plan")384- spaced, err := book.AddSheet(" Plan", 1)385- if err != nil {386- t.Fatal(err)387- }388- spaced.Set(addr(t, "A1"), "=' Plan'!A2+Plan!A1")389- name := filepath.Join(t.TempDir(), "renamed.xlsx")390- res, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{})391- if err != nil {392- t.Fatal(err)393- }394- want := "1 formula naming a sheet that doesn't exist saved as values, e.g. 'Plan (2)'!A1 (' Plan')"395- if len(res.Notes) != 1 || res.Notes[0] != want {396- t.Errorf("notes %q, want %q", res.Notes, want)397- }398-}399-400380 func TestUniqueSheetName(t *testing.T) {401381 used := map[string]bool{}402382 long := strings.Repeat("x", 31)@@ -0,0 +1,79 @@
1+package fileio2+3+import (4+ "context"5+ "path/filepath"6+ "testing"7+8+ "github.com/xuri/excelize/v2"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// A sheet whose name Excel can't take as is (spaces at its ends, or the14+// same as another's but for them and case) gets another, and the15+// formulas and named ranges naming it name that one.16+func TestXLSXRenamedSheets(t *testing.T) {17+ src := build(t, map[string]string{"A1": "1", "A2": "=' Plan'!A2+Plan!A1", "A3": "='Q3 '!A1*2",18+ "A4": "=SUM('plan '!A1:A2)", "A5": "=SUM(Slash)+Spaced"})19+ book := src.Book()20+ book.RenameSheet(src, "Plan")21+ for _, sh := range []struct {22+ name string23+ cells map[string]string24+ }{25+ {" Plan", map[string]string{"A2": "10"}}, {"Q3 ", map[string]string{"A1": "20", "A2": "30"}},26+ {"plan ", map[string]string{"A1": "40", "A2": "50"}},27+ } {28+ name, cells := sh.name, sh.cells29+ s, err := book.AddSheet(name, book.Len())30+ if err != nil {31+ t.Fatal(err)32+ }33+ for a, in := range cells {34+ s.Set(addr(t, a), in)35+ }36+ }37+ book.DefineName("Slash", book.Lookup("Q3 "), sheet.NewRect(addr(t, "A1"), addr(t, "A2")))38+ book.DefineName("Spaced", book.Lookup(" Plan"), sheet.NewRect(addr(t, "A2"), addr(t, "A2")))39+ want := map[string]string{"A2": "11", "A3": "40", "A4": "90", "A5": "60"}40+ for a, v := range want {41+ if got := shown(src, addr(t, a)); got != v {42+ t.Fatalf("%s = %s before export, want %s", a, got, v)43+ }44+ }45+ name := filepath.Join(t.TempDir(), "renamed.xlsx")46+ res, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{})47+ if err != nil {48+ t.Fatal(err)49+ }50+ if len(res.Notes) > 0 {51+ t.Errorf("notes %q", res.Notes)52+ }53+ x, err := excelize.OpenFile(name)54+ if err != nil {55+ t.Fatal(err)56+ }57+ defer x.Close()58+ if f, _ := x.GetCellFormula("Plan", "A2"); f != "'Plan (2)'!A2+Plan!A1" {59+ t.Errorf("Excel formula in A2 = %q", f)60+ }61+ got, err := Import(context.Background(), name, Options{})62+ if err != nil {63+ t.Fatal(err)64+ }65+ s := got.Sheet.Book().Lookup("Plan")66+ for a, v := range want {67+ if g := shown(s, addr(t, a)); g != v {68+ t.Errorf("%s = %s after the round trip (%s), want %s", a, g, input(s, addr(t, a)), v)69+ }70+ }71+ if g := input(s, addr(t, "A4")); g != "=SUM('plan (3)'!A1:A2)" {72+ t.Errorf("A4 = %q after the round trip", g)73+ }74+ for n, ws := range map[string]string{"Slash": "Q3", "Spaced": "Plan (2)"} {75+ if nm, ok := got.Sheet.Book().LookupName(n); !ok || nm.Sheet.Name() != ws {76+ t.Errorf("name %s: %+v", n, nm)77+ }78+ }79+}@@ -29,8 +29,9 @@ type xlsxWriter struct {
2929 // refuse.3030 values valueCount3131 missing valueCount32- missingSheet string // the sheet missing's example names33- known map[string]bool // keys of the sheets written32+ missingSheet string // the sheet missing's example names33+ known map[string]bool // keys of the sheets written34+ renamed map[string]string // the names of sheets written under another, by key3435 }3536 3637 // valueCount counts formulas written as values, keeping the first one's@@ -166,7 +167,7 @@ func (w *xlsxWriter) formula(ws string, a sheet.Addr, c SnapCell) string {
166167 w.missing.add(w.multi, ws, a)167168 return ""168169 }169- fx, ok := toExcelFormula(c.Input)170+ fx, ok := toExcelFormula(c.Input, w.renamed)170171 if !ok {171172 w.values.add(w.multi, ws, a)172173 }