️ perf(import-export): 降低大文件导入导出内存占用

- xlsx 导出改为临时 sheet 加 zip 直写,避免整包缓冲到内存
- xlsx 导入改为 zip xml 流式解析,并将 shared strings 落到临时文件
- 大任务完成后按行数和文件大小阈值触发内存回收
- 补充导入导出流式链路的测试与基准覆盖
This commit is contained in:
Syngnat
2026-06-18 09:21:01 +08:00
parent 5ce4dddd7a
commit ee78b9b57c
8 changed files with 1390 additions and 203 deletions

View File

@@ -12,7 +12,6 @@ import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"github.com/xuri/excelize/v2"
)
const (
@@ -276,8 +275,10 @@ func streamImportFile(filePath string, consumer importFileConsumer) error {
return streamJSONImportFile(filePath, consumer)
case strings.HasSuffix(lower, ".csv"):
return streamCSVImportFile(filePath, consumer)
case strings.HasSuffix(lower, ".xlsx"), strings.HasSuffix(lower, ".xls"):
return streamExcelImportFile(filePath, consumer)
case strings.HasSuffix(lower, ".xlsx"):
return streamXLSXImportFile(filePath, consumer)
case strings.HasSuffix(lower, ".xls"):
return streamLegacyExcelImportFile(filePath, consumer)
default:
return fmt.Errorf("Unsupported file format")
}
@@ -361,57 +362,6 @@ func streamCSVImportFile(filePath string, consumer importFileConsumer) error {
}
}
func streamExcelImportFile(filePath string, consumer importFileConsumer) error {
workbook, err := excelize.OpenFile(filePath)
if err != nil {
return fmt.Errorf("Excel Parse Error: %w", err)
}
defer workbook.Close()
sheetName := workbook.GetSheetName(0)
if sheetName == "" {
return fmt.Errorf("Excel file has no sheets")
}
rows, err := workbook.Rows(sheetName)
if err != nil {
return fmt.Errorf("Excel Read Error: %w", err)
}
defer rows.Close()
if !rows.Next() {
if err := rows.Error(); err != nil {
return fmt.Errorf("Excel Read Error: %w", err)
}
return fmt.Errorf("Excel empty or missing header")
}
header, err := rows.Columns()
if err != nil {
return fmt.Errorf("Excel Read Error: %w", err)
}
columns := cloneImportColumns(header)
if !hasImportUsableColumns(columns) {
return fmt.Errorf("Excel empty or missing header")
}
if err := consumer.SetColumns(columns); err != nil {
return err
}
for rows.Next() {
record, err := rows.Columns()
if err != nil {
return fmt.Errorf("Excel Read Error: %w", err)
}
if err := consumer.ConsumeRow(buildImportRowFromValues(columns, record)); err != nil {
return err
}
}
if err := rows.Error(); err != nil {
return fmt.Errorf("Excel Read Error: %w", err)
}
return nil
}
func buildImportInsertQuery(dbType, tableName string, columns []string, row map[string]interface{}, columnTypeMap map[string]string) (string, error) {
quotedCols := make([]string, 0, len(columns))
values := make([]string, 0, len(columns))

View File

@@ -0,0 +1,79 @@
package app
import (
"os"
"runtime"
"runtime/debug"
"strings"
"sync/atomic"
"time"
"GoNavi-Wails/internal/logger"
)
const (
fileTransferMemoryTrimRowsThreshold int64 = 100000
fileTransferMemoryTrimFileSizeThreshold int64 = 64 * 1024 * 1024
fileTransferMemoryTrimMinInterval = 3 * time.Second
)
var (
fileTransferMemoryTrimRunning atomic.Bool
fileTransferMemoryTrimLastAt atomic.Int64
runFileTransferMemoryTrimAsync = func(fn func()) {
go fn()
}
fileTransferMemoryTrimFn = func() {
runtime.GC()
debug.FreeOSMemory()
}
)
func maybeReleaseFileTransferMemory(reason string, rows int64, filePath string) {
if !shouldReleaseFileTransferMemory(rows, filePath) {
return
}
if !fileTransferMemoryTrimRunning.CompareAndSwap(false, true) {
return
}
runFileTransferMemoryTrimAsync(func() {
defer fileTransferMemoryTrimRunning.Store(false)
if delay := nextFileTransferMemoryTrimDelay(); delay > 0 {
time.Sleep(delay)
}
logger.Infof("大文件导入导出任务结束尝试回收进程内存reason=%s rows=%d file=%s", strings.TrimSpace(reason), rows, strings.TrimSpace(filePath))
fileTransferMemoryTrimFn()
fileTransferMemoryTrimLastAt.Store(time.Now().UnixNano())
})
}
func shouldReleaseFileTransferMemory(rows int64, filePath string) bool {
if rows >= fileTransferMemoryTrimRowsThreshold {
return true
}
filePath = strings.TrimSpace(filePath)
if filePath == "" {
return false
}
info, err := os.Stat(filePath)
if err != nil {
return false
}
return info.Size() >= fileTransferMemoryTrimFileSizeThreshold
}
func nextFileTransferMemoryTrimDelay() time.Duration {
lastUnixNano := fileTransferMemoryTrimLastAt.Load()
if lastUnixNano <= 0 {
return 0
}
elapsed := time.Since(time.Unix(0, lastUnixNano))
if elapsed >= fileTransferMemoryTrimMinInterval {
return 0
}
return fileTransferMemoryTrimMinInterval - elapsed
}

View File

@@ -0,0 +1,88 @@
package app
import (
"os"
"testing"
)
func TestShouldReleaseFileTransferMemory_UsesRowsThreshold(t *testing.T) {
if !shouldReleaseFileTransferMemory(fileTransferMemoryTrimRowsThreshold, "") {
t.Fatal("达到行数阈值时应触发内存回收")
}
if shouldReleaseFileTransferMemory(fileTransferMemoryTrimRowsThreshold-1, "") {
t.Fatal("未达到行数阈值时不应仅凭行数触发内存回收")
}
}
func TestShouldReleaseFileTransferMemory_UsesFileSizeThreshold(t *testing.T) {
f, err := os.CreateTemp("", "gonavi-memory-trim-*.bin")
if err != nil {
t.Fatalf("创建临时文件失败: %v", err)
}
defer os.Remove(f.Name())
defer f.Close()
if err := f.Truncate(fileTransferMemoryTrimFileSizeThreshold); err != nil {
t.Fatalf("设置临时文件大小失败: %v", err)
}
if !shouldReleaseFileTransferMemory(0, f.Name()) {
t.Fatal("达到文件大小阈值时应触发内存回收")
}
}
func TestMaybeReleaseFileTransferMemory_TriggersTrimForLargeJobs(t *testing.T) {
originalAsync := runFileTransferMemoryTrimAsync
originalTrim := fileTransferMemoryTrimFn
t.Cleanup(func() {
runFileTransferMemoryTrimAsync = originalAsync
fileTransferMemoryTrimFn = originalTrim
fileTransferMemoryTrimRunning.Store(false)
fileTransferMemoryTrimLastAt.Store(0)
})
fileTransferMemoryTrimRunning.Store(false)
fileTransferMemoryTrimLastAt.Store(0)
triggered := 0
runFileTransferMemoryTrimAsync = func(fn func()) {
fn()
}
fileTransferMemoryTrimFn = func() {
triggered++
}
maybeReleaseFileTransferMemory("test-large-job", fileTransferMemoryTrimRowsThreshold, "")
if triggered != 1 {
t.Fatalf("大任务完成后应触发一次内存回收got=%d", triggered)
}
}
func TestMaybeReleaseFileTransferMemory_SkipsSmallJobs(t *testing.T) {
originalAsync := runFileTransferMemoryTrimAsync
originalTrim := fileTransferMemoryTrimFn
t.Cleanup(func() {
runFileTransferMemoryTrimAsync = originalAsync
fileTransferMemoryTrimFn = originalTrim
fileTransferMemoryTrimRunning.Store(false)
fileTransferMemoryTrimLastAt.Store(0)
})
fileTransferMemoryTrimRunning.Store(false)
fileTransferMemoryTrimLastAt.Store(0)
triggered := 0
runFileTransferMemoryTrimAsync = func(fn func()) {
fn()
}
fileTransferMemoryTrimFn = func() {
triggered++
}
maybeReleaseFileTransferMemory("test-small-job", 10, "")
if triggered != 0 {
t.Fatalf("小任务不应触发内存回收got=%d", triggered)
}
}

View File

@@ -24,7 +24,6 @@ import (
"GoNavi-Wails/internal/utils"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/xuri/excelize/v2"
)
const minExportQueryTimeout = 5 * time.Minute
@@ -2045,14 +2044,19 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName,
runtime.EventsEmit(a.ctx, "import:progress", state)
})
if err := streamImportFile(filePath, consumer); err != nil {
resultData := consumer.Result()
maybeReleaseFileTransferMemory("import-stream-error", int64(resultData.Total), filePath)
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := consumer.Flush(); err != nil {
resultData := consumer.Result()
maybeReleaseFileTransferMemory("import-flush-error", int64(resultData.Total), filePath)
return connection.QueryResult{Success: false, Message: err.Error()}
}
resultData := consumer.Result()
if resultData.Total == 0 {
maybeReleaseFileTransferMemory("import-empty", 0, filePath)
return connection.QueryResult{Success: true, Message: "无可导入数据"}
}
@@ -2064,6 +2068,7 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName,
"errorSummary": fmt.Sprintf("Imported: %d, Failed: %d", resultData.Success, resultData.Failed),
}
maybeReleaseFileTransferMemory("import-finished", int64(resultData.Total), filePath)
return connection.QueryResult{Success: true, Data: result, Message: fmt.Sprintf("Imported: %d, Failed: %d", resultData.Success, resultData.Failed)}
}
@@ -2170,6 +2175,7 @@ func (a *App) ExportTableWithOptions(config connection.ConnectionConfig, dbName
reporter.Finalizing(0)
reporter.Done(0)
maybeReleaseFileTransferMemory("export-table-sql-finished", 0, filename)
return connection.QueryResult{Success: true, Message: "导出完成"}
}
@@ -2185,9 +2191,11 @@ func (a *App) ExportTableWithOptions(config connection.ConnectionConfig, dbName
rowCount, _, err := exportQueryResultToFile(f, dbInst, runConfig, query, options, reporter)
if err != nil {
reporter.Error(rowCount, "写入失败:"+err.Error())
maybeReleaseFileTransferMemory("export-table-error", rowCount, filename)
return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()}
}
reporter.Done(rowCount)
maybeReleaseFileTransferMemory("export-table-finished", rowCount, filename)
return connection.QueryResult{Success: true, Message: "导出完成"}
}
@@ -3488,11 +3496,13 @@ func (a *App) ExportDataWithOptions(data []map[string]interface{}, columns []str
if err != nil {
logger.Warnf("ExportData 写入失败file=%s err=%v", filename, err)
reporter.Error(writtenRows, "写入失败:"+err.Error())
maybeReleaseFileTransferMemory("export-data-error", writtenRows, filename)
return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()}
}
logger.Infof("ExportData 完成file=%s rows=%d", filename, len(data))
reporter.Done(writtenRows)
maybeReleaseFileTransferMemory("export-data-finished", writtenRows, filename)
return connection.QueryResult{Success: true, Message: "导出完成"}
}
@@ -3550,11 +3560,13 @@ func (a *App) ExportQueryWithOptions(config connection.ConnectionConfig, dbName
if err != nil {
logger.Warnf("ExportQuery 查询失败type=%s db=%s err=%v sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), err, sqlSnippet(query))
reporter.Error(rowCount, err.Error())
maybeReleaseFileTransferMemory("export-query-error", rowCount, filename)
return connection.QueryResult{Success: false, Message: err.Error()}
}
logger.Infof("ExportQuery 完成file=%s rows=%d cols=%d", filename, rowCount, len(columns))
reporter.Done(rowCount)
maybeReleaseFileTransferMemory("export-query-finished", rowCount, filename)
return connection.QueryResult{Success: true, Message: "导出完成"}
}
@@ -3997,128 +4009,6 @@ func (w *htmlExportFileWriter) Close() error {
return w.writer.Flush()
}
type xlsxExportFileWriter struct {
filename string
workbook *excelize.File
stream *excelize.StreamWriter
sheet string
columns []string
header []interface{}
rowBuf []interface{}
nextRow int
sheetNo int
rowCount int
maxRows int
}
func newXLSXExportFileWriter(filename string, maxRowsPerSheet int) (*xlsxExportFileWriter, error) {
workbook := excelize.NewFile()
sheet := workbook.GetSheetName(workbook.GetActiveSheetIndex())
stream, err := workbook.NewStreamWriter(sheet)
if err != nil {
_ = workbook.Close()
return nil, err
}
return &xlsxExportFileWriter{
filename: filename,
workbook: workbook,
stream: stream,
sheet: sheet,
sheetNo: 1,
nextRow: 2,
maxRows: normalizeXLSXRowsPerSheet(maxRowsPerSheet),
}, nil
}
func (w *xlsxExportFileWriter) SetColumns(columns []string) error {
w.columns = append([]string(nil), columns...)
w.rowCount = 0
w.nextRow = 2
w.header = make([]interface{}, len(columns))
w.rowBuf = make([]interface{}, len(columns))
for i, col := range columns {
w.header[i] = col
}
return w.stream.SetRow("A1", w.header)
}
func (w *xlsxExportFileWriter) rotateSheet() error {
if err := w.stream.Flush(); err != nil {
return err
}
w.sheetNo++
w.sheet = fmt.Sprintf("Sheet%d", w.sheetNo)
if _, err := w.workbook.NewSheet(w.sheet); err != nil {
return err
}
stream, err := w.workbook.NewStreamWriter(w.sheet)
if err != nil {
return err
}
w.stream = stream
w.rowCount = 0
w.nextRow = 2
return w.stream.SetRow("A1", w.header)
}
func (w *xlsxExportFileWriter) ConsumeRow(row map[string]interface{}) error {
if w.rowCount >= w.maxRows {
if err := w.rotateSheet(); err != nil {
return err
}
}
values := w.rowBuf
for i, col := range w.columns {
val := row[col]
if val == nil {
values[i] = "NULL"
continue
}
values[i] = formatExportCellText(val)
}
cell := "A" + strconv.Itoa(w.nextRow)
w.nextRow++
w.rowCount++
return w.stream.SetRow(cell, values)
}
func (w *xlsxExportFileWriter) ConsumeRowValues(values []interface{}) error {
if w.rowCount >= w.maxRows {
if err := w.rotateSheet(); err != nil {
return err
}
}
rowBuf := w.rowBuf
for i := range w.columns {
var value interface{}
if i < len(values) {
value = values[i]
}
if value == nil {
rowBuf[i] = "NULL"
continue
}
rowBuf[i] = formatExportCellText(value)
}
cell := "A" + strconv.Itoa(w.nextRow)
w.nextRow++
w.rowCount++
return w.stream.SetRow(cell, rowBuf)
}
func (w *xlsxExportFileWriter) Close() error {
if err := w.stream.Flush(); err != nil {
_ = w.workbook.Close()
return err
}
saveErr := w.workbook.SaveAs(w.filename)
closeErr := w.workbook.Close()
if saveErr != nil {
return saveErr
}
return closeErr
}
type sqlInsertExportConsumer struct {
w *bufio.Writer
dbType string
@@ -4304,11 +4194,7 @@ func newExportFileWriter(f *os.File, options ExportFileOptions) (exportFileWrite
case "html":
return newHTMLExportFileWriter(f), nil
case "xlsx":
filename := f.Name()
if err := f.Close(); err != nil {
return nil, err
}
return newXLSXExportFileWriter(filename, options.XLSXMaxRowsPerSheet)
return newXLSXExportFileWriter(f, options.XLSXMaxRowsPerSheet)
default:
return nil, fmt.Errorf("unsupported format: %s", options.Format)
}
@@ -4756,29 +4642,23 @@ func normalizeExportJSONValue(val interface{}) interface{} {
// writeRowsToXlsx 使用 excelize 写入真正的 xlsx 格式文件
func writeRowsToXlsx(filename string, data []map[string]interface{}, columns []string) error {
xlsx := excelize.NewFile()
defer xlsx.Close()
sheet := "Sheet1"
// 写入表头
for i, col := range columns {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
xlsx.SetCellValue(sheet, cell, col)
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
// 写入数据行
for rowIdx, rowMap := range data {
for colIdx, col := range columns {
cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
val := rowMap[col]
if val == nil {
xlsx.SetCellValue(sheet, cell, "NULL")
} else {
xlsx.SetCellValue(sheet, cell, formatExportCellText(val))
}
writer, err := newXLSXExportFileWriter(file, 0)
if err != nil {
return err
}
if err := writer.SetColumns(columns); err != nil {
return err
}
for _, rowMap := range data {
if err := writer.ConsumeRow(rowMap); err != nil {
return err
}
}
return xlsx.SaveAs(filename)
return writer.Close()
}

View File

@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
@@ -44,6 +45,13 @@ type fakeValueStreamExportDB struct {
valueHits int
}
type fakeGeneratedValueStreamExportDB struct {
streamCols []string
rowCount int
streamHits int
valueHits int
}
func (f *fakeExportQueryDB) Connect(config connection.ConnectionConfig) error { return nil }
func (f *fakeExportQueryDB) Close() error { return nil }
func (f *fakeExportQueryDB) Ping() error { return nil }
@@ -154,6 +162,82 @@ func (f *fakeValueStreamExportDB) StreamQueryContext(_ context.Context, query st
return nil
}
func (f *fakeGeneratedValueStreamExportDB) Connect(config connection.ConnectionConfig) error {
return nil
}
func (f *fakeGeneratedValueStreamExportDB) Close() error { return nil }
func (f *fakeGeneratedValueStreamExportDB) Ping() error { return nil }
func (f *fakeGeneratedValueStreamExportDB) Query(query string) ([]map[string]interface{}, []string, error) {
return nil, nil, context.DeadlineExceeded
}
func (f *fakeGeneratedValueStreamExportDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
return nil, nil, context.DeadlineExceeded
}
func (f *fakeGeneratedValueStreamExportDB) Exec(query string) (int64, error) { return 0, nil }
func (f *fakeGeneratedValueStreamExportDB) GetDatabases() ([]string, error) { return nil, nil }
func (f *fakeGeneratedValueStreamExportDB) GetTables(dbName string) ([]string, error) {
return nil, nil
}
func (f *fakeGeneratedValueStreamExportDB) GetCreateStatement(dbName, tableName string) (string, error) {
return "", nil
}
func (f *fakeGeneratedValueStreamExportDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return nil, nil
}
func (f *fakeGeneratedValueStreamExportDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return nil, nil
}
func (f *fakeGeneratedValueStreamExportDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return nil, nil
}
func (f *fakeGeneratedValueStreamExportDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return nil, nil
}
func (f *fakeGeneratedValueStreamExportDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return nil, nil
}
func (f *fakeGeneratedValueStreamExportDB) StreamQuery(query string, consumer db.QueryStreamConsumer) error {
return f.StreamQueryContext(context.Background(), query, consumer)
}
func (f *fakeGeneratedValueStreamExportDB) StreamQueryContext(_ context.Context, query string, consumer db.QueryStreamConsumer) error {
f.streamHits++
if err := consumer.SetColumns(f.streamCols); err != nil {
return err
}
valueConsumer, ok := consumer.(db.QueryStreamValueConsumer)
if !ok {
return fmt.Errorf("value stream consumer required")
}
for i := 0; i < f.rowCount; i++ {
f.valueHits++
if err := valueConsumer.ConsumeRowValues([]interface{}{
i + 1,
"benchmark-user",
"plain export payload without timezone marker",
"2026-06-17 12:34:56",
"enabled",
}); err != nil {
return err
}
}
return nil
}
func TestFormatExportCellText_FloatNoScientificNotation(t *testing.T) {
got := formatExportCellText(1.445663e+06)
if strings.Contains(strings.ToLower(got), "e+") || strings.Contains(strings.ToLower(got), "e-") {
@@ -730,6 +814,35 @@ func BenchmarkExportQueryResultToFile_XLSX_StreamValues_20000Rows(b *testing.B)
}
}
func BenchmarkExportQueryResultToFile_XLSX_StreamGenerated_50000Rows(b *testing.B) {
streamDB := &fakeGeneratedValueStreamExportDB{
streamCols: []string{"id", "name", "note", "created_at", "status"},
rowCount: 50000,
}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
f, err := os.CreateTemp("", "gonavi-export-stream-generated-*.xlsx")
if err != nil {
b.Fatalf("创建临时文件失败: %v", err)
}
name := f.Name()
if _, _, err := exportQueryResultToFile(
f,
streamDB,
connection.ConnectionConfig{Type: "mysql", Timeout: 10},
"SELECT * FROM users",
ExportFileOptions{Format: "xlsx"},
nil,
); err != nil {
_ = os.Remove(name)
b.Fatalf("流式生成导出失败: %v", err)
}
if err := os.Remove(name); err != nil {
b.Fatalf("删除临时文件失败: %v", err)
}
}
}
func BenchmarkDumpTableSQL_SQLBackup_StreamMap_20000Rows(b *testing.B) {
rows, columns := benchmarkExportRows(20000)
streamDB := &fakeStreamExportDB{

View File

@@ -8,6 +8,8 @@ import (
"reflect"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
func TestReadImportedConnectionConfigFileRejectsOversizedFiles(t *testing.T) {
@@ -82,6 +84,94 @@ func TestBuildImportRowFromValuesPreservesPositionsWhenHeaderContainsBlankColumn
}
}
func TestBuildImportPreviewXLSXStreamSupportsInlineStrings(t *testing.T) {
path := filepath.Join(t.TempDir(), "inline.xlsx")
file, err := os.Create(path)
if err != nil {
t.Fatalf("创建 xlsx 文件失败: %v", err)
}
writer, err := newXLSXExportFileWriter(file, 0)
if err != nil {
t.Fatalf("创建 xlsx writer 失败: %v", err)
}
if err := writer.SetColumns([]string{"id", "name"}); err != nil {
t.Fatalf("SetColumns 失败: %v", err)
}
if err := writer.ConsumeRowValues([]interface{}{1, "alice"}); err != nil {
t.Fatalf("写入第 1 行失败: %v", err)
}
if err := writer.ConsumeRowValues([]interface{}{2, "bob"}); err != nil {
t.Fatalf("写入第 2 行失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("关闭 xlsx writer 失败: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("关闭 xlsx 文件失败: %v", err)
}
preview, err := buildImportPreview(path, 5)
if err != nil {
t.Fatalf("buildImportPreview 返回错误: %v", err)
}
if !reflect.DeepEqual(preview.Columns, []string{"id", "name"}) {
t.Fatalf("unexpected columns: %#v", preview.Columns)
}
if preview.TotalRows != 2 {
t.Fatalf("expected 2 rows, got %d", preview.TotalRows)
}
if got := preview.PreviewRows[1]["name"]; got != "bob" {
t.Fatalf("expected second row name bob, got %#v", got)
}
}
func TestBuildImportPreviewXLSXStreamSupportsSharedStrings(t *testing.T) {
path := filepath.Join(t.TempDir(), "shared.xlsx")
workbook := excelize.NewFile()
if err := workbook.SetCellValue("Sheet1", "A1", "id"); err != nil {
t.Fatalf("设置表头失败: %v", err)
}
if err := workbook.SetCellValue("Sheet1", "B1", "name"); err != nil {
t.Fatalf("设置表头失败: %v", err)
}
if err := workbook.SetCellValue("Sheet1", "A2", "1"); err != nil {
t.Fatalf("设置数据失败: %v", err)
}
if err := workbook.SetCellValue("Sheet1", "B2", "alice"); err != nil {
t.Fatalf("设置数据失败: %v", err)
}
if err := workbook.SetCellValue("Sheet1", "A3", "2"); err != nil {
t.Fatalf("设置数据失败: %v", err)
}
if err := workbook.SetCellValue("Sheet1", "B3", "bob"); err != nil {
t.Fatalf("设置数据失败: %v", err)
}
if err := workbook.SaveAs(path); err != nil {
t.Fatalf("保存 shared-string xlsx 失败: %v", err)
}
if err := workbook.Close(); err != nil {
t.Fatalf("关闭 shared-string xlsx 失败: %v", err)
}
preview, err := buildImportPreview(path, 5)
if err != nil {
t.Fatalf("buildImportPreview 返回错误: %v", err)
}
if !reflect.DeepEqual(preview.Columns, []string{"id", "name"}) {
t.Fatalf("unexpected columns: %#v", preview.Columns)
}
if preview.TotalRows != 2 {
t.Fatalf("expected 2 rows, got %d", preview.TotalRows)
}
if got := preview.PreviewRows[0]["name"]; got != "alice" {
t.Fatalf("expected first row name alice, got %#v", got)
}
if got := preview.PreviewRows[1]["id"]; got != "2" {
t.Fatalf("expected second row id 2, got %#v", got)
}
}
type fakeImportRowWriter struct {
columns []string
batchCalls int

View File

@@ -0,0 +1,544 @@
package app
import (
"archive/zip"
"bufio"
"encoding/binary"
"encoding/xml"
"fmt"
"io"
"os"
"path"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
)
const (
xlsxWorkbookXMLPath = "xl/workbook.xml"
xlsxWorkbookRelsXMLPath = "xl/_rels/workbook.xml.rels"
xlsxSharedStringsXML = "xl/sharedStrings.xml"
)
type xlsxSharedStringStore struct {
path string
file *os.File
writer *bufio.Writer
offsets []int64
size int64
}
func newXLSXSharedStringStore() (*xlsxSharedStringStore, error) {
file, err := os.CreateTemp("", "gonavi-xlsx-shared-strings-*.bin")
if err != nil {
return nil, err
}
return &xlsxSharedStringStore{
path: file.Name(),
file: file,
writer: bufio.NewWriterSize(file, 1024*256),
}, nil
}
func (s *xlsxSharedStringStore) Add(value string) error {
if s == nil || s.file == nil || s.writer == nil {
return fmt.Errorf("shared string store unavailable")
}
s.offsets = append(s.offsets, s.size)
if err := binary.Write(s.writer, binary.LittleEndian, uint32(len(value))); err != nil {
return err
}
written, err := s.writer.WriteString(value)
s.size += 4 + int64(written)
return err
}
func (s *xlsxSharedStringStore) Get(index int) (string, error) {
if s == nil {
return "", nil
}
if index < 0 || index >= len(s.offsets) {
return "", fmt.Errorf("shared string index out of range: %d", index)
}
if err := s.flush(); err != nil {
return "", err
}
if _, err := s.file.Seek(s.offsets[index], io.SeekStart); err != nil {
return "", err
}
var length uint32
if err := binary.Read(s.file, binary.LittleEndian, &length); err != nil {
return "", err
}
buf := make([]byte, int(length))
if _, err := io.ReadFull(s.file, buf); err != nil {
return "", err
}
return string(buf), nil
}
func (s *xlsxSharedStringStore) flush() error {
if s == nil || s.writer == nil {
return nil
}
return s.writer.Flush()
}
func (s *xlsxSharedStringStore) Close() error {
if s == nil {
return nil
}
if s.writer != nil {
_ = s.writer.Flush()
}
var err error
if s.file != nil {
err = s.file.Close()
}
if s.path != "" {
_ = os.Remove(s.path)
}
return err
}
func streamXLSXImportFile(filePath string, consumer importFileConsumer) error {
reader, err := zip.OpenReader(filePath)
if err != nil {
return fmt.Errorf("Excel Parse Error: %w", err)
}
defer reader.Close()
entryByPath := make(map[string]*zip.File, len(reader.File))
for _, entry := range reader.File {
entryByPath[entry.Name] = entry
}
sheetPath, err := resolveXLSXFirstSheetPath(entryByPath)
if err != nil {
return fmt.Errorf("Excel Parse Error: %w", err)
}
sharedStrings, err := loadXLSXSharedStrings(entryByPath[xlsxSharedStringsXML])
if err != nil {
return fmt.Errorf("Excel Parse Error: %w", err)
}
if sharedStrings != nil {
defer sharedStrings.Close()
}
sheetEntry := entryByPath[sheetPath]
if sheetEntry == nil {
return fmt.Errorf("Excel Parse Error: worksheet not found: %s", sheetPath)
}
if err := streamXLSXSheetRows(sheetEntry, sharedStrings, consumer); err != nil {
return fmt.Errorf("Excel Read Error: %w", err)
}
return nil
}
func streamLegacyExcelImportFile(filePath string, consumer importFileConsumer) error {
workbook, err := excelize.OpenFile(filePath)
if err != nil {
return fmt.Errorf("Excel Parse Error: %w", err)
}
defer workbook.Close()
sheetName := workbook.GetSheetName(0)
if sheetName == "" {
return fmt.Errorf("Excel file has no sheets")
}
rows, err := workbook.Rows(sheetName)
if err != nil {
return fmt.Errorf("Excel Read Error: %w", err)
}
defer rows.Close()
if !rows.Next() {
if err := rows.Error(); err != nil {
return fmt.Errorf("Excel Read Error: %w", err)
}
return fmt.Errorf("Excel empty or missing header")
}
header, err := rows.Columns()
if err != nil {
return fmt.Errorf("Excel Read Error: %w", err)
}
columns := cloneImportColumns(header)
if !hasImportUsableColumns(columns) {
return fmt.Errorf("Excel empty or missing header")
}
if err := consumer.SetColumns(columns); err != nil {
return err
}
for rows.Next() {
record, err := rows.Columns()
if err != nil {
return fmt.Errorf("Excel Read Error: %w", err)
}
if err := consumer.ConsumeRow(buildImportRowFromValues(columns, record)); err != nil {
return err
}
}
if err := rows.Error(); err != nil {
return fmt.Errorf("Excel Read Error: %w", err)
}
return nil
}
func resolveXLSXFirstSheetPath(entryByPath map[string]*zip.File) (string, error) {
workbookEntry := entryByPath[xlsxWorkbookXMLPath]
if workbookEntry == nil {
return "", fmt.Errorf("workbook.xml missing")
}
workbookReader, err := workbookEntry.Open()
if err != nil {
return "", err
}
defer workbookReader.Close()
firstSheetRelID, err := readXLSXFirstSheetRelID(workbookReader)
if err != nil {
return "", err
}
if firstSheetRelID == "" {
return "", fmt.Errorf("workbook has no sheets")
}
relsEntry := entryByPath[xlsxWorkbookRelsXMLPath]
if relsEntry == nil {
return "", fmt.Errorf("workbook rels missing")
}
relsReader, err := relsEntry.Open()
if err != nil {
return "", err
}
defer relsReader.Close()
target, err := readXLSXWorkbookRelTarget(relsReader, firstSheetRelID)
if err != nil {
return "", err
}
if target == "" {
return "", fmt.Errorf("worksheet target missing for relationship %s", firstSheetRelID)
}
target = strings.TrimPrefix(strings.TrimSpace(target), "/")
if strings.HasPrefix(target, "xl/") {
return path.Clean(target), nil
}
return path.Clean(path.Join("xl", target)), nil
}
func readXLSXFirstSheetRelID(reader io.Reader) (string, error) {
decoder := xml.NewDecoder(reader)
for {
token, err := decoder.Token()
if err != nil {
if err == io.EOF {
return "", nil
}
return "", err
}
start, ok := token.(xml.StartElement)
if !ok || start.Name.Local != "sheet" {
continue
}
for _, attr := range start.Attr {
if attr.Name.Local == "id" {
return strings.TrimSpace(attr.Value), nil
}
}
}
}
func readXLSXWorkbookRelTarget(reader io.Reader, relID string) (string, error) {
decoder := xml.NewDecoder(reader)
for {
token, err := decoder.Token()
if err != nil {
if err == io.EOF {
return "", nil
}
return "", err
}
start, ok := token.(xml.StartElement)
if !ok || start.Name.Local != "Relationship" {
continue
}
var id string
var target string
for _, attr := range start.Attr {
switch attr.Name.Local {
case "Id":
id = strings.TrimSpace(attr.Value)
case "Target":
target = strings.TrimSpace(attr.Value)
}
}
if id == relID {
return target, nil
}
}
}
func loadXLSXSharedStrings(entry *zip.File) (*xlsxSharedStringStore, error) {
if entry == nil {
return nil, nil
}
reader, err := entry.Open()
if err != nil {
return nil, err
}
defer reader.Close()
store, err := newXLSXSharedStringStore()
if err != nil {
return nil, err
}
if err := parseXLSXSharedStrings(reader, store); err != nil {
_ = store.Close()
return nil, err
}
if err := store.flush(); err != nil {
_ = store.Close()
return nil, err
}
return store, nil
}
func parseXLSXSharedStrings(reader io.Reader, store *xlsxSharedStringStore) error {
decoder := xml.NewDecoder(reader)
for {
token, err := decoder.Token()
if err != nil {
if err == io.EOF {
return nil
}
return err
}
start, ok := token.(xml.StartElement)
if !ok || start.Name.Local != "si" {
continue
}
value, err := readXLSXSharedStringItem(decoder)
if err != nil {
return err
}
if err := store.Add(value); err != nil {
return err
}
}
}
func readXLSXSharedStringItem(decoder *xml.Decoder) (string, error) {
var builder strings.Builder
depth := 1
for depth > 0 {
token, err := decoder.Token()
if err != nil {
return "", err
}
switch typed := token.(type) {
case xml.StartElement:
if typed.Name.Local == "si" {
depth++
continue
}
if typed.Name.Local == "t" {
text, err := readXMLTextNode(decoder, typed.Name.Local)
if err != nil {
return "", err
}
builder.WriteString(text)
}
case xml.EndElement:
if typed.Name.Local == "si" {
depth--
}
}
}
return builder.String(), nil
}
func streamXLSXSheetRows(entry *zip.File, sharedStrings *xlsxSharedStringStore, consumer importFileConsumer) error {
reader, err := entry.Open()
if err != nil {
return err
}
defer reader.Close()
decoder := xml.NewDecoder(reader)
var columns []string
for {
token, err := decoder.Token()
if err != nil {
if err == io.EOF {
break
}
return err
}
start, ok := token.(xml.StartElement)
if !ok || start.Name.Local != "row" {
continue
}
values, err := readXLSXRow(decoder, sharedStrings)
if err != nil {
return err
}
if columns == nil {
columns = cloneImportColumns(values)
if !hasImportUsableColumns(columns) {
return fmt.Errorf("Excel empty or missing header")
}
if err := consumer.SetColumns(columns); err != nil {
return err
}
continue
}
if err := consumer.ConsumeRow(buildImportRowFromValues(columns, values)); err != nil {
return err
}
}
if columns == nil {
return fmt.Errorf("Excel empty or missing header")
}
return nil
}
func readXLSXRow(decoder *xml.Decoder, sharedStrings *xlsxSharedStringStore) ([]string, error) {
values := make([]string, 0, 16)
currentColumn := 0
for {
token, err := decoder.Token()
if err != nil {
return nil, err
}
switch typed := token.(type) {
case xml.StartElement:
if typed.Name.Local != "c" {
continue
}
columnIndex := currentColumn + 1
cellType := ""
for _, attr := range typed.Attr {
switch attr.Name.Local {
case "r":
if idx := xlsxCellRefColumnIndex(attr.Value); idx > 0 {
columnIndex = idx
}
case "t":
cellType = strings.TrimSpace(attr.Value)
}
}
if columnIndex <= 0 {
columnIndex = currentColumn + 1
}
cellValue, err := readXLSXCell(decoder, cellType, sharedStrings)
if err != nil {
return nil, err
}
for len(values) < columnIndex {
values = append(values, "")
}
values[columnIndex-1] = cellValue
currentColumn = columnIndex
case xml.EndElement:
if typed.Name.Local == "row" {
return values, nil
}
}
}
}
func readXLSXCell(decoder *xml.Decoder, cellType string, sharedStrings *xlsxSharedStringStore) (string, error) {
var rawValue strings.Builder
var inlineValue strings.Builder
for {
token, err := decoder.Token()
if err != nil {
return "", err
}
switch typed := token.(type) {
case xml.StartElement:
switch typed.Name.Local {
case "v":
text, err := readXMLTextNode(decoder, typed.Name.Local)
if err != nil {
return "", err
}
rawValue.WriteString(text)
case "t":
text, err := readXMLTextNode(decoder, typed.Name.Local)
if err != nil {
return "", err
}
inlineValue.WriteString(text)
}
case xml.EndElement:
if typed.Name.Local != "c" {
continue
}
switch cellType {
case "s":
indexText := strings.TrimSpace(rawValue.String())
if indexText == "" {
return "", nil
}
index, err := strconv.Atoi(indexText)
if err != nil {
return "", err
}
return sharedStrings.Get(index)
case "inlineStr":
return inlineValue.String(), nil
default:
if inlineValue.Len() > 0 {
return inlineValue.String(), nil
}
return rawValue.String(), nil
}
}
}
}
func readXMLTextNode(decoder *xml.Decoder, endLocal string) (string, error) {
var builder strings.Builder
for {
token, err := decoder.Token()
if err != nil {
return "", err
}
switch typed := token.(type) {
case xml.CharData:
builder.Write([]byte(typed))
case xml.EndElement:
if typed.Name.Local == endLocal {
return builder.String(), nil
}
}
}
}
func xlsxCellRefColumnIndex(ref string) int {
ref = strings.TrimSpace(ref)
if ref == "" {
return 0
}
value := 0
for i := 0; i < len(ref); i++ {
ch := ref[i]
switch {
case ch >= 'A' && ch <= 'Z':
value = value*26 + int(ch-'A'+1)
case ch >= 'a' && ch <= 'z':
value = value*26 + int(ch-'a'+1)
default:
if value > 0 {
return value
}
}
}
return value
}

View File

@@ -0,0 +1,443 @@
package app
import (
"archive/zip"
"bufio"
"encoding/xml"
"fmt"
"io"
"os"
"strconv"
"strings"
"sync"
"github.com/xuri/excelize/v2"
)
const (
xlsxMainNamespace = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xlsxRelationshipsNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xlsxPackageRelationshipsNS = "http://schemas.openxmlformats.org/package/2006/relationships"
xlsxWorksheetType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
xlsxStylesType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"
xlsxThemeType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme"
xlsxWorkbookContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"
xlsxWorksheetContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"
xlsxStylesContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"
xlsxThemeContentType = "application/vnd.openxmlformats-officedocument.theme+xml"
xlsxCoreContentType = "application/vnd.openxmlformats-package.core-properties+xml"
xlsxAppContentType = "application/vnd.openxmlformats-officedocument.extended-properties+xml"
)
var (
xlsxTemplatePartsOnce sync.Once
xlsxTemplateParts map[string][]byte
xlsxTemplatePartsErr error
)
type xlsxExportTempSheet struct {
name string
path string
file *os.File
writer *bufio.Writer
closed bool
}
type xlsxExportFileWriter struct {
file *os.File
columns []string
columnRefs []string
rowBuf []string
sheets []*xlsxExportTempSheet
current *xlsxExportTempSheet
nextRow int
sheetNo int
rowCount int
maxRows int
}
func newXLSXExportFileWriter(f *os.File, maxRowsPerSheet int) (*xlsxExportFileWriter, error) {
if f == nil {
return nil, fmt.Errorf("file required")
}
return &xlsxExportFileWriter{
file: f,
maxRows: normalizeXLSXRowsPerSheet(maxRowsPerSheet),
}, nil
}
func (w *xlsxExportFileWriter) SetColumns(columns []string) error {
w.columns = append([]string(nil), columns...)
w.columnRefs = make([]string, len(columns))
w.rowBuf = make([]string, len(columns))
for i := range columns {
ref, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
w.columnRefs[i] = ref
}
return w.rotateSheet()
}
func (w *xlsxExportFileWriter) rotateSheet() error {
if err := w.closeCurrentSheet(); err != nil {
return err
}
tmpFile, err := os.CreateTemp("", "gonavi-export-sheet-*.xml")
if err != nil {
return err
}
sheet := &xlsxExportTempSheet{
name: fmt.Sprintf("Sheet%d", w.sheetNo+1),
path: tmpFile.Name(),
file: tmpFile,
writer: bufio.NewWriterSize(tmpFile, 1024*256),
}
if err := writeXLSXSheetHeader(sheet.writer); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpFile.Name())
return err
}
w.sheetNo++
w.current = sheet
w.sheets = append(w.sheets, sheet)
w.rowCount = 0
w.nextRow = 2
return w.writeStringRow(1, w.columns)
}
func (w *xlsxExportFileWriter) ConsumeRow(row map[string]interface{}) error {
if w.rowCount >= w.maxRows {
if err := w.rotateSheet(); err != nil {
return err
}
}
values := w.rowBuf
for i, col := range w.columns {
val := row[col]
if val == nil {
values[i] = "NULL"
continue
}
values[i] = formatExportCellText(val)
}
if err := w.writeStringRow(w.nextRow, values); err != nil {
return err
}
w.nextRow++
w.rowCount++
return nil
}
func (w *xlsxExportFileWriter) ConsumeRowValues(values []interface{}) error {
if w.rowCount >= w.maxRows {
if err := w.rotateSheet(); err != nil {
return err
}
}
record := w.rowBuf
for i := range w.columns {
var value interface{}
if i < len(values) {
value = values[i]
}
if value == nil {
record[i] = "NULL"
continue
}
record[i] = formatExportCellText(value)
}
if err := w.writeStringRow(w.nextRow, record); err != nil {
return err
}
w.nextRow++
w.rowCount++
return nil
}
func (w *xlsxExportFileWriter) Close() error {
if err := w.closeCurrentSheet(); err != nil {
w.cleanupTempSheets()
return err
}
if len(w.sheets) == 0 {
w.cleanupTempSheets()
return nil
}
if err := w.file.Truncate(0); err != nil {
w.cleanupTempSheets()
return err
}
if _, err := w.file.Seek(0, 0); err != nil {
w.cleanupTempSheets()
return err
}
zw := zip.NewWriter(w.file)
defer w.cleanupTempSheets()
if err := writeXLSXZipFile(zw, w.sheets); err != nil {
_ = zw.Close()
return err
}
return zw.Close()
}
func (w *xlsxExportFileWriter) closeCurrentSheet() error {
if w.current == nil || w.current.closed {
return nil
}
if _, err := w.current.writer.WriteString(`</sheetData></worksheet>`); err != nil {
return err
}
if err := w.current.writer.Flush(); err != nil {
return err
}
if err := w.current.file.Close(); err != nil {
return err
}
w.current.closed = true
w.current = nil
return nil
}
func (w *xlsxExportFileWriter) cleanupTempSheets() {
for _, sheet := range w.sheets {
if sheet == nil {
continue
}
if !sheet.closed && sheet.file != nil {
_ = sheet.writer.Flush()
_ = sheet.file.Close()
}
if sheet.path != "" {
_ = os.Remove(sheet.path)
}
}
}
func (w *xlsxExportFileWriter) writeStringRow(rowNumber int, values []string) error {
if w.current == nil {
return fmt.Errorf("xlsx sheet not initialized")
}
if _, err := w.current.writer.WriteString(`<row r="`); err != nil {
return err
}
if _, err := w.current.writer.WriteString(strconv.Itoa(rowNumber)); err != nil {
return err
}
if _, err := w.current.writer.WriteString(`">`); err != nil {
return err
}
rowRef := strconv.Itoa(rowNumber)
for i, value := range values {
if err := writeXLSXInlineStringCell(w.current.writer, w.columnRefs[i], rowRef, value); err != nil {
return err
}
}
_, err := w.current.writer.WriteString(`</row>`)
return err
}
func writeXLSXZipFile(zw *zip.Writer, sheets []*xlsxExportTempSheet) error {
parts, err := loadXLSXTemplateParts()
if err != nil {
return err
}
if err := writeZipBytes(zw, "[Content_Types].xml", buildXLSXContentTypes(len(sheets))); err != nil {
return err
}
if err := writeZipBytes(zw, "_rels/.rels", parts["_rels/.rels"]); err != nil {
return err
}
if err := writeZipBytes(zw, "docProps/app.xml", parts["docProps/app.xml"]); err != nil {
return err
}
if err := writeZipBytes(zw, "docProps/core.xml", parts["docProps/core.xml"]); err != nil {
return err
}
if err := writeZipBytes(zw, "xl/workbook.xml", buildXLSXWorkbookXML(sheets)); err != nil {
return err
}
if err := writeZipBytes(zw, "xl/_rels/workbook.xml.rels", buildXLSXWorkbookRels(sheets)); err != nil {
return err
}
if err := writeZipBytes(zw, "xl/styles.xml", parts["xl/styles.xml"]); err != nil {
return err
}
if err := writeZipBytes(zw, "xl/theme/theme1.xml", parts["xl/theme/theme1.xml"]); err != nil {
return err
}
for idx, sheet := range sheets {
if err := writeZipFileFromPath(zw, fmt.Sprintf("xl/worksheets/sheet%d.xml", idx+1), sheet.path); err != nil {
return err
}
}
return nil
}
func loadXLSXTemplateParts() (map[string][]byte, error) {
xlsxTemplatePartsOnce.Do(func() {
workbook := excelize.NewFile()
defer workbook.Close()
required := []string{
"_rels/.rels",
"docProps/app.xml",
"docProps/core.xml",
"xl/styles.xml",
"xl/theme/theme1.xml",
}
xlsxTemplateParts = make(map[string][]byte, len(required))
for _, path := range required {
value, ok := workbook.Pkg.Load(path)
if !ok {
xlsxTemplatePartsErr = fmt.Errorf("xlsx template part missing: %s", path)
return
}
xlsxTemplateParts[path] = append([]byte(nil), value.([]byte)...)
}
})
return xlsxTemplateParts, xlsxTemplatePartsErr
}
func writeZipBytes(zw *zip.Writer, name string, content []byte) error {
writer, err := zw.Create(name)
if err != nil {
return err
}
_, err = writer.Write(content)
return err
}
func writeZipFileFromPath(zw *zip.Writer, name string, path string) error {
writer, err := zw.Create(name)
if err != nil {
return err
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(writer, file)
return err
}
func writeXLSXSheetHeader(writer *bufio.Writer) error {
_, err := writer.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` +
`<worksheet xmlns="` + xlsxMainNamespace + `" xmlns:r="` + xlsxRelationshipsNamespace + `">` +
`<sheetViews><sheetView workbookViewId="0"/></sheetViews>` +
`<sheetFormatPr defaultRowHeight="15"/>` +
`<sheetData>`)
return err
}
func writeXLSXInlineStringCell(writer *bufio.Writer, columnRef string, rowRef string, value string) error {
if _, err := writer.WriteString(`<c r="`); err != nil {
return err
}
if _, err := writer.WriteString(columnRef); err != nil {
return err
}
if _, err := writer.WriteString(rowRef); err != nil {
return err
}
if _, err := writer.WriteString(`" t="inlineStr"><is><t`); err != nil {
return err
}
if value != strings.TrimSpace(value) {
if _, err := writer.WriteString(` xml:space="preserve"`); err != nil {
return err
}
}
if _, err := writer.WriteString(`>`); err != nil {
return err
}
if err := xml.EscapeText(writer, []byte(value)); err != nil {
return err
}
_, err := writer.WriteString(`</t></is></c>`)
return err
}
func buildXLSXContentTypes(sheetCount int) []byte {
var builder strings.Builder
builder.Grow(1024 + sheetCount*128)
builder.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
builder.WriteString(`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`)
builder.WriteString(`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>`)
builder.WriteString(`<Default Extension="xml" ContentType="application/xml"/>`)
builder.WriteString(`<Override PartName="/xl/workbook.xml" ContentType="` + xlsxWorkbookContentType + `"/>`)
builder.WriteString(`<Override PartName="/xl/styles.xml" ContentType="` + xlsxStylesContentType + `"/>`)
builder.WriteString(`<Override PartName="/xl/theme/theme1.xml" ContentType="` + xlsxThemeContentType + `"/>`)
builder.WriteString(`<Override PartName="/docProps/core.xml" ContentType="` + xlsxCoreContentType + `"/>`)
builder.WriteString(`<Override PartName="/docProps/app.xml" ContentType="` + xlsxAppContentType + `"/>`)
for i := 1; i <= sheetCount; i++ {
builder.WriteString(`<Override PartName="/xl/worksheets/sheet`)
builder.WriteString(strconv.Itoa(i))
builder.WriteString(`.xml" ContentType="`)
builder.WriteString(xlsxWorksheetContentType)
builder.WriteString(`"/>`)
}
builder.WriteString(`</Types>`)
return []byte(builder.String())
}
func buildXLSXWorkbookXML(sheets []*xlsxExportTempSheet) []byte {
var builder strings.Builder
builder.Grow(512 + len(sheets)*96)
builder.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
builder.WriteString(`<workbook xmlns="` + xlsxMainNamespace + `" xmlns:r="` + xlsxRelationshipsNamespace + `">`)
builder.WriteString(`<bookViews><workbookView workbookViewId="0"/></bookViews>`)
builder.WriteString(`<sheets>`)
for i, sheet := range sheets {
builder.WriteString(`<sheet name="`)
builder.WriteString(sheet.name)
builder.WriteString(`" sheetId="`)
builder.WriteString(strconv.Itoa(i + 1))
builder.WriteString(`" r:id="rId`)
builder.WriteString(strconv.Itoa(i + 1))
builder.WriteString(`"/>`)
}
builder.WriteString(`</sheets>`)
builder.WriteString(`</workbook>`)
return []byte(builder.String())
}
func buildXLSXWorkbookRels(sheets []*xlsxExportTempSheet) []byte {
var builder strings.Builder
builder.Grow(512 + len(sheets)*160)
builder.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
builder.WriteString(`<Relationships xmlns="` + xlsxPackageRelationshipsNS + `">`)
for i := range sheets {
builder.WriteString(`<Relationship Id="rId`)
builder.WriteString(strconv.Itoa(i + 1))
builder.WriteString(`" Type="`)
builder.WriteString(xlsxWorksheetType)
builder.WriteString(`" Target="worksheets/sheet`)
builder.WriteString(strconv.Itoa(i + 1))
builder.WriteString(`.xml"/>`)
}
builder.WriteString(`<Relationship Id="rId`)
builder.WriteString(strconv.Itoa(len(sheets) + 1))
builder.WriteString(`" Type="`)
builder.WriteString(xlsxStylesType)
builder.WriteString(`" Target="styles.xml"/>`)
builder.WriteString(`<Relationship Id="rId`)
builder.WriteString(strconv.Itoa(len(sheets) + 2))
builder.WriteString(`" Type="`)
builder.WriteString(xlsxThemeType)
builder.WriteString(`" Target="theme/theme1.xml"/>`)
builder.WriteString(`</Relationships>`)
return []byte(builder.String())
}