- commit
- 9ab612ff9b817a70ff286fb420a6eb8177ddaaa1
- parent
- f7564165d87663a25e07699488dbd5e678c23aa4
- parent
- 59bfdb3fb8bbff606e1dc10eaf23b4e4a04ab336
- author
- Dami <dami.etoile@gmail.com>
| M | Makefile | 2 | + |
| M | docs/ | 21 | +- |
| M | docs/ | 2 | +- |
| M | docs/ | 44 | ++- |
| M | docs/ | 16 | +- |
| M | internal/ | 98 | +++++- |
| M | internal/ | 475 | +++++++----------------------- |
| A | internal/ | 205 | +++++++++++++ |
| A | internal/ | 212 | +++++++++++++ |
| M | internal/ | 85 | +++++ |
| A | internal/ | 297 | +++++++++++++++++++ |
| A | internal/ | 281 | ++++++++++++++++++ |
| A | internal/ | 105 | +++++++ |
| A | internal/ | 294 | +++++++++++++++++++ |
| A | internal/ | 273 | +++++++++++++++++ |
| A | internal/ | 410 | ++++++++++++++++++++++++++ |
| A | internal/ | 178 | +++++++++++ |
| A | internal/ | 223 | ++++++++++++++ |
| A | internal/ | 359 | +++++++++++++++++++++++ |
@@ -27,6 +27,8 @@ fuzz:
2727 go test ./internal/sheet -run '^$$' -fuzz FuzzParse -fuzztime 60s2828 go test ./internal/fileio -run '^$$' -fuzz FuzzReadDelimited -fuzztime 60s2929 go test ./internal/fileio -run '^$$' -fuzz FuzzReadWK1 -fuzztime 60s30+ go test ./internal/fileio -run '^$$' -fuzz 'FuzzReadXLSX$$' -fuzztime 60s31+ go test ./internal/fileio -run '^$$' -fuzz FuzzReadXLSXParts -fuzztime 60s3032 3133 e2e: $(GHOSTTY_STAMP)3234 cd e2e && PKG_CONFIG_PATH=$(GHOSTTY_OUT)/share/pkgconfig go test -count=1 ./...@@ -23,7 +23,7 @@ internal/ui the Bubble Tea model: modes, menus, overlays, rendering
2323 rowtext laying out a row of cell text across the columns on screen2424 formula reading the formula being typed (F4, the word and call at the caret)2525 e2e/ end-to-end tests through libghostty (separate module, cgo)26-oracle/ differential tests against excelize (separate module)26+oracle/ differential tests against excelize's calculation (separate module)2727 ```2828 2929 ## The engine@@ -205,12 +205,19 @@ falls back to a formula's cached value when it can't be translated, and
205205 counts what didn't fit. They stream: CSV and TSV are read record by206206 record, Parquet a batch of rows at a time, SQLite a row at a time, and207207 Parquet files and SQLite tables stop at the sheet's last row, taking the208-number of rows left out from the file. XLSX goes through excelize, which209-holds the worksheet in memory while its rows are read, so it is the one210-format read twice; its files are small next to the others' and it's211-bounded by the sheet's size. Exporters write a `Snapshot` taken on the UI212-goroutine, in the background, through one atomic write-then-rename; CSV213-and TSV are written a row at a time. One package suits formats that share208+number of rows left out from the file. XLSX is read by 012's own209+SpreadsheetML reader on `archive/zip` and `encoding/xml`: the workbook,210+shared strings (kept end to end in one buffer) and styles first, then211+each worksheet a token at a time, a row at a time, expanding shared212+formulas only for the cells kept. The file is untrusted, so the reader213+caps what it can be made to do (`xlsxLimits`: uncompressed bytes per214+part and in all, compression ratio, zip entries, XML nesting and token215+size, shared strings, styles, sheets, and the text shared formulas216+expand to) and refuses unsafe part names and references past Excel's217+edges; `FuzzReadXLSX` and `FuzzReadXLSXParts` fuzz it. XLSX is written218+with the same packages, a worksheet part streamed per sheet. Exporters219+write a `Snapshot` taken on the UI goroutine, in the background, through220+one atomic write-then-rename; CSV and TSV are written a row at a time. One package suits formats that share214221 this much (the builder, number formats, serial dates and Excel formula215222 translation); a format that grew its own dependencies would move to a216223 subpackage behind the same table row.@@ -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. Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed | Every sheet, the same, with formulas in Excel's syntax and their results cached. 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 |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, 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. 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 |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 | |@@ -24,7 +24,7 @@ it lags, and past a second it stalls.
2424 | Full recalc | Any sheet without heavy range fan-in: under 40 ms for 213 k numbers | 1000 full-column SUMs: 170 ms | 8192 running totals: 0.7 s | Same as above |2525 | Rendering | Any sheet at up to 200 x 60: 1 ms a frame; 400 x 120: 4.5 ms | 20 charts at 400 x 120: 7 ms | | View building styled strings, then Bubble Tea parsing and diffing them |2626 | Selection statistics | Any selection, once computed (cached) | Extending a selection over 2 M cells: 30 ms per key | | Summing 2 M map entries per change |27-| Imports | CSV, SQLite, Parquet: 1 to 3 M cells/s (a full 2.1 M-cell sheet in about 1 s) | XLSX with formulas: 200 k cells/s | Data past the limits (dropped, with a note) | Building cells one at a time; XLSX formula translation |27+| Imports | CSV, SQLite, Parquet: 1 to 3 M cells/s (a full 2.1 M-cell sheet in about 1 s); XLSX numbers or text: 0.7 to 1.1 M cells/s | XLSX with formulas: 0.4 to 0.6 M cells/s | Data past the limits (dropped, with a note); XLSX files past the reader's limits (refused) | Building cells one at a time; XML decoding; XLSX formula translation |2828 | Undo | One step of any size: undo costs what the edit cost | History capped at 100 steps and 256 MB of before-images: 100 whole-column steps hold 250 MB | | Whole-cell before-images, about 300 B per cell per step |2929 | JEV | Up to about 1000 JEV cells: 0.3 ms of CPU per answer | 4000 JEV cells: 1.3 ms per answer, 5 s of CPU to answer them all | | Every answer recalculates every JEV cell (they're volatile) |3030 | Formula depth | 10,000 nested parentheses or IFs: under 5 ms | | No explicit limit; recursion grows the stack | Recursive parser and evaluator |@@ -44,9 +44,14 @@ Lotus 1-2-3 Release 2.
4444 | Live heap | 64 MB | 602 MB (about 300 B per number cell) |4545 | Save `.012` (JSON, one line per cell) | 74 ms, 4.6 MB | 1.04 s, 47 MB |4646 | Open `.012` | 134 ms (116 MB allocated) | 2.08 s (1.3 GB allocated) |47-| Export CSV / TSV / XLSX / SQLite | 41 / 55 / 87 / 40 ms | 0.71 / 0.66 / 0.91 / 0.72 s |47+| Export CSV / TSV / XLSX / SQLite | 41 / 55 / 62 / 40 ms | 0.71 / 0.66 / 0.81 / 0.72 s |4848 | Frame at 200 x 60, whole | 1.06 ms | 1.05 ms |4949 50+XLSX exports are written by 012 with archive/zip since excelize went:51+measured back to back with the excelize writer, 8192 x 26 went from 33552+to 62 ms and 379 to 1.9 MB allocated, 8192 x 256 from 0.97 to 0.81 s53+(mostly compression) and 259 to 17 MB allocated.54+5055 Heap per non-blank cell after loading (`BenchmarkMemory`,5156 `BenchmarkImport`): numbers 301 B, formulas 707 B (the parsed tree and5257 reference lists), 200-character text 790 B, imported CSV 275 to 300 B.@@ -120,7 +125,7 @@ Real, openly licensed datasets fetched by `scripts/stress-data.sh`
120125 | Airport codes (CSV) | 86,134 x 13, first 8192 rows kept | 83 ms | 107 MB/s | 274 B |121126 | NOAA daily CO2 (CSV) | 18,304 x 2 | 6 ms | 2.7 M cells/s | 294 B |122127 | Country codes (CSV, Arabic, CJK) | 249 x 56 | 5.6 ms | 2.2 M cells/s | 272 B |123-| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas | 19 ms | 205 k cells/s | 358 B |128+| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas, 3828 cells | 9.7 ms | 395 k cells/s | 425 B |124129 | Chinook PlaylistTrack (SQLite) | 8715 x 2 | 5.5 ms | 3.0 M cells/s | 293 B |125130 | Parquet alltypes_tiny_pages | 7300 x 13 | 40 ms | 2.4 M cells/s | 276 B |126131 @@ -128,9 +133,36 @@ Past the limits, imports keep the first 8192 rows and 256 columns and
128133 say how much they left out. Parquet files and SQLite tables stop reading129134 at the last row and take the count of the rest from the file; CSV, TSV130135 and SQLite queries are read to their end to count it, without keeping131-it (the airport codes file is read to its end). Every importer but XLSX132-streams, so memory follows the sheet, not the file; excelize holds an133-XLSX worksheet in memory while its rows are read.136+it (the airport codes file is read to its end). Every importer streams,137+so memory follows the sheet, not the file. XLSX used to go through138+excelize, which held each worksheet in memory while its rows were read;139+012's own reader streams it a token at a time, keeping only the shared140+strings (in one buffer, 4 bytes a string besides the text), the cell141+formats, and the first cell of each shared formula.142+143+XLSX imports of full sheets written by 012's exporter144+(`BenchmarkImportXLSX`) and the POI file, before (excelize) and after145+(012's reader), on an Apple M5 Pro. Peak heap is sampled while146+importing, per cell kept, and includes garbage not yet collected:147+148+| Workbook | Time | Allocated | Allocations | Heap per cell after | Peak heap per cell |149+|---|---|---|---|---|---|150+| 8192 x 26 numbers (213 k cells) | 480 -> 199 ms | 491 -> 170 MB | 8.4 -> 3.3 M | 304 -> 290 B | 1365 -> 589 B |151+| 8192 x 26 table, text and numbers | 642 -> 296 ms | 644 -> 234 MB | 14.4 -> 7.4 M | 297 -> 297 B | 1432 -> 722 B |152+| 8192 formulas (`=A1+1` down a column) | 33 -> 13.9 ms | 38 -> 17 MB | 718 -> 321 k | 752 -> 746 B | 4790 -> 2188 B |153+| Apache POI formula tests | 21.1 -> 9.7 ms | 22 -> 9.7 MB | 373 -> 179 k | 429 -> 425 B | 2343 -> 1288 B |154+155+The reader refuses files past `xlsxLimits` (in `internal/fileio/xlsxpkg.go`),156+with a message naming the limit: 10,000 files in the zip; 1 GB157+uncompressed in one part and 2 GB in all, counted as the bytes come out158+whatever the zip's headers say; a part compressed more than 250 to 1159+once past 16 MB (a zip bomb); XML nested more than 256 deep or a single160+tag or text over 32 MB; 16.7 M shared strings; 65,536 cell formats,161+fonts, number formats or names; 4096 sheets; 256 MB of text from162+expanding shared formulas; rows past 1,048,576, columns past XFD and163+rows of more than 16,384 cells; part names that are absolute, climb with164+`..` or hold a backslash. encoding/xml expands no external or declared165+entities, so entity bombs fail as unknown entities.134166 135167 ## Undo136168 @@ -3,7 +3,7 @@
33 ```sh44 make lint # go vet, cognitive complexity at most 25, Go files at most 500 lines55 make test # engine, file formats and UI unit tests6-make fuzz # fuzz the formula parser and the CSV and .wk1 readers6+make fuzz # fuzz the formula parser and the CSV, .wk1 and XLSX readers77 make oracle # compare formulas and number formats with excelize88 make e2e # run the real binary in a terminal emulator (needs Zig and pkg-config)99 make screens # rewrite the golden screens and build the review gallery@@ -63,6 +63,20 @@ formatted values with 012's. Known differences between Sheets and Excel,
6363 and places where excelize departs from Excel, are listed as skips with a6464 reason.6565 66+## The XLSX differential test67+68+`TestXLSXDifferential` (`internal/fileio/xlsx_diff_test.go`) imports a69+corpus of workbooks with 012's own XLSX reader and with the excelize70+importer it replaced (kept in `xlsx_excelize_test.go`), and compares71+every sheet as saved, the widths, names, sheet shown, rows and notes.72+The corpus is workbooks written by the test (hand-written SpreadsheetML,73+excelize and 012's exporter), the stress datasets' XLSX files when74+fetched (`STRESS_DIR`), and any directories in `XLSX_CORPUS`, such as75+excelize's own `test/` files in the module cache. Where excelize reads a76+file wrongly (formulas spread across merged cells, spaces dropped from77+shared formulas) the comparison leaves the difference out, with the78+reason next to it.79+6680 ## Live checks6781 6882 `JEV_LIVE_TEST=1 go test ./internal/jev -run TestLive` asks the real JEV@@ -12,8 +12,10 @@ import (
1212 "os"1313 "path/filepath"1414 "runtime"15+ "runtime/metrics"1516 "strconv"1617 "testing"18+ "time"1719 1820 "github.com/FelineStateMachine/012/internal/sheet"1921 "github.com/FelineStateMachine/012/internal/stress"@@ -61,25 +63,93 @@ func BenchmarkImport(b *testing.B) {
6163 if k, _ := KindOf(name); k == SQLite {6264 opt.Table = "PlaylistTrack" // the biggest table6365 }64- var res *Result65- for b.Loop() {66- if res, err = Import(context.Background(), path, opt); err != nil {67- b.Fatal(err)68- }66+ benchImport(b, path, st.Size(), opt)67+ })68+ }69+}70+71+// BenchmarkImportXLSX imports synthetic workbooks written by 012's72+// exporter: numbers, text and a column of formulas, each a full 819273+// rows.74+func BenchmarkImportXLSX(b *testing.B) {75+ for _, tc := range []struct {76+ name string77+ s *sheet.Sheet78+ }{79+ {"numbers-8192x26", stress.Dense(sheet.MaxRows, 26)},80+ {"table-8192x26", stress.Table(sheet.MaxRows-1, 26)},81+ {"formulas-8192", stress.Chain(sheet.MaxRows)},82+ } {83+ b.Run(tc.name, func(b *testing.B) {84+ path := filepath.Join(b.TempDir(), tc.name+".xlsx")85+ if _, err := Export(context.Background(), path, XLSX, Snap(tc.s, sheet.Rect{}, "data"), ExportOptions{}); err != nil {86+ b.Fatal(err)6987 }70- b.SetBytes(st.Size())71- cells := res.Sheet.Len()72- b.ReportMetric(float64(cells), "cells")73- b.ReportMetric(float64(cells)*float64(b.N)/b.Elapsed().Seconds(), "cells/s")74- res = nil75- before := liveHeap()76- res, _ = Import(context.Background(), path, opt)77- b.ReportMetric(float64(liveHeap()-before)/float64(max(cells, 1)), "B/cell")78- runtime.KeepAlive(res)88+ st, err := os.Stat(path)89+ if err != nil {90+ b.Fatal(err)91+ }92+ benchImport(b, path, st.Size(), Options{})7993 })8094 }8195 }8296 97+// benchImport imports path in a loop and reports throughput, cells kept,98+// the live heap of the imported sheet per cell (B/cell) and the peak99+// heap while importing, per cell (peak-B/cell), sampled.100+func benchImport(b *testing.B, path string, size int64, opt Options) {101+ var res *Result102+ var err error103+ for b.Loop() {104+ if res, err = Import(context.Background(), path, opt); err != nil {105+ b.Fatal(err)106+ }107+ }108+ b.SetBytes(size)109+ cells := res.Sheet.Len()110+ b.ReportMetric(float64(cells), "cells")111+ b.ReportMetric(float64(cells)*float64(b.N)/b.Elapsed().Seconds(), "cells/s")112+ res = nil113+ before := liveHeap()114+ stop := samplePeak()115+ res, _ = Import(context.Background(), path, opt)116+ peak := stop()117+ b.ReportMetric(float64(liveHeap()-before)/float64(max(cells, 1)), "B/cell")118+ b.ReportMetric(float64(peak-min(peak, before))/float64(max(cells, 1)), "peak-B/cell")119+ runtime.KeepAlive(res)120+}121+122+// samplePeak samples the heap every 100 microseconds until the returned123+// function is called, which returns the most seen: live objects and124+// garbage not yet collected, what the process holds.125+func samplePeak() func() uint64 {126+ sample := []metrics.Sample{{Name: "/memory/classes/heap/objects:bytes"}}127+ read := func() uint64 {128+ metrics.Read(sample)129+ return sample[0].Value.Uint64()130+ }131+ done := make(chan struct{})132+ peak := make(chan uint64)133+ go func() {134+ hi := read()135+ t := time.NewTicker(100 * time.Microsecond)136+ defer t.Stop()137+ for {138+ select {139+ case <-done:140+ peak <- max(hi, read())141+ return142+ case <-t.C:143+ hi = max(hi, read())144+ }145+ }146+ }()147+ return func() uint64 {148+ close(done)149+ return <-peak150+ }151+}152+83153 // BenchmarkExport writes a dense numeric sheet in each export format and84154 // reports the file size.85155 func BenchmarkExport(b *testing.B) {@@ -1,19 +1,22 @@
11 package fileio22 33 import (4+ "archive/zip"5+ "bufio"46 "context"57 "fmt"68 "io"7- "math"89 "strconv"910 "strings"1011 11- "github.com/xuri/excelize/v2"12-1312 "github.com/FelineStateMachine/012/internal/formula"1413 "github.com/FelineStateMachine/012/internal/sheet"1514 )1615 16+// XLSX files are read by xlsximport.go (on xlsxpkg.go, xlsxbook.go,17+// xlsxstyles.go and xlsxsheet.go) and written here and in xlsxwrite.go,18+// all with archive/zip and encoding/xml's escaping.19+1720 // Column widths: Excel measures in characters of its default font, 0121821 // in terminal columns including one of padding.1922 const (@@ -21,257 +24,58 @@ const (
2124 excelPadding = 12225 )2326 24-// importXLSX reads every sheet of a workbook, with its named ranges, and25-// returns the sheet that was active in Excel.26-func importXLSX(ctx context.Context, name string, opt Options) (*Result, error) {27- prog := opt.Progress28- x, err := excelize.OpenFile(name)29- if err != nil {30- return nil, err31- }32- defer x.Close()33- names := x.GetSheetList()34- if len(names) == 0 {35- return nil, fmt.Errorf("the workbook has no sheets")36- }37- // Rows across all sheets, for the progress bar.38- dims := make([]int, len(names))39- total := 040- for i, ws := range names {41- if dim, err := x.GetSheetDimension(ws); err == nil {42- if _, to, ok := strings.Cut(dim, ":"); ok {43- if _, r, err := excelize.CellNameToCoordinates(to); err == nil {44- dims[i] = r45- }46- }47- }48- total += dims[i]49- }50-51- b := newBuilder()52- book := b.s.Book()53- styles := map[int]xlsxStyle{}54- var notes []string55- done := 056- for i, ws := range names {57- if i == 0 {58- err = book.RenameSheet(b.s, ws)59- } else {60- b.s, err = book.AddSheet(ws, i)61- }62- if err != nil {63- return nil, fmt.Errorf("sheet %s: %w", ws, err)64- }65- rows, err := importXLSXSheet(ctx, x, b, ws, styles, func(row int) {66- prog.setRows(done + row)67- prog.setFrac(int64(done+row), int64(total))68- })69- if err != nil {70- return nil, err71- }72- done += rows73- }74- notes = append(notes, importXLSXNames(x, book)...)75- active := book.Sheet(clamp(x.GetActiveSheetIndex(), 0, book.Len()-1))76- book.SetActive(active)77- for i, ws := range names {78- if shown, err := x.GetSheetVisible(ws); err == nil && !shown && book.Sheet(i) != active {79- book.HideSheet(book.Sheet(i)) // hidden in Excel, hidden here80- }81- }82- b.s = active83- prog.setRows(done)84- s, notes := b.finish(notes)85- return &Result{Sheet: s, Rows: done, Notes: notes}, nil86-}87-8827 func clamp(v, lo, hi int) int { return max(lo, min(v, hi)) }8928 90-// importXLSXSheet reads one sheet into b.s, reporting rows read, and91-// returns how many rows it read.92-func importXLSXSheet(ctx context.Context, x *excelize.File, b *builder, ws string, styles map[int]xlsxStyle, report func(int)) (int, error) {93- rows, err := x.Rows(ws)94- if err != nil {95- return 0, err96- }97- defer rows.Close()98- row := 099- for ; rows.Next(); row++ {100- if row%128 == 0 {101- if err := ctx.Err(); err != nil {102- return 0, err103- }104- report(row)105- }106- cols, err := rows.Columns(excelize.Options{RawCellValue: true})107- if err != nil {108- return 0, err109- }110- for col, raw := range cols {111- a := sheet.Addr{Col: col, Row: row}112- if !b.fits(a) {113- break114- }115- cell, _ := excelize.CoordinatesToCellName(col+1, row+1)116- importXLSXCell(x, b, ws, cell, a, raw, styles)117- }118- }119- if err := rows.Error(); err != nil {120- return 0, err121- }122- for c := range sheet.MaxCols {123- colName, _ := excelize.ColumnNumberToName(c + 1)124- w, err := x.GetColWidth(ws, colName)125- if err != nil || math.Abs(w-excelDefaultWidth) < 0.01 || math.Abs(w-9.140625) < 0.01 {126- continue127- }128- b.s.SetColWidth(c, max(int(math.Round(w))+excelPadding, 1))129- }130- return row, nil131-}132-133-// importXLSXNames defines the workbook's named ranges that are a range on134-// one sheet, such as Sales = Q3!$B$2:$B$20, and notes the others (sheet135-// scoped names, constants, formulas).136-func importXLSXNames(x *excelize.File, book *sheet.Workbook) []string {137- skipped := 0138- example := ""139- for _, dn := range x.GetDefinedName() {140- if strings.HasPrefix(dn.Name, "_xlnm.") {141- continue // print areas and the like142- }143- ref := strings.TrimPrefix(dn.RefersTo, "=")144- ws, rest := sheet.SplitSheet(ref)145- r, ok := sheet.ParseRange(rest)146- s := book.Lookup(ws)147- if dn.Scope != "" && dn.Scope != "Workbook" || !ok || s == nil || book.DefineName(dn.Name, s, r) != nil {148- skipped++149- if example == "" {150- example = dn.Name151- }152- }153- }154- if skipped > 0 {155- return []string{fmt.Sprintf("%s left out, e.g. %s", count(skipped, "named range", "named ranges"), example)}156- }157- return nil158-}159-16029 // xlsxStyle is what 012 keeps of an Excel cell style.16130 type xlsxStyle struct {16231 format sheet.Format16332 style sheet.Style16433 }16534 166-func styleOf(x *excelize.File, id int, cache map[int]xlsxStyle) xlsxStyle {167- if st, ok := cache[id]; ok {168- return st169- }170- var out xlsxStyle171- if st, err := x.GetStyle(id); err == nil && st != nil {172- code := ""173- if st.CustomNumFmt != nil {174- code = *st.CustomNumFmt175- }176- out.format = formatOf(st.NumFmt, code)177- if f := st.Font; f != nil {178- out.style.Bold, out.style.Italic, out.style.Strikethrough = f.Bold, f.Italic, f.Strike179- out.style.Underline = f.Underline != "" && f.Underline != "none"180- }181- if al := st.Alignment; al != nil {182- switch al.Horizontal {183- case "left":184- out.style.Align = sheet.AlignLeft185- case "center", "centerContinuous":186- out.style.Align = sheet.AlignCenter187- case "right":188- out.style.Align = sheet.AlignRight189- }190- }191- }192- cache[id] = out193- return out194-}195-196-func importXLSXCell(x *excelize.File, b *builder, ws, cell string, a sheet.Addr, raw string, styles map[int]xlsxStyle) {197- var st xlsxStyle198- if id, err := x.GetCellStyle(ws, cell); err == nil && id != 0 {199- st = styleOf(x, id, styles)200- }201- typ, _ := x.GetCellType(ws, cell)202- keep := func() {203- switch typ {204- case excelize.CellTypeBool:205- b.boolean(a, raw == "1" || strings.EqualFold(raw, "TRUE"), st.style)206- return207- case excelize.CellTypeSharedString, excelize.CellTypeInlineString, excelize.CellTypeFormula, excelize.CellTypeError:208- b.text(a, raw, st.format, st.style)209- return210- }211- if v, err := strconv.ParseFloat(raw, 64); err == nil {212- b.number(a, fromExcelSerial(v, st.format), st.format, st.style)213- return214- }215- b.text(a, raw, st.format, st.style)216- }217- if f, err := x.GetCellFormula(ws, cell); err == nil && f != "" {218- b.formula(a, fromExcelFormula(f), st.format, st.style, keep)219- return220- }221- if raw == "" {222- b.put(a, "", st.format, st.style)223- return224- }225- keep()226-}227-22835 // exportXLSX writes a workbook: the snapshot's sheets (or just the22936 // snapshot), each with values, formulas in Excel's syntax with their23037 // results cached, number formats, text styles, alignment and column231-// widths, and the named ranges.38+// widths, and the named ranges. Excel recalculates it on opening.23239 func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions) (*ExportResult, error) {233- x := excelize.NewFile()234- defer x.Close()23540 sheets := snap.Sheets23641 if len(sheets) == 0 {23742 sheets = []*Snapshot{snap}23843 }239- w := &xlsxWriter{x: x, styleIDs: map[xlsxStyle]int{}, multi: len(sheets) > 1, known: map[string]bool{}}240- for _, sn := range sheets {241- w.known[formula.SheetKey(sn.Name)] = true242- }44+ w := &xlsxWriter{styles: newXLSXStyleTable(), multi: len(sheets) > 1, known: map[string]bool{}}24345 res := &ExportResult{}46+ names, hidden, active := make([]string, len(sheets)), make([]bool, len(sheets)), 047+ used := map[string]bool{}24448 for i, sn := range sheets {245- ws := sheetName(sn.Name)246- var err error247- if i == 0 {248- err = x.SetSheetName("Sheet1", ws)249- } else {250- _, err = x.NewSheet(ws)251- }252- if err != nil {253- return nil, err254- }255- rows, err := w.sheet(ws, sn)256- if err != nil {257- return nil, err258- }259- res.Rows += rows260- if sn == snap || snap.Sheets == nil {261- x.SetActiveSheet(i)262- } else if sn.Hidden {263- if err := x.SetSheetVisible(ws, false); err != nil {264- return nil, err49+ names[i] = uniqueSheetName(sheetName(sn.Name), used)50+ if sn == snap {51+ active = i52+ }53+ 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)] = true58+ }59+ }60+ hidden[active] = false61+ err := writeFile(name, func(out io.Writer) error {62+ zw := zip.NewWriter(out)63+ for i, sn := range sheets {64+ rows, err := w.writeSheet(zw, i, names[i], sn, i == active)65+ if err != nil {66+ return err26567 }68+ res.Rows += rows26669 }267- }268- for _, n := range snap.Names {269- if err := x.SetDefinedName(&excelize.DefinedName{Name: n[0], RefersTo: n[1]}); err != nil {270- return nil, err70+ if err := writePart(zw, "xl/styles.xml", w.styles.xml()); err != nil {71+ return err27172 }272- }273- yes := true274- if err := x.SetCalcProps(&excelize.CalcPropsOptions{FullCalcOnLoad: &yes}); err != nil {73+ if err := writePackage(zw, names, hidden, active, snap.Names); err != nil {74+ return err75+ }76+ return zw.Close()77+ })78+ if err != nil {27579 return nil, err27680 }27781 if w.values.n > 0 {@@ -282,151 +86,96 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
28286 res.Notes = append(res.Notes, fmt.Sprintf("%s naming a sheet that doesn't exist saved as values, e.g. %s (%s)",28387 count(w.missing.n, "formula", "formulas"), w.missing.example, sheet.QuoteSheet(w.missingSheet)))28488 }285- return res, writeFile(name, func(w io.Writer) error { return x.Write(w) })286-}287-288-// xlsxWriter writes sheets into a workbook, sharing cell styles.289-type xlsxWriter struct {290- x *excelize.File291- styleIDs map[xlsxStyle]int292- multi bool // examples name their sheet293-294- // Formulas are written as their values when Excel has no equivalent,295- // or when they name a sheet the file doesn't have, which Excel would296- // refuse.297- values valueCount298- missing valueCount299- missingSheet string // the sheet missing's example names300- known map[string]bool // keys of the sheets written301-}302-303-// valueCount counts formulas written as values, keeping the first one's304-// address as an example.305-type valueCount struct {306- n int307- example string89+ return res, nil30890 }30991 310-func (v *valueCount) add(multi bool, ws string, a sheet.Addr) {311- v.n++312- if v.example == "" {313- v.example = a.String()314- if multi {315- v.example = sheet.QuoteSheet(ws) + "!" + a.String()316- }92+// uniqueSheetName is name, or name with a number when another sheet93+// already took it (Excel's names ignore case).94+func uniqueSheetName(name string, used map[string]bool) string {95+ out := name96+ for n := 2; used[strings.ToLower(out)]; n++ {97+ suffix := " (" + strconv.Itoa(n) + ")"98+ r := []rune(name)99+ out = string(r[:min(len(r), 31-len(suffix))]) + suffix317100 }101+ used[strings.ToLower(out)] = true102+ return out318103 }319104 320-// unknownSheet returns a sheet c's formula names that isn't written.321-func (w *xlsxWriter) unknownSheet(c SnapCell) (string, bool) {322- for _, name := range c.Sheets {323- if !w.known[formula.SheetKey(name)] {324- return name, true325- }326- }327- return "", false328-}105+const (106+ xmlHead = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n"107+ relsNS = `http://schemas.openxmlformats.org/package/2006/relationships`108+ officeRel = `http://schemas.openxmlformats.org/officeDocument/2006/relationships`109+ sheetMain = `http://schemas.openxmlformats.org/spreadsheetml/2006/main`110+ mlType = `application/vnd.openxmlformats-officedocument.spreadsheetml.`111+)329112 330-func (w *xlsxWriter) styleFor(f sheet.Format, st sheet.Style) (int, error) {331- key := xlsxStyle{f, st}332- if id, ok := w.styleIDs[key]; ok {333- return id, nil334- }335- xs := &excelize.Style{}336- if code := excelCode(f); code != "" {337- xs.CustomNumFmt = &code338- }339- if st.Bold || st.Italic || st.Underline || st.Strikethrough {340- xs.Font = &excelize.Font{Bold: st.Bold, Italic: st.Italic, Strike: st.Strikethrough}341- if st.Underline {342- xs.Font.Underline = "single"113+// writePackage writes the parts around the worksheets and styles: the114+// workbook with its sheets, active tab, names and calculation settings,115+// the relationships and the content types.116+func writePackage(zw *zip.Writer, names []string, hidden []bool, active int, defined [][2]string) error {117+ var types, rels, book strings.Builder118+ types.WriteString(xmlHead + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +119+ `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +120+ `<Default Extension="xml" ContentType="application/xml"/>` +121+ `<Override PartName="/xl/workbook.xml" ContentType="` + mlType + `sheet.main+xml"/>` +122+ `<Override PartName="/xl/styles.xml" ContentType="` + mlType + `styles+xml"/>`)123+ rels.WriteString(xmlHead + `<Relationships xmlns="` + relsNS + `">`)124+ book.WriteString(xmlHead + `<workbook xmlns="` + sheetMain + `" xmlns:r="` + officeRel + `">` +125+ `<bookViews><workbookView activeTab="` + strconv.Itoa(active) + `"/></bookViews><sheets>`)126+ for i, ws := range names {127+ n := strconv.Itoa(i + 1)128+ fmt.Fprintf(&types, `<Override PartName="/xl/worksheets/sheet%s.xml" ContentType="%sworksheet+xml"/>`, n, mlType)129+ fmt.Fprintf(&rels, `<Relationship Id="rId%s" Type="%s/worksheet" Target="worksheets/sheet%s.xml"/>`, n, officeRel, n)130+ state := ""131+ if hidden[i] {132+ state = ` state="hidden"`133+ }134+ fmt.Fprintf(&book, `<sheet name="%s" sheetId="%s"%s r:id="rId%s"/>`, escapeXML(ws, true), n, state, n)135+ }136+ fmt.Fprintf(&rels, `<Relationship Id="rId%d" Type="%s/styles" Target="styles.xml"/></Relationships>`, len(names)+1, officeRel)137+ types.WriteString(`</Types>`)138+ book.WriteString(`</sheets>`)139+ if len(defined) > 0 {140+ book.WriteString(`<definedNames>`)141+ for _, n := range defined {142+ fmt.Fprintf(&book, `<definedName name="%s">%s</definedName>`, escapeXML(n[0], true), escapeXML(n[1], false))143+ }144+ book.WriteString(`</definedNames>`)145+ }146+ book.WriteString(`<calcPr calcId="191029" fullCalcOnLoad="1"/></workbook>`)147+ for _, p := range []struct{ name, body string }{148+ {"[Content_Types].xml", types.String()},149+ {"_rels/.rels", xmlHead + `<Relationships xmlns="` + relsNS + `"><Relationship Id="rId1" Type="` + officeRel + `/officeDocument" Target="xl/workbook.xml"/></Relationships>`},150+ {"xl/workbook.xml", book.String()},151+ {"xl/_rels/workbook.xml.rels", rels.String()},152+ } {153+ if err := writePart(zw, p.name, p.body); err != nil {154+ return err343155 }344156 }345- if st.Align != sheet.AlignAuto {346- xs.Alignment = &excelize.Alignment{Horizontal: st.Align.String()}347- }348- id, err := w.x.NewStyle(xs)349- w.styleIDs[key] = id350- return id, err157+ return nil351158 }352159 353-// sheet writes a snapshot to sheet ws and returns the rows written.354-func (w *xlsxWriter) sheet(ws string, snap *Snapshot) (int, error) {355- sw, err := w.x.NewStreamWriter(ws)160+func writePart(zw *zip.Writer, name, body string) error {161+ w, err := zw.Create(name)356162 if err != nil {357- return 0, err358- }359- for c, width := range snap.Widths {360- if err := sw.SetColWidth(c+1, c+1, float64(max(width-excelPadding, 1))); err != nil {361- return 0, err362- }363- }364- r := snap.Range365- line := make([]any, r.To.Col-r.From.Col+1)366- for row := r.From.Row; row <= r.To.Row; row++ {367- for col := r.From.Col; col <= r.To.Col; col++ {368- a := sheet.Addr{Col: col, Row: row}369- line[col-r.From.Col] = nil370- if c, ok := snap.Cells[a]; ok {371- if line[col-r.From.Col], err = w.cell(ws, a, c); err != nil {372- return 0, err373- }374- }375- }376- cell, _ := excelize.CoordinatesToCellName(r.From.Col+1, row+1)377- if err := sw.SetRow(cell, line); err != nil {378- return 0, err379- }163+ return err380164 }381- if err := sw.Flush(); err != nil {382- return 0, err383- }384- return r.To.Row - r.From.Row + 1, nil165+ _, err = io.WriteString(w, body)166+ return err385167 }386168 387-// cell is the snapshot cell c at a as excelize writes it. A formula with388-// no Excel equivalent, or naming a sheet that isn't written, is written389-// as its value, and counted.390-func (w *xlsxWriter) cell(ws string, a sheet.Addr, c SnapCell) (excelize.Cell, error) {391- // A formula's inferred format is written too, so its result shows392- // the same in Excel.393- id := 0394- if !c.Format.IsZero() || !c.Style.IsZero() {395- var err error396- if id, err = w.styleFor(c.Format, c.Style); err != nil {397- return excelize.Cell{}, err398- }399- }400- xc := excelize.Cell{StyleID: id, Value: xlsxValue(c.Value, c.Format)}401- if !c.Formula {402- return xc, nil403- }404- if name, ok := w.unknownSheet(c); ok {405- if w.missing.n == 0 {406- w.missingSheet = name407- }408- w.missing.add(w.multi, ws, a)409- return xc, nil410- }411- if fx, ok := toExcelFormula(c.Input); ok {412- xc.Formula = fx413- return xc, nil414- }415- w.values.add(w.multi, ws, a)416- return xc, nil417-}418-419-// xlsxValue is a computed value as excelize writes it.420-func xlsxValue(v sheet.Value, f sheet.Format) any {421- switch v.Kind {422- case sheet.Number:423- return toExcelSerial(v.Num, f)424- case sheet.Bool:425- return v.Num != 0426- case sheet.Text, sheet.Error:427- return v.Str169+// writeSheet writes snapshot sn as worksheet part i and returns the rows170+// written.171+func (w *xlsxWriter) writeSheet(zw *zip.Writer, i int, ws string, sn *Snapshot, active bool) (int, error) {172+ part, err := zw.Create("xl/worksheets/sheet" + strconv.Itoa(i+1) + ".xml")173+ if err != nil {174+ return 0, err428175 }429- return nil176+ bw := bufio.NewWriterSize(part, 64<<10)177+ rows := w.sheet(bw, ws, sn, active)178+ return rows, bw.Flush()430179 }431180 432181 // sheetName makes a valid Excel sheet name: at most 31 characters, none@@ -0,0 +1,205 @@
1+package fileio2+3+import (4+ "bytes"5+ "context"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ "github.com/xuri/excelize/v2"12+13+ "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/stress"15+)16+17+// TestXLSXDifferential imports a corpus of workbooks with excelize (the18+// reader 012 used before its own) and with 012's streaming reader, and19+// checks the results are the same: every sheet's cells as saved (input,20+// format and style), column widths, named ranges, the sheet shown, the21+// rows counted and the notes. The corpus is workbooks written here, the22+// stress datasets' XLSX files when fetched (scripts/stress-data.sh), and23+// every .xlsx file in the directories listed in XLSX_CORPUS24+// (colon-separated), such as excelize's own test files.25+//26+// Known differences, where excelize's reading is wrong and 012's isn't,27+// are left out of the comparison (see compareXLSX).28+func TestXLSXDifferential(t *testing.T) {29+ for _, path := range xlsxCorpus(t) {30+ t.Run(filepath.Base(path), func(t *testing.T) {31+ want, errWant := importXLSXExcelize(context.Background(), path, Options{})32+ got, errGot := importXLSX(context.Background(), path, Options{})33+ if (errWant == nil) != (errGot == nil) {34+ t.Fatalf("excelize: %v; 012: %v", errWant, errGot)35+ }36+ if errWant != nil {37+ t.Logf("both fail: excelize: %v; 012: %v", errWant, errGot)38+ return39+ }40+ compareXLSX(t, path, want, got)41+ })42+ }43+}44+45+func xlsxCorpus(t *testing.T) []string {46+ dir := t.TempDir()47+ files := []string{writeParts(t, dir, "kitchen.xlsx", kitchenParts())}48+ files = append(files, writeExcelizeBook(t, dir), writeExportedBook(t, dir))49+ stressData := os.Getenv("STRESS_DIR")50+ if stressData == "" {51+ stressData = filepath.Join("..", "..", ".deps", "stress")52+ }53+ files = append(files, globXLSX(stressData)...)54+ for _, d := range filepath.SplitList(os.Getenv("XLSX_CORPUS")) {55+ files = append(files, globXLSX(d)...)56+ }57+ return files58+}59+60+func globXLSX(dir string) []string {61+ if dir == "" {62+ return nil63+ }64+ m, _ := filepath.Glob(filepath.Join(dir, "*.xlsx"))65+ return m66+}67+68+// writeExcelizeBook writes a workbook with excelize: sheets referring to69+// each other, styles, widths and names.70+func writeExcelizeBook(t *testing.T, dir string) string {71+ x := excelize.NewFile()72+ defer x.Close()73+ x.SetSheetName("Sheet1", "Q1")74+ x.NewSheet("Q2 plan")75+ x.SetSheetRow("Q1", "A1", &[]any{"Region", 1234.5, "=text", 45000, true, "12%", "TRUE", -0.5})76+ x.SetCellFormula("Q1", "A2", "'Q2 plan'!A1*2")77+ x.SetCellFormula("Q1", "B2", "SUM(B1,1)")78+ x.SetCellFormula("Q1", "C2", "CUBEVALUE(1)")79+ x.SetCellValue("Q1", "E4", "after a gap")80+ x.SetCellValue("Q2 plan", "A1", 42)81+ x.SetCellValue("Q2 plan", "B300", "far down")82+ bold, _ := x.NewStyle(&excelize.Style{NumFmt: 4, Font: &excelize.Font{Bold: true, Underline: "double"}})83+ date, _ := x.NewStyle(&excelize.Style{NumFmt: 15, Alignment: &excelize.Alignment{Horizontal: "right"}})84+ code := `[$$-409]#,##0.00`85+ money, _ := x.NewStyle(&excelize.Style{CustomNumFmt: &code, Font: &excelize.Font{Italic: true, Strike: true}})86+ x.SetCellStyle("Q1", "B1", "B1", bold)87+ x.SetCellStyle("Q1", "D1", "D1", date)88+ x.SetCellStyle("Q1", "A2", "C3", money)89+ x.SetColWidth("Q1", "A", "B", 14)90+ x.SetColWidth("Q2 plan", "C", "C", 30)91+ x.SetDefinedName(&excelize.DefinedName{Name: "Rent", RefersTo: "Q1!$B$1"})92+ x.SetDefinedName(&excelize.DefinedName{Name: "Mine", RefersTo: "Q1!$B$1", Scope: "Q2 plan"})93+ x.SetActiveSheet(1)94+ path := filepath.Join(dir, "excelize.xlsx")95+ if err := x.SaveAs(path); err != nil {96+ t.Fatal(err)97+ }98+ return path99+}100+101+// writeExportedBook writes a table and a sheet of formulas with 012's102+// exporter.103+func writeExportedBook(t *testing.T, dir string) string {104+ s := stress.Table(60, 10)105+ book := s.Book()106+ f, _ := book.AddSheet("Formulas", 1)107+ for row := range 40 {108+ f.Set(sheet.Addr{Col: 0, Row: row}, "=Sheet1!C"+itoa(row+2)+"*2")109+ f.Set(sheet.Addr{Col: 1, Row: row}, "=SUM($A$1:A"+itoa(row+1)+")")110+ }111+ f.SetColWidth(0, 20)112+ path := filepath.Join(dir, "exported.xlsx")113+ if _, err := Export(context.Background(), path, XLSX, SnapBook(f), ExportOptions{}); err != nil {114+ t.Fatal(err)115+ }116+ return path117+}118+119+// compareXLSX compares two imports of path.120+func compareXLSX(t *testing.T, path string, want, got *Result) {121+ t.Helper()122+ if want.Rows != got.Rows {123+ t.Errorf("rows %d, excelize %d", got.Rows, want.Rows)124+ }125+ if w, g := strings.Join(want.Notes, "; "), strings.Join(got.Notes, "; "); w != g {126+ t.Errorf("notes %q, excelize %q", g, w)127+ }128+ if want.Sheet.Name() != got.Sheet.Name() {129+ t.Errorf("showing %s, excelize %s", got.Sheet.Name(), want.Sheet.Name())130+ }131+ skip := mergedCells(t, path)132+ w, g := savedLines(t, want.Sheet, skip), savedLines(t, got.Sheet, skip)133+ if w == g {134+ return135+ }136+ wl, gl := strings.Split(w, "\n"), strings.Split(g, "\n")137+ shown := 0138+ for i := 0; i < max(len(wl), len(gl)) && shown < 20; i++ {139+ var a, b string140+ if i < len(wl) {141+ a = wl[i]142+ }143+ if i < len(gl) {144+ b = gl[i]145+ }146+ if a != b && !sameSharedFormula(a, b) {147+ t.Errorf("line %d:\n 012: %s\n excelize: %s", i+1, b, a)148+ shown++149+ }150+ }151+}152+153+// sameSharedFormula reports whether two saved lines are the same formula154+// but for spaces. excelize expands a shared formula by parsing it and155+// writing it back, which drops the spaces between its tokens; 012 moves156+// the references in the text as written.157+func sameSharedFormula(a, b string) bool {158+ return strings.Contains(a, `": "=`) && strings.ReplaceAll(a, " ", "") == strings.ReplaceAll(b, " ", "")159+}160+161+// savedLines is the workbook as 012 saves it, without the cells in skip162+// (by sheet), which excelize reads wrongly.163+func savedLines(t *testing.T, s *sheet.Sheet, skip map[string][]sheet.Rect) string {164+ t.Helper()165+ book := s.Book()166+ for i := range book.Len() {167+ ws := book.Sheet(i)168+ for _, r := range skip[ws.Name()] {169+ for _, a := range ws.Addrs() {170+ if r.Contains(a) && a != r.From {171+ ws.EraseRange(sheet.NewRect(a, a))172+ }173+ }174+ }175+ }176+ var buf bytes.Buffer177+ if err := s.Write(&buf); err != nil {178+ t.Fatal(err)179+ }180+ return buf.String()181+}182+183+// mergedCells are the merged ranges of each sheet. excelize reads any184+// cell of a merged range as its first cell, so a formula there spreads185+// to the others (when a cell after them in the row has a value); 012186+// reads each cell as written.187+func mergedCells(t *testing.T, path string) map[string][]sheet.Rect {188+ x, err := excelize.OpenFile(path)189+ if err != nil {190+ t.Fatal(err)191+ }192+ defer x.Close()193+ out := map[string][]sheet.Rect{}194+ for _, ws := range x.GetSheetList() {195+ merged, _ := x.GetMergeCells(ws)196+ for _, m := range merged {197+ if r, ok := sheet.ParseRange(m.GetStartAxis() + ":" + m.GetEndAxis()); ok {198+ out[ws] = append(out[ws], r)199+ }200+ }201+ }202+ return out203+}204+205+func itoa(n int) string { return numInput(float64(n)) }@@ -0,0 +1,212 @@
1+package fileio2+3+// The XLSX importer 012 used before its own reader, on excelize, kept4+// as the reference for TestXLSXDifferential.5+6+import (7+ "context"8+ "fmt"9+ "math"10+ "strconv"11+ "strings"12+13+ "github.com/xuri/excelize/v2"14+15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+// importXLSXExcelize reads every sheet of a workbook, with its named ranges, and19+// returns the sheet that was active in Excel.20+func importXLSXExcelize(ctx context.Context, name string, opt Options) (*Result, error) {21+ prog := opt.Progress22+ x, err := excelize.OpenFile(name)23+ if err != nil {24+ return nil, err25+ }26+ defer x.Close()27+ names := x.GetSheetList()28+ if len(names) == 0 {29+ return nil, fmt.Errorf("the workbook has no sheets")30+ }31+ // Rows across all sheets, for the progress bar.32+ dims := make([]int, len(names))33+ total := 034+ for i, ws := range names {35+ if dim, err := x.GetSheetDimension(ws); err == nil {36+ if _, to, ok := strings.Cut(dim, ":"); ok {37+ if _, r, err := excelize.CellNameToCoordinates(to); err == nil {38+ dims[i] = r39+ }40+ }41+ }42+ total += dims[i]43+ }44+45+ b := newBuilder()46+ book := b.s.Book()47+ styles := map[int]xlsxStyle{}48+ var notes []string49+ done := 050+ for i, ws := range names {51+ if i == 0 {52+ err = book.RenameSheet(b.s, ws)53+ } else {54+ b.s, err = book.AddSheet(ws, i)55+ }56+ if err != nil {57+ return nil, fmt.Errorf("sheet %s: %w", ws, err)58+ }59+ rows, err := excelizeSheet(ctx, x, b, ws, styles, func(row int) {60+ prog.setRows(done + row)61+ prog.setFrac(int64(done+row), int64(total))62+ })63+ if err != nil {64+ return nil, err65+ }66+ done += rows67+ }68+ notes = append(notes, excelizeNames(x, book)...)69+ active := book.Sheet(clamp(x.GetActiveSheetIndex(), 0, book.Len()-1))70+ book.SetActive(active)71+ for i, ws := range names {72+ if shown, err := x.GetSheetVisible(ws); err == nil && !shown && book.Sheet(i) != active {73+ book.HideSheet(book.Sheet(i)) // hidden in Excel, hidden here74+ }75+ }76+ b.s = active77+ prog.setRows(done)78+ s, notes := b.finish(notes)79+ return &Result{Sheet: s, Rows: done, Notes: notes}, nil80+}81+82+// excelizeSheet reads one sheet into b.s, reporting rows read, and83+// returns how many rows it read.84+func excelizeSheet(ctx context.Context, x *excelize.File, b *builder, ws string, styles map[int]xlsxStyle, report func(int)) (int, error) {85+ rows, err := x.Rows(ws)86+ if err != nil {87+ return 0, err88+ }89+ defer rows.Close()90+ row := 091+ for ; rows.Next(); row++ {92+ if row%128 == 0 {93+ if err := ctx.Err(); err != nil {94+ return 0, err95+ }96+ report(row)97+ }98+ cols, err := rows.Columns(excelize.Options{RawCellValue: true})99+ if err != nil {100+ return 0, err101+ }102+ for col, raw := range cols {103+ a := sheet.Addr{Col: col, Row: row}104+ if !b.fits(a) {105+ break106+ }107+ cell, _ := excelize.CoordinatesToCellName(col+1, row+1)108+ excelizeCell(x, b, ws, cell, a, raw, styles)109+ }110+ }111+ if err := rows.Error(); err != nil {112+ return 0, err113+ }114+ for c := range sheet.MaxCols {115+ colName, _ := excelize.ColumnNumberToName(c + 1)116+ w, err := x.GetColWidth(ws, colName)117+ if err != nil || math.Abs(w-excelDefaultWidth) < 0.01 || math.Abs(w-9.140625) < 0.01 {118+ continue119+ }120+ b.s.SetColWidth(c, max(int(math.Round(w))+excelPadding, 1))121+ }122+ return row, nil123+}124+125+// excelizeNames defines the workbook's named ranges that are a range on126+// one sheet, such as Sales = Q3!$B$2:$B$20, and notes the others (sheet127+// scoped names, constants, formulas).128+func excelizeNames(x *excelize.File, book *sheet.Workbook) []string {129+ skipped := 0130+ example := ""131+ for _, dn := range x.GetDefinedName() {132+ if strings.HasPrefix(dn.Name, "_xlnm.") {133+ continue // print areas and the like134+ }135+ ref := strings.TrimPrefix(dn.RefersTo, "=")136+ ws, rest := sheet.SplitSheet(ref)137+ r, ok := sheet.ParseRange(rest)138+ s := book.Lookup(ws)139+ if dn.Scope != "" && dn.Scope != "Workbook" || !ok || s == nil || book.DefineName(dn.Name, s, r) != nil {140+ skipped++141+ if example == "" {142+ example = dn.Name143+ }144+ }145+ }146+ if skipped > 0 {147+ return []string{fmt.Sprintf("%s left out, e.g. %s", count(skipped, "named range", "named ranges"), example)}148+ }149+ return nil150+}151+152+func excelizeStyleOf(x *excelize.File, id int, cache map[int]xlsxStyle) xlsxStyle {153+ if st, ok := cache[id]; ok {154+ return st155+ }156+ var out xlsxStyle157+ if st, err := x.GetStyle(id); err == nil && st != nil {158+ code := ""159+ if st.CustomNumFmt != nil {160+ code = *st.CustomNumFmt161+ }162+ out.format = formatOf(st.NumFmt, code)163+ if f := st.Font; f != nil {164+ out.style.Bold, out.style.Italic, out.style.Strikethrough = f.Bold, f.Italic, f.Strike165+ out.style.Underline = f.Underline != "" && f.Underline != "none"166+ }167+ if al := st.Alignment; al != nil {168+ switch al.Horizontal {169+ case "left":170+ out.style.Align = sheet.AlignLeft171+ case "center", "centerContinuous":172+ out.style.Align = sheet.AlignCenter173+ case "right":174+ out.style.Align = sheet.AlignRight175+ }176+ }177+ }178+ cache[id] = out179+ return out180+}181+182+func excelizeCell(x *excelize.File, b *builder, ws, cell string, a sheet.Addr, raw string, styles map[int]xlsxStyle) {183+ var st xlsxStyle184+ if id, err := x.GetCellStyle(ws, cell); err == nil && id != 0 {185+ st = excelizeStyleOf(x, id, styles)186+ }187+ typ, _ := x.GetCellType(ws, cell)188+ keep := func() {189+ switch typ {190+ case excelize.CellTypeBool:191+ b.boolean(a, raw == "1" || strings.EqualFold(raw, "TRUE"), st.style)192+ return193+ case excelize.CellTypeSharedString, excelize.CellTypeInlineString, excelize.CellTypeFormula, excelize.CellTypeError:194+ b.text(a, raw, st.format, st.style)195+ return196+ }197+ if v, err := strconv.ParseFloat(raw, 64); err == nil {198+ b.number(a, fromExcelSerial(v, st.format), st.format, st.style)199+ return200+ }201+ b.text(a, raw, st.format, st.style)202+ }203+ if f, err := x.GetCellFormula(ws, cell); err == nil && f != "" {204+ b.formula(a, fromExcelFormula(f), st.format, st.style, keep)205+ return206+ }207+ if raw == "" {208+ b.put(a, "", st.format, st.style)209+ return210+ }211+ keep()212+}@@ -329,6 +329,91 @@ func TestXLSXWorkbookRoundTrip(t *testing.T) {
329329 }330330 }331331 332+// Text that XML can't hold, or that reads as one of SpreadsheetML's333+// escapes, goes out escaped and comes back the same, through excelize334+// and 012; text past Excel's limit is cut.335+func TestXLSXWriteText(t *testing.T) {336+ src := sheet.New()337+ long := strings.Repeat("x", excelMaxText+10)338+ want := map[string]string{339+ "A1": "bell\x07 and nul\x00", "A2": "_x0041_ stays", "A3": `<&> "quoted" 'too'`,340+ "A4": "cr\rend", "A5": "￾ noncharacter", "A6": long, "A7": "emoji \U0001F642",341+ }342+ for cell, text := range want {343+ if err := src.Load(addr(t, cell), "'"+text, sheet.Format{}, sheet.Style{}); err != nil {344+ t.Fatal(err)345+ }346+ }347+ src.RecalcAll()348+ for cell := range want {349+ want[cell] = src.Value(addr(t, cell)).Str // as 012 keeps it350+ }351+ want["A6"] = long[:excelMaxText]352+ name := filepath.Join(t.TempDir(), "text.xlsx")353+ if _, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{}); err != nil {354+ t.Fatal(err)355+ }356+ x, err := excelize.OpenFile(name)357+ if err != nil {358+ t.Fatal(err)359+ }360+ defer x.Close()361+ for cell, text := range want {362+ if got, _ := x.GetCellValue("Sheet1", cell); got != text {363+ t.Errorf("excelize reads %s as %q, want %q", cell, trim(got), trim(text))364+ }365+ }366+ res, err := Import(context.Background(), name, Options{})367+ if err != nil {368+ t.Fatal(err)369+ }370+ back := res.Sheet.Book().Sheet(0)371+ for cell, text := range want {372+ if got := input(back, addr(t, cell)); got != flatten(text) {373+ t.Errorf("012 reads %s as %q, want %q", cell, trim(got), trim(text))374+ }375+ }376+}377+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+400+func TestUniqueSheetName(t *testing.T) {401+ used := map[string]bool{}402+ long := strings.Repeat("x", 31)403+ for _, tc := range [][2]string{{"Plan", "Plan"}, {"plan", "plan (2)"}, {"Plan", "Plan (3)"}, {long, long}, {long, long[:27] + " (2)"}} {404+ if got := uniqueSheetName(tc[0], used); got != tc[1] {405+ t.Errorf("uniqueSheetName(%q) = %q, want %q", tc[0], got, tc[1])406+ }407+ }408+}409+410+func trim(s string) string {411+ if len(s) > 40 {412+ return s[:40] + "..."413+ }414+ return s415+}416+332417 // A formula naming a sheet the workbook doesn't have goes out as its333418 // value (#REF!), with a note, since Excel would refuse the reference.334419 func TestXLSXMissingSheetAsValue(t *testing.T) {@@ -0,0 +1,297 @@
1+package fileio2+3+import (4+ "bytes"5+ "encoding/xml"6+ "fmt"7+ "io"8+ "path"9+ "strconv"10+)11+12+// xlsxBook is an open workbook: its sheets, names, shared strings and13+// cell styles. Worksheets are streamed one at a time by readSheet.14+type xlsxBook struct {15+ pkg *xlsxPackage16+ sheets []xlsxSheetInfo17+ active int // index of the sheet shown in Excel18+ names []xlsxName19+ date1904 bool20+ sst sharedStrings21+ styles xlsxStyles22+ expanded int64 // bytes of shared formulas expanded, see formula23+}24+25+// xlsxSheetInfo is a sheet as the workbook lists it.26+type xlsxSheetInfo struct {27+ name string28+ id int // sheetId29+ part string // the worksheet part, "" when missing30+ kind string // the relationship type: worksheet, chartsheet...31+ hidden bool // state hidden or veryHidden32+}33+34+// xlsxName is a defined name: sheet is the index of the sheet it is35+// scoped to, or -1 for the workbook.36+type xlsxName struct {37+ name, refersTo string38+ sheet int39+}40+41+// openXLSX opens a workbook and reads everything but its worksheets.42+func openXLSX(r io.ReaderAt, size int64, lim xlsxLimits) (*xlsxBook, error) {43+ p, err := openXLSXPackage(r, size, lim)44+ if err != nil {45+ return nil, err46+ }47+ bk := &xlsxBook{pkg: p}48+ wb := "xl/workbook.xml"49+ root, err := p.rels("")50+ if err != nil {51+ return nil, err52+ }53+ for _, rel := range root {54+ if rel.typ == "officeDocument" && p.has(rel.target) {55+ wb = rel.target56+ }57+ }58+ rels, err := p.rels(wb)59+ if err != nil {60+ return nil, err61+ }62+ ids, err := bk.readWorkbook(wb)63+ if err != nil {64+ return nil, err65+ }66+ dir := path.Dir(wb) + "/"67+ sst, styles := dir+"sharedStrings.xml", dir+"styles.xml"68+ for _, rel := range rels {69+ switch rel.typ {70+ case "sharedStrings":71+ sst = rel.target72+ case "styles":73+ styles = rel.target74+ }75+ }76+ for i, id := range ids {77+ if rel, ok := rels[id]; ok && p.has(rel.target) {78+ bk.sheets[i].part, bk.sheets[i].kind = rel.target, rel.typ79+ }80+ }81+ if err := bk.sst.read(p, sst); err != nil {82+ return nil, err83+ }84+ if err := bk.styles.read(p, styles); err != nil {85+ return nil, err86+ }87+ return bk, nil88+}89+90+// readWorkbook reads the workbook part: its sheets (returning their91+// relationship ids), the active tab, defined names and date system.92+func (bk *xlsxBook) readWorkbook(part string) (ids []string, err error) {93+ x, err := bk.pkg.open(part)94+ if err != nil {95+ return nil, err96+ }97+ if x == nil {98+ return nil, fmt.Errorf("not an Excel workbook: no %s", part)99+ }100+ defer x.close()101+ activeTab, views := 0, 0102+ for {103+ t, err := x.next()104+ if err == io.EOF {105+ break106+ }107+ if err != nil {108+ return nil, err109+ }110+ se, ok := t.(xml.StartElement)111+ if !ok {112+ continue113+ }114+ switch se.Name.Local {115+ case "sheet":116+ if len(bk.sheets) >= bk.pkg.lim.sheets {117+ return nil, fmt.Errorf("the workbook has more than %d sheets: %w", bk.pkg.lim.sheets, errXLSXLimit)118+ }119+ name, _ := attr(se, "name")120+ id, _ := attr(se, "sheetId")121+ state, _ := attr(se, "state")122+ n, _ := strconv.Atoi(id)123+ bk.sheets = append(bk.sheets, xlsxSheetInfo{name: name, id: n, hidden: state == "hidden" || state == "veryHidden"})124+ ids = append(ids, relAttr(se))125+ case "workbookView":126+ if views++; views == 1 {127+ v, _ := attr(se, "activeTab")128+ activeTab, _ = strconv.Atoi(v)129+ }130+ case "workbookPr":131+ v, _ := attr(se, "date1904")132+ bk.date1904 = v == "1" || v == "true"133+ case "definedName":134+ if err := bk.readName(x, se); err != nil {135+ return nil, err136+ }137+ }138+ }139+ bk.active = bk.activeIndex(activeTab)140+ return ids, nil141+}142+143+// activeIndex is the index of the sheet Excel showed, as excelize finds144+// it: the active tab's sheet by its sheetId, else the first.145+func (bk *xlsxBook) activeIndex(tab int) int {146+ if len(bk.sheets) == 0 {147+ return 0148+ }149+ id := bk.sheets[0].id150+ if tab >= 0 && tab < len(bk.sheets) && bk.sheets[tab].id != 0 {151+ id = bk.sheets[tab].id152+ }153+ for i, s := range bk.sheets {154+ if s.id == id {155+ return i156+ }157+ }158+ return 0159+}160+161+func (bk *xlsxBook) readName(x *xmlStream, se xml.StartElement) error {162+ if len(bk.names) >= bk.pkg.lim.styles {163+ return fmt.Errorf("the workbook has more than %d names: %w", bk.pkg.lim.styles, errXLSXLimit)164+ }165+ n := xlsxName{sheet: -1}166+ n.name, _ = attr(se, "name")167+ if v, ok := attr(se, "localSheetId"); ok {168+ if id, err := strconv.Atoi(v); err == nil && id >= 0 {169+ n.sheet = id170+ }171+ }172+ text, err := x.text(nil)173+ n.refersTo = string(text)174+ bk.names = append(bk.names, n)175+ return err176+}177+178+// sharedStrings holds the workbook's shared strings end to end in one179+// buffer, so a table of many short strings costs its text and four180+// bytes a string rather than a string header and allocation each.181+type sharedStrings struct {182+ buf []byte183+ ends []uint32184+}185+186+func (s *sharedStrings) get(i int) (string, bool) {187+ if i < 0 || i >= len(s.ends) {188+ return "", false189+ }190+ from := uint32(0)191+ if i > 0 {192+ from = s.ends[i-1]193+ }194+ return string(s.buf[from:s.ends[i]]), true195+}196+197+// read streams the shared strings part: each <si> is its <t> text, or198+// the text of its rich-text runs (<r><t>), leaving out phonetic hints.199+func (s *sharedStrings) read(p *xlsxPackage, part string) error {200+ x, err := p.open(part)201+ if x == nil || err != nil {202+ return err203+ }204+ defer x.close()205+ var item []byte206+ for {207+ t, err := x.next()208+ if err == io.EOF {209+ return nil210+ }211+ if err != nil {212+ return err213+ }214+ se, ok := t.(xml.StartElement)215+ if !ok || se.Name.Local != "si" {216+ continue217+ }218+ if item, err = inlineText(x, item[:0]); err != nil {219+ return err220+ }221+ if len(s.ends) >= p.lim.strings || len(s.buf)+len(item) > 1<<32-1 {222+ return fmt.Errorf("the workbook has more than %d shared strings: %w", p.lim.strings, errXLSXLimit)223+ }224+ s.buf = append(s.buf, unescapeOOXML(item)...)225+ s.ends = append(s.ends, uint32(len(s.buf)))226+ }227+}228+229+// inlineText reads the rest of an <si> or <is> element: the text of its230+// <t> children and of the <t> in its <r> runs, not in <rPh> hints.231+func inlineText(x *xmlStream, buf []byte) ([]byte, error) {232+ depth := x.depth233+ inT, skipDepth := false, 0234+ for {235+ t, err := x.next()236+ if err != nil {237+ return buf, eofAsUnexpected(err)238+ }239+ switch t := t.(type) {240+ case xml.StartElement:241+ switch {242+ case skipDepth > 0:243+ case t.Name.Local == "rPh" || t.Name.Local == "phoneticPr":244+ skipDepth = x.depth245+ case t.Name.Local == "t":246+ inT = true247+ }248+ case xml.EndElement:249+ if x.depth < depth {250+ return buf, nil251+ }252+ if skipDepth > 0 && x.depth < skipDepth {253+ skipDepth = 0254+ }255+ inT = false256+ case xml.CharData:257+ if inT && skipDepth == 0 {258+ buf = append(buf, t...)259+ }260+ }261+ }262+}263+264+// unescapeOOXML decodes _xHHHH_, how OOXML writes characters XML can't265+// hold (_x000D_ for a carriage return), and _x005F_, an escaped _.266+func unescapeOOXML(b []byte) []byte {267+ if !bytes.Contains(b, []byte("_x")) {268+ return b269+ }270+ out := make([]byte, 0, len(b))271+ for i := 0; i < len(b); {272+ if r, ok := ooxmlEscape(b[i:]); ok {273+ if r == '_' {274+ out = append(out, '_')275+ } else {276+ out = append(out, string(r)...)277+ }278+ i += 7279+ continue280+ }281+ out = append(out, b[i])282+ i++283+ }284+ return out285+}286+287+// ooxmlEscape decodes the _xHHHH_ at the start of b.288+func ooxmlEscape(b []byte) (rune, bool) {289+ if len(b) < 7 || b[0] != '_' || b[1] != 'x' || b[6] != '_' {290+ return 0, false291+ }292+ v, err := strconv.ParseUint(string(b[2:6]), 16, 16)293+ if err != nil {294+ return 0, false295+ }296+ return rune(v), true297+}@@ -0,0 +1,281 @@
1+package fileio2+3+import (4+ "context"5+ "fmt"6+ "math"7+ "os"8+ "slices"9+ "strconv"10+ "strings"11+ "time"12+13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// importXLSX reads every sheet of a workbook, with its named17+// ranges, and returns the sheet that was active in Excel. Worksheets are18+// streamed a row at a time (see xlsxpkg.go).19+func importXLSX(ctx context.Context, name string, opt Options) (*Result, error) {20+ f, err := os.Open(name)21+ if err != nil {22+ return nil, err23+ }24+ defer f.Close()25+ st, err := f.Stat()26+ if err != nil {27+ return nil, err28+ }29+ bk, err := openXLSX(f, st.Size(), defaultXLSXLimits)30+ if err != nil {31+ return nil, err32+ }33+ return bk.importBook(ctx, opt.Progress)34+}35+36+func (bk *xlsxBook) importBook(ctx context.Context, prog *Progress) (*Result, error) {37+ if len(bk.sheets) == 0 {38+ return nil, fmt.Errorf("the workbook has no sheets")39+ }40+ // Rows across all sheets, for the progress bar.41+ total := 042+ for i := range bk.sheets {43+ total += bk.dimension(i)44+ }45+ b := newBuilder()46+ book := b.s.Book()47+ done := 048+ var err error49+ for i, info := range bk.sheets {50+ if i == 0 {51+ err = book.RenameSheet(b.s, info.name)52+ } else {53+ b.s, err = book.AddSheet(info.name, i)54+ }55+ if err != nil {56+ return nil, fmt.Errorf("sheet %s: %w", info.name, err)57+ }58+ rows, err := bk.importSheet(ctx, b, i, func(row int) {59+ prog.setRows(done + row)60+ prog.setFrac(int64(done+row), int64(total))61+ })62+ if err != nil {63+ return nil, err64+ }65+ done += rows66+ }67+ notes := bk.importNames(book)68+ active := book.Sheet(clamp(bk.active, 0, book.Len()-1))69+ book.SetActive(active)70+ for i, info := range bk.sheets {71+ if info.hidden && book.Sheet(i) != active {72+ book.HideSheet(book.Sheet(i)) // hidden in Excel, hidden here73+ }74+ }75+ b.s = active76+ prog.setRows(done)77+ s, notes := b.finish(notes)78+ return &Result{Sheet: s, Rows: done, Notes: notes}, nil79+}80+81+// importSheet reads sheet i into b.s, reporting rows read, and returns82+// the number of its last row.83+func (bk *xlsxBook) importSheet(ctx context.Context, b *builder, i int, report func(int)) (int, error) {84+ r, err := bk.openSheet(i)85+ if err != nil {86+ return 0, err87+ }88+ defer r.close()89+ last := 090+ for n := 0; ; n++ {91+ ok, err := r.next()92+ if err != nil {93+ return 0, fmt.Errorf("sheet %s: %w", bk.sheets[i].name, err)94+ }95+ if !ok {96+ break97+ }98+ if n%128 == 0 {99+ if err := ctx.Err(); err != nil {100+ return 0, err101+ }102+ report(r.row.num)103+ }104+ if err := bk.importRow(b, r); err != nil {105+ return 0, fmt.Errorf("sheet %s: %w", bk.sheets[i].name, err)106+ }107+ last = max(last, r.row.num)108+ }109+ for c := range sheet.MaxCols {110+ w := r.colWidth(c + 1)111+ if math.Abs(w-excelDefaultWidth) < 0.01 || math.Abs(w-9.140625) < 0.01 || !(w > 0 && w < 1000) {112+ continue113+ }114+ b.s.SetColWidth(c, max(int(math.Round(w))+excelPadding, 1))115+ }116+ return last, nil117+}118+119+// importRow stores a row's cells. As Excel's cells are read, a cell120+// with neither a value nor a formula only counts for its format, and121+// only up to the row's last cell with one: formatted blanks after it are122+// left out.123+func (bk *xlsxBook) importRow(b *builder, r *xlsxSheetReader) error {124+ row := &r.row125+ cells := row.cells126+ if !slices.IsSortedFunc(cells, func(a, b xlsxCell) int { return a.col - b.col }) {127+ slices.SortStableFunc(cells, func(a, b xlsxCell) int { return a.col - b.col })128+ }129+ lastCol := 0130+ for i := range cells {131+ if cells[i].kept() {132+ lastCol = cells[i].col133+ }134+ }135+ if lastCol == 0 || !b.fits(sheet.Addr{Col: lastCol - 1, Row: row.num - 1}) && row.num > sheet.MaxRows {136+ return nil137+ }138+ j := 0139+ for col := 1; col <= min(lastCol, sheet.MaxCols); col++ {140+ a := sheet.Addr{Col: col - 1, Row: row.num - 1}141+ for j < len(cells) && cells[j].col < col {142+ j++143+ }144+ var c *xlsxCell145+ id := 0146+ if j < len(cells) && cells[j].col == col {147+ c = &cells[j]148+ id = c.style149+ }150+ if id == 0 {151+ id = row.style152+ }153+ if id == 0 {154+ id = r.colStyle(col)155+ }156+ st := bk.styles.style(id)157+ if c == nil || !c.kept() {158+ b.put(a, "", st.format, st.style)159+ continue160+ }161+ f, err := r.formula(c)162+ if err != nil {163+ return err164+ }165+ bk.importCell(b, a, c, f, st)166+ }167+ return nil168+}169+170+func (bk *xlsxBook) importCell(b *builder, a sheet.Addr, c *xlsxCell, formula string, st xlsxStyle) {171+ if formula != "" {172+ b.formula(a, fromExcelFormula(formula), st.format, st.style, func() { bk.keep(b, a, c, st) })173+ return174+ }175+ if c.value == "" {176+ b.put(a, "", st.format, st.style)177+ return178+ }179+ bk.keep(b, a, c, st)180+}181+182+// keep stores a cell's value: its type says whether it is a boolean or183+// text; anything else that reads as a number is one.184+func (bk *xlsxBook) keep(b *builder, a sheet.Addr, c *xlsxCell, st xlsxStyle) {185+ raw := c.value186+ switch c.typ {187+ case "b":188+ b.boolean(a, raw == "1" || strings.EqualFold(raw, "TRUE"), st.style)189+ return190+ case "s", "inlineStr", "str", "e":191+ b.text(a, raw, st.format, st.style)192+ return193+ case "d":194+ if v, f, ok := isoDate(raw); ok {195+ if st.format.IsZero() {196+ st.format = f197+ }198+ b.number(a, v, st.format, st.style)199+ return200+ }201+ }202+ if v, err := strconv.ParseFloat(raw, 64); err == nil {203+ b.number(a, bk.serial(v, st.format), st.format, st.style)204+ return205+ }206+ b.text(a, raw, st.format, st.style)207+}208+209+// serial converts an Excel serial number to 012's: in the 1904 date210+// system, day 0 is January 1, 1904.211+func (bk *xlsxBook) serial(v float64, f sheet.Format) float64 {212+ if bk.date1904 && isDateFormat(f) {213+ return v + date1904Offset214+ }215+ return fromExcelSerial(v, f)216+}217+218+// date1904Offset is the serial of January 1, 1904 in 012's (and the 1900219+// system's) count.220+const date1904Offset = 1462221+222+// isoDate reads a t="d" cell's ISO 8601 date, time or both as 012's223+// serial number, with the format that shows it.224+func isoDate(s string) (float64, sheet.Format, bool) {225+ s = strings.TrimSpace(s)226+ for _, l := range []struct {227+ layout string228+ kind sheet.FormatKind229+ }{230+ {"2006-01-02T15:04:05.999999999Z07:00", sheet.FmtDateTime},231+ {"2006-01-02T15:04:05.999999999", sheet.FmtDateTime},232+ {"2006-01-02", sheet.FmtDate},233+ {"T15:04:05.999999999", sheet.FmtTime},234+ {"15:04:05.999999999", sheet.FmtTime},235+ } {236+ t, err := time.Parse(l.layout, s)237+ if err != nil {238+ continue239+ }240+ wall := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.UTC)241+ base := time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)242+ if l.kind == sheet.FmtTime {243+ base = time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC)244+ }245+ return wall.Sub(base).Hours() / 24, sheet.Format{Kind: l.kind}, true246+ }247+ return 0, sheet.Format{}, false248+}249+250+// importNames defines the workbook's named ranges that are a range on251+// one sheet, such as Sales = Q3!$B$2:$B$20, and notes the others (sheet252+// scoped names, constants, formulas).253+func (bk *xlsxBook) importNames(book *sheet.Workbook) []string {254+ skipped := 0255+ example := ""256+ for _, dn := range bk.names {257+ if strings.HasPrefix(dn.name, "_xlnm.") {258+ continue // print areas and the like259+ }260+ ws, rest := sheet.SplitSheet(strings.TrimPrefix(dn.refersTo, "="))261+ r, ok := sheet.ParseRange(rest)262+ s := book.Lookup(ws)263+ if bk.scoped(dn) || !ok || s == nil || book.DefineName(dn.name, s, r) != nil {264+ skipped++265+ if example == "" {266+ example = dn.name267+ }268+ }269+ }270+ if skipped > 0 {271+ return []string{fmt.Sprintf("%s left out, e.g. %s", count(skipped, "named range", "named ranges"), example)}272+ }273+ return nil274+}275+276+// scoped reports whether a name belongs to one sheet. As excelize has277+// it, a name scoped to a sheet that doesn't exist, or to one called278+// Workbook, is the workbook's.279+func (bk *xlsxBook) scoped(dn xlsxName) bool {280+ return dn.sheet >= 0 && dn.sheet < len(bk.sheets) && bk.sheets[dn.sheet].name != "Workbook"281+}@@ -0,0 +1,105 @@
1+package fileio2+3+import (4+ "archive/zip"5+ "bytes"6+ "maps"7+ "os"8+ "path/filepath"9+ "slices"10+ "testing"11+)12+13+// Hand-written SpreadsheetML for tests of the XLSX reader: what Excel14+// writes and excelize doesn't (shared formulas, inline and rich text,15+// row and column styles, cells without references).16+17+const (18+ contentTypes = `<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet3.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>`19+ rootRels = `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`20+ bookRels = `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="/xl/worksheets/sheet2.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet3.xml"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/><Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`21+ mainNS = `xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`22+23+ kitchenBook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>24+<workbook ` + mainNS + `><workbookPr defaultThemeVersion="124226"/><bookViews><workbookView activeTab="2"/></bookViews><sheets><sheet name="Data" sheetId="1" r:id="rId1"/><sheet name="Hidden" sheetId="2" state="hidden" r:id="rId2"/><sheet name="Refs" sheetId="5" r:id="rId3"/></sheets><definedNames><definedName name="_xlnm.Print_Area" localSheetId="0">Data!$A$1:$F$8</definedName><definedName name="Total">Data!$A$2:$B$2</definedName><definedName name="Local" localSheetId="0">Data!$A$1</definedName><definedName name="Const">42</definedName></definedNames><calcPr calcId="152511"/></workbook>`25+26+ kitchenStrings = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>27+<sst ` + mainNS + ` count="6" uniqueCount="6"><si><t>Region</t></si><si><r><rPr><b/></rPr><t xml:space="preserve">Rich </t></r><r><t>text</t></r><rPh sb="0" eb="1"><t>hint</t></rPh></si><si><t>line_x000D_break _x005F_x0041_ tab	end</t></si><si><t>=not a formula</t></si><si><t>123</t></si><si><t/></si></sst>`28+29+ kitchenStyles = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>30+<styleSheet ` + mainNS + `><numFmts count="2"><numFmt numFmtId="164" formatCode="0.000"/><numFmt numFmtId="165" formatCode="yyyy\-mm\-dd"/></numFmts><fonts count="5"><font><sz val="11"/></font><font><b/><sz val="11"/></font><font><i/><u/></font><font><strike/><u val="none"/></font><font><b val="0"/></font></fonts><cellStyleXfs count="1"><xf numFmtId="0" fontId="1"/></cellStyleXfs><cellXfs count="9"><xf numFmtId="0" fontId="0"/><xf numFmtId="4" fontId="1" applyFont="1" applyNumberFormat="1"/><xf numFmtId="164" fontId="0"/><xf numFmtId="165" fontId="0"/><xf numFmtId="0" fontId="2"><alignment horizontal="center"/></xf><xf numFmtId="10" fontId="3" applyAlignment="0"><alignment horizontal="right"/></xf><xf numFmtId="5" fontId="0"/><xf numFmtId="0" fontId="4"><alignment horizontal="centerContinuous"/></xf><xf numFmtId="14" fontId="1" applyFont="0"><alignment horizontal="left"/></xf></cellXfs></styleSheet>`31+32+ kitchenData = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>33+<worksheet ` + mainNS + `><dimension ref="A1:F9"/><sheetFormatPr defaultColWidth="10" defaultRowHeight="15"/><cols><col min="1" max="1" width="20" customWidth="1"/><col min="3" max="5" width="5" style="2" customWidth="1"/><col min="6" max="6" style="4"/><col min="5" max="5" width="30"/></cols><sheetData>` +34+ `<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c><c r="C1" t="inlineStr"><is><t>inline</t></is></c><c r="D1" t="b"><v>1</v></c><c r="E1" t="e"><v>#N/A</v></c><c r="F1" t="str"><f>A1&"x"</f><v>Regionx</v></c></row>` +35+ `<row r="2" s="3" customFormat="1"><c><v>1.5</v></c><c s="2"><v>2</v></c><c r="E2" s="8"><v>45000</v></c></row>` +36+ `<row r="3"><c r="A3"><f>SUM(A2:B2)</f><v>3.5</v></c><c r="B3"><f t="shared" ref="B3:D4" si="0">A3*2+$A$2+A$2+$A2+SUM(A1:A2)+"A1"</f><v>1</v></c><c r="C3"><f t="shared" si="0"/><v>2</v></c><c r="D3"><f t="shared" si="0"/></c></row>` +37+ `<row r="4"><c r="B4"><f t="shared" si="0"/><v>4</v></c><c r="C4"><f t="shared" si="7"/><v>9</v></c></row>` +38+ `<row r="5"><c r="A5" s="1"><v>10</v></c><c r="E5" s="5" t="s"><v>3</v></c><c r="F5" s="4"/></row>` +39+ `<row r="6"><c r="A6" s="7"/><c r="B6" t="s"><v> 4 </v></c><c r="C6" s="6"><v>12</v></c></row>` +40+ `<row r="7"><c r="A7"><f>CUBEVALUE(1)</f><v>7</v></c><c r="B7" t="str"><f>_xlfn.CONCAT(A1,"a")</f><v>Regiona</v></c><c r="C7" t="b"><f>CUBESET(1)</f><v>0</v></c><c r="D7" t="e"><f>CUBEMEMBER(1)</f><v>#REF!</v></c><c r="E7" t="s"><f>CUBEKPI(1)</f><v>0</v></c></row>` +41+ `<row r="8"><c r="A8" t="n"><v>1E+300</v></c><c r="B8"><v> 12</v></c><c r="C8"><v>abc</v></c><c r="D8" t="s"><v>99</v></c><c r="E8"><v>5</v></c><c r="F8" t="s"><v>5</v></c></row>` +42+ `<row r="9"><c r="B9" t="inlineStr"><is><r><t>in</t></r><r><t>_x0041_</t></r></is></c><c r="C9" t="b"><v>TRUE</v></c><c r="D9" s="8"><v>30</v></c><c r="E9" t="x"><v>8</v></c></row>` +43+ `</sheetData><mergeCells count="1"><mergeCell ref="E3:F3"/></mergeCells></worksheet>`44+45+ kitchenHidden = `<?xml version="1.0" encoding="UTF-8"?><worksheet ` + mainNS + `><sheetData><row><c><v>1</v></c></row><row><c/><c t="s"><v>0</v></c></row></sheetData></worksheet>`46+47+ kitchenRefs = `<?xml version="1.0" encoding="UTF-8"?><worksheet ` + mainNS + `><dimension ref="A1"/><sheetFormatPr baseColWidth="12"/><sheetData><row r="1"><c r="A1"><v>42</v></c><c r="B1"><f>Data!A5*2+SUM(Total)</f><v>84</v></c><c r="C1"><f>SUM(Hidden!A1:A2)</f></c></row></sheetData></worksheet>`48+)49+50+// kitchenParts is a workbook of three sheets using most of what the51+// reader reads.52+func kitchenParts() map[string]string {53+ return map[string]string{54+ "[Content_Types].xml": contentTypes,55+ "_rels/.rels": rootRels,56+ "xl/workbook.xml": kitchenBook,57+ "xl/_rels/workbook.xml.rels": bookRels,58+ "xl/sharedStrings.xml": kitchenStrings,59+ "xl/styles.xml": kitchenStyles,60+ "xl/worksheets/sheet1.xml": kitchenData,61+ "xl/worksheets/sheet2.xml": kitchenHidden,62+ "xl/worksheets/sheet3.xml": kitchenRefs,63+ }64+}65+66+// oneSheetParts is a workbook of one sheet with the given sheetData67+// content, and the workbook's properties.68+func oneSheetParts(workbookPr, sheetData string) map[string]string {69+ p := kitchenParts()70+ p["xl/workbook.xml"] = `<workbook ` + mainNS + `>` + workbookPr + `<sheets><sheet name="S" sheetId="1" r:id="rId1"/></sheets></workbook>`71+ p["xl/worksheets/sheet1.xml"] = `<worksheet ` + mainNS + `><sheetData>` + sheetData + `</sheetData></worksheet>`72+ delete(p, "xl/worksheets/sheet2.xml")73+ delete(p, "xl/worksheets/sheet3.xml")74+ return p75+}76+77+// zipParts packs parts into an XLSX file's bytes, in name order.78+func zipParts(t testing.TB, parts map[string]string) []byte {79+ t.Helper()80+ var buf bytes.Buffer81+ zw := zip.NewWriter(&buf)82+ for _, name := range slices.Sorted(maps.Keys(parts)) {83+ w, err := zw.Create(name)84+ if err != nil {85+ t.Fatal(err)86+ }87+ if _, err := w.Write([]byte(parts[name])); err != nil {88+ t.Fatal(err)89+ }90+ }91+ if err := zw.Close(); err != nil {92+ t.Fatal(err)93+ }94+ return buf.Bytes()95+}96+97+// writeParts writes parts as an XLSX file in dir and returns its path.98+func writeParts(t testing.TB, dir, name string, parts map[string]string) string {99+ t.Helper()100+ path := filepath.Join(dir, name)101+ if err := os.WriteFile(path, zipParts(t, parts), 0o644); err != nil {102+ t.Fatal(err)103+ }104+ return path105+}@@ -0,0 +1,294 @@
1+package fileio2+3+import (4+ "archive/zip"5+ "encoding/xml"6+ "errors"7+ "fmt"8+ "io"9+ "io/fs"10+ "path"11+ "strings"12+)13+14+// An XLSX file is a zip of XML parts (SpreadsheetML in an Open Packaging15+// Conventions package). 012 reads it with archive/zip and encoding/xml,16+// streaming each part a token at a time, so a worksheet is never held in17+// memory. The file is untrusted: xlsxLimits bound what it can make the18+// reader do.19+20+// xlsxLimits bound the work a file can cause. encoding/xml expands no21+// external or custom entities, so entity bombs can't happen; these cap22+// zip bombs, deep nesting and huge single tokens.23+type xlsxLimits struct {24+ entries int // files in the zip25+ part int64 // uncompressed bytes of one part26+ total int64 // uncompressed bytes of all parts read27+ ratio int64 // uncompressed / compressed, past slack bytes28+ slack int6429+ depth int // XML nesting30+ token int64 // bytes of one XML token (a tag or a run of text)31+ strings int // shared strings32+ styles int // cell formats, fonts and number formats, each33+ sheets int34+ shared int64 // bytes of shared formulas expanded35+}36+37+var defaultXLSXLimits = xlsxLimits{38+ entries: 10_000,39+ part: 1 << 30,40+ total: 2 << 30,41+ ratio: 250,42+ slack: 16 << 20,43+ depth: 256,44+ token: 32 << 20,45+ strings: 1 << 24,46+ styles: 1 << 16,47+ sheets: 4096,48+ shared: 256 << 20,49+}50+51+// errXLSXLimit is wrapped by every error for a file past a limit.52+var errXLSXLimit = errors.New("past the reader's limits")53+54+// xlsxPackage is an open XLSX zip: its parts by lower-case name (part55+// names are case-insensitive) and what has been read of them so far.56+type xlsxPackage struct {57+ parts map[string]*zip.File58+ lim xlsxLimits59+ read int64 // uncompressed bytes read, all parts60+}61+62+func openXLSXPackage(r io.ReaderAt, size int64, lim xlsxLimits) (*xlsxPackage, error) {63+ zr, err := zip.NewReader(r, size)64+ if err != nil && !errors.Is(err, zip.ErrInsecurePath) {65+ return nil, fmt.Errorf("not an Excel workbook, or an encrypted one (%w)", err)66+ }67+ if len(zr.File) > lim.entries {68+ return nil, fmt.Errorf("the workbook holds %d files, more than %d: %w", len(zr.File), lim.entries, errXLSXLimit)69+ }70+ p := &xlsxPackage{parts: make(map[string]*zip.File, len(zr.File)), lim: lim}71+ for _, f := range zr.File {72+ name := f.Name73+ if strings.HasSuffix(name, "/") {74+ continue // a directory75+ }76+ if !fs.ValidPath(name) || strings.ContainsRune(name, '\\') {77+ return nil, fmt.Errorf("the workbook holds a file with an unsafe name, %q", name)78+ }79+ key := strings.ToLower(name)80+ if _, dup := p.parts[key]; dup {81+ return nil, fmt.Errorf("the workbook holds %s twice", name)82+ }83+ p.parts[key] = f84+ }85+ return p, nil86+}87+88+// has reports whether the package holds a part.89+func (p *xlsxPackage) has(name string) bool {90+ _, ok := p.parts[strings.ToLower(name)]91+ return ok92+}93+94+// open starts streaming a part's XML, or returns nil, nil when there is95+// no such part.96+func (p *xlsxPackage) open(name string) (*xmlStream, error) {97+ f, ok := p.parts[strings.ToLower(name)]98+ if !ok {99+ return nil, nil100+ }101+ rc, err := f.Open()102+ if err != nil {103+ return nil, fmt.Errorf("%s: %w", name, err)104+ }105+ g := &guardReader{r: rc, c: rc, p: p, name: name, comp: int64(f.CompressedSize64)}106+ d := xml.NewDecoder(g)107+ return &xmlStream{d: d, g: g, name: name, maxDepth: p.lim.depth}, nil108+}109+110+// guardReader counts a part's uncompressed bytes as they are read and111+// fails past the limits, so a zip bomb stops early whatever its headers112+// claim.113+type guardReader struct {114+ r io.Reader115+ c io.Closer116+ p *xlsxPackage117+ name string118+ comp int64 // compressed size119+ n int64 // uncompressed bytes read120+ tok int64 // n when the current XML token began121+}122+123+func (g *guardReader) Read(b []byte) (int, error) {124+ k, err := g.r.Read(b)125+ g.n += int64(k)126+ g.p.read += int64(k)127+ lim := g.p.lim128+ switch {129+ case g.n > lim.part:130+ return k, fmt.Errorf("%s unpacks to more than %s: %w", g.name, mb(lim.part), errXLSXLimit)131+ case g.p.read > lim.total:132+ return k, fmt.Errorf("the workbook unpacks to more than %s: %w", mb(lim.total), errXLSXLimit)133+ case g.n > lim.slack && g.n/max(g.comp, 1) > lim.ratio:134+ return k, fmt.Errorf("%s is compressed over %d to 1, like a zip bomb: %w", g.name, lim.ratio, errXLSXLimit)135+ case g.n-g.tok > lim.token:136+ return k, fmt.Errorf("%s has a tag or text over %s: %w", g.name, mb(lim.token), errXLSXLimit)137+ }138+ return k, err139+}140+141+func mb(n int64) string { return fmt.Sprintf("%d MB", n>>20) }142+143+// xmlStream is a part's XML as tokens, checking nesting depth and the144+// size of each token.145+type xmlStream struct {146+ d *xml.Decoder147+ g *guardReader148+ name string149+ depth int150+ maxDepth int151+}152+153+// next returns the next token, or io.EOF at the end of the part. Char154+// data is only valid until the following call.155+func (x *xmlStream) next() (xml.Token, error) {156+ x.g.tok = x.g.n157+ t, err := x.d.Token()158+ if err != nil {159+ if err == io.EOF {160+ return nil, err161+ }162+ return nil, fmt.Errorf("%s: %w", x.name, err)163+ }164+ switch t.(type) {165+ case xml.StartElement:166+ x.depth++167+ if x.depth > x.maxDepth {168+ return nil, fmt.Errorf("%s nests elements more than %d deep: %w", x.name, x.maxDepth, errXLSXLimit)169+ }170+ case xml.EndElement:171+ x.depth--172+ }173+ return t, nil174+}175+176+// skip passes over the rest of the element just started.177+func (x *xmlStream) skip() error {178+ for depth := x.depth; ; {179+ t, err := x.next()180+ if err != nil {181+ return eofAsUnexpected(err)182+ }183+ if _, ok := t.(xml.EndElement); ok && x.depth < depth {184+ return nil185+ }186+ }187+}188+189+// text returns the text of the element just started, up to its end.190+func (x *xmlStream) text(buf []byte) ([]byte, error) {191+ for depth := x.depth; ; {192+ t, err := x.next()193+ if err != nil {194+ return buf, eofAsUnexpected(err)195+ }196+ switch t := t.(type) {197+ case xml.CharData:198+ buf = append(buf, t...)199+ case xml.EndElement:200+ if x.depth < depth {201+ return buf, nil202+ }203+ }204+ }205+}206+207+func (x *xmlStream) close() error { return x.g.c.Close() }208+209+func eofAsUnexpected(err error) error {210+ if err == io.EOF {211+ return io.ErrUnexpectedEOF212+ }213+ return err214+}215+216+// attr returns the value of the attribute with local name name.217+func attr(se xml.StartElement, name string) (string, bool) {218+ for _, a := range se.Attr {219+ if a.Name.Local == name {220+ return a.Value, true221+ }222+ }223+ return "", false224+}225+226+// relAttr returns the r:id of an element: the id attribute in the227+// relationships namespace (transitional or strict).228+func relAttr(se xml.StartElement) string {229+ for _, a := range se.Attr {230+ if a.Name.Local == "id" && strings.HasSuffix(a.Name.Space, "/relationships") {231+ return a.Value232+ }233+ }234+ return ""235+}236+237+// xlsxRel is a relationship from a part to another.238+type xlsxRel struct {239+ typ string // the last element of the type URI, e.g. "worksheet"240+ target string // the part's name, resolved241+}242+243+// rels reads the relationships of part (from its _rels part), resolving244+// targets to part names. Targets outside the package are left out.245+func (p *xlsxPackage) rels(part string) (map[string]xlsxRel, error) {246+ dir, base := path.Split(part)247+ x, err := p.open(dir + "_rels/" + base + ".rels")248+ if x == nil || err != nil {249+ return nil, err250+ }251+ defer x.close()252+ out := map[string]xlsxRel{}253+ for {254+ t, err := x.next()255+ if err == io.EOF {256+ return out, nil257+ }258+ if err != nil {259+ return nil, err260+ }261+ se, ok := t.(xml.StartElement)262+ if !ok || se.Name.Local != "Relationship" {263+ continue264+ }265+ if mode, _ := attr(se, "TargetMode"); strings.EqualFold(mode, "External") {266+ continue267+ }268+ id, _ := attr(se, "Id")269+ typ, _ := attr(se, "Type")270+ target, _ := attr(se, "Target")271+ if name, ok := resolvePart(dir, target); ok && len(out) < p.lim.entries {272+ out[id] = xlsxRel{typ: typ[strings.LastIndexByte(typ, '/')+1:], target: name}273+ }274+ }275+}276+277+// resolvePart resolves a relationship's target against the directory of278+// its source part: /xl/x.xml is from the root, x.xml from dir. A target279+// that climbs out of the package is refused.280+func resolvePart(dir, target string) (string, bool) {281+ if target == "" {282+ return "", false283+ }284+ if strings.HasPrefix(target, "/") {285+ target = target[1:]286+ } else {287+ target = dir + target288+ }289+ name := path.Clean(target)290+ if !fs.ValidPath(name) || name == "." {291+ return "", false292+ }293+ return name, true294+}@@ -0,0 +1,273 @@
1+package fileio2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "strings"8+ "testing"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// readParts imports a workbook of parts with the given limits.14+func readParts(t testing.TB, parts map[string]string, lim xlsxLimits) (*Result, error) {15+ t.Helper()16+ data := zipParts(t, parts)17+ bk, err := openXLSX(bytes.NewReader(data), int64(len(data)), lim)18+ if err != nil {19+ return nil, err20+ }21+ return bk.importBook(context.Background(), nil)22+}23+24+func TestXLSXReader(t *testing.T) {25+ res, err := readParts(t, kitchenParts(), defaultXLSXLimits)26+ if err != nil {27+ t.Fatal(err)28+ }29+ book := res.Sheet.Book()30+ if book.Len() != 3 || res.Sheet.Name() != "Refs" || res.Rows != 12 {31+ t.Fatalf("%d sheets, showing %s, %d rows", book.Len(), res.Sheet.Name(), res.Rows)32+ }33+ data := book.Sheet(0)34+ for cell, want := range map[string]string{35+ "A1": "Region", "B1": "Rich text", "C1": "inline", "D1": "TRUE", "E1": "#N/A", "F1": `=A1&"x"`,36+ "A3": "=SUM(A2:B2)",37+ "B3": `=A3*2+$A$2+A$2+$A2+SUM(A1:A2)+"A1"`,38+ "C3": `=B3*2+$A$2+B$2+$A2+SUM(B1:B2)+"A1"`,39+ "D3": `=C3*2+$A$2+C$2+$A2+SUM(C1:C2)+"A1"`,40+ "B4": `=A4*2+$A$2+A$2+$A3+SUM(A2:A3)+"A1"`,41+ "C4": "9", "E5": "'=not a formula", "B6": "'123", "C6": "12",42+ "A7": "7", "B7": `=CONCAT(A1,"a")`, "C7": "FALSE", "D7": "#REF!", "E7": "Region",43+ "A8": "1E+300", "B8": "' 12", "C8": "abc", "D8": "", "E8": "5",44+ "B9": "inA", "C9": "TRUE", "E9": "8",45+ } {46+ if g := input(data, addr(t, cell)); g != want {47+ t.Errorf("Data!%s input %q, want %q", cell, g, want)48+ }49+ }50+ if g := input(data, addr(t, "A1")); g != "Region" {51+ t.Errorf("A1 %q", g)52+ }53+ for _, cell := range []string{"C2", "D8", "A6"} {54+ if data.Cell(addr(t, cell)) == nil {55+ t.Errorf("formatted blank %s left out", cell)56+ }57+ }58+ for _, cell := range []string{"F5", "F8"} {59+ if c := data.Cell(addr(t, cell)); c != nil {60+ t.Errorf("%s kept: %+v", cell, c)61+ }62+ }63+ checkKitchenStyles(t, data)64+ for col, want := range map[int]int{0: 21, 1: 11, 2: 6, 3: 6, 4: 31, 5: 11, 200: 11} {65+ if w := data.ColWidth(col); w != want {66+ t.Errorf("Data column %d width %d, want %d", col, w, want)67+ }68+ }69+ if w := book.Sheet(2).ColWidth(7); w != 13 {70+ t.Errorf("Refs width %d", w)71+ }72+ if !book.Sheet(1).Hidden() || res.Sheet.Hidden() {73+ t.Errorf("Hidden hidden %v, Refs hidden %v", book.Sheet(1).Hidden(), res.Sheet.Hidden())74+ }75+ if g := input(book.Sheet(1), addr(t, "B2")); g != "Region" {76+ t.Errorf("Hidden!B2 %q", g)77+ }78+ if g := shown(book.Sheet(2), addr(t, "B1")); g != "24.50" {79+ t.Errorf("Refs!B1 shows %q", g) // Data!A5*2 + SUM(Total), A2 a date: 2.580+ }81+ notes := strings.Join(res.Notes, "; ")82+ for _, want := range []string{83+ "2 named ranges left out, e.g. Local",84+ "4 formulas kept as values, e.g. A7 =CUBEVALUE(1)",85+ } {86+ if !strings.Contains(notes, want) {87+ t.Errorf("notes %q, want %q", notes, want)88+ }89+ }90+}91+92+func checkKitchenStyles(t *testing.T, data *sheet.Sheet) {93+ t.Helper()94+ cell := func(s string) sheet.Cell {95+ if c := data.Cell(addr(t, s)); c != nil {96+ return *c97+ }98+ return sheet.Cell{}99+ }100+ if f := cell("A2").Format; f.Kind != sheet.FmtDate { // the row's style101+ t.Errorf("A2 format %+v", f)102+ }103+ if f := cell("B2").Format; f != (sheet.Format{Kind: sheet.FmtCustom, Pattern: "0.000"}) {104+ t.Errorf("B2 format %+v", f)105+ }106+ if c := cell("E2"); c.Format.Kind != sheet.FmtDate || c.Style.Bold || c.Style.Align != sheet.AlignLeft {107+ t.Errorf("E2 %+v %+v (applyFont=0 isn't bold)", c.Format, c.Style)108+ }109+ if c := cell("A5"); !c.Style.Bold || c.Format != (sheet.Format{Kind: sheet.FmtNumber, Decimals: 2}) {110+ t.Errorf("A5 %+v %+v", c.Format, c.Style)111+ }112+ if st := cell("E5").Style; !st.Strikethrough || st.Underline || st.Align != sheet.AlignAuto {113+ t.Errorf("E5 style %+v", st)114+ }115+ if st := cell("A6").Style; st.Bold || st.Align != sheet.AlignCenter {116+ t.Errorf("A6 style %+v", st)117+ }118+ if f := cell("C6").Format; !f.IsZero() {119+ t.Errorf("C6 format %+v (id 5 is no built-in to excelize)", f)120+ }121+ if c := cell("D9"); c.Format.Kind != sheet.FmtDate || shown(data, addr(t, "D9")) != "1/30/1900" {122+ t.Errorf("D9 %+v shows %s", c.Format, shown(data, addr(t, "D9")))123+ }124+}125+126+func TestShiftFormula(t *testing.T) {127+ for _, tc := range []struct {128+ in string129+ dCol, dRow int130+ want string131+ }{132+ {"A1+B2", 1, 1, "B2+C3"},133+ {"$A$1+$A1+A$1", 2, 3, "$A$1+$A4+C$1"},134+ {"SUM(A1:B2)*LOG10(A1)", 0, 1, "SUM(A2:B3)*LOG10(A2)"},135+ {"Sheet2!A1+'My sheet'!B$2", 1, 1, "Sheet2!B2+'My sheet'!C$2"},136+ {"SUM(A:A,1:1,$B:$B,$2:$2)", 1, 1, "SUM(B:B,2:2,$B:$B,$2:$2)"},137+ {`"A1"&A1&Total&TRUE&1E+5&0.5`, 1, 0, `"A1"&B1&Total&TRUE&1E+5&0.5`},138+ {"A1-1", -1, 0, "#REF!-1"},139+ {"XFD1+A1048576", 1, 1, "#REF!+#REF!"},140+ {"Table1[[#This Row],[A1]]+A1", 1, 0, "Table1[[#This Row],[A1]]+B1"},141+ {"_xlfn.XLOOKUP(A1,B:B,C:C)", 0, 5, "_xlfn.XLOOKUP(A6,B:B,C:C)"},142+ {"#REF!+A1", 0, 1, "#REF!+A2"},143+ {"A1 B1", 25, 0, "Z1 AA1"},144+ {"ZZ1", 1, 0, "AAA1"},145+ } {146+ if got := shiftFormula(tc.in, tc.dCol, tc.dRow); got != tc.want {147+ t.Errorf("shiftFormula(%q, %d, %d) = %q, want %q", tc.in, tc.dCol, tc.dRow, got, tc.want)148+ }149+ }150+}151+152+// Things excelize reads wrongly or not at all: shared formulas referring153+// to another sheet move, the 1904 date system, t="d" ISO dates, and154+// columns past the sheet's edge are all counted.155+func TestXLSXBeyondExcelize(t *testing.T) {156+ parts := oneSheetParts(`<workbookPr date1904="1"/>`,157+ `<row r="1"><c r="A1"><v>1</v></c><c r="B1"><f t="shared" ref="B1:B2" si="0">Other!A1+A1</f></c><c r="C1" s="8"><v>0</v></c><c r="D1" t="d"><v>2026-09-26T14:30:00</v></c><c r="E1" t="d"><v>2026-09-26</v></c><c r="F1" t="d"><v>soon</v></c><c r="IZ1"><v>1</v></c></row>`+158+ `<row r="2"><c r="B2"><f t="shared" si="0"/></c></row>`)159+ res, err := readParts(t, parts, defaultXLSXLimits)160+ if err != nil {161+ t.Fatal(err)162+ }163+ s := res.Sheet164+ if g := input(s, addr(t, "B2")); g != "=Other!A2+A2" {165+ t.Errorf("B2 %q", g)166+ }167+ for cell, want := range map[string]string{"C1": "1/1/1904", "D1": "9/26/2026 14:30:00", "E1": "9/26/2026", "F1": "soon"} {168+ if g := shown(s, addr(t, cell)); g != want {169+ t.Errorf("%s shows %q, want %q", cell, g, want)170+ }171+ }172+ if notes := strings.Join(res.Notes, "; "); !strings.Contains(notes, "4 columns left out") {173+ t.Errorf("notes %q", notes)174+ }175+}176+177+func TestXLSXLimits(t *testing.T) {178+ small := defaultXLSXLimits179+ small.part, small.total, small.slack, small.token, small.ratio, small.entries, small.shared = 1<<20, 2<<20, 64<<10, 64<<10, 100, 50, 1<<20180+ bomb := oneSheetParts("", strings.Repeat(`<row><c><v>0</v></c></row>`, 100_000))181+ var rows, text strings.Builder182+ for i := range 60_000 {183+ rows.WriteString(`<row><c><v>` + itoa(i*7919%100_003) + `</v></c></row>`)184+ text.WriteString(itoa(i * 7919 % 100_003))185+ }186+ big := oneSheetParts("", rows.String())187+ long := oneSheetParts("", `<row><c t="inlineStr"><is><t>`+text.String()+`</t></is></c></row>`)188+ deep := oneSheetParts("", `<row><c><v>1</v><x>`+strings.Repeat("<y>", 300)+strings.Repeat("</y>", 300)+`</x></c></row>`)189+ many := kitchenParts()190+ for i := range small.entries {191+ many["xl/media/image"+itoa(i)+".png"] = ""192+ }193+ for name, tc := range map[string]struct {194+ parts map[string]string195+ want string196+ ratio int64 // instead of small.ratio197+ }{198+ "zip bomb": {bomb, "compressed over 100 to 1, like a zip bomb", 0},199+ "big part": {big, "unpacks to more than 1 MB", 0},200+ "long text": {long, "a tag or text over", 0},201+ "deep": {deep, "nests elements more than 256 deep", 0},202+ "many files": {many, "files, more than 50", 0},203+ "row past Excel": {oneSheetParts("", `<row r="1048577"><c><v>1</v></c></row>`), "past Excel's last row", 0},204+ "col past Excel": {oneSheetParts("", `<row><c r="XFE1"><v>1</v></c></row>`), `"XFE1" isn't a cell reference`, 0},205+ "bad reference": {oneSheetParts("", `<row><c r="A0"><v>1</v></c></row>`), `"A0" isn't a cell reference`, 0},206+ "too many cells": {oneSheetParts("", `<row>`+strings.Repeat(`<c r="A1"/>`, excelCols+1)+`</row>`), "more than 16,384 cells", 1 << 30},207+ "unsafe name": {mergeParts(kitchenParts(), "../evil.xml", "x"), "unsafe name", 0},208+ "absolute name": {mergeParts(kitchenParts(), "/etc/passwd", "x"), "unsafe name", 0},209+ "duplicate": {mergeParts(kitchenParts(), "XL/Workbook.xml", "x"), "twice", 0},210+ "entity": {oneSheetParts("", `<row><c t="inlineStr"><is><t>&bomb;</t></is></c></row>`), "invalid character entity &bomb;", 0},211+ "shared formulas": {oneSheetParts("", `<row r="1"><c r="A1"><f t="shared" ref="A1:A9" si="0">`+strings.Repeat("B1+", 1000)+`1</f></c></row>`+212+ strings.Repeat(`<row><c r="A2"><f t="shared" si="0"/></c></row>`, 400)), "shared formulas expand to more than 1 MB", 0},213+ "not xml": {oneSheetParts("", `<row><c>`), "closed by </sheetData>", 0},214+ } {215+ t.Run(name, func(t *testing.T) {216+ lim := small217+ if tc.ratio > 0 {218+ lim.ratio = tc.ratio219+ }220+ _, err := readParts(t, tc.parts, lim)221+ if err == nil || !strings.Contains(err.Error(), tc.want) {222+ t.Fatalf("error %v, want %q", err, tc.want)223+ }224+ })225+ }226+ // The limits error wraps errXLSXLimit.227+ if _, err := readParts(t, bomb, small); !errors.Is(err, errXLSXLimit) {228+ t.Errorf("%v isn't a limits error", err)229+ }230+ // A DTD declaring entities is refused, not expanded.231+ dtd := oneSheetParts("", "")232+ dtd["xl/sharedStrings.xml"] = `<?xml version="1.0"?><!DOCTYPE sst [<!ENTITY a "aaaaaaaaaa"><!ENTITY b "&a;&a;&a;&a;">]><sst ` + mainNS + `><si><t>&b;</t></si></sst>`233+ if _, err := readParts(t, dtd, small); err == nil || !strings.Contains(err.Error(), "invalid character entity &b;") {234+ t.Errorf("entity: %v", err)235+ }236+}237+238+func mergeParts(p map[string]string, name, content string) map[string]string {239+ p[name] = content240+ return p241+}242+243+// FuzzReadXLSX feeds the reader arbitrary files: it must fail cleanly,244+// never panic or run past its limits.245+func FuzzReadXLSX(f *testing.F) {246+ f.Add(zipParts(f, kitchenParts()))247+ f.Add(zipParts(f, oneSheetParts(`<workbookPr date1904="1"/>`, `<row><c r="B2" t="d"><v>2026-01-01</v></c></row>`)))248+ f.Fuzz(func(t *testing.T, data []byte) {249+ lim := defaultXLSXLimits250+ lim.part, lim.total = 4<<20, 8<<20251+ bk, err := openXLSX(bytes.NewReader(data), int64(len(data)), lim)252+ if err != nil {253+ return254+ }255+ bk.importBook(context.Background(), nil)256+ })257+}258+259+// FuzzReadXLSXParts fuzzes the XML of a worksheet, the shared strings260+// and the styles, zipped into a workbook, where the zip's checksums261+// would stop most mutations of a whole file.262+func FuzzReadXLSXParts(f *testing.F) {263+ k := kitchenParts()264+ f.Add(k["xl/worksheets/sheet1.xml"], k["xl/sharedStrings.xml"], k["xl/styles.xml"])265+ f.Add(`<worksheet><sheetData><row r="3"><c r="C3"><f t="shared" ref="C3:C9" si="0">A1:B$2+'x y'!A1</f></c></row><row><c r="C4"><f t="shared" si="0"/></c></row></sheetData></worksheet>`, "", "")266+ f.Fuzz(func(t *testing.T, ws, sst, styles string) {267+ p := kitchenParts()268+ p["xl/worksheets/sheet1.xml"], p["xl/sharedStrings.xml"], p["xl/styles.xml"] = ws, sst, styles269+ lim := defaultXLSXLimits270+ lim.part, lim.total = 4<<20, 8<<20271+ readParts(t, p, lim)272+ })273+}@@ -0,0 +1,410 @@
1+package fileio2+3+import (4+ "encoding/xml"5+ "fmt"6+ "io"7+ "strconv"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Excel's own limits on a worksheet. A reference past them is an error,14+// as it is in Excel.15+const (16+ excelRows = 1 << 2017+ excelCols = 1 << 1418+)19+20+// xlsxCell is a cell of a worksheet row, as the reader passes it on.21+type xlsxCell struct {22+ col int // from 123+ style int // the cell format index (s), 0 when not given24+ typ string // the type (t): s, str, inlineStr, b, e, n, d or ""25+ value string // the value, a shared string looked up26+ f cellFormula // its <f>, see xlsxSheetReader.formula27+ hasF bool // the cell has a formula element, even an empty one28+}29+30+// kept reports whether excelize's row reader would return the cell: it31+// has a value or a formula element. The others are formatted blanks.32+func (c *xlsxCell) kept() bool { return c.value != "" || c.hasF }33+34+// xlsxCol is a <col> element: widths and styles for columns min to max.35+type xlsxCol struct {36+ min, max int37+ width float6438+ hasWidth bool39+ style int40+}41+42+// xlsxSheetReader streams a worksheet's rows. Opening it reads what43+// comes before the cells: column widths and styles, the default width.44+type xlsxSheetReader struct {45+ bk *xlsxBook46+ x *xmlStream47+ cols []xlsxCol48+ // sheetFormatPr's defaultColWidth and baseColWidth.49+ defaultWidth float6450+ baseWidth int51+52+ colStyles []int // by column, see colStyle53+54+ inData bool // inside <sheetData>55+ row xlsxRowData56+ shared map[int]sharedFormula57+ buf []byte58+}59+60+// xlsxRowData is the row just read.61+type xlsxRowData struct {62+ num int // from 163+ style int // the row's cell format index, for cells not written64+ cells []xlsxCell65+}66+67+// sharedFormula is the master cell of a shared formula.68+type sharedFormula struct {69+ text string70+ col, row int71+}72+73+// openSheet opens sheet i. A sheet with no worksheet part (a chart74+// sheet) reads as empty.75+func (bk *xlsxBook) openSheet(i int) (*xlsxSheetReader, error) {76+ info := bk.sheets[i]77+ if info.part == "" {78+ return nil, fmt.Errorf("sheet %s: its part is missing", info.name)79+ }80+ x, err := bk.pkg.open(info.part)81+ if err != nil {82+ return nil, err83+ }84+ r := &xlsxSheetReader{bk: bk, x: x, shared: map[int]sharedFormula{}}85+ if info.kind != "worksheet" {86+ return r, nil87+ }88+ return r, r.readHead()89+}90+91+func (r *xlsxSheetReader) close() error { return r.x.close() }92+93+// readHead reads up to the start of <sheetData>.94+func (r *xlsxSheetReader) readHead() error {95+ for {96+ t, err := r.x.next()97+ if err == io.EOF {98+ return nil99+ }100+ if err != nil {101+ return err102+ }103+ se, ok := t.(xml.StartElement)104+ if !ok || r.x.depth != 2 && se.Name.Local != "col" {105+ continue106+ }107+ switch se.Name.Local {108+ case "sheetData":109+ r.inData = true110+ return nil111+ case "sheetFormatPr":112+ r.defaultWidth, _ = strconv.ParseFloat(attrOr(se, "defaultColWidth", "0"), 64)113+ r.baseWidth = intAttr(se, "baseColWidth", 0)114+ case "col":115+ if r.x.depth == 3 && len(r.cols) < excelCols {116+ w, err := strconv.ParseFloat(attrOr(se, "width", ""), 64)117+ r.cols = append(r.cols, xlsxCol{118+ min: intAttr(se, "min", 0), max: intAttr(se, "max", 0),119+ width: w, hasWidth: err == nil, style: intAttr(se, "style", 0),120+ })121+ }122+ }123+ }124+}125+126+// colStyle is the cell format of column col (from 1): the first <col>127+// covering it with a style. Columns up to sheet.MaxCols, the ones asked128+// for once per cell, are looked up once.129+func (r *xlsxSheetReader) colStyle(col int) int {130+ if r.colStyles == nil {131+ r.colStyles = make([]int, sheet.MaxCols)132+ for _, c := range r.cols {133+ for i := max(c.min, 1); i <= min(c.max, sheet.MaxCols); i++ {134+ if r.colStyles[i-1] == 0 {135+ r.colStyles[i-1] = c.style136+ }137+ }138+ }139+ }140+ if col >= 1 && col <= len(r.colStyles) {141+ return r.colStyles[col-1]142+ }143+ for _, c := range r.cols {144+ if c.min <= col && col <= c.max && c.style != 0 {145+ return c.style146+ }147+ }148+ return 0149+}150+151+// colWidth is column col's width in Excel's characters: the last <col>152+// covering it with a width, else the sheet's default.153+func (r *xlsxSheetReader) colWidth(col int) float64 {154+ w := 0.0155+ for _, c := range r.cols {156+ if c.min <= col && col <= c.max && c.hasWidth {157+ w = c.width158+ }159+ }160+ switch {161+ case w != 0:162+ return w163+ case r.defaultWidth > 0:164+ return r.defaultWidth165+ case r.baseWidth > 0:166+ return float64(r.baseWidth)167+ }168+ return excelDefaultWidth169+}170+171+// next reads the next row into r.row, returning false after the last.172+func (r *xlsxSheetReader) next() (bool, error) {173+ for r.inData {174+ t, err := r.x.next()175+ if err != nil {176+ return false, eofAsUnexpected(err)177+ }178+ switch t := t.(type) {179+ case xml.StartElement:180+ if t.Name.Local == "row" && r.x.depth == 3 {181+ return true, r.readRow(t)182+ }183+ case xml.EndElement:184+ if r.x.depth < 2 {185+ r.inData = false186+ }187+ }188+ }189+ return false, nil190+}191+192+// readRow reads a <row>. A row without a number follows the one before.193+func (r *xlsxSheetReader) readRow(se xml.StartElement) error {194+ num := intAttr(se, "r", 0)195+ if num <= 0 {196+ num = r.row.num + 1197+ }198+ if num > excelRows {199+ return fmt.Errorf("row %s is past Excel's last row, %s", thousands(num), thousands(excelRows))200+ }201+ r.row.num, r.row.style = num, intAttr(se, "s", 0)202+ r.row.cells = r.row.cells[:0]203+ col := 0204+ for {205+ t, err := r.x.next()206+ if err != nil {207+ return eofAsUnexpected(err)208+ }209+ switch t := t.(type) {210+ case xml.StartElement:211+ if t.Name.Local != "c" || r.x.depth != 4 {212+ if err := r.x.skip(); err != nil {213+ return err214+ }215+ continue216+ }217+ if len(r.row.cells) >= excelCols {218+ return fmt.Errorf("row %s has more than %s cells", thousands(num), thousands(excelCols))219+ }220+ if col, err = r.readCell(t, col); err != nil {221+ return err222+ }223+ case xml.EndElement:224+ return nil225+ }226+ }227+}228+229+// readCell reads a <c> following column prev and appends it to the row,230+// returning its column.231+func (r *xlsxSheetReader) readCell(se xml.StartElement, prev int) (int, error) {232+ c := xlsxCell{col: prev + 1, style: intAttr(se, "s", 0)}233+ c.typ, _ = attr(se, "t")234+ if ref, ok := attr(se, "r"); ok && ref != "" {235+ col, _, ok := parseCellRef(ref)236+ if !ok {237+ return 0, fmt.Errorf("row %s: %q isn't a cell reference", thousands(r.row.num), ref)238+ }239+ c.col = col240+ }241+ if c.col > excelCols {242+ return 0, fmt.Errorf("row %s: a cell past Excel's last column, XFD", thousands(r.row.num))243+ }244+ var v, is string245+ var hasIS bool246+ var f cellFormula247+ depth := r.x.depth248+ for {249+ t, err := r.x.next()250+ if err != nil {251+ return 0, eofAsUnexpected(err)252+ }253+ if _, ok := t.(xml.EndElement); ok && r.x.depth < depth {254+ break255+ }256+ se, ok := t.(xml.StartElement)257+ if !ok {258+ continue259+ }260+ switch se.Name.Local {261+ case "v":262+ r.buf, err = r.x.text(r.buf[:0])263+ v = string(r.buf)264+ case "f":265+ c.hasF = true266+ f, err = r.readFormula(se)267+ case "is":268+ r.buf, err = inlineText(r.x, r.buf[:0])269+ is, hasIS = string(unescapeOOXML(r.buf)), true270+ default:271+ err = r.x.skip()272+ }273+ if err != nil {274+ return 0, err275+ }276+ }277+ c.value = r.value(c.typ, v, is, hasIS)278+ if c.hasF {279+ c.f = f280+ if _, ok := r.shared[f.si]; !ok && f.typ == "shared" && f.si >= 0 && f.ref != "" {281+ r.shared[f.si] = sharedFormula{text: f.text, col: c.col, row: r.row.num}282+ }283+ }284+ r.row.cells = append(r.row.cells, c)285+ return c.col, nil286+}287+288+// value is a cell's value: its shared string, its inline string, or the289+// text of its <v>.290+func (r *xlsxSheetReader) value(typ, v, is string, hasIS bool) string {291+ switch typ {292+ case "s":293+ if v == "" {294+ return ""295+ }296+ i, err := strconv.Atoi(strings.TrimSpace(v))297+ if err != nil {298+ return ""299+ }300+ s, _ := r.bk.sst.get(i)301+ return s302+ case "inlineStr":303+ if hasIS {304+ return is305+ }306+ }307+ return v308+}309+310+// cellFormula is an <f> element.311+type cellFormula struct {312+ text, typ, ref string313+ si int // -1 when not given314+}315+316+func (r *xlsxSheetReader) readFormula(se xml.StartElement) (cellFormula, error) {317+ f := cellFormula{si: intAttr(se, "si", -1)}318+ f.typ, _ = attr(se, "t")319+ f.ref, _ = attr(se, "ref")320+ var err error321+ r.buf, err = r.x.text(r.buf[:0])322+ f.text = string(r.buf)323+ return f, err324+}325+326+// formula is the formula of cell c of the current row: its own text,327+// or for a cell sharing a formula, the first cell's with its relative328+// references moved by the distance between the cells ("" when the first329+// is missing). Shared formulas are expanded only on request, and only330+// up to the limit on their text: a small file of shared formulas could331+// otherwise expand to gigabytes.332+func (r *xlsxSheetReader) formula(c *xlsxCell) (string, error) {333+ f := c.f334+ if !c.hasF || f.typ != "shared" || f.si < 0 {335+ return f.text, nil336+ }337+ m, ok := r.shared[f.si]338+ if !ok {339+ return "", nil340+ }341+ out := shiftFormula(m.text, c.col-m.col, r.row.num-m.row)342+ if r.bk.expanded += int64(len(out)); r.bk.expanded > r.bk.pkg.lim.shared {343+ return "", fmt.Errorf("shared formulas expand to more than %s: %w", mb(r.bk.pkg.lim.shared), errXLSXLimit)344+ }345+ return out, nil346+}347+348+// parseCellRef parses an A1 reference (without $) into a column and row349+// from 1, within Excel's limits.350+func parseCellRef(ref string) (col, row int, ok bool) {351+ i := 0352+ for i < len(ref) && i < 4 && isLetter(ref[i]) {353+ col = col*26 + int(upper(ref[i])-'A'+1)354+ i++355+ }356+ if i == 0 || i == len(ref) || col > excelCols {357+ return 0, 0, false358+ }359+ for _, c := range []byte(ref[i:]) {360+ if !isDigitByte(c) {361+ return 0, 0, false362+ }363+ row = row*10 + int(c-'0')364+ if row > excelRows {365+ return 0, 0, false366+ }367+ }368+ return col, row, row >= 1369+}370+371+func isLetter(c byte) bool { return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' }372+373+func upper(c byte) byte {374+ if c >= 'a' && c <= 'z' {375+ return c - 'a' + 'A'376+ }377+ return c378+}379+380+// dimension is the last row of sheet i by its <dimension> element, for381+// the progress bar; 0 when it doesn't say.382+func (bk *xlsxBook) dimension(i int) int {383+ if bk.sheets[i].part == "" {384+ return 0385+ }386+ x, err := bk.pkg.open(bk.sheets[i].part)387+ if x == nil || err != nil {388+ return 0389+ }390+ defer x.close()391+ for {392+ t, err := x.next()393+ if err != nil {394+ return 0395+ }396+ se, ok := t.(xml.StartElement)397+ switch {398+ case !ok:399+ case se.Name.Local == "sheetData":400+ return 0401+ case se.Name.Local == "dimension":402+ ref, _ := attr(se, "ref")403+ if _, to, ok := strings.Cut(ref, ":"); ok {404+ _, row, _ := parseCellRef(strings.ReplaceAll(to, "$", ""))405+ return row406+ }407+ return 0408+ }409+ }410+}@@ -0,0 +1,178 @@
1+package fileio2+3+import (4+ "strconv"5+ "strings"6+)7+8+// shiftFormula moves the relative references of an Excel formula by9+// dCol columns and dRow rows, as Excel does to copy a shared formula10+// from its first cell to the others: A1 and A$1 move, $A$1 doesn't, and11+// whole columns (A:B) and rows (1:2) move along their axis. Text,12+// quoted sheet names and function names stay as they are. A reference13+// moved off the sheet becomes #REF!.14+func shiftFormula(f string, dCol, dRow int) string {15+ if dCol == 0 && dRow == 0 {16+ return f17+ }18+ var b strings.Builder19+ for i := 0; i < len(f); {20+ c := f[i]21+ switch {22+ case c == '"':23+ j := stringEnd(f, i)24+ if j < 0 {25+ j = len(f)26+ }27+ b.WriteString(f[i:j])28+ i = j29+ case c == '\'':30+ j := quoteEnd(f, i)31+ b.WriteString(f[i:j])32+ i = j33+ case c == '[':34+ // A structured reference or an external book's index.35+ j := strings.IndexByte(f[i:], ']')36+ if j < 0 {37+ j = len(f) - i - 138+ }39+ b.WriteString(f[i : i+j+1])40+ i += j + 141+ case isRefByte(c):42+ j := i43+ for j < len(f) && isRefByte(f[j]) {44+ j++45+ }46+ if word := f[i:j]; j < len(f) && f[j] == '(' {47+ b.WriteString(word) // a function48+ } else {49+ b.WriteString(shiftRef(word, dCol, dRow))50+ }51+ i = j52+ default:53+ b.WriteByte(c)54+ i++55+ }56+ }57+ return b.String()58+}59+60+func isRefByte(c byte) bool { return isIdentByte(c) || c == ':' || c == '!' }61+62+// refPart is one side of a reference: a cell (A1), a column (A) or a63+// row (1), each part with or without $.64+type refPart struct {65+ prefix string // a sheet name and !, if any66+ col, row int // from 1; 0 when absent67+ absCol, absRow bool68+}69+70+// shiftRef moves a reference such as A1, $A1:B$2, Sheet1!A1 or A:B, and71+// returns anything else (a name, a number) unchanged.72+func shiftRef(word string, dCol, dRow int) string {73+ sides := strings.Split(word, ":")74+ if len(sides) > 2 {75+ return word76+ }77+ var parts [2]refPart78+ for i, s := range sides {79+ p, ok := parseRefPart(s)80+ if !ok {81+ return word82+ }83+ parts[i] = p84+ }85+ first := parts[0]86+ for _, p := range parts[1:len(sides)] {87+ if (p.col > 0) != (first.col > 0) || (p.row > 0) != (first.row > 0) {88+ return word // mixed kinds: not a reference89+ }90+ }91+ if len(sides) == 1 && (first.col == 0 || first.row == 0) {92+ return word // a lone column or row is a name or a number93+ }94+ out := make([]string, len(sides))95+ for i, p := range parts[:len(sides)] {96+ if p.col > 0 && !p.absCol {97+ p.col += dCol98+ }99+ if p.row > 0 && !p.absRow {100+ p.row += dRow101+ }102+ if first.col > 0 && (p.col < 1 || p.col > excelCols) || first.row > 0 && (p.row < 1 || p.row > excelRows) {103+ return first.prefix + "#REF!"104+ }105+ out[i] = p.String()106+ }107+ return strings.Join(out, ":")108+}109+110+// parseRefPart parses [Sheet!][$]COL[$]ROW, [$]COL or [$]ROW.111+func parseRefPart(s string) (refPart, bool) {112+ var p refPart113+ if k := strings.LastIndexByte(s, '!'); k >= 0 {114+ p.prefix, s = s[:k+1], s[k+1:]115+ }116+ i := 0117+ dollar := i < len(s) && s[i] == '$'118+ if dollar {119+ i++120+ }121+ letters := i122+ for i < len(s) && isLetter(s[i]) && i-letters < 3 {123+ p.col = p.col*26 + int(upper(s[i])-'A'+1)124+ i++125+ }126+ if i == letters {127+ p.absRow = dollar // no column: a row, and the $ is the row's128+ } else {129+ p.absCol = dollar130+ if i < len(s) && s[i] == '$' {131+ p.absRow = true132+ i++133+ }134+ }135+ digits := i136+ for i < len(s) && isDigitByte(s[i]) && i-digits < 7 {137+ p.row = p.row*10 + int(s[i]-'0')138+ i++139+ }140+ switch {141+ case i != len(s), i == letters, p.col > excelCols, p.row > excelRows:142+ return p, false143+ case i > digits && p.row == 0, i == digits && p.absRow:144+ return p, false // row 0, or a $ with no row after it145+ }146+ return p, true147+}148+149+func (p refPart) String() string {150+ var b strings.Builder151+ b.WriteString(p.prefix)152+ if p.col > 0 {153+ if p.absCol {154+ b.WriteByte('$')155+ }156+ b.WriteString(excelColName(p.col))157+ }158+ if p.row > 0 {159+ if p.absRow {160+ b.WriteByte('$')161+ }162+ b.WriteString(strconv.Itoa(p.row))163+ }164+ return b.String()165+}166+167+// excelColName is the letters of column col, from 1 (A) to 16384 (XFD).168+func excelColName(col int) string {169+ var b [3]byte170+ i := len(b)171+ for col > 0 && i > 0 {172+ col--173+ i--174+ b[i] = byte('A' + col%26)175+ col /= 26176+ }177+ return string(b[i:])178+}@@ -0,0 +1,223 @@
1+package fileio2+3+import (4+ "encoding/xml"5+ "fmt"6+ "io"7+ "strconv"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// xlsxStyles is what 012 reads of the styles part: custom number14+// formats, fonts, and the cell formats (cellXfs) that cells refer to by15+// index, resolved to 012 formats and styles on first use.16+type xlsxStyles struct {17+ numFmts map[int]string18+ fonts []xlsxFont19+ xfs []xlsxXf20+ cache map[int]xlsxStyle21+}22+23+type xlsxFont struct{ bold, italic, strike, underline bool }24+25+// xlsxXf is a cell format; numFmt and font are -1 when not given.26+type xlsxXf struct {27+ numFmt, font int28+ applyFont, applyAlign bool29+ align sheet.Align30+}31+32+// style is the cell format with index id, as 012 keeps it; an unknown33+// id is the default.34+func (s *xlsxStyles) style(id int) xlsxStyle {35+ if id <= 0 || id >= len(s.xfs) {36+ return xlsxStyle{}37+ }38+ if st, ok := s.cache[id]; ok {39+ return st40+ }41+ xf := s.xfs[id]42+ var out xlsxStyle43+ out.format = s.format(xf.numFmt)44+ if xf.applyFont && xf.font >= 0 && xf.font < len(s.fonts) {45+ f := s.fonts[xf.font]46+ out.style.Bold, out.style.Italic, out.style.Strikethrough, out.style.Underline = f.bold, f.italic, f.strike, f.underline47+ }48+ if xf.applyAlign {49+ out.style.Align = xf.align50+ }51+ if s.cache == nil {52+ s.cache = map[int]xlsxStyle{}53+ }54+ s.cache[id] = out55+ return out56+}57+58+// format maps a number format id to a 012 format. Built-in ids (and the59+// locale-dependent ones) mean their built-in code even when the file60+// defines them again; others are looked up among the custom codes.61+func (s *xlsxStyles) format(id int) sheet.Format {62+ if id < 0 {63+ return sheet.Format{}64+ }65+ if builtinNumFmt(id) {66+ return formatOf(id, "")67+ }68+ return formatOf(0, s.numFmts[id])69+}70+71+// builtinNumFmt reports whether id is one of Excel's built-in number72+// formats: the ones every locale shares, and the ranges each locale73+// defines for itself.74+func builtinNumFmt(id int) bool {75+ switch {76+ case id >= 0 && id <= 4, id >= 9 && id <= 22, id >= 37 && id <= 49:77+ return true78+ case id >= 27 && id <= 36, id >= 50 && id <= 62, id >= 67 && id <= 81:79+ return true80+ }81+ return false82+}83+84+// read streams the styles part.85+func (s *xlsxStyles) read(p *xlsxPackage, part string) error {86+ x, err := p.open(part)87+ if x == nil || err != nil {88+ return err89+ }90+ defer x.close()91+ s.numFmts = map[int]string{}92+ section := "" // numFmts, fonts or cellXfs, at depth 293+ var cur *xlsxXf // the <xf> being read94+ for {95+ t, err := x.next()96+ if err == io.EOF {97+ return nil98+ }99+ if err != nil {100+ return err101+ }102+ switch t := t.(type) {103+ case xml.StartElement:104+ if x.depth == 2 {105+ section = t.Name.Local106+ continue107+ }108+ if cur, err = s.element(x, t, section, cur); err != nil {109+ return err110+ }111+ case xml.EndElement:112+ if x.depth < 2 {113+ section = ""114+ }115+ }116+ }117+}118+119+// element reads one element inside a section of the styles part.120+func (s *xlsxStyles) element(x *xmlStream, se xml.StartElement, section string, cur *xlsxXf) (*xlsxXf, error) {121+ full := len(s.numFmts) >= x.g.p.lim.styles || len(s.fonts) >= x.g.p.lim.styles || len(s.xfs) >= x.g.p.lim.styles122+ switch {123+ case section == "numFmts" && se.Name.Local == "numFmt" && x.depth == 3:124+ if full {125+ return nil, fmt.Errorf("the workbook has more than %d styles: %w", x.g.p.lim.styles, errXLSXLimit)126+ }127+ id, err := strconv.Atoi(attrOr(se, "numFmtId", ""))128+ if err == nil {129+ s.numFmts[id], _ = attr(se, "formatCode")130+ }131+ case section == "fonts" && se.Name.Local == "font" && x.depth == 3:132+ if full {133+ return nil, fmt.Errorf("the workbook has more than %d fonts: %w", x.g.p.lim.styles, errXLSXLimit)134+ }135+ f, err := readFont(x)136+ if err != nil {137+ return nil, err138+ }139+ s.fonts = append(s.fonts, f)140+ case section == "cellXfs" && se.Name.Local == "xf" && x.depth == 3:141+ if full {142+ return nil, fmt.Errorf("the workbook has more than %d cell formats: %w", x.g.p.lim.styles, errXLSXLimit)143+ }144+ s.xfs = append(s.xfs, xlsxXf{145+ numFmt: intAttr(se, "numFmtId", -1), font: intAttr(se, "fontId", -1),146+ applyFont: boolAttr(se, "applyFont", true), applyAlign: boolAttr(se, "applyAlignment", true),147+ })148+ return &s.xfs[len(s.xfs)-1], nil149+ case section == "cellXfs" && se.Name.Local == "alignment" && x.depth == 4 && cur != nil:150+ switch h, _ := attr(se, "horizontal"); h {151+ case "left":152+ cur.align = sheet.AlignLeft153+ case "center", "centerContinuous":154+ cur.align = sheet.AlignCenter155+ case "right":156+ cur.align = sheet.AlignRight157+ }158+ }159+ return cur, nil160+}161+162+// readFont reads the rest of a <font>: bold, italic, strikethrough and163+// underline (any but u val="none").164+func readFont(x *xmlStream) (xlsxFont, error) {165+ var f xlsxFont166+ depth := x.depth167+ for {168+ t, err := x.next()169+ if err != nil {170+ return f, eofAsUnexpected(err)171+ }172+ switch t := t.(type) {173+ case xml.StartElement:174+ if x.depth != depth+1 {175+ continue176+ }177+ switch t.Name.Local {178+ case "b":179+ f.bold = boolAttr(t, "val", true)180+ case "i":181+ f.italic = boolAttr(t, "val", true)182+ case "strike":183+ f.strike = boolAttr(t, "val", true)184+ case "u":185+ f.underline = attrOr(t, "val", "single") != "none"186+ }187+ case xml.EndElement:188+ if x.depth < depth {189+ return f, nil190+ }191+ }192+ }193+}194+195+func attrOr(se xml.StartElement, name, def string) string {196+ if v, ok := attr(se, name); ok {197+ return v198+ }199+ return def200+}201+202+func intAttr(se xml.StartElement, name string, def int) int {203+ if v, ok := attr(se, name); ok {204+ if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {205+ return n206+ }207+ }208+ return def209+}210+211+// boolAttr reads an xsd:boolean attribute; an empty value is true, and212+// one that isn't a boolean is false.213+func boolAttr(se xml.StartElement, name string, def bool) bool {214+ v, ok := attr(se, name)215+ if !ok {216+ return def217+ }218+ if v = strings.TrimSpace(v); v == "" {219+ return true220+ }221+ b, _ := strconv.ParseBool(v)222+ return b223+}@@ -0,0 +1,359 @@
1+package fileio2+3+import (4+ "bufio"5+ "fmt"6+ "math"7+ "slices"8+ "strconv"9+ "strings"10+ "unicode/utf8"11+12+ "github.com/FelineStateMachine/012/internal/formula"13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// excelMaxText is the most characters an Excel cell holds; longer text17+// is cut, or Excel reports the file as damaged.18+const excelMaxText = 3276719+20+// xlsxWriter writes worksheets, sharing cell formats among them.21+type xlsxWriter struct {22+ styles *xlsxStyleTable23+ multi bool // examples name their sheet24+ buf []byte // a row's cells25+26+ // Formulas are written as their values when Excel has no equivalent,27+ // or when they name a sheet the file doesn't have, which Excel would28+ // refuse.29+ values valueCount30+ missing valueCount31+ missingSheet string // the sheet missing's example names32+ known map[string]bool // keys of the sheets written33+}34+35+// valueCount counts formulas written as values, keeping the first one's36+// address as an example.37+type valueCount struct {38+ n int39+ example string40+}41+42+func (v *valueCount) add(multi bool, ws string, a sheet.Addr) {43+ v.n++44+ if v.example == "" {45+ v.example = a.String()46+ if multi {47+ v.example = sheet.QuoteSheet(ws) + "!" + a.String()48+ }49+ }50+}51+52+// unknownSheet returns a sheet c's formula names that isn't written.53+func (w *xlsxWriter) unknownSheet(c SnapCell) (string, bool) {54+ for _, name := range c.Sheets {55+ if !w.known[formula.SheetKey(name)] {56+ return name, true57+ }58+ }59+ return "", false60+}61+62+// sheet writes a snapshot as a worksheet and returns the rows written.63+// Errors stick in bw.64+func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active bool) int {65+ r := snap.Range66+ bw.WriteString(xmlHead + `<worksheet xmlns="` + sheetMain + `" xmlns:r="` + officeRel + `">`)67+ fmt.Fprintf(bw, `<dimension ref="%s%d:%s%d"/>`, excelColName(r.From.Col+1), r.From.Row+1, excelColName(r.To.Col+1), r.To.Row+1)68+ bw.WriteString(`<sheetViews><sheetView workbookViewId="0"`)69+ if active {70+ bw.WriteString(` tabSelected="1"`)71+ }72+ bw.WriteString(`/></sheetViews><sheetFormatPr defaultRowHeight="15"/>`)73+ if len(snap.Widths) > 0 {74+ bw.WriteString(`<cols>`)75+ for _, c := range slices.Sorted(mapKeys(snap.Widths)) {76+ fmt.Fprintf(bw, `<col min="%d" max="%d" width="%d" customWidth="1"/>`, c+1, c+1, max(snap.Widths[c]-excelPadding, 1))77+ }78+ bw.WriteString(`</cols>`)79+ }80+ bw.WriteString(`<sheetData>`)81+ for row := r.From.Row; row <= r.To.Row; row++ {82+ b := w.buf[:0]83+ for col := r.From.Col; col <= r.To.Col; col++ {84+ a := sheet.Addr{Col: col, Row: row}85+ if c, ok := snap.Cells[a]; ok {86+ b = w.cell(b, ws, a, c)87+ }88+ }89+ if len(b) > 0 {90+ fmt.Fprintf(bw, `<row r="%d">`, row+1)91+ bw.Write(b)92+ bw.WriteString(`</row>`)93+ }94+ w.buf = b95+ }96+ bw.WriteString(`</sheetData></worksheet>`)97+ return r.To.Row - r.From.Row + 198+}99+100+func mapKeys(m map[int]int) func(func(int) bool) {101+ return func(yield func(int) bool) {102+ for k := range m {103+ if !yield(k) {104+ return105+ }106+ }107+ }108+}109+110+// cell appends snapshot cell c at a as a <c> element. A formula's value111+// is written as its cached result; a formula with no Excel equivalent,112+// or naming a sheet that isn't written, is written as its value alone,113+// and counted.114+func (w *xlsxWriter) cell(b []byte, ws string, a sheet.Addr, c SnapCell) []byte {115+ fx := w.formula(ws, a, c)116+ b = append(b, `<c r="`...)117+ b = append(b, excelColName(a.Col+1)...)118+ b = strconv.AppendInt(b, int64(a.Row+1), 10)119+ b = append(b, '"')120+ // A formula's inferred format is written too, so its result shows121+ // the same in Excel.122+ if !c.Format.IsZero() || !c.Style.IsZero() {123+ b = append(b, ` s="`...)124+ b = strconv.AppendInt(b, int64(w.styles.id(c.Format, c.Style)), 10)125+ b = append(b, '"')126+ }127+ typ, v, inline := cellValue(c, fx != "")128+ if typ != "" {129+ b = append(b, ` t="`...)130+ b = append(b, typ...)131+ b = append(b, '"')132+ }133+ if fx == "" && v == "" {134+ return append(b, "/>"...)135+ }136+ b = append(b, '>')137+ if fx != "" {138+ b = append(b, "<f>"...)139+ b = appendEscaped(b, fx, false)140+ b = append(b, "</f>"...)141+ }142+ switch {143+ case inline && fx == "":144+ b = append(b, `<is><t xml:space="preserve">`...)145+ b = appendEscaped(b, v, true)146+ b = append(b, "</t></is>"...)147+ case v != "":148+ b = append(b, "<v>"...)149+ b = appendEscaped(b, v, typ == "str")150+ b = append(b, "</v>"...)151+ }152+ return append(b, "</c>"...)153+}154+155+// formula is c's formula in Excel's syntax, or "" when it has none or156+// is written as its value, which it counts.157+func (w *xlsxWriter) formula(ws string, a sheet.Addr, c SnapCell) string {158+ if !c.Formula {159+ return ""160+ }161+ if name, ok := w.unknownSheet(c); ok {162+ if w.missing.n == 0 {163+ w.missingSheet = name164+ }165+ w.missing.add(w.multi, ws, a)166+ return ""167+ }168+ fx, ok := toExcelFormula(c.Input)169+ if !ok {170+ w.values.add(w.multi, ws, a)171+ }172+ return fx173+}174+175+// cellValue is a cell's value as Excel stores it: the type attribute,176+// the text, and whether text goes inline (<is>) rather than in <v>,177+// which only a formula's cached text does (t="str"). Errors are written178+// as text, as Excel recalculates on opening.179+func cellValue(c SnapCell, hasFormula bool) (typ, v string, inline bool) {180+ switch c.Value.Kind {181+ case sheet.Number:182+ n := toExcelSerial(c.Value.Num, c.Format)183+ if math.IsNaN(n) || math.IsInf(n, 0) {184+ return "e", "#NUM!", false185+ }186+ return "", numInput(n), false187+ case sheet.Bool:188+ if c.Value.Num != 0 {189+ return "b", "1", false190+ }191+ return "b", "0", false192+ case sheet.Text, sheet.Error:193+ s := c.Value.Str194+ if utf8.RuneCountInString(s) > excelMaxText {195+ s = string([]rune(s)[:excelMaxText])196+ }197+ if hasFormula {198+ return "str", s, false199+ }200+ return "inlineStr", s, true201+ }202+ return "", "", false203+}204+205+// escapeXML escapes s for XML text or, with attr, an attribute value.206+func escapeXML(s string, attr bool) string {207+ return string(appendXML(nil, s, attr))208+}209+210+func appendXML(b []byte, s string, attr bool) []byte {211+ for _, r := range s {212+ switch {213+ case r == '&':214+ b = append(b, "&"...)215+ case r == '<':216+ b = append(b, "<"...)217+ case r == '>':218+ b = append(b, ">"...)219+ case r == '"' && attr:220+ b = append(b, """...)221+ case r == '\r' || attr && (r == '\n' || r == '\t'):222+ b = append(b, "&#x"...)223+ b = strconv.AppendInt(b, int64(r), 16)224+ b = append(b, ';')225+ case !xmlChar(r):226+ b = utf8.AppendRune(b, utf8.RuneError)227+ default:228+ b = utf8.AppendRune(b, r)229+ }230+ }231+ return b232+}233+234+// appendEscaped appends s as XML text; with ooxml, as SpreadsheetML cell235+// text, where characters XML can't hold are written _xHHHH_ and a _x236+// that would read as such an escape is itself escaped (_x005F_).237+func appendEscaped(b []byte, s string, ooxml bool) []byte {238+ if !ooxml {239+ return appendXML(b, s, false)240+ }241+ for i := 0; i < len(s); {242+ r, size := utf8.DecodeRuneInString(s[i:])243+ switch {244+ case r == '_' && isOOXMLEscape(s[i:]):245+ b = append(b, "_x005F"...)246+ case !xmlChar(r) && r != utf8.RuneError && r <= 0xFFFF:247+ b = fmt.Appendf(b, "_x%04X_", r)248+ i += size249+ continue250+ }251+ b = appendXML(b, s[i:i+size], false)252+ i += size253+ }254+ return b255+}256+257+func isOOXMLEscape(s string) bool {258+ _, ok := ooxmlEscape([]byte(s[:min(len(s), 7)]))259+ return ok260+}261+262+// xmlChar reports whether XML 1.0 allows r in a document.263+func xmlChar(r rune) bool {264+ return r == '\t' || r == '\n' || r == '\r' ||265+ r >= 0x20 && r <= 0xD7FF || r >= 0xE000 && r <= 0xFFFD || r >= 0x10000 && r <= 0x10FFFF266+}267+268+// xlsxStyleTable collects the cell formats a workbook uses: each269+// distinct number format and text style is a cellXfs entry, with its270+// number format code and font.271+type xlsxStyleTable struct {272+ ids map[xlsxStyle]int273+ xfs []xlsxStyle // index 0 is the default274+ codes []string // custom number formats, from id 164275+ fonts []sheet.Style276+}277+278+func newXLSXStyleTable() *xlsxStyleTable {279+ return &xlsxStyleTable{ids: map[xlsxStyle]int{{}: 0}, xfs: []xlsxStyle{{}}, fonts: []sheet.Style{{}}}280+}281+282+// id is the index of the cell format for f and st.283+func (t *xlsxStyleTable) id(f sheet.Format, st sheet.Style) int {284+ key := xlsxStyle{f, st}285+ if id, ok := t.ids[key]; ok {286+ return id287+ }288+ id := len(t.xfs)289+ t.ids[key] = id290+ t.xfs = append(t.xfs, key)291+ return id292+}293+294+// xml is the styles part.295+func (t *xlsxStyleTable) xml() string {296+ var xfs strings.Builder297+ for _, x := range t.xfs {298+ numFmt := 0299+ if code := excelCode(x.format); code != "" {300+ i := slices.Index(t.codes, code)301+ if i < 0 {302+ i = len(t.codes)303+ t.codes = append(t.codes, code)304+ }305+ numFmt = 164 + i306+ }307+ font := fontOf(x.style)308+ i := slices.Index(t.fonts, font)309+ if i < 0 {310+ i = len(t.fonts)311+ t.fonts = append(t.fonts, font)312+ }313+ fmt.Fprintf(&xfs, `<xf numFmtId="%d" fontId="%d" fillId="0" borderId="0" xfId="0"`, numFmt, i)314+ if numFmt != 0 {315+ xfs.WriteString(` applyNumberFormat="1"`)316+ }317+ if i != 0 {318+ xfs.WriteString(` applyFont="1"`)319+ }320+ if x.style.Align == sheet.AlignAuto {321+ xfs.WriteString(`/>`)322+ continue323+ }324+ fmt.Fprintf(&xfs, ` applyAlignment="1"><alignment horizontal="%s"/></xf>`, x.style.Align)325+ }326+ var b strings.Builder327+ b.WriteString(xmlHead + `<styleSheet xmlns="` + sheetMain + `">`)328+ if len(t.codes) > 0 {329+ fmt.Fprintf(&b, `<numFmts count="%d">`, len(t.codes))330+ for i, code := range t.codes {331+ fmt.Fprintf(&b, `<numFmt numFmtId="%d" formatCode="%s"/>`, 164+i, escapeXML(code, true))332+ }333+ b.WriteString(`</numFmts>`)334+ }335+ fmt.Fprintf(&b, `<fonts count="%d">`, len(t.fonts))336+ for _, f := range t.fonts {337+ b.WriteString(`<font>`)338+ for _, on := range []struct {339+ set bool340+ tag string341+ }{{f.Bold, `<b/>`}, {f.Italic, `<i/>`}, {f.Strikethrough, `<strike/>`}, {f.Underline, `<u/>`}} {342+ if on.set {343+ b.WriteString(on.tag)344+ }345+ }346+ b.WriteString(`<sz val="11"/><name val="Calibri"/><family val="2"/></font>`)347+ }348+ b.WriteString(`</fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>` +349+ `<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>` +350+ `<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>`)351+ fmt.Fprintf(&b, `<cellXfs count="%d">%s</cellXfs>`, len(t.xfs), xfs.String())352+ b.WriteString(`<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`)353+ return b.String()354+}355+356+// fontOf is the part of a style a font holds.357+func fontOf(st sheet.Style) sheet.Style {358+ return sheet.Style{Bold: st.Bold, Italic: st.Italic, Underline: st.Underline, Strikethrough: st.Strikethrough}359+}