PHP Classes

File: extension/core/workbook.go

Recommend this page to a friend!
  Packages of Roni   Easy-Excel   extension/core/workbook.go   Download  
File: extension/core/workbook.go
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Easy-Excel
Export and read XLSX files with a PHP Go extension
Author: By
Last change:
Date: 6 days ago
Size: 29,148 bytes
 

Contents

Class file image Download
// Package core adapts excelize to PhpSpreadsheet semantics behind the flat // handle ABI (PLAN.md §4). The central design is the dual write path: // // - streaming mode (default for fresh workbooks): ascending row writes go // through excelize's StreamWriter at constant memory; // - random-access mode: excelize's in-memory model, used for loaded files // and after the one-time documented degrade triggered by out-of-order // writes, reads, or styling of an already-streamed sheet. // // The degrade serializes the workbook to memory and reopens it, so it is // correct but costs O(file size); the PHP shim's write-behind buffering makes // it rare in practice. package core import ( "bytes" "errors" "fmt" "io" "os" "path/filepath" "strings" "sync" "github.com/xuri/excelize/v2" "github.com/xiidea/easy-excel/extension/compat" "github.com/xiidea/easy-excel/extension/exio" "github.com/xiidea/easy-excel/extension/limits" ) // GetMode selects what GetCell returns, mirroring PhpSpreadsheet's // getValue / getFormattedValue / getCalculatedValue. type GetMode byte const ( GetRaw GetMode = iota GetFormatted GetCalculated ) // Safe unzip defaults (PLAN.md §8): 1GiB total, 16MiB per XML part. const ( unzipSizeLimit = 1 << 30 unzipXMLSizeLimit = 16 << 20 baseEstimate = 8 << 20 // accounting floor per live workbook ) var errClosed = errors.New("easy-excel: workbook is closed") type sheetState struct { sw *excelize.StreamWriter // non-nil while streaming eligible bool // may still enter/continue streaming lastRow int // highest row written through sw maxRow int // tracked dimensions (writes + opened file) maxCol int dimsKnown bool // false until tracked dims are trustworthy (lazy scan) iter *excelize.Rows // cached forward read iterator iterNext int // 1-based row the iterator yields next iterRaw bool // Phase-2 structure logs (see structure.go for the lifecycle) styleLog []styleEntry rowHeights map[int]*heightEntry preWidths []colWidthOp prePanes *excelize.Panes preMerges [][2]string pending []pendingOp preDefault bool // workbook default style awaits the StreamWriter } // Workbook wraps one excelize file plus per-sheet streaming state. // All methods are safe for concurrent use; a single mutex confines the // workbook to one operation at a time (PLAN.md §7.4). type Workbook struct { mu sync.Mutex f *excelize.File sheets map[string]*sheetState filters map[string]string // sheet ? auto-filter ref injected at save defaultSpec compat.StyleSpec // workbook default style (getDefaultStyle) gate *limits.Gate policy *exio.Policy styles styleInterner estBytes int64 degraded bool needsReopen bool // streams were flushed by a save; model edits need a reopen closed bool } // Env wires the process-wide gate and path policy into workbooks. type Env struct { Gate *limits.Gate Policy *exio.Policy } func (e *Env) gate() *limits.Gate { if e == nil || e.Gate == nil { panic("easy-excel: core.Env requires a limits.Gate") } return e.Gate } func (e *Env) policy() *exio.Policy { if e == nil || e.Policy == nil { p, _ := exio.NewPolicy(nil) return p } return e.Policy } // New creates an empty workbook with one sheet named "Worksheet", // matching `new PhpOffice\PhpSpreadsheet\Spreadsheet()`. func New(env *Env) (*Workbook, error) { if err := env.gate().ReserveMemory(baseEstimate); err != nil { return nil, err } f := excelize.NewFile() if err := f.SetSheetName("Sheet1", "Worksheet"); err != nil { env.gate().ReserveMemory(-baseEstimate) return nil, err } w := &Workbook{ f: f, sheets: map[string]*sheetState{"Worksheet": {eligible: true, dimsKnown: true}}, filters: map[string]string{}, gate: env.gate(), policy: env.policy(), estBytes: baseEstimate, } return w, nil } // Open loads an existing workbook; all sheets start in random-access mode. // password decrypts agile-encrypted files ("" for plain ones). func Open(path, password string, env *Env) (*Workbook, error) { abs, err := env.policy().Resolve(path) if err != nil { return nil, err } release, err := env.gate().AcquireHeavy() if err != nil { return nil, err } defer release() est := int64(baseEstimate) if fi, err := os.Stat(abs); err == nil && fi.Size()*4 > est { est = fi.Size() * 4 } if err := env.gate().ReserveMemory(est); err != nil { return nil, err } f, err := excelize.OpenFile(abs, excelize.Options{ Password: password, UnzipSizeLimit: unzipSizeLimit, UnzipXMLSizeLimit: unzipXMLSizeLimit, }) if err != nil { env.gate().ReserveMemory(-est) return nil, fmt.Errorf("easy-excel: open %s: %w", path, err) } w := &Workbook{ f: f, sheets: map[string]*sheetState{}, filters: map[string]string{}, gate: env.gate(), policy: env.policy(), estBytes: est, degraded: true, // random-access from the start } for _, name := range f.GetSheetList() { st := &sheetState{} st.maxRow, st.maxCol = dimensionOf(f, name) // excelize-written files often carry a degenerate <dimension // ref="A1"/>; only trust non-trivial dimensions, otherwise scan // lazily on first Dimensions() call st.dimsKnown = st.maxRow > 1 || st.maxCol > 1 if !st.dimsKnown { st.maxRow, st.maxCol = 0, 0 } w.sheets[name] = st } return w, nil } func dimensionOf(f *excelize.File, sheet string) (maxRow, maxCol int) { dim, err := f.GetSheetDimension(sheet) if err != nil || dim == "" { return 0, 0 } parts := strings.Split(dim, ":") corner := parts[len(parts)-1] col, row, err := excelize.CellNameToCoordinates(corner) if err != nil { return 0, 0 } return row, col } func (w *Workbook) state(sheet string) (*sheetState, error) { st, ok := w.sheets[sheet] if !ok { return nil, fmt.Errorf("easy-excel: sheet %q does not exist", sheet) } return st, nil } // --- sheet management ----------------------------------------------------- // AddSheet appends a sheet and returns its 0-based index. func (w *Workbook) AddSheet(name string) (int, error) { w.mu.Lock() defer w.mu.Unlock() if w.closed { return 0, errClosed } if _, exists := w.sheets[name]; exists { return 0, fmt.Errorf("easy-excel: sheet %q already exists", name) } idx, err := w.f.NewSheet(name) if err != nil { return 0, err } st := &sheetState{eligible: !w.degraded, dimsKnown: true} if w.defaultSpec != nil { if st.eligible { st.preDefault = true } else if err := w.applyDefaultColStyle(name); err != nil { return 0, err } } w.sheets[name] = st return idx, nil } // DeleteSheet removes a sheet (the last sheet cannot be removed). func (w *Workbook) DeleteSheet(name string) error { w.mu.Lock() defer w.mu.Unlock() if w.closed { return errClosed } st, err := w.state(name) if err != nil { return err } if len(w.sheets) == 1 { return errors.New("easy-excel: cannot remove the only sheet") } w.closeIter(st) if st.sw != nil { _ = st.sw.Flush() } if err := w.f.DeleteSheet(name); err != nil { return err } delete(w.sheets, name) delete(w.filters, name) return nil } // RenameSheet renames a sheet, keeping its streaming state. func (w *Workbook) RenameSheet(oldName, newName string) error { w.mu.Lock() defer w.mu.Unlock() if w.closed { return errClosed } st, err := w.state(oldName) if err != nil { return err } if oldName == newName { return nil } if _, exists := w.sheets[newName]; exists { return fmt.Errorf("easy-excel: sheet %q already exists", newName) } if err := w.f.SetSheetName(oldName, newName); err != nil { return err } delete(w.sheets, oldName) w.sheets[newName] = st if ref, ok := w.filters[oldName]; ok { delete(w.filters, oldName) w.filters[newName] = ref } return nil } // Sheets returns sheet names in workbook order. func (w *Workbook) Sheets() []string { w.mu.Lock() defer w.mu.Unlock() return w.f.GetSheetList() } // SetActiveSheet selects the active sheet by 0-based position. func (w *Workbook) SetActiveSheet(index int) error { w.mu.Lock() defer w.mu.Unlock() if w.closed { return errClosed } list := w.f.GetSheetList() if index < 0 || index >= len(list) { return fmt.Errorf("easy-excel: sheet index %d out of range", index) } idx, err := w.f.GetSheetIndex(list[index]) if err != nil { return err } w.f.SetActiveSheet(idx) return nil } // ActiveSheet returns the 0-based position and name of the active sheet. func (w *Workbook) ActiveSheet() (int, string) { w.mu.Lock() defer w.mu.Unlock() list := w.f.GetSheetList() active := w.f.GetActiveSheetIndex() for pos, name := range list { if idx, err := w.f.GetSheetIndex(name); err == nil && idx == active { return pos, name } } if len(list) > 0 { return 0, list[0] } return 0, "" } // --- write path ------------------------------------------------------------- // WriteRows writes a batch of rows anchored at (startCol, startRow), the hot // path called by the PHP shim's write-behind buffer. Cells with Kind == Skip // are left untouched in random mode and written empty in streaming mode. func (w *Workbook) WriteRows(sheet string, startRow, startCol int, rows [][]compat.Cell) error { w.mu.Lock() defer w.mu.Unlock() if w.closed { return errClosed } if startRow < 1 || startCol < 1 { return fmt.Errorf("easy-excel: invalid anchor row %d col %d", startRow, startCol) } st, err := w.state(sheet) if err != nil { return err } if st.eligible && startRow > st.lastRow { if err := w.streamRows(sheet, st, startRow, startCol, rows); err != nil { return err } } else { if err := w.ensureRandom(sheet, st); err != nil { return err } if err := w.randomRows(sheet, startRow, startCol, rows); err != nil { return err } } endRow := startRow + len(rows) - 1 if endRow > st.maxRow { st.maxRow = endRow } for _, r := range rows { if c := startCol + len(r) - 1; c > st.maxCol { st.maxCol = c } } return nil } func (w *Workbook) streamRows(sheet string, st *sheetState, startRow, startCol int, rows [][]compat.Cell) error { if st.sw == nil { sw, err := w.f.NewStreamWriter(sheet) if err != nil { return err } if err := w.applyPreOps(sw, st); err != nil { return err } st.sw = sw } styler := w.newInlineStyler(st, startRow, startRow+len(rows)-1) for i, row := range rows { rowNum := startRow + i if rowNum <= st.lastRow { return fmt.Errorf("easy-excel: stream rows must ascend (row %d after %d)", rowNum, st.lastRow) } values := make([]interface{}, len(row)) for j, c := range row { styleID := 0 if styler != nil { id, err := styler.styleID(rowNum, startCol+j) if err != nil { return err } styleID = id } switch c.Kind { case compat.Skip: if styleID != 0 { values[j] = excelize.Cell{StyleID: styleID} } else { values[j] = nil } case compat.Str: if styleID != 0 { values[j] = excelize.Cell{StyleID: styleID, Value: c.Str} } else { values[j] = c.Str } case compat.Num: if styleID != 0 { values[j] = excelize.Cell{StyleID: styleID, Value: c.Num} } else { values[j] = c.Num } case compat.Boolean: if styleID != 0 { values[j] = excelize.Cell{StyleID: styleID, Value: c.Bool} } else { values[j] = c.Bool } case compat.Formula: values[j] = excelize.Cell{StyleID: styleID, Formula: c.Str} } } anchor, err := excelize.CoordinatesToCellName(startCol, rowNum) if err != nil { return err } var opts []excelize.RowOpts if h, ok := st.rowHeights[rowNum]; ok && !h.done { opts = append(opts, excelize.RowOpts{Height: h.height}) h.done = true } if err := st.sw.SetRow(anchor, values, opts...); err != nil { return err } st.lastRow = rowNum } st.markStreamed() return nil } func (w *Workbook) randomRows(sheet string, startRow, startCol int, rows [][]compat.Cell) error { for i, row := range rows { for j, c := range row { if c.Kind == compat.Skip { continue } axis, err := excelize.CoordinatesToCellName(startCol+j, startRow+i) if err != nil { return err } if err := w.setCellLocked(sheet, axis, c); err != nil { return err } } } return nil } // SetCell writes one cell (the explicit/slow path). func (w *Workbook) SetCell(sheet, axis string, c compat.Cell) error { w.mu.Lock() defer w.mu.Unlock() if w.closed { return errClosed } st, err := w.state(sheet) if err != nil { return err } if err := w.ensureRandom(sheet, st); err != nil { return err } if c.Kind == compat.Skip { return nil } if err := w.setCellLocked(sheet, axis, c); err != nil { return err } if col, row, err := excelize.CellNameToCoordinates(axis); err == nil { if row > st.maxRow { st.maxRow = row } if col > st.maxCol { st.maxCol = col } } return nil } func (w *Workbook) setCellLocked(sheet, axis string, c compat.Cell) error { switch c.Kind { case compat.Str: return w.f.SetCellStr(sheet, axis, c.Str) case compat.Num: return w.f.SetCellValue(sheet, axis, c.Num) case compat.Boolean: return w.f.SetCellBool(sheet, axis, c.Bool) case compat.Formula: return w.f.SetCellFormula(sheet, axis, c.Str) } return nil } // --- degrade --------------------------------------------------------------- // ensureRandom leaves streaming mode for the whole workbook. If any rows were // streamed the workbook is serialized to memory and reopened (the one-time // documented de-optimization, PLAN.md §4.2); otherwise it is a free flag flip. // A workbook whose streams were flushed by an earlier save also reopens: // excelize silently discards model edits made after a StreamWriter flush, so // post-save mutations need the real model back (wave 4.3 finding). func (w *Workbook) ensureRandom(sheet string, st *sheetState) error { if !st.eligible && st.sw == nil { if w.needsReopen { return w.reopenFlushed() } return nil } anyStreamed := w.needsReopen for _, s := range w.sheets { if s.sw != nil { anyStreamed = true } } if !anyStreamed { for _, s := range w.sheets { s.eligible = false } return w.replayAll() } for _, s := range w.sheets { s.eligible = false } return w.reopenFlushed() } // reopenFlushed serializes the workbook (including stream temp data) and // reopens it as a real in-memory model, then replays queued work. func (w *Workbook) reopenFlushed() error { if err := w.gate.ReserveMemory(w.estBytes); err != nil { return err } w.estBytes *= 2 for _, s := range w.sheets { if s.sw != nil { if err := s.sw.Flush(); err != nil { return err } s.sw = nil } s.eligible = false w.closeIter(s) } var buf bytes.Buffer if err := w.f.Write(&buf); err != nil { return err } if err := w.f.Close(); err != nil { return err } f, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()), excelize.Options{ UnzipSizeLimit: unzipSizeLimit, UnzipXMLSizeLimit: unzipXMLSizeLimit, }) if err != nil { return fmt.Errorf("easy-excel: degrade reopen: %w", err) } w.f = f w.styles.reset() w.degraded = true w.needsReopen = false return w.replayAll() } // mutable prepares the workbook for direct model mutation: after a save // flushed stream writers, the model must be reopened first or excelize // drops the edit silently. func (w *Workbook) mutable() error { if w.needsReopen { return w.reopenFlushed() } return nil } // ensureRandomAll leaves streaming mode for every sheet and replays queued // structure work; used by save when pending ops cannot be streamed. func (w *Workbook) ensureRandomAll() error { for name, st := range w.sheets { if !st.random() { return w.ensureRandom(name, st) } } return w.replayAll() } // Degraded reports whether the workbook left streaming mode (test/metrics hook). func (w *Workbook) Degraded() bool { w.mu.Lock() defer w.mu.Unlock() return w.degraded } // --- read path --------------------------------------------------------------- // GetCell reads one cell. Raw mode returns the formula source (with leading // '=') for formula cells, native types for numbers and booleans, and strings // otherwise ? matching Cell::getValue(). func (w *Workbook) GetCell(sheet, axis string, mode GetMode) (any, error) { w.mu.Lock() defer w.mu.Unlock() if w.closed { return nil, errClosed } st, err := w.state(sheet) if err != nil { return nil, err } if err := w.ensureRandom(sheet, st); err != nil { return nil, err } switch mode { case GetFormatted: return w.f.GetCellValue(sheet, axis) case GetCalculated: // PhpSpreadsheet's getCalculatedValue() returns the raw computed // value, not the number-format-rendered string return w.f.CalcCellValue(sheet, axis, excelize.Options{RawCellValue: true}) } if formula, err := w.f.GetCellFormula(sheet, axis); err == nil && formula != "" { return "=" + formula, nil } raw, err := w.f.GetCellValue(sheet, axis, excelize.Options{RawCellValue: true}) if err != nil { return nil, err } return typedValue(w.f, sheet, axis, raw), nil } func typedValue(f *excelize.File, sheet, axis, raw string) any { ct, err := f.GetCellType(sheet, axis) if err != nil { return raw } switch ct { case excelize.CellTypeBool: return raw == "1" || strings.EqualFold(raw, "true") case excelize.CellTypeNumber, excelize.CellTypeUnset: if raw == "" { return nil } var n float64 if _, err := fmt.Sscanf(raw, "%g", &n); err == nil { return n } return raw default: return raw } } // ReadRows returns up to maxRows rows starting at startRow (1-based) as // formatted or raw strings. Sequential chunked reads reuse a cached forward // iterator, so a full-sheet scan in chunks stays O(n) (PLAN.md §6). // With calc, formula cells without a cached result are evaluated through // excelize's engine (cached results are trusted, like PhpSpreadsheet's // pre-calculated values). The second return value is false when the sheet // is exhausted. func (w *Workbook) ReadRows(sheet string, startRow, maxRows int, raw, calc bool) ([][]string, bool, error) { w.mu.Lock() defer w.mu.Unlock() if w.closed { return nil, false, errClosed } st, err := w.state(sheet) if err != nil { return nil, false, err } if err := w.ensureRandom(sheet, st); err != nil { return nil, false, err } if st.iter == nil || startRow < st.iterNext || st.iterRaw != raw { w.closeIter(st) iter, err := w.f.Rows(sheet) if err != nil { return nil, false, err } st.iter, st.iterNext, st.iterRaw = iter, 1, raw } for st.iterNext < startRow { if !st.iter.Next() { w.closeIter(st) return nil, false, nil } st.iterNext++ } var opts []excelize.Options if raw { opts = append(opts, excelize.Options{RawCellValue: true}) } out := make([][]string, 0, maxRows) more := true for len(out) < maxRows { if !st.iter.Next() { more = false w.closeIter(st) break } cols, err := st.iter.Columns(opts...) if err != nil { return nil, false, err } if calc { row := st.iterNext for j, v := range cols { if v != "" { continue } axis, err := excelize.CoordinatesToCellName(j+1, row) if err != nil { continue } if formula, err := w.f.GetCellFormula(sheet, axis); err == nil && formula != "" { // raw read: unformatted calc value, mirroring GetCalculated var opts []excelize.Options if raw { opts = append(opts, excelize.Options{RawCellValue: true}) } if val, err := w.f.CalcCellValue(sheet, axis, opts...); err == nil { cols[j] = val } } } } st.iterNext++ out = append(out, cols) } return out, more, nil } func (w *Workbook) closeIter(st *sheetState) { if st.iter != nil { _ = st.iter.Close() st.iter = nil st.iterNext = 0 } } // Dimensions returns the highest used row and column (1-based; 0 when empty). // For loaded files without a trustworthy stored dimension the sheet is // scanned once and the result cached; writes keep it current afterwards. func (w *Workbook) Dimensions(sheet string) (maxRow, maxCol int, err error) { w.mu.Lock() defer w.mu.Unlock() if w.closed { return 0, 0, errClosed } st, err := w.state(sheet) if err != nil { return 0, 0, err } if !st.dimsKnown { if err := w.scanDims(sheet, st); err != nil { return 0, 0, err } } return st.maxRow, st.maxCol, nil } func (w *Workbook) scanDims(sheet string, st *sheetState) error { rows, err := w.f.Rows(sheet) if err != nil { return err } defer rows.Close() row := 0 for rows.Next() { row++ cols, err := rows.Columns(excelize.Options{RawCellValue: true}) if err != nil { return err } if len(cols) > st.maxCol { st.maxCol = len(cols) } if len(cols) > 0 { st.maxRow = row } } st.dimsKnown = true return rows.Error() } // --- styling / structure ------------------------------------------------------ // SetNumberFormat applies a number-format code to a cell range through the // style log, so it streams like any other style (structure.go). func (w *Workbook) SetNumberFormat(sheet, ref, code string) error { w.mu.Lock() defer w.mu.Unlock() if w.closed { return errClosed } st, err := w.state(sheet) if err != nil { return err } spec := compat.StyleSpec{"numberFormat": map[string]any{"formatCode": code}} return w.applySpecLocked(sheet, st, ref, spec) } // MergeCells merges a range like "A1:C3"; streaming sheets use the // StreamWriter's native merge support instead of degrading. func (w *Workbook) MergeCells(sheet, ref string) error { w.mu.Lock() defer w.mu.Unlock() if w.closed { return errClosed } st, err := w.state(sheet) if err != nil { return err } tl, br, err := splitRange(ref) if err != nil { return err } if st.sw != nil { return st.sw.MergeCell(tl, br) } if !st.random() { st.preMerges = append(st.preMerges, [2]string{tl, br}) return nil } if err := w.mutable(); err != nil { return err } return w.f.MergeCell(sheet, tl, br) } func splitRange(ref string) (string, string, error) { parts := strings.Split(ref, ":") switch len(parts) { case 1: return parts[0], parts[0], nil case 2: return parts[0], parts[1], nil } return "", "", fmt.Errorf("easy-excel: invalid range %q", ref) } // --- save ------------------------------------------------------------------- // saveAsAnyPath writes the workbook to abs regardless of its extension. // excelize's SaveAs rejects paths outside the known workbook extensions, // but PhpSpreadsheet writes to any filename (temp staging in the shim and // consumer code both rely on that) ? stage next to the target and rename. func (w *Workbook) saveAsAnyPath(abs string, opts ...excelize.Options) error { switch strings.ToLower(filepath.Ext(abs)) { case ".xlsx", ".xlsm", ".xltm", ".xltx": return w.f.SaveAs(abs, opts...) } staged := abs + ".eexcel.xlsx" if err := w.f.SaveAs(staged, opts...); err != nil { return err } return os.Rename(staged, abs) } // SaveXlsx flushes all stream writers and writes the workbook to path; a // non-empty password produces an agile-encrypted container. The workbook // stays usable afterwards (further writes use random mode). func (w *Workbook) SaveXlsx(path, password string) error { w.mu.Lock() defer w.mu.Unlock() if w.closed { return errClosed } abs, err := w.policy.Resolve(path) if err != nil { return err } release, err := w.gate.AcquireHeavy() if err != nil { return err } defer release() // the auto-filter container patch cannot run on an encrypted (CFB) // container, so encryption forces filters through the degrade path if err := w.settleForSave(password == ""); err != nil { return err } if password != "" { return w.saveAsAnyPath(abs, excelize.Options{Password: password}) } patches := w.filterPatches() if len(patches) == 0 { return w.saveAsAnyPath(abs) } tmp := abs + ".unpatched.xlsx" // excelize validates the extension if err := w.f.SaveAs(tmp); err != nil { return err } defer os.Remove(tmp) src, err := os.Open(tmp) if err != nil { return err } defer src.Close() fi, err := src.Stat() if err != nil { return err } out, err := os.Create(abs) if err != nil { return err } if err := patchAutoFilters(src, fi.Size(), out, patches); err != nil { out.Close() return err } return out.Close() } // WriteXlsxTo streams the workbook to an arbitrary writer (php:// targets). func (w *Workbook) WriteXlsxTo(out io.Writer) error { w.mu.Lock() defer w.mu.Unlock() if w.closed { return errClosed } release, err := w.gate.AcquireHeavy() if err != nil { return err } defer release() if err := w.settleForSave(true); err != nil { return err } patches := w.filterPatches() if len(patches) == 0 { return w.f.Write(out) } tmp, err := os.CreateTemp("", "easyexcel-*.xlsx") if err != nil { return err } defer os.Remove(tmp.Name()) defer tmp.Close() if err := w.f.Write(tmp); err != nil { return err } fi, err := tmp.Stat() if err != nil { return err } return patchAutoFilters(tmp, fi.Size(), out, patches) } // settleForSave brings the workbook into a saveable state: queued structure // work that excelize cannot stream forces the documented one-time degrade, // otherwise the stream writers are just flushed. Auto-filters on streamed // sheets are special-cased: when they are the only reason to degrade, they // are pulled out and injected into the saved container instead // (filterpatch.go), keeping the save streaming. func (w *Workbook) settleForSave(allowPatch bool) error { if !allowPatch { // container patching is off (encrypted output): route any patched // filters from earlier saves back through the pending queue for name, ref := range w.filters { if st, ok := w.sheets[name]; ok { st.pending = append(st.pending, pendingOp{kind: opAutoFilter, ref: ref}) } delete(w.filters, name) } } anyStreamed := false for _, st := range w.sheets { if st.sw != nil { anyStreamed = true } } if anyStreamed && allowPatch { extracted := map[string]string{} for name, st := range w.sheets { kept := st.pending[:0] for _, op := range st.pending { if op.kind == opAutoFilter { tl, br, err := splitRange(op.ref) if err != nil { return err } extracted[name] = tl + ":" + br } else { kept = append(kept, op) } } st.pending = kept } if w.hasAnyPendingWork() { // a real degrade is unavoidable anyway: apply filters there too for name, ref := range extracted { st := w.sheets[name] st.pending = append(st.pending, pendingOp{kind: opAutoFilter, ref: ref}) } } else { for name, ref := range extracted { w.filters[name] = ref } } } if w.hasAnyPendingWork() { if err := w.ensureRandomAll(); err != nil { return err } } return w.flushStreams() } func (w *Workbook) filterPatches() []filterPatch { patches := make([]filterPatch, 0, len(w.filters)) for sheet, ref := range w.filters { patches = append(patches, filterPatch{sheet: sheet, ref: ref}) } return patches } func (w *Workbook) flushStreams() error { for _, st := range w.sheets { if st.sw != nil { if err := st.sw.Flush(); err != nil { return err } st.sw = nil // post-flush, excelize discards model edits to this sheet: // the next mutation must reopen first (see mutable()) w.needsReopen = true } // flushed sheets can no longer accept stream rows st.eligible = false } return nil } // CsvOptions mirrors PhpSpreadsheet's Csv writer settings (Phase-1 subset: // enclosure is fixed to '"'). type CsvOptions struct { Delimiter rune UseCRLF bool UseBOM bool GuardFormula bool // opt-in OWASP injection guard } // SaveCsv writes one sheet as CSV with formatted values. func (w *Workbook) SaveCsv(path, sheet string, opts CsvOptions) error { w.mu.Lock() if w.closed { w.mu.Unlock() return errClosed } abs, err := w.policy.Resolve(path) if err != nil { w.mu.Unlock() return err } w.mu.Unlock() out, err := os.Create(abs) if err != nil { return err } defer out.Close() return w.writeCsv(out, sheet, opts) } // WriteCsvTo writes one sheet as CSV to an arbitrary writer. func (w *Workbook) WriteCsvTo(out io.Writer, sheet string, opts CsvOptions) error { return w.writeCsv(out, sheet, opts) } func (w *Workbook) writeCsv(out io.Writer, sheet string, opts CsvOptions) error { w.mu.Lock() defer w.mu.Unlock() if w.closed { return errClosed } st, err := w.state(sheet) if err != nil { return err } release, err := w.gate.AcquireHeavy() if err != nil { return err } defer release() if err := w.ensureRandom(sheet, st); err != nil { return err } return writeCsvRows(w.f, out, sheet, opts) } // Close releases excelize resources and memory accounting; idempotent. func (w *Workbook) Close() error { w.mu.Lock() defer w.mu.Unlock() if w.closed { return nil } w.closed = true for _, st := range w.sheets { w.closeIter(st) st.sw = nil } w.gate.ReserveMemory(-w.estBytes) w.estBytes = 0 return w.f.Close() }