Repository: tableauio/tableau
Pull request #41 (2023-03-08 01:51:54)

feat(xml): export csv when debug mode open

codecov[bot]: # [Codecov](https://codecov.io/gh/tableauio/tableau/pull/41?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) Report
> Merging [#41](https://codecov.io/gh/tableauio/tableau/pull/41?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) (3a6e38b) into [master](https://codecov.io/gh/tableauio/tableau/commit/68ffb131604d8e74f8e3bc4d69d1f14ed6fd6b95?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) (68ffb13) will **decrease** coverage by `0.04%`.
> The diff coverage is `0.00%`.

```diff
@@            Coverage Diff             @@
##           master      #41      +/-   ##
==========================================
- Coverage   25.06%   25.03%   -0.04%     
==========================================
  Files          23       23              
  Lines        3267     3272       +5     
==========================================
  Hits          819      819              
- Misses       2393     2398       +5     
  Partials       55       55              
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `25.03% <0.00%> (-0.04%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/tableauio/tableau/pull/41?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) | Coverage Δ | |
|---|---|---|
| [internal/importer/xml.go](https://codecov.io/gh/tableauio/tableau/pull/41?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio#diff-aW50ZXJuYWwvaW1wb3J0ZXIveG1sLmdv) | `55.63% <0.00%> (-0.32%)` | :arrow_down: |
| [log/log.go](https://codecov.io/gh/tableauio/tableau/pull/41?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio#diff-bG9nL2xvZy5nbw==) | `18.57% <0.00%> (-0.55%)` | :arrow_down: |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio)


Patch:
diff --git a/internal/confgen/confgen.go b/internal/confgen/confgen.go
index 5ba3ece..830d75e 100644
--- a/internal/confgen/confgen.go
+++ b/internal/confgen/confgen.go
@@ -220,18 +220,22 @@ func (gen *Generator) convert(fd protoreflect.FileDescriptor, worksheetName stri
 		// log.Debugf("%s", md.FullName())
 		log.Infof("%18s: %s#%s (%s#%s)", "parsing worksheet", fd.Path(), sheetInfo.MD.Name(), workbook.Name, sheetName)
 
-		// get merger importers
-		importers, err := importer.GetMergerImporters(wbPath, sheetName, sheetInfo.Opts.Merger)
-		if err != nil {
-			return xerrors.WithMessageKV(err, xerrors.KeyModule, xerrors.ModuleConf, xerrors.KeyBookName, workbook.Name)
+		if sheetInfo.HasScatter() {
+			if sheetInfo.HasMerger() {
+				return xerrors.ErrorKV("option Scatter and Merger cannot be both set at one sheet",
+					xerrors.KeyModule, xerrors.ModuleConf, xerrors.KeyBookName, workbook.Name, xerrors.KeySheetName, worksheetName)
+			}
+			err := gen.processScatter(imp, &sheetInfo, wbPath, sheetName)
+			if err != nil {
+				return xerrors.WithMessageKV(err, xerrors.KeyModule, xerrors.ModuleConf, xerrors.KeyBookName, workbook.Name, xerrors.KeySheetName, worksheetName)
+			}
+		} else {
+			err := gen.processMerger(imp, &sheetInfo, wbPath, sheetName)
+			if err != nil {
+				return xerrors.WithMessageKV(err, xerrors.KeyModule, xerrors.ModuleConf, xerrors.KeyBookName, workbook.Name, xerrors.KeySheetName, worksheetName)
+			}
 		}
-		// append self
-		importers = append(importers, imp)
 
-		exporter := NewSheetExporter(gen.OutputDir, gen.OutputOpt)
-		if err := exporter.Export(&sheetInfo, importers...); err != nil {
-			return xerrors.WithMessageKV(err, xerrors.KeyModule, xerrors.ModuleConf, xerrors.KeyBookName, workbook.Name)
-		}
 		seconds := time.Since(sheetBeginTime).Milliseconds() + bookPrepareMilliseconds
 		gen.PerfStats.Store(sheetInfo.MD.Name(), seconds)
 	}
@@ -244,6 +248,34 @@ func (gen *Generator) convert(fd protoreflect.FileDescriptor, worksheetName stri
 	return nil
 }
 
+func (gen *Generator) processScatter(self importer.Importer, sheetInfo *SheetInfo, wbPath, sheetName string) error {
+	importers, err := importer.GetScatterImporters(wbPath, sheetName, sheetInfo.Opts.Scatter)
+	if err != nil {
+		return err
+	}
+	// append self
+	importers = append(importers, self)
+	exporter := NewSheetExporter(gen.OutputDir, gen.OutputOpt)
+	if err := exporter.ScatterAndExport(sheetInfo, importers...); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (gen *Generator) processMerger(self importer.Importer, sheetInfo *SheetInfo, wbPath, sheetName string) error {
+	importers, err := importer.GetMergerImporters(wbPath, sheetName, sheetInfo.Opts.Merger)
+	if err != nil {
+		return err
+	}
+	// append self
+	importers = append(importers, self)
+	exporter := NewSheetExporter(gen.OutputDir, gen.OutputOpt)
+	if err := exporter.MergeAndExport(sheetInfo, importers...); err != nil {
+		return err
+	}
+	return nil
+}
+
 // get protoregistry.Files with specified package name
 func (gen *Generator) loadProtoRegistryFiles(protoPackage string, protoPaths []string, protoFiles []string, excludeProtoFiles ...string) error {
 	count := 0
diff --git a/internal/confgen/parser.go b/internal/confgen/parser.go
index cab3788..4c85578 100644
--- a/internal/confgen/parser.go
+++ b/internal/confgen/parser.go
@@ -38,8 +38,46 @@ func NewSheetExporter(outputDir string, output *options.ConfOutputOption) *sheet
 	}
 }
 
-// parse and export the protomsg message.
-func (x *sheetExporter) Export(info *SheetInfo, importers ...importer.Importer) error {
+// ScatterAndExport parse multiple importers into separate protomsgs, then export each other.
+func (x *sheetExporter) ScatterAndExport(info *SheetInfo, importers ...importer.Importer) error {
+	// NOTE: use map-reduce pattern to accelerate parsing multiple importers.
+	var mu sync.Mutex // guard msgs
+	var msgs []oneMsg
+
+	var eg errgroup.Group
+	for _, imp := range importers {
+		imp := imp
+		// map-reduce: map jobs for concurrent processing
+		eg.Go(func() error {
+			protomsg, err := parseMessageFromOneImporter(info, imp)
+			if err != nil {
+				return err
+			}
+			mu.Lock()
+			msgs = append(msgs, oneMsg{
+				protomsg: protomsg,
+				bookName: imp.BookName(),
+			})
+			mu.Unlock()
+			return nil
+		})
+	}
+	if err := eg.Wait(); err != nil {
+		return err
+	}
+	for _, msg := range msgs {
+		// name pattern is : <BookName>_<SheetName>
+		name := fmt.Sprintf("%s_%s", msg.bookName, info.MD.Name())
+		exporter := mexporter.New(name, msg.protomsg, x.OutputDir, x.OutputOpt, info.Opts)
+		if err := exporter.Export(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// MergeAndExport parse multiple importers and merge into one protomsg, then export it.
+func (x *sheetExporter) MergeAndExport(info *SheetInfo, importers ...importer.Importer) error {
 	protomsg, err := ParseMessage(info, importers...)
 	if err != nil {
 		return err
@@ -146,6 +184,14 @@ func NewSheetInfo(protoPackage, locationName string, md protoreflect.MessageDesc
 	}
 }
 
+func (si *SheetInfo) HasScatter() bool {
+	return si.Opts != nil && len(si.Opts.Scatter) != 0
+}
+
+func (si *SheetInfo) HasMerger() bool {
+	return si.Opts != nil && len(si.Opts.Merger) != 0
+}
+
 func newSheetParserInternal(info *SheetInfo) *sheetParser {
 	parser := NewSheetParser(info.ProtoPackage, info.LocationName, info.Opts)
 	parser.gen = info.gen // set generator
@@ -1111,11 +1157,11 @@ func (sp *sheetParser) parseUnionField(field *Field, msg protoreflect.Message, r
 		return false, nil
 	}
 	valueFD := unionDesc.GetValueByNumber(fieldNumber)
-	if valueFD == nil{
+	if valueFD == nil {
 		typeValue := unionDesc.Type.Enum().Values().ByNumber(protoreflect.EnumNumber(fieldNumber)).Name()
 		err := xerrors.E2010(typeValue, fieldNumber)
 		kvs := append(rc.CellDebugKV(typeColName), xerrors.KeyPBFieldType, "enum")
-		return false, xerrors.WithMessageKV(err, kvs...) 
+		return false, xerrors.WithMessageKV(err, kvs...)
 	}
 	fieldValue := structValue.Message().NewField(valueFD)
 	if valueFD.Kind() == protoreflect.MessageKind {
@@ -1124,7 +1170,7 @@ func (sp *sheetParser) parseUnionField(field *Field, msg protoreflect.Message, r
 		msg := fieldValue.Message()
 		for i := 0; i < md.Fields().Len(); i++ {
 			fd := md.Fields().Get(i)
-			colName := prefix + field.opts.Name + unionDesc.ValueFieldName() + strconv.Itoa(i+1)
+			colName := prefix + field.opts.Name + unionDesc.ValueFieldName() + strconv.Itoa(int(fd.Number()))
 			err := func() error {
 				subField := parseFieldDescriptor(fd, sp.opts.Sep, sp.opts.Subsep)
 				defer subField.release()
diff --git a/internal/confgen/util.go b/internal/confgen/util.go
index b4a8cd7..a036d7c 100644
--- a/internal/confgen/util.go
+++ b/internal/confgen/util.go
@@ -39,8 +39,8 @@ func parseFieldDescriptor(fd protoreflect.FieldDescriptor, sheetSep, sheetSubsep
 	span := tableaupb.Span_SPAN_DEFAULT
 	key := ""
 	layout := tableaupb.Layout_LAYOUT_DEFAULT
-	sep := ","
-	subsep := ":"
+	sep := ""
+	subsep := ""
 	optional := false
 	var prop *tableaupb.FieldProp
 
diff --git a/internal/fs/csv.go b/internal/fs/csv.go
new file mode 100644
index 0000000..a41954b
--- /dev/null
+++ b/internal/fs/csv.go
@@ -0,0 +1,33 @@
+package fs
+
+import (
+	"path/filepath"
+	"strings"
+
+	"github.com/pkg/errors"
+	"github.com/tableauio/tableau/format"
+)
+
+func ParseCSVFilenamePattern(filename string) (bookName, sheetName string, err error) {
+	// Recognize pattern: "<BookName>#<SheetName>.csv"
+	basename := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
+	splits := strings.SplitN(basename, "#", 2)
+	if len(splits) == 2 {
+		return splits[0], splits[1], nil
+	}
+	return "", "", errors.Errorf("cannot parse the book name and sheet name from filename: %s", filename)
+}
+
+func GenCSVBooknamePattern(dir, bookName string) string {
+	bookNamePattern := bookName + "#*" + format.CSVExt
+	return filepath.Join(dir, bookNamePattern)
+}
+
+func ParseCSVBooknamePatternFrom(filename string) (string, error) {
+	dir := filepath.Dir(filename)
+	bookName, _, err := ParseCSVFilenamePattern(filename)
+	if err != nil {
+		return "", err
+	}
+	return GenCSVBooknamePattern(dir, bookName), nil
+}
\ No newline at end of file
diff --git a/internal/fs/util.go b/internal/fs/util.go
index 9fc1605..965e5a1 100644
--- a/internal/fs/util.go
+++ b/internal/fs/util.go
@@ -8,6 +8,7 @@ import (
 	"strings"
 
 	"github.com/pkg/errors"
+	"github.com/tableauio/tableau/format"
 )
 
 // CopyFile copies a file from src to dst. If src and dst files exist, and are
@@ -123,3 +124,51 @@ func RewriteSubdir(filename string, subdirRewrites map[string]string) string {
 	}
 	return filename
 }
+
+func RangeFilesByFormat(dir string, fmt format.Format, callback func(bookPath string) error) error {
+	dirEntries, err := os.ReadDir(dir)
+	if err != nil {
+		return err
+	}
+
+	// book name -> existence(bool)
+	csvBooks := map[string]bool{}
+	for _, entry := range dirEntries {
+		if entry.IsDir() {
+			// scan and generate subdir recursively
+			subdir := filepath.Join(dir, entry.Name())
+			err = RangeFilesByFormat(subdir, fmt, callback)
+			if err != nil {
+				return err
+			}
+			continue
+		}
+		fileFmt := format.Ext2Format(filepath.Ext(entry.Name()))
+		if fileFmt != fmt {
+			continue
+		}
+		switch fmt {
+		case format.Excel:
+			bookPath := filepath.Join(dir, entry.Name())
+			if err := callback(bookPath); err != nil {
+				return err
+			}
+		case format.CSV:
+			bookName, _, err := ParseCSVFilenamePattern(entry.Name())
+			if err != nil {
+				return err
+			}
+			if _, ok := csvBooks[bookName]; ok {
+				// NOTE: multiple CSV files construct the same book.
+				continue
+			}
+			csvBooks[bookName] = true
+			if err := callback(GenCSVBooknamePattern(dir, bookName)); err != nil {
+				return err
+			}
+		default:
+			return errors.New("unknown fommat: " + string(fmt))
+		}
+	}
+	return nil
+}
diff --git a/internal/importer/csv.go b/internal/importer/csv.go
index a3071d7..a64e9a8 100644
--- a/internal/importer/csv.go
+++ b/internal/importer/csv.go
@@ -4,11 +4,10 @@ import (
 	"encoding/csv"
 	"os"
 	"path/filepath"
-	"strings"
 
 	"github.com/emirpasic/gods/sets/treeset"
 	"github.com/pkg/errors"
-	"github.com/tableauio/tableau/format"
+	"github.com/tableauio/tableau/internal/fs"
 	"github.com/tableauio/tableau/internal/importer/book"
 )
 
@@ -29,7 +28,7 @@ func NewCSVImporter(filename string, sheetNames []string, parser book.SheetParse
 }
 
 func parseCSVBook(filename string, sheetNames []string, parser book.SheetParser) (*book.Book, error) {
-	_, _, err := ParseCSVFilenamePattern(filename)
+	_, _, err := fs.ParseCSVFilenamePattern(filename)
 	if err != nil {
 		return nil, err
 	}
@@ -53,11 +52,11 @@ func parseCSVBook(filename string, sheetNames []string, parser book.SheetParser)
 }
 
 func readCSVBook(filename string, parser book.SheetParser) (*book.Book, error) {
-	bookName, _, err := ParseCSVFilenamePattern(filename)
+	bookName, _, err := fs.ParseCSVFilenamePattern(filename)
 	if err != nil {
 		return nil, errors.Errorf("cannot parse the book name from filename: %s", filename)
 	}
-	globFilename := GenCSVBooknamePattern(filepath.Dir(filename), bookName)
+	globFilename := fs.GenCSVBooknamePattern(filepath.Dir(filename), bookName)
 	matches, err := filepath.Glob(globFilename)
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to glob %s", globFilename)
@@ -75,7 +74,7 @@ func readCSVBook(filename string, parser book.SheetParser) (*book.Book, error) {
 	newBook := book.NewBook(bookName, globFilename, parser)
 	for _, val := range set.Values() {
 		filename := val.(string)
-		_, sheetName, err := ParseCSVFilenamePattern(filename)
+		_, sheetName, err := fs.ParseCSVFilenamePattern(filename)
 		if err != nil {
 			return nil, errors.Errorf("cannot parse the sheet name from filename: %s", filename)
 		}
@@ -90,30 +89,6 @@ func readCSVBook(filename string, parser book.SheetParser) (*book.Book, error) {
 	return newBook, nil
 }
 
-func GenCSVBooknamePattern(dir, bookName string) string {
-	bookNamePattern := bookName + "#*" + format.CSVExt
-	return filepath.Join(dir, bookNamePattern)
-}
-
-func ParseCSVFilenamePattern(filename string) (bookName, sheetName string, err error) {
-	// Recognize pattern: "<BookName>#<SheetName>.csv"
-	basename := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
-	splits := strings.SplitN(basename, "#", 2)
-	if len(splits) == 2 {
-		return splits[0], splits[1], nil
-	}
-	return "", "", errors.Errorf("cannot parse the book name and sheet name from filename: %s", filename)
-}
-
-func ParseCSVBooknamePatternFrom(filename string) (string, error) {
-	dir := filepath.Dir(filename)
-	bookName, _, err := ParseCSVFilenamePattern(filename)
-	if err != nil {
-		return "", err
-	}
-	return GenCSVBooknamePattern(dir, bookName), nil
-}
-
 func readCSV(filename string) ([][]string, error) {
 	f, err := os.Open(filename)
 	if err != nil {
diff --git a/internal/importer/excel.go b/internal/importer/excel.go
index 370fdc8..c8117a4 100644
--- a/internal/importer/excel.go
+++ b/internal/importer/excel.go
@@ -17,7 +17,7 @@ type ExcelImporter struct {
 	*book.Book
 }
 
-func NewExcelImporter(filename string, sheetNames []string, parser book.SheetParser, mode ImporterMode, merged bool) (*ExcelImporter, error) {
+func NewExcelImporter(filename string, sheetNames []string, parser book.SheetParser, mode ImporterMode, cloned bool) (*ExcelImporter, error) {
 	file, err := excelize.OpenFile(filename)
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to open file %s", filename)
@@ -31,7 +31,7 @@ func NewExcelImporter(filename string, sheetNames []string, parser book.SheetPar
 
 	var topN uint
 	if mode == Protogen {
-		n, err := adjustTopN(file, parser, merged)
+		n, err := adjustTopN(file, parser, cloned)
 		if err != nil {
 			return nil, errors.WithMessagef(err, "failed to read book: %s", filename)
 		}
@@ -54,8 +54,8 @@ func NewExcelImporter(filename string, sheetNames []string, parser book.SheetPar
 	}, nil
 }
 
-func adjustTopN(file *excelize.File, parser book.SheetParser, merged bool) (uint, error) {
-	if parser != nil && !merged {
+func adjustTopN(file *excelize.File, parser book.SheetParser, cloned bool) (uint, error) {
+	if parser != nil && !cloned {
 		// parse metasheet, and change topN to 0 if any sheet is transpose
 		metasheet, err := readExcelSheet(file, book.MetasheetName, 0)
 		if err != nil {
diff --git a/internal/importer/importer.go b/internal/importer/importer.go
index 1cc97c3..580886c 100644
--- a/internal/importer/importer.go
+++ b/internal/importer/importer.go
@@ -36,7 +36,7 @@ func New(filename string, setters ...Option) (Importer, error) {
 	fmt := format.Ext2Format(filepath.Ext(filename))
 	switch fmt {
 	case format.Excel:
-		return NewExcelImporter(filename, opts.Sheets, opts.Parser, opts.Mode, opts.Merged)
+		return NewExcelImporter(filename, opts.Sheets, opts.Parser, opts.Mode, opts.Cloned)
 	case format.CSV:
 		return NewCSVImporter(filename, opts.Sheets, opts.Parser)
 	case format.XML:
@@ -46,21 +46,62 @@ func New(filename string, setters ...Option) (Importer, error) {
 	}
 }
 
-// GetMergerImporters gathers all merger importers.
+// GetMergerImporters return all related importers.
+//    1. support Glob pattern, refer https://pkg.go.dev/path/filepath#Glob
+//    2. exclude self
+//    3. special process for CSV filename pattern: "<BookName>#<SheetName>.csv"
+func GetMergerImporters(primaryBookPath, sheetName string, bookNameGlobs []string) ([]Importer, error) {
+	bookPaths, err := resolveBookPaths(primaryBookPath, sheetName, bookNameGlobs)
+	if err != nil {
+		return nil, errors.WithMessagef(err, "failed to resolve workbook paths")
+	}
+	var importers []Importer
+	for fpath := range bookPaths {
+		log.Infof("%18s: %s", "merge workbook", fpath)
+		importer, err := New(fpath, Sheets([]string{sheetName}), Cloned())
+		if err != nil {
+			return nil, errors.WithMessagef(err, "failed to create importer: %s", fpath)
+		}
+		importers = append(importers, importer)
+	}
+	return importers, nil
+}
+
+// GetScatterImporters return all related importers.
 // 	1. support Glob pattern, refer https://pkg.go.dev/path/filepath#Glob
 // 	2. exclude self
 //  3. special process for CSV filename pattern: "<BookName>#<SheetName>.csv"
-func GetMergerImporters(primaryWorkbookPath, sheetName string, merger []string) ([]Importer, error) {
-	if len(merger) == 0 {
-		return nil, nil
+func GetScatterImporters(primaryBookPath, sheetName string, bookNameGlobs []string) ([]Importer, error) {
+	bookPaths, err := resolveBookPaths(primaryBookPath, sheetName, bookNameGlobs)
+	if err != nil {
+		return nil, errors.WithMessagef(err, "failed to resolve workbook paths")
 	}
+	var importers []Importer
+	for fpath := range bookPaths {
+		log.Infof("%18s: %s", "scatter workbook", fpath)
+		importer, err := New(fpath, Sheets([]string{sheetName}), Cloned())
+		if err != nil {
+			return nil, errors.WithMessagef(err, "failed to create importer: %s", fpath)
+		}
+		importers = append(importers, importer)
+	}
+	return importers, nil
+}
 
-	fmt := format.Ext2Format(filepath.Ext(primaryWorkbookPath))
+// resolveBookPaths resolve and return all related workbook paths.
+// 	1. support Glob pattern, refer https://pkg.go.dev/path/filepath#Glob
+// 	2. exclude self
+//  3. special process for CSV filename pattern: "<BookName>#<SheetName>.csv"
+func resolveBookPaths(primaryBookPath, sheetName string, bookNameGlobs []string) (map[string]bool, error) {
+	bookPaths := map[string]bool{}
+	if len(bookNameGlobs) == 0 {
+		return bookPaths, nil
+	}
 
-	curDir := filepath.Dir(primaryWorkbookPath)
-	mergerWorkbookPaths := map[string]bool{}
-	for _, merger := range merger {
-		pattern := filepath.Join(curDir, merger)
+	fmt := format.Ext2Format(filepath.Ext(primaryBookPath))
+	curDir := filepath.Dir(primaryBookPath)
+	for _, nameGlob := range bookNameGlobs {
+		pattern := filepath.Join(curDir, nameGlob)
 		matches, err := filepath.Glob(pattern)
 		if err != nil {
 			return nil, errors.WithMessagef(err, "failed to glob pattern: %s", pattern)
@@ -69,26 +110,18 @@ func GetMergerImporters(primaryWorkbookPath, sheetName string, merger []string)
 			path := match
 			if fmt == format.CSV {
 				// special process for CSV filename pattern: "<BookName>#<SheetName>.csv"
-				path, err = ParseCSVBooknamePatternFrom(match)
+				path, err = fs.ParseCSVBooknamePatternFrom(match)
 				if err != nil {
 					return nil, err
 				}
 			}
-			if fs.IsSamePath(path, primaryWorkbookPath) {
+			if fs.IsSamePath(path, primaryBookPath) {
 				// exclude self
 				continue
 			}
-			mergerWorkbookPaths[path] = true
+			slashPath := fs.GetCleanSlashPath(path)
+			bookPaths[slashPath] = true
 		}
 	}
-	var importers []Importer
-	for fpath := range mergerWorkbookPaths {
-		log.Infof("%18s: %s", "merge workbook", fpath)
-		importer, err := New(fpath, Sheets([]string{sheetName}), Merged(true))
-		if err != nil {
-			return nil, errors.WithMessagef(err, "failed to create importer: %s", fpath)
-		}
-		importers = append(importers, importer)
-	}
-	return importers, nil
+	return bookPaths, nil
 }
diff --git a/internal/importer/options.go b/internal/importer/options.go
index 59a42fb..4d85226 100644
--- a/internal/importer/options.go
+++ b/internal/importer/options.go
@@ -19,7 +19,7 @@ type Options struct {
 	Sheets []string         // sheet names to import
 	Parser book.SheetParser // parser to parse the worksheet
 	Mode   ImporterMode     // importer mode
-	Merged bool             // this book is merged to the main book
+	Cloned bool             // this book cloned (same schema different data) from the main book
 }
 
 // Option is the functional option type.
@@ -43,9 +43,9 @@ func Mode(m ImporterMode) Option {
 	}
 }
 
-func Merged(merged bool) Option {
+func Cloned() Option {
 	return func(opts *Options) {
-		opts.Merged = merged
+		opts.Cloned = true
 	}
 }
 
diff --git a/internal/importer/xml.go b/internal/importer/xml.go
index 34c10e0..28918d1 100644
--- a/internal/importer/xml.go
+++ b/internal/importer/xml.go
@@ -17,6 +17,7 @@ import (
 	"github.com/tableauio/tableau/internal/types"
 	"github.com/tableauio/tableau/internal/xlsxgen"
 	"github.com/tableauio/tableau/log"
+	"github.com/tableauio/tableau/log/core"
 	"github.com/tableauio/tableau/options"
 	"github.com/tableauio/tableau/proto/tableaupb"
 )
@@ -69,7 +70,9 @@ func NewXMLImporter(filename string, sheets []string, parser book.SheetParser, m
 			Book: book.NewBook(bookName, filename, nil),
 		}, nil
 	}
-	// newBook.ExportCSV()
+	if log.Level() == core.DebugLevel.CapitalString() {
+		newBook.ExportCSV()
+	}
 
 	return &XMLImporter{
 		Book: newBook,
diff --git a/internal/protogen/protogen.go b/internal/protogen/protogen.go
index 6f7d0e8..24fd285 100644
--- a/internal/protogen/protogen.go
+++ b/internal/protogen/protogen.go
@@ -209,7 +209,7 @@ func (gen *Generator) generate(dir string) (err error) {
 		}
 
 		if fmt == format.CSV {
-			bookName, _, err := importer.ParseCSVFilenamePattern(entry.Name())
+			bookName, _, err := fs.ParseCSVFilenamePattern(entry.Name())
 			if err != nil {
 				return err
 			}
@@ -322,6 +322,8 @@ func (gen *Generator) convert(dir, filename string, checkProtoFileConflicts bool
 				AdjacentKey:   sheet.Meta.AdjacentKey,
 				FieldPresence: sheet.Meta.FieldPresence,
 				Template:      sheet.Meta.Template,
+				Mode:          sheet.Meta.Mode,
+				Scatter:       sheet.Meta.Scatter,
 				// Loader options:
 				OrderedMap: sheet.Meta.OrderedMap,
 				Index:      parseIndexes(sheet.Meta.Index),
diff --git a/log/log.go b/log/log.go
index a4025a1..1bb5100 100644
--- a/log/log.go
+++ b/log/log.go
@@ -47,6 +47,10 @@ func Mode() string {
 	return gOpts.Mode
 }
 
+func Level() string {
+	return gOpts.Level
+}
+
 func SetDriver(driver driver.Driver) {
 	defaultLogger.driver = driver
 }
diff --git a/proto/tableau/protobuf/metabook.proto b/proto/tableau/protobuf/metabook.proto
index 0e90772..8ecfa53 100644
--- a/proto/tableau/protobuf/metabook.proto
+++ b/proto/tableau/protobuf/metabook.proto
@@ -32,7 +32,7 @@ message Metasheet {
   bool nested = 10 [(tableau.field) = { name: "Nested" optional: true }];
   string sep = 11 [(tableau.field) = { name: "Sep" optional: true }];
   string subsep = 12 [(tableau.field) = { name: "Subsep" optional: true }];
-  // merger of multiple sheets: [Element]...
+  // merge multiple sheets with same schema to one.
   // each element is:
   // - a workbook name or Glob(https://pkg.go.dev/path/filepath#Glob) to merge (relative to this workbook): <Workbook>,
   //   then the sheet name is the same as this sheet.
@@ -56,6 +56,12 @@ message Metasheet {
   bool template = 16 [(tableau.field) = { name: "Template" optional: true }];
   // Sheet mode.
   Mode mode = 17 [(tableau.field) = { name: "Mode" optional: true }];
+  // Scatter convert sheets separately with same schema.
+  // each element is:
+  // - a workbook name or Glob(https://pkg.go.dev/path/filepath#Glob) which is relative to this workbook: <Workbook>,
+  //   then the sheet name is the same as this sheet.
+  // - or a workbook name which is relative to this workbook with a worksheet name: <Workbook>#<Worksheet>.
+  repeated string scatter = 18 [(tableau.field) = { name: "Scatter" optional: true layout: LAYOUT_INCELL }];
 
   ////////// Loader related options below //////////
   // Generate ordered map accessers
@@ -80,14 +86,3 @@ message Metasheet {
   // - const STRUCT_TYPE* FindFirst<IndexName>(INDEX_TYPE index);
   string index = 51 [(tableau.field) = { name: "Index" optional: true }];
 }
-
-enum Mode {
-  MODE_DEFAULT = 0;  // Default mode.
-  // UE DataTable references:
-  //  - https://docs.unrealengine.com/5.1/en-US/data-driven-gameplay-elements-in-unreal-engine/
-  //  - https://docs.unrealengine.com/5.1/en-US/BlueprintAPI/EditorScripting/DataTable/
-  MODE_UE_CSV = 1;      // CSV format of UE DataTable.
-  MODE_UE_JSON = 2;     // JSON format of UE DataTable.
-  MODE_ENUM_TYPE = 3;   // Enum type definition in sheet.
-  MODE_UNION_TYPE = 4;  // Union type definition in sheet.
-}
diff --git a/proto/tableau/protobuf/tableau.proto b/proto/tableau/protobuf/tableau.proto
index 2c9e484..329377a 100644
--- a/proto/tableau/protobuf/tableau.proto
+++ b/proto/tableau/protobuf/tableau.proto
@@ -92,8 +92,8 @@ message WorksheetOptions {
   //
   // Default: ":".
   string subsep = 12;
-  // Merge multiple workbook sheets (comma-separated) into this one
-  // with the same structure. E.g.: Item1.xlsx,Item2.xlsx.
+  // Merger merge multiple workbook sheets (comma-separated) into one
+  // with same schema. E.g.: Item1.xlsx,Item2.xlsx,ItemAward*.xlsx.
   repeated string merger = 13;
   // Merge adjacent rows with the same key. If the key cell is not set,
   // it will be treated the same as the nearest key above the same column.
@@ -104,6 +104,11 @@ message WorksheetOptions {
   // Indicate this XML sheet is a template or not. Template sheet structure will
   // only be parsed based on metasheet "@TABLEAU" in comments.
   bool template = 16;
+  // Sheet mode.
+  Mode mode = 17;
+  // Scatter convert multiple workbook sheets (comma-separated) separately
+  // with same schema. E.g.: Item1.xlsx,Item2.xlsx,ItemAward*.xlsx.
+  repeated string scatter = 18;
 
   ////////// Loader related options below //////////
   // Generate OrderedMap accessers or not.
@@ -131,7 +136,7 @@ message EnumValueOptions {
 }
 
 message OneofOptions {
-  string name = 1;  // Alias. NOT USED currently.
+  string name = 1;   // Alias. NOT USED currently.
   string field = 2;  // Field name.
 }
 
@@ -180,8 +185,8 @@ message FieldProp {
   bool fixed = 6;
   // Explicitly specify fixed size of horizontal list or map.
   uint32 size = 7;
-  // Content format for parsing.
-  Format format = 8;
+  // Cell's data form for parsing.
+  Form form = 8;
 }
 
 // Layout of list and map.
@@ -207,8 +212,20 @@ enum Span {
   SPAN_INNER_CELL = 2;  // The field data spans inside one cell.
 }
 
-enum Format {
-  FORMAT_DEFAULT = 0;  // Default format.
-  FORMAT_TEXT = 1;     // Refer: https://developers.google.com/protocol-buffers/docs/text-format-spec
-  FORMAT_JSON = 2;     // Refer: https://developers.google.com/protocol-buffers/docs/proto3#json
+// Sheet mode.
+enum Mode {
+  MODE_DEFAULT = 0;  // Default mode.
+  // UE DataTable references:
+  //  - https://docs.unrealengine.com/5.1/en-US/data-driven-gameplay-elements-in-unreal-engine/
+  //  - https://docs.unrealengine.com/5.1/en-US/BlueprintAPI/EditorScripting/DataTable/
+  MODE_UE_CSV = 1;      // CSV format of UE DataTable.
+  MODE_UE_JSON = 2;     // JSON format of UE DataTable.
+  MODE_ENUM_TYPE = 3;   // Enum type definition in sheet.
+  MODE_UNION_TYPE = 4;  // Union type definition in sheet.
+}
+
+enum Form {
+  FORM_DEFAULT = 0;  // Default form which confgen parser defines.
+  FORM_TEXT = 1;     // Refer: https://developers.google.com/protocol-buffers/docs/text-format-spec
+  FORM_JSON = 2;     // Refer: https://developers.google.com/protocol-buffers/docs/proto3#json
 }
diff --git a/proto/tableaupb/metabook.pb.go b/proto/tableaupb/metabook.pb.go
index e1982c3..e855c39 100644
--- a/proto/tableaupb/metabook.pb.go
+++ b/proto/tableaupb/metabook.pb.go
@@ -23,64 +23,6 @@ const (
 	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
 )
 
-type Mode int32
-
-const (
-	Mode_MODE_DEFAULT Mode = 0 // Default mode.
-	// UE DataTable references:
-	//  - https://docs.unrealengine.com/5.1/en-US/data-driven-gameplay-elements-in-unreal-engine/
-	//  - https://docs.unrealengine.com/5.1/en-US/BlueprintAPI/EditorScripting/DataTable/
-	Mode_MODE_UE_CSV     Mode = 1 // CSV format of UE DataTable.
-	Mode_MODE_UE_JSON    Mode = 2 // JSON format of UE DataTable.
-	Mode_MODE_ENUM_TYPE  Mode = 3 // Enum type definition in sheet.
-	Mode_MODE_UNION_TYPE Mode = 4 // Union type definition in sheet.
-)
-
-// Enum value maps for Mode.
-var (
-	Mode_name = map[int32]string{
-		0: "MODE_DEFAULT",
-		1: "MODE_UE_CSV",
-		2: "MODE_UE_JSON",
-		3: "MODE_ENUM_TYPE",
-		4: "MODE_UNION_TYPE",
-	}
-	Mode_value = map[string]int32{
-		"MODE_DEFAULT":    0,
-		"MODE_UE_CSV":     1,
-		"MODE_UE_JSON":    2,
-		"MODE_ENUM_TYPE":  3,
-		"MODE_UNION_TYPE": 4,
-	}
-)
-
-func (x Mode) Enum() *Mode {
-	p := new(Mode)
-	*p = x
-	return p
-}
-
-func (x Mode) String() string {
-	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
-}
-
-func (Mode) Descriptor() protoreflect.EnumDescriptor {
-	return file_tableau_protobuf_metabook_proto_enumTypes[0].Descriptor()
-}
-
-func (Mode) Type() protoreflect.EnumType {
-	return &file_tableau_protobuf_metabook_proto_enumTypes[0]
-}
-
-func (x Mode) Number() protoreflect.EnumNumber {
-	return protoreflect.EnumNumber(x)
-}
-
-// Deprecated: Use Mode.Descriptor instead.
-func (Mode) EnumDescriptor() ([]byte, []int) {
-	return file_tableau_protobuf_metabook_proto_rawDescGZIP(), []int{0}
-}
-
 type Metabook struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -146,7 +88,7 @@ type Metasheet struct {
 	Nested bool   `protobuf:"varint,10,opt,name=nested,proto3" json:"nested,omitempty"`
 	Sep    string `protobuf:"bytes,11,opt,name=sep,proto3" json:"sep,omitempty"`
 	Subsep string `protobuf:"bytes,12,opt,name=subsep,proto3" json:"subsep,omitempty"`
-	// merger of multiple sheets: [Element]...
+	// merge multiple sheets with same schema to one.
 	// each element is:
 	// - a workbook name or Glob(https://pkg.go.dev/path/filepath#Glob) to merge (relative to this workbook): <Workbook>,
 	//   then the sheet name is the same as this sheet.
@@ -170,6 +112,12 @@ type Metasheet struct {
 	Template bool `protobuf:"varint,16,opt,name=template,proto3" json:"template,omitempty"`
 	// Sheet mode.
 	Mode Mode `protobuf:"varint,17,opt,name=mode,proto3,enum=tableau.Mode" json:"mode,omitempty"`
+	// Scatter convert sheets separately with same schema.
+	// each element is:
+	// - a workbook name or Glob(https://pkg.go.dev/path/filepath#Glob) which is relative to this workbook: <Workbook>,
+	//   then the sheet name is the same as this sheet.
+	// - or a workbook name which is relative to this workbook with a worksheet name: <Workbook>#<Worksheet>.
+	Scatter []string `protobuf:"bytes,18,rep,name=scatter,proto3" json:"scatter,omitempty"`
 	////////// Loader related options below //////////
 	// Generate ordered map accessers
 	OrderedMap bool `protobuf:"varint,50,opt,name=ordered_map,json=orderedMap,proto3" json:"ordered_map,omitempty"`
@@ -345,6 +293,13 @@ func (x *Metasheet) GetMode() Mode {
 	return Mode_MODE_DEFAULT
 }
 
+func (x *Metasheet) GetScatter() []string {
+	if x != nil {
+		return x.Scatter
+	}
+	return nil
+}
+
 func (x *Metasheet) GetOrderedMap() bool {
 	if x != nil {
 		return x.OrderedMap
@@ -379,7 +334,7 @@ var file_tableau_protobuf_metabook_proto_rawDesc = []byte{
 	0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x4d,
 	0x65, 0x74, 0x61, 0x73, 0x68, 0x65, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
 	0x02, 0x38, 0x01, 0x3a, 0x12, 0x82, 0xb5, 0x18, 0x0e, 0x0a, 0x08, 0x40, 0x54, 0x41, 0x42, 0x4c,
-	0x45, 0x41, 0x55, 0x10, 0x01, 0x28, 0x02, 0x22, 0xd4, 0x06, 0x0a, 0x09, 0x4d, 0x65, 0x74, 0x61,
+	0x45, 0x41, 0x55, 0x10, 0x01, 0x28, 0x02, 0x22, 0x81, 0x07, 0x0a, 0x09, 0x4d, 0x65, 0x74, 0x61,
 	0x73, 0x68, 0x65, 0x65, 0x74, 0x12, 0x21, 0x0a, 0x05, 0x73, 0x68, 0x65, 0x65, 0x74, 0x18, 0x01,
 	0x20, 0x01, 0x28, 0x09, 0x42, 0x0b, 0x82, 0xb5, 0x18, 0x07, 0x0a, 0x05, 0x53, 0x68, 0x65, 0x65,
 	0x74, 0x52, 0x05, 0x73, 0x68, 0x65, 0x65, 0x74, 0x12, 0x23, 0x0a, 0x05, 0x61, 0x6c, 0x69, 0x61,
@@ -427,22 +382,19 @@ var file_tableau_protobuf_metabook_proto_rawDesc = []byte{
 	0x61, 0x74, 0x65, 0x12, 0x2f, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x11, 0x20, 0x01, 0x28,
 	0x0e, 0x32, 0x0d, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x4d, 0x6f, 0x64, 0x65,
 	0x42, 0x0c, 0x82, 0xb5, 0x18, 0x08, 0x0a, 0x04, 0x4d, 0x6f, 0x64, 0x65, 0x40, 0x01, 0x52, 0x04,
-	0x6d, 0x6f, 0x64, 0x65, 0x12, 0x33, 0x0a, 0x0b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x65, 0x64, 0x5f,
-	0x6d, 0x61, 0x70, 0x18, 0x32, 0x20, 0x01, 0x28, 0x08, 0x42, 0x12, 0x82, 0xb5, 0x18, 0x0e, 0x0a,
-	0x0a, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x40, 0x01, 0x52, 0x0a, 0x6f,
-	0x72, 0x64, 0x65, 0x72, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x12, 0x23, 0x0a, 0x05, 0x69, 0x6e, 0x64,
-	0x65, 0x78, 0x18, 0x33, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0d, 0x82, 0xb5, 0x18, 0x09, 0x0a, 0x05,
-	0x49, 0x6e, 0x64, 0x65, 0x78, 0x40, 0x01, 0x52, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x2a, 0x64,
-	0x0a, 0x04, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x10, 0x0a, 0x0c, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x44,
-	0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x4d, 0x4f, 0x44, 0x45,
-	0x5f, 0x55, 0x45, 0x5f, 0x43, 0x53, 0x56, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x4d, 0x4f, 0x44,
-	0x45, 0x5f, 0x55, 0x45, 0x5f, 0x4a, 0x53, 0x4f, 0x4e, 0x10, 0x02, 0x12, 0x12, 0x0a, 0x0e, 0x4d,
-	0x4f, 0x44, 0x45, 0x5f, 0x45, 0x4e, 0x55, 0x4d, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x10, 0x03, 0x12,
-	0x13, 0x0a, 0x0f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59,
-	0x50, 0x45, 0x10, 0x04, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63,
-	0x6f, 0x6d, 0x2f, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x69, 0x6f, 0x2f, 0x74, 0x61, 0x62,
-	0x6c, 0x65, 0x61, 0x75, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x74, 0x61, 0x62, 0x6c, 0x65,
-	0x61, 0x75, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+	0x6d, 0x6f, 0x64, 0x65, 0x12, 0x2b, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x74, 0x74, 0x65, 0x72, 0x18,
+	0x12, 0x20, 0x03, 0x28, 0x09, 0x42, 0x11, 0x82, 0xb5, 0x18, 0x0d, 0x0a, 0x07, 0x53, 0x63, 0x61,
+	0x74, 0x74, 0x65, 0x72, 0x20, 0x03, 0x40, 0x01, 0x52, 0x07, 0x73, 0x63, 0x61, 0x74, 0x74, 0x65,
+	0x72, 0x12, 0x33, 0x0a, 0x0b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x65, 0x64, 0x5f, 0x6d, 0x61, 0x70,
+	0x18, 0x32, 0x20, 0x01, 0x28, 0x08, 0x42, 0x12, 0x82, 0xb5, 0x18, 0x0e, 0x0a, 0x0a, 0x4f, 0x72,
+	0x64, 0x65, 0x72, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x40, 0x01, 0x52, 0x0a, 0x6f, 0x72, 0x64, 0x65,
+	0x72, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x12, 0x23, 0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18,
+	0x33, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0d, 0x82, 0xb5, 0x18, 0x09, 0x0a, 0x05, 0x49, 0x6e, 0x64,
+	0x65, 0x78, 0x40, 0x01, 0x52, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x42, 0x2e, 0x5a, 0x2c, 0x67,
+	0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61,
+	0x75, 0x69, 0x6f, 0x2f, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2f, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x2f, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x33,
 }
 
 var (
@@ -457,18 +409,17 @@ func file_tableau_protobuf_metabook_proto_rawDescGZIP() []byte {
 	return file_tableau_protobuf_metabook_proto_rawDescData
 }
 
-var file_tableau_protobuf_metabook_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
 var file_tableau_protobuf_metabook_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
 var file_tableau_protobuf_metabook_proto_goTypes = []interface{}{
-	(Mode)(0),         // 0: tableau.Mode
-	(*Metabook)(nil),  // 1: tableau.Metabook
-	(*Metasheet)(nil), // 2: tableau.Metasheet
-	nil,               // 3: tableau.Metabook.MetasheetMapEntry
+	(*Metabook)(nil),  // 0: tableau.Metabook
+	(*Metasheet)(nil), // 1: tableau.Metasheet
+	nil,               // 2: tableau.Metabook.MetasheetMapEntry
+	(Mode)(0),         // 3: tableau.Mode
 }
 var file_tableau_protobuf_metabook_proto_depIdxs = []int32{
-	3, // 0: tableau.Metabook.metasheet_map:type_name -> tableau.Metabook.MetasheetMapEntry
-	0, // 1: tableau.Metasheet.mode:type_name -> tableau.Mode
-	2, // 2: tableau.Metabook.MetasheetMapEntry.value:type_name -> tableau.Metasheet
+	2, // 0: tableau.Metabook.metasheet_map:type_name -> tableau.Metabook.MetasheetMapEntry
+	3, // 1: tableau.Metasheet.mode:type_name -> tableau.Mode
+	1, // 2: tableau.Metabook.MetasheetMapEntry.value:type_name -> tableau.Metasheet
 	3, // [3:3] is the sub-list for method output_type
 	3, // [3:3] is the sub-list for method input_type
 	3, // [3:3] is the sub-list for extension type_name
@@ -513,14 +464,13 @@ func file_tableau_protobuf_metabook_proto_init() {
 		File: protoimpl.DescBuilder{
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: file_tableau_protobuf_metabook_proto_rawDesc,
-			NumEnums:      1,
+			NumEnums:      0,
 			NumMessages:   3,
 			NumExtensions: 0,
 			NumServices:   0,
 		},
 		GoTypes:           file_tableau_protobuf_metabook_proto_goTypes,
 		DependencyIndexes: file_tableau_protobuf_metabook_proto_depIdxs,
-		EnumInfos:         file_tableau_protobuf_metabook_proto_enumTypes,
 		MessageInfos:      file_tableau_protobuf_metabook_proto_msgTypes,
 	}.Build()
 	File_tableau_protobuf_metabook_proto = out.File
diff --git a/proto/tableaupb/tableau.pb.go b/proto/tableaupb/tableau.pb.go
index b96598f..ed1a068 100644
--- a/proto/tableaupb/tableau.pb.go
+++ b/proto/tableaupb/tableau.pb.go
@@ -135,55 +135,114 @@ func (Span) EnumDescriptor() ([]byte, []int) {
 	return file_tableau_protobuf_tableau_proto_rawDescGZIP(), []int{1}
 }
 
-type Format int32
+// Sheet mode.
+type Mode int32
 
 const (
-	Format_FORMAT_DEFAULT Format = 0 // Default format.
-	Format_FORMAT_TEXT    Format = 1 // Refer: https://developers.google.com/protocol-buffers/docs/text-format-spec
-	Format_FORMAT_JSON    Format = 2 // Refer: https://developers.google.com/protocol-buffers/docs/proto3#json
+	Mode_MODE_DEFAULT Mode = 0 // Default mode.
+	// UE DataTable references:
+	//  - https://docs.unrealengine.com/5.1/en-US/data-driven-gameplay-elements-in-unreal-engine/
+	//  - https://docs.unrealengine.com/5.1/en-US/BlueprintAPI/EditorScripting/DataTable/
+	Mode_MODE_UE_CSV     Mode = 1 // CSV format of UE DataTable.
+	Mode_MODE_UE_JSON    Mode = 2 // JSON format of UE DataTable.
+	Mode_MODE_ENUM_TYPE  Mode = 3 // Enum type definition in sheet.
+	Mode_MODE_UNION_TYPE Mode = 4 // Union type definition in sheet.
 )
 
-// Enum value maps for Format.
+// Enum value maps for Mode.
 var (
-	Format_name = map[int32]string{
-		0: "FORMAT_DEFAULT",
-		1: "FORMAT_TEXT",
-		2: "FORMAT_JSON",
+	Mode_name = map[int32]string{
+		0: "MODE_DEFAULT",
+		1: "MODE_UE_CSV",
+		2: "MODE_UE_JSON",
+		3: "MODE_ENUM_TYPE",
+		4: "MODE_UNION_TYPE",
 	}
-	Format_value = map[string]int32{
-		"FORMAT_DEFAULT": 0,
-		"FORMAT_TEXT":    1,
-		"FORMAT_JSON":    2,
+	Mode_value = map[string]int32{
+		"MODE_DEFAULT":    0,
+		"MODE_UE_CSV":     1,
+		"MODE_UE_JSON":    2,
+		"MODE_ENUM_TYPE":  3,
+		"MODE_UNION_TYPE": 4,
 	}
 )
 
-func (x Format) Enum() *Format {
-	p := new(Format)
+func (x Mode) Enum() *Mode {
+	p := new(Mode)
 	*p = x
 	return p
 }
 
-func (x Format) String() string {
+func (x Mode) String() string {
 	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
 }
 
-func (Format) Descriptor() protoreflect.EnumDescriptor {
+func (Mode) Descriptor() protoreflect.EnumDescriptor {
 	return file_tableau_protobuf_tableau_proto_enumTypes[2].Descriptor()
 }
 
-func (Format) Type() protoreflect.EnumType {
+func (Mode) Type() protoreflect.EnumType {
 	return &file_tableau_protobuf_tableau_proto_enumTypes[2]
 }
 
-func (x Format) Number() protoreflect.EnumNumber {
+func (x Mode) Number() protoreflect.EnumNumber {
 	return protoreflect.EnumNumber(x)
 }
 
-// Deprecated: Use Format.Descriptor instead.
-func (Format) EnumDescriptor() ([]byte, []int) {
+// Deprecated: Use Mode.Descriptor instead.
+func (Mode) EnumDescriptor() ([]byte, []int) {
 	return file_tableau_protobuf_tableau_proto_rawDescGZIP(), []int{2}
 }
 
+type Form int32
+
+const (
+	Form_FORM_DEFAULT Form = 0 // Default form which confgen parser defines.
+	Form_FORM_TEXT    Form = 1 // Refer: https://developers.google.com/protocol-buffers/docs/text-format-spec
+	Form_FORM_JSON    Form = 2 // Refer: https://developers.google.com/protocol-buffers/docs/proto3#json
+)
+
+// Enum value maps for Form.
+var (
+	Form_name = map[int32]string{
+		0: "FORM_DEFAULT",
+		1: "FORM_TEXT",
+		2: "FORM_JSON",
+	}
+	Form_value = map[string]int32{
+		"FORM_DEFAULT": 0,
+		"FORM_TEXT":    1,
+		"FORM_JSON":    2,
+	}
+)
+
+func (x Form) Enum() *Form {
+	p := new(Form)
+	*p = x
+	return p
+}
+
+func (x Form) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (Form) Descriptor() protoreflect.EnumDescriptor {
+	return file_tableau_protobuf_tableau_proto_enumTypes[3].Descriptor()
+}
+
+func (Form) Type() protoreflect.EnumType {
+	return &file_tableau_protobuf_tableau_proto_enumTypes[3]
+}
+
+func (x Form) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use Form.Descriptor instead.
+func (Form) EnumDescriptor() ([]byte, []int) {
+	return file_tableau_protobuf_tableau_proto_rawDescGZIP(), []int{3}
+}
+
 type WorkbookOptions struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -281,8 +340,8 @@ type WorksheetOptions struct {
 	//
 	// Default: ":".
 	Subsep string `protobuf:"bytes,12,opt,name=subsep,proto3" json:"subsep,omitempty"`
-	// Merge multiple workbook sheets (comma-separated) into this one
-	// with the same structure. E.g.: Item1.xlsx,Item2.xlsx.
+	// Merger merge multiple workbook sheets (comma-separated) into one
+	// with same schema. E.g.: Item1.xlsx,Item2.xlsx,ItemAward*.xlsx.
 	Merger []string `protobuf:"bytes,13,rep,name=merger,proto3" json:"merger,omitempty"`
 	// Merge adjacent rows with the same key. If the key cell is not set,
 	// it will be treated the same as the nearest key above the same column.
@@ -293,6 +352,11 @@ type WorksheetOptions struct {
 	// Indicate this XML sheet is a template or not. Template sheet structure will
 	// only be parsed based on metasheet "@TABLEAU" in comments.
 	Template bool `protobuf:"varint,16,opt,name=template,proto3" json:"template,omitempty"`
+	// Sheet mode.
+	Mode Mode `protobuf:"varint,17,opt,name=mode,proto3,enum=tableau.Mode" json:"mode,omitempty"`
+	// Scatter convert multiple workbook sheets (comma-separated) separately
+	// with same schema. E.g.: Item1.xlsx,Item2.xlsx,ItemAward*.xlsx.
+	Scatter []string `protobuf:"bytes,18,rep,name=scatter,proto3" json:"scatter,omitempty"`
 	////////// Loader related options below //////////
 	// Generate OrderedMap accessers or not.
 	OrderedMap bool `protobuf:"varint,50,opt,name=ordered_map,json=orderedMap,proto3" json:"ordered_map,omitempty"`
@@ -455,6 +519,20 @@ func (x *WorksheetOptions) GetTemplate() bool {
 	return false
 }
 
+func (x *WorksheetOptions) GetMode() Mode {
+	if x != nil {
+		return x.Mode
+	}
+	return Mode_MODE_DEFAULT
+}
+
+func (x *WorksheetOptions) GetScatter() []string {
+	if x != nil {
+		return x.Scatter
+	}
+	return nil
+}
+
 func (x *WorksheetOptions) GetOrderedMap() bool {
 	if x != nil {
 		return x.OrderedMap
@@ -763,8 +841,8 @@ type FieldProp struct {
 	Fixed bool `protobuf:"varint,6,opt,name=fixed,proto3" json:"fixed,omitempty"`
 	// Explicitly specify fixed size of horizontal list or map.
 	Size uint32 `protobuf:"varint,7,opt,name=size,proto3" json:"size,omitempty"`
-	// Content format for parsing.
-	Format Format `protobuf:"varint,8,opt,name=format,proto3,enum=tableau.Format" json:"format,omitempty"`
+	// Cell's data form for parsing.
+	Form Form `protobuf:"varint,8,opt,name=form,proto3,enum=tableau.Form" json:"form,omitempty"`
 }
 
 func (x *FieldProp) Reset() {
@@ -848,11 +926,11 @@ func (x *FieldProp) GetSize() uint32 {
 	return 0
 }
 
-func (x *FieldProp) GetFormat() Format {
+func (x *FieldProp) GetForm() Form {
 	if x != nil {
-		return x.Format
+		return x.Form
 	}
-	return Format_FORMAT_DEFAULT
+	return Form_FORM_DEFAULT
 }
 
 var file_tableau_protobuf_tableau_proto_extTypes = []protoimpl.ExtensionInfo{
@@ -962,7 +1040,7 @@ var file_tableau_protobuf_tableau_proto_rawDesc = []byte{
 	0x69, 0x70, 0x74, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x25, 0x0a, 0x0f, 0x57,
 	0x6f, 0x72, 0x6b, 0x62, 0x6f, 0x6f, 0x6b, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12,
 	0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61,
-	0x6d, 0x65, 0x22, 0xef, 0x03, 0x0a, 0x10, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x68, 0x65, 0x65, 0x74,
+	0x6d, 0x65, 0x22, 0xac, 0x04, 0x0a, 0x10, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x68, 0x65, 0x65, 0x74,
 	0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18,
 	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6e,
 	0x61, 0x6d, 0x65, 0x72, 0x6f, 0x77, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x6e, 0x61,
@@ -989,107 +1067,116 @@ var file_tableau_protobuf_tableau_proto_rawDesc = []byte{
 	0x65, 0x6c, 0x64, 0x5f, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x0f, 0x20, 0x01,
 	0x28, 0x08, 0x52, 0x0d, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63,
 	0x65, 0x12, 0x1a, 0x0a, 0x08, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x18, 0x10, 0x20,
-	0x01, 0x28, 0x08, 0x52, 0x08, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x0a,
-	0x0b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x65, 0x64, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x32, 0x20, 0x01,
-	0x28, 0x08, 0x52, 0x0a, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x12, 0x14,
-	0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x33, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x69,
-	0x6e, 0x64, 0x65, 0x78, 0x22, 0x21, 0x0a, 0x0b, 0x45, 0x6e, 0x75, 0x6d, 0x4f, 0x70, 0x74, 0x69,
-	0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
-	0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x26, 0x0a, 0x10, 0x45, 0x6e, 0x75, 0x6d, 0x56,
-	0x61, 0x6c, 0x75, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e,
-	0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22,
-	0x38, 0x0a, 0x0c, 0x4f, 0x6e, 0x65, 0x6f, 0x66, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12,
-	0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
-	0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01,
-	0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x22, 0x82, 0x02, 0x0a, 0x0c, 0x46, 0x69,
-	0x65, 0x6c, 0x64, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61,
-	0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12,
-	0x0a, 0x04, 0x6e, 0x6f, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x6f,
-	0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x03, 0x6b, 0x65, 0x79, 0x12, 0x27, 0x0a, 0x06, 0x6c, 0x61, 0x79, 0x6f, 0x75, 0x74, 0x18, 0x04,
-	0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x4c,
-	0x61, 0x79, 0x6f, 0x75, 0x74, 0x52, 0x06, 0x6c, 0x61, 0x79, 0x6f, 0x75, 0x74, 0x12, 0x21, 0x0a,
-	0x04, 0x73, 0x70, 0x61, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0d, 0x2e, 0x74, 0x61,
-	0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x53, 0x70, 0x61, 0x6e, 0x52, 0x04, 0x73, 0x70, 0x61, 0x6e,
-	0x12, 0x10, 0x0a, 0x03, 0x73, 0x65, 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73,
-	0x65, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x75, 0x62, 0x73, 0x65, 0x70, 0x18, 0x07, 0x20, 0x01,
-	0x28, 0x09, 0x52, 0x06, 0x73, 0x75, 0x62, 0x73, 0x65, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x70,
-	0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6f, 0x70,
-	0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x12, 0x26, 0x0a, 0x04, 0x70, 0x72, 0x6f, 0x70, 0x18, 0x0f,
-	0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x46,
-	0x69, 0x65, 0x6c, 0x64, 0x50, 0x72, 0x6f, 0x70, 0x52, 0x04, 0x70, 0x72, 0x6f, 0x70, 0x22, 0xea,
-	0x01, 0x0a, 0x09, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x50, 0x72, 0x6f, 0x70, 0x12, 0x14, 0x0a, 0x05,
-	0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x72, 0x61, 0x6e,
-	0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
-	0x28, 0x08, 0x52, 0x06, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65,
-	0x66, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x72, 0x65, 0x66, 0x65, 0x72,
-	0x12, 0x1f, 0x0a, 0x08, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01,
-	0x28, 0x03, 0x48, 0x00, 0x52, 0x08, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x65, 0x88, 0x01,
-	0x01, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x18, 0x05, 0x20, 0x01,
-	0x28, 0x09, 0x52, 0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66,
-	0x69, 0x78, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x66, 0x69, 0x78, 0x65,
-	0x64, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0d, 0x52,
-	0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x27, 0x0a, 0x06, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x18,
-	0x08, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e,
-	0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x52, 0x06, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x42, 0x0b,
-	0x0a, 0x09, 0x5f, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x65, 0x2a, 0x5b, 0x0a, 0x06, 0x4c,
-	0x61, 0x79, 0x6f, 0x75, 0x74, 0x12, 0x12, 0x0a, 0x0e, 0x4c, 0x41, 0x59, 0x4f, 0x55, 0x54, 0x5f,
-	0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x13, 0x0a, 0x0f, 0x4c, 0x41, 0x59,
-	0x4f, 0x55, 0x54, 0x5f, 0x56, 0x45, 0x52, 0x54, 0x49, 0x43, 0x41, 0x4c, 0x10, 0x01, 0x12, 0x15,
-	0x0a, 0x11, 0x4c, 0x41, 0x59, 0x4f, 0x55, 0x54, 0x5f, 0x48, 0x4f, 0x52, 0x49, 0x5a, 0x4f, 0x4e,
-	0x54, 0x41, 0x4c, 0x10, 0x02, 0x12, 0x11, 0x0a, 0x0d, 0x4c, 0x41, 0x59, 0x4f, 0x55, 0x54, 0x5f,
-	0x49, 0x4e, 0x43, 0x45, 0x4c, 0x4c, 0x10, 0x03, 0x2a, 0x42, 0x0a, 0x04, 0x53, 0x70, 0x61, 0x6e,
-	0x12, 0x10, 0x0a, 0x0c, 0x53, 0x50, 0x41, 0x4e, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54,
-	0x10, 0x00, 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x50, 0x41, 0x4e, 0x5f, 0x43, 0x52, 0x4f, 0x53, 0x53,
-	0x5f, 0x43, 0x45, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x50, 0x41, 0x4e, 0x5f,
-	0x49, 0x4e, 0x4e, 0x45, 0x52, 0x5f, 0x43, 0x45, 0x4c, 0x4c, 0x10, 0x02, 0x2a, 0x3e, 0x0a, 0x06,
-	0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x12, 0x12, 0x0a, 0x0e, 0x46, 0x4f, 0x52, 0x4d, 0x41, 0x54,
-	0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x46, 0x4f,
-	0x52, 0x4d, 0x41, 0x54, 0x5f, 0x54, 0x45, 0x58, 0x54, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x46,
-	0x4f, 0x52, 0x4d, 0x41, 0x54, 0x5f, 0x4a, 0x53, 0x4f, 0x4e, 0x10, 0x02, 0x3a, 0x54, 0x0a, 0x08,
-	0x77, 0x6f, 0x72, 0x6b, 0x62, 0x6f, 0x6f, 0x6b, 0x12, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
-	0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x4f,
-	0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0xd0, 0x86, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18,
-	0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x62, 0x6f, 0x6f,
-	0x6b, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x08, 0x77, 0x6f, 0x72, 0x6b, 0x62, 0x6f,
-	0x6f, 0x6b, 0x3a, 0x5a, 0x0a, 0x09, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x68, 0x65, 0x65, 0x74, 0x12,
-	0x1f, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
-	0x66, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73,
-	0x18, 0xd0, 0x86, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65,
-	0x61, 0x75, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x68, 0x65, 0x65, 0x74, 0x4f, 0x70, 0x74, 0x69,
-	0x6f, 0x6e, 0x73, 0x52, 0x09, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x68, 0x65, 0x65, 0x74, 0x3a, 0x37,
-	0x0a, 0x05, 0x75, 0x6e, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
-	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
-	0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0xd1, 0x86, 0x03, 0x20, 0x01, 0x28, 0x08,
-	0x52, 0x05, 0x75, 0x6e, 0x69, 0x6f, 0x6e, 0x3a, 0x4c, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64,
-	0x12, 0x1d, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
-	0x75, 0x66, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18,
-	0xd0, 0x86, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61,
-	0x75, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x05,
-	0x66, 0x69, 0x65, 0x6c, 0x64, 0x3a, 0x4a, 0x0a, 0x05, 0x65, 0x74, 0x79, 0x70, 0x65, 0x12, 0x1c,
-	0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
-	0x2e, 0x45, 0x6e, 0x75, 0x6d, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0xd0, 0x86, 0x03,
-	0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x45,
-	0x6e, 0x75, 0x6d, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x05, 0x65, 0x74, 0x79, 0x70,
-	0x65, 0x3a, 0x56, 0x0a, 0x06, 0x65, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x21, 0x2e, 0x67, 0x6f,
-	0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6e,
-	0x75, 0x6d, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0xd0,
-	0x86, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75,
-	0x2e, 0x45, 0x6e, 0x75, 0x6d, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e,
-	0x73, 0x52, 0x06, 0x65, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x4f, 0x0a, 0x05, 0x6f, 0x6e, 0x65,
-	0x6f, 0x66, 0x12, 0x1d, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
-	0x6f, 0x62, 0x75, 0x66, 0x2e, 0x4f, 0x6e, 0x65, 0x6f, 0x66, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e,
-	0x73, 0x18, 0xd0, 0x86, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x74, 0x61, 0x62, 0x6c,
-	0x65, 0x61, 0x75, 0x2e, 0x4f, 0x6e, 0x65, 0x6f, 0x66, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73,
-	0x52, 0x05, 0x6f, 0x6e, 0x65, 0x6f, 0x66, 0x88, 0x01, 0x01, 0x42, 0x75, 0x0a, 0x14, 0x63, 0x6f,
-	0x6d, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
-	0x75, 0x66, 0x42, 0x0c, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x50, 0x72, 0x6f, 0x74, 0x6f,
-	0x50, 0x01, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74,
-	0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x69, 0x6f, 0x2f, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75,
-	0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x70, 0x62,
-	0xa2, 0x02, 0x03, 0x54, 0x50, 0x42, 0xaa, 0x02, 0x18, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75,
-	0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x61,
-	0x75, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+	0x01, 0x28, 0x08, 0x52, 0x08, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x21, 0x0a,
+	0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0d, 0x2e, 0x74, 0x61,
+	0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65,
+	0x12, 0x18, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x74, 0x74, 0x65, 0x72, 0x18, 0x12, 0x20, 0x03, 0x28,
+	0x09, 0x52, 0x07, 0x73, 0x63, 0x61, 0x74, 0x74, 0x65, 0x72, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x72,
+	0x64, 0x65, 0x72, 0x65, 0x64, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x32, 0x20, 0x01, 0x28, 0x08, 0x52,
+	0x0a, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x12, 0x14, 0x0a, 0x05, 0x69,
+	0x6e, 0x64, 0x65, 0x78, 0x18, 0x33, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6e, 0x64, 0x65,
+	0x78, 0x22, 0x21, 0x0a, 0x0b, 0x45, 0x6e, 0x75, 0x6d, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73,
+	0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
+	0x6e, 0x61, 0x6d, 0x65, 0x22, 0x26, 0x0a, 0x10, 0x45, 0x6e, 0x75, 0x6d, 0x56, 0x61, 0x6c, 0x75,
+	0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x38, 0x0a, 0x0c,
+	0x4f, 0x6e, 0x65, 0x6f, 0x66, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04,
+	0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65,
+	0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x22, 0x82, 0x02, 0x0a, 0x0c, 0x46, 0x69, 0x65, 0x6c, 0x64,
+	0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e,
+	0x6f, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x6f, 0x74, 0x65, 0x12,
+	0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
+	0x79, 0x12, 0x27, 0x0a, 0x06, 0x6c, 0x61, 0x79, 0x6f, 0x75, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28,
+	0x0e, 0x32, 0x0f, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x4c, 0x61, 0x79, 0x6f,
+	0x75, 0x74, 0x52, 0x06, 0x6c, 0x61, 0x79, 0x6f, 0x75, 0x74, 0x12, 0x21, 0x0a, 0x04, 0x73, 0x70,
+	0x61, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0d, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65,
+	0x61, 0x75, 0x2e, 0x53, 0x70, 0x61, 0x6e, 0x52, 0x04, 0x73, 0x70, 0x61, 0x6e, 0x12, 0x10, 0x0a,
+	0x03, 0x73, 0x65, 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x65, 0x70, 0x12,
+	0x16, 0x0a, 0x06, 0x73, 0x75, 0x62, 0x73, 0x65, 0x70, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x06, 0x73, 0x75, 0x62, 0x73, 0x65, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x70, 0x74, 0x69, 0x6f,
+	0x6e, 0x61, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6f, 0x70, 0x74, 0x69, 0x6f,
+	0x6e, 0x61, 0x6c, 0x12, 0x26, 0x0a, 0x04, 0x70, 0x72, 0x6f, 0x70, 0x18, 0x0f, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x12, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x46, 0x69, 0x65, 0x6c,
+	0x64, 0x50, 0x72, 0x6f, 0x70, 0x52, 0x04, 0x70, 0x72, 0x6f, 0x70, 0x22, 0xe4, 0x01, 0x0a, 0x09,
+	0x46, 0x69, 0x65, 0x6c, 0x64, 0x50, 0x72, 0x6f, 0x70, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x61, 0x6e,
+	0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x12,
+	0x16, 0x0a, 0x06, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52,
+	0x06, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x66, 0x65, 0x72,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x72, 0x65, 0x66, 0x65, 0x72, 0x12, 0x1f, 0x0a,
+	0x08, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x48,
+	0x00, 0x52, 0x08, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x65, 0x88, 0x01, 0x01, 0x12, 0x18,
+	0x0a, 0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x78, 0x65,
+	0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x66, 0x69, 0x78, 0x65, 0x64, 0x12, 0x12,
+	0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x73, 0x69,
+	0x7a, 0x65, 0x12, 0x21, 0x0a, 0x04, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0e,
+	0x32, 0x0d, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x46, 0x6f, 0x72, 0x6d, 0x52,
+	0x04, 0x66, 0x6f, 0x72, 0x6d, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e,
+	0x63, 0x65, 0x2a, 0x5b, 0x0a, 0x06, 0x4c, 0x61, 0x79, 0x6f, 0x75, 0x74, 0x12, 0x12, 0x0a, 0x0e,
+	0x4c, 0x41, 0x59, 0x4f, 0x55, 0x54, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00,
+	0x12, 0x13, 0x0a, 0x0f, 0x4c, 0x41, 0x59, 0x4f, 0x55, 0x54, 0x5f, 0x56, 0x45, 0x52, 0x54, 0x49,
+	0x43, 0x41, 0x4c, 0x10, 0x01, 0x12, 0x15, 0x0a, 0x11, 0x4c, 0x41, 0x59, 0x4f, 0x55, 0x54, 0x5f,
+	0x48, 0x4f, 0x52, 0x49, 0x5a, 0x4f, 0x4e, 0x54, 0x41, 0x4c, 0x10, 0x02, 0x12, 0x11, 0x0a, 0x0d,
+	0x4c, 0x41, 0x59, 0x4f, 0x55, 0x54, 0x5f, 0x49, 0x4e, 0x43, 0x45, 0x4c, 0x4c, 0x10, 0x03, 0x2a,
+	0x42, 0x0a, 0x04, 0x53, 0x70, 0x61, 0x6e, 0x12, 0x10, 0x0a, 0x0c, 0x53, 0x50, 0x41, 0x4e, 0x5f,
+	0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x50, 0x41,
+	0x4e, 0x5f, 0x43, 0x52, 0x4f, 0x53, 0x53, 0x5f, 0x43, 0x45, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x13,
+	0x0a, 0x0f, 0x53, 0x50, 0x41, 0x4e, 0x5f, 0x49, 0x4e, 0x4e, 0x45, 0x52, 0x5f, 0x43, 0x45, 0x4c,
+	0x4c, 0x10, 0x02, 0x2a, 0x64, 0x0a, 0x04, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x10, 0x0a, 0x0c, 0x4d,
+	0x4f, 0x44, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x0f, 0x0a,
+	0x0b, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x45, 0x5f, 0x43, 0x53, 0x56, 0x10, 0x01, 0x12, 0x10,
+	0x0a, 0x0c, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x45, 0x5f, 0x4a, 0x53, 0x4f, 0x4e, 0x10, 0x02,
+	0x12, 0x12, 0x0a, 0x0e, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x45, 0x4e, 0x55, 0x4d, 0x5f, 0x54, 0x59,
+	0x50, 0x45, 0x10, 0x03, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, 0x49,
+	0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x10, 0x04, 0x2a, 0x36, 0x0a, 0x04, 0x46, 0x6f, 0x72,
+	0x6d, 0x12, 0x10, 0x0a, 0x0c, 0x46, 0x4f, 0x52, 0x4d, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c,
+	0x54, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x46, 0x4f, 0x52, 0x4d, 0x5f, 0x54, 0x45, 0x58, 0x54,
+	0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x46, 0x4f, 0x52, 0x4d, 0x5f, 0x4a, 0x53, 0x4f, 0x4e, 0x10,
+	0x02, 0x3a, 0x54, 0x0a, 0x08, 0x77, 0x6f, 0x72, 0x6b, 0x62, 0x6f, 0x6f, 0x6b, 0x12, 0x1c, 0x2e,
+	0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
+	0x46, 0x69, 0x6c, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0xd0, 0x86, 0x03, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x57, 0x6f,
+	0x72, 0x6b, 0x62, 0x6f, 0x6f, 0x6b, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x08, 0x77,
+	0x6f, 0x72, 0x6b, 0x62, 0x6f, 0x6f, 0x6b, 0x3a, 0x5a, 0x0a, 0x09, 0x77, 0x6f, 0x72, 0x6b, 0x73,
+	0x68, 0x65, 0x65, 0x74, 0x12, 0x1f, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4f, 0x70,
+	0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0xd0, 0x86, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e,
+	0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x68, 0x65, 0x65,
+	0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x09, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x68,
+	0x65, 0x65, 0x74, 0x3a, 0x37, 0x0a, 0x05, 0x75, 0x6e, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x2e, 0x67,
+	0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x4d,
+	0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0xd1, 0x86,
+	0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x75, 0x6e, 0x69, 0x6f, 0x6e, 0x3a, 0x4c, 0x0a, 0x05,
+	0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x1d, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x4f, 0x70, 0x74,
+	0x69, 0x6f, 0x6e, 0x73, 0x18, 0xd0, 0x86, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x74,
+	0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x4f, 0x70, 0x74, 0x69,
+	0x6f, 0x6e, 0x73, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x3a, 0x4a, 0x0a, 0x05, 0x65, 0x74,
+	0x79, 0x70, 0x65, 0x12, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6e, 0x75, 0x6d, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e,
+	0x73, 0x18, 0xd0, 0x86, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x74, 0x61, 0x62, 0x6c,
+	0x65, 0x61, 0x75, 0x2e, 0x45, 0x6e, 0x75, 0x6d, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52,
+	0x05, 0x65, 0x74, 0x79, 0x70, 0x65, 0x3a, 0x56, 0x0a, 0x06, 0x65, 0x76, 0x61, 0x6c, 0x75, 0x65,
+	0x12, 0x21, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+	0x75, 0x66, 0x2e, 0x45, 0x6e, 0x75, 0x6d, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x4f, 0x70, 0x74, 0x69,
+	0x6f, 0x6e, 0x73, 0x18, 0xd0, 0x86, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x74, 0x61,
+	0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x45, 0x6e, 0x75, 0x6d, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x4f,
+	0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x06, 0x65, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x4f,
+	0x0a, 0x05, 0x6f, 0x6e, 0x65, 0x6f, 0x66, 0x12, 0x1d, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x4f, 0x6e, 0x65, 0x6f, 0x66, 0x4f,
+	0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0xd0, 0x86, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15,
+	0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x4f, 0x6e, 0x65, 0x6f, 0x66, 0x4f, 0x70,
+	0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x05, 0x6f, 0x6e, 0x65, 0x6f, 0x66, 0x88, 0x01, 0x01, 0x42,
+	0x75, 0x0a, 0x14, 0x63, 0x6f, 0x6d, 0x2e, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x42, 0x0c, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75,
+	0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e,
+	0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x69, 0x6f, 0x2f, 0x74, 0x61,
+	0x62, 0x6c, 0x65, 0x61, 0x75, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x74, 0x61, 0x62, 0x6c,
+	0x65, 0x61, 0x75, 0x70, 0x62, 0xa2, 0x02, 0x03, 0x54, 0x50, 0x42, 0xaa, 0x02, 0x18, 0x54, 0x61,
+	0x62, 0x6c, 0x65, 0x61, 0x75, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54,
+	0x61, 0x62, 0x6c, 0x65, 0x61, 0x75, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
@@ -1104,49 +1191,51 @@ func file_tableau_protobuf_tableau_proto_rawDescGZIP() []byte {
 	return file_tableau_protobuf_tableau_proto_rawDescData
 }
 
-var file_tableau_protobuf_tableau_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
+var file_tableau_protobuf_tableau_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
 var file_tableau_protobuf_tableau_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
 var file_tableau_protobuf_tableau_proto_goTypes = []interface{}{
 	(Layout)(0),                           // 0: tableau.Layout
 	(Span)(0),                             // 1: tableau.Span
-	(Format)(0),                           // 2: tableau.Format
-	(*WorkbookOptions)(nil),               // 3: tableau.WorkbookOptions
-	(*WorksheetOptions)(nil),              // 4: tableau.WorksheetOptions
-	(*EnumOptions)(nil),                   // 5: tableau.EnumOptions
-	(*EnumValueOptions)(nil),              // 6: tableau.EnumValueOptions
-	(*OneofOptions)(nil),                  // 7: tableau.OneofOptions
-	(*FieldOptions)(nil),                  // 8: tableau.FieldOptions
-	(*FieldProp)(nil),                     // 9: tableau.FieldProp
-	(*descriptorpb.FileOptions)(nil),      // 10: google.protobuf.FileOptions
-	(*descriptorpb.MessageOptions)(nil),   // 11: google.protobuf.MessageOptions
-	(*descriptorpb.FieldOptions)(nil),     // 12: google.protobuf.FieldOptions
-	(*descriptorpb.EnumOptions)(nil),      // 13: google.protobuf.EnumOptions
-	(*descriptorpb.EnumValueOptions)(nil), // 14: google.protobuf.EnumValueOptions
-	(*descriptorpb.OneofOptions)(nil),     // 15: google.protobuf.OneofOptions
+	(Mode)(0),                             // 2: tableau.Mode
+	(Form)(0),                             // 3: tableau.Form
+	(*WorkbookOptions)(nil),               // 4: tableau.WorkbookOptions
+	(*WorksheetOptions)(nil),              // 5: tableau.WorksheetOptions
+	(*EnumOptions)(nil),                   // 6: tableau.EnumOptions
+	(*EnumValueOptions)(nil),              // 7: tableau.EnumValueOptions
+	(*OneofOptions)(nil),                  // 8: tableau.OneofOptions
+	(*FieldOptions)(nil),                  // 9: tableau.FieldOptions
+	(*FieldProp)(nil),                     // 10: tableau.FieldProp
+	(*descriptorpb.FileOptions)(nil),      // 11: google.protobuf.FileOptions
+	(*descriptorpb.MessageOptions)(nil),   // 12: google.protobuf.MessageOptions
+	(*descriptorpb.FieldOptions)(nil),     // 13: google.protobuf.FieldOptions
+	(*descriptorpb.EnumOptions)(nil),      // 14: google.protobuf.EnumOptions
+	(*descriptorpb.EnumValueOptions)(nil), // 15: google.protobuf.EnumValueOptions
+	(*descriptorpb.OneofOptions)(nil),     // 16: google.protobuf.OneofOptions
 }
 var file_tableau_protobuf_tableau_proto_depIdxs = []int32{
-	0,  // 0: tableau.FieldOptions.layout:type_name -> tableau.Layout
-	1,  // 1: tableau.FieldOptions.span:type_name -> tableau.Span
-	9,  // 2: tableau.FieldOptions.prop:type_name -> tableau.FieldProp
-	2,  // 3: tableau.FieldProp.format:type_name -> tableau.Format
-	10, // 4: tableau.workbook:extendee -> google.protobuf.FileOptions
-	11, // 5: tableau.worksheet:extendee -> google.protobuf.MessageOptions
-	11, // 6: tableau.union:extendee -> google.protobuf.MessageOptions
-	12, // 7: tableau.field:extendee -> google.protobuf.FieldOptions
-	13, // 8: tableau.etype:extendee -> google.protobuf.EnumOptions
-	14, // 9: tableau.evalue:extendee -> google.protobuf.EnumValueOptions
-	15, // 10: tableau.oneof:extendee -> google.protobuf.OneofOptions
-	3,  // 11: tableau.workbook:type_name -> tableau.WorkbookOptions
-	4,  // 12: tableau.worksheet:type_name -> tableau.WorksheetOptions
-	8,  // 13: tableau.field:type_name -> tableau.FieldOptions
-	5,  // 14: tableau.etype:type_name -> tableau.EnumOptions
-	6,  // 15: tableau.evalue:type_name -> tableau.EnumValueOptions
-	7,  // 16: tableau.oneof:type_name -> tableau.OneofOptions
-	17, // [17:17] is the sub-list for method output_type
-	17, // [17:17] is the sub-list for method input_type
-	11, // [11:17] is the sub-list for extension type_name
-	4,  // [4:11] is the sub-list for extension extendee
-	0,  // [0:4] is the sub-list for field type_name
+	2,  // 0: tableau.WorksheetOptions.mode:type_name -> tableau.Mode
+	0,  // 1: tableau.FieldOptions.layout:type_name -> tableau.Layout
+	1,  // 2: tableau.FieldOptions.span:type_name -> tableau.Span
+	10, // 3: tableau.FieldOptions.prop:type_name -> tableau.FieldProp
+	3,  // 4: tableau.FieldProp.form:type_name -> tableau.Form
+	11, // 5: tableau.workbook:extendee -> google.protobuf.FileOptions
+	12, // 6: tableau.worksheet:extendee -> google.protobuf.MessageOptions
+	12, // 7: tableau.union:extendee -> google.protobuf.MessageOptions
+	13, // 8: tableau.field:extendee -> google.protobuf.FieldOptions
+	14, // 9: tableau.etype:extendee -> google.protobuf.EnumOptions
+	15, // 10: tableau.evalue:extendee -> google.protobuf.EnumValueOptions
+	16, // 11: tableau.oneof:extendee -> google.protobuf.OneofOptions
+	4,  // 12: tableau.workbook:type_name -> tableau.WorkbookOptions
+	5,  // 13: tableau.worksheet:type_name -> tableau.WorksheetOptions
+	9,  // 14: tableau.field:type_name -> tableau.FieldOptions
+	6,  // 15: tableau.etype:type_name -> tableau.EnumOptions
+	7,  // 16: tableau.evalue:type_name -> tableau.EnumValueOptions
+	8,  // 17: tableau.oneof:type_name -> tableau.OneofOptions
+	18, // [18:18] is the sub-list for method output_type
+	18, // [18:18] is the sub-list for method input_type
+	12, // [12:18] is the sub-list for extension type_name
+	5,  // [5:12] is the sub-list for extension extendee
+	0,  // [0:5] is the sub-list for field type_name
 }
 
 func init() { file_tableau_protobuf_tableau_proto_init() }
@@ -1246,7 +1335,7 @@ func file_tableau_protobuf_tableau_proto_init() {
 		File: protoimpl.DescBuilder{
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: file_tableau_protobuf_tableau_proto_rawDesc,
-			NumEnums:      3,
+			NumEnums:      4,
 			NumMessages:   7,
 			NumExtensions: 7,
 			NumServices:   0,


Test patch:
diff --git a/internal/fs/csv_test.go b/internal/fs/csv_test.go
new file mode 100644
index 0000000..8731bdb
--- /dev/null
+++ b/internal/fs/csv_test.go
@@ -0,0 +1,74 @@
+package fs
+
+import "testing"
+
+func TestParseCSVFilenamePattern(t *testing.T) {
+	type args struct {
+		filename string
+	}
+	tests := []struct {
+		name          string
+		args          args
+		wantBookName  string
+		wantSheetName string
+		wantErr       bool
+	}{
+		{
+			name: "case1",
+			args: args{
+				filename: "BookName#SheetName.csv",
+			},
+			wantBookName:  "BookName",
+			wantSheetName: "SheetName",
+			wantErr:       false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotBookName, gotSheetName, err := ParseCSVFilenamePattern(tt.args.filename)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("ParseCSVFilenamePattern() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if gotBookName != tt.wantBookName {
+				t.Errorf("ParseCSVFilenamePattern() gotBookName = %v, want %v", gotBookName, tt.wantBookName)
+			}
+			if gotSheetName != tt.wantSheetName {
+				t.Errorf("ParseCSVFilenamePattern() gotSheetName = %v, want %v", gotSheetName, tt.wantSheetName)
+			}
+		})
+	}
+}
+
+func TestParseCSVBooknamePatternFrom(t *testing.T) {
+	type args struct {
+		filename string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    string
+		wantErr bool
+	}{
+		{
+			name: "case1",
+			args: args{
+				filename: "BookName#SheetName.csv",
+			},
+			want:    "BookName#*.csv",
+			wantErr: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := ParseCSVBooknamePatternFrom(tt.args.filename)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("ParseCSVBooknamePatternFrom() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if got != tt.want {
+				t.Errorf("ParseCSVBooknamePatternFrom() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/internal/importer/csv_test.go b/internal/importer/csv_test.go
index 189634c..5b0bbcf 100644
--- a/internal/importer/csv_test.go
+++ b/internal/importer/csv_test.go
@@ -25,74 +25,3 @@ func TestCSVImporter_ExportExcel(t *testing.T) {
 		})
 	}
 }
-
-func TestParseCSVFilenamePattern(t *testing.T) {
-	type args struct {
-		filename string
-	}
-	tests := []struct {
-		name          string
-		args          args
-		wantBookName  string
-		wantSheetName string
-		wantErr       bool
-	}{
-		{
-			name: "case1",
-			args: args{
-				filename: "BookName#SheetName.csv",
-			},
-			wantBookName:  "BookName",
-			wantSheetName: "SheetName",
-			wantErr:       false,
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			gotBookName, gotSheetName, err := ParseCSVFilenamePattern(tt.args.filename)
-			if (err != nil) != tt.wantErr {
-				t.Errorf("ParseCSVFilenamePattern() error = %v, wantErr %v", err, tt.wantErr)
-				return
-			}
-			if gotBookName != tt.wantBookName {
-				t.Errorf("ParseCSVFilenamePattern() gotBookName = %v, want %v", gotBookName, tt.wantBookName)
-			}
-			if gotSheetName != tt.wantSheetName {
-				t.Errorf("ParseCSVFilenamePattern() gotSheetName = %v, want %v", gotSheetName, tt.wantSheetName)
-			}
-		})
-	}
-}
-
-func TestParseCSVBooknamePatternFrom(t *testing.T) {
-	type args struct {
-		filename string
-	}
-	tests := []struct {
-		name    string
-		args    args
-		want    string
-		wantErr bool
-	}{
-		{
-			name: "case1",
-			args: args{
-				filename: "BookName#SheetName.csv",
-			},
-			want:    "BookName#*.csv",
-			wantErr: false,
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			got, err := ParseCSVBooknamePatternFrom(tt.args.filename)
-			if (err != nil) != tt.wantErr {
-				t.Errorf("ParseCSVBooknamePatternFrom() error = %v, wantErr %v", err, tt.wantErr)
-				return
-			}
-			if got != tt.want {
-				t.Errorf("ParseCSVBooknamePatternFrom() = %v, want %v", got, tt.want)
-			}
-		})
-	}
-}
diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go
new file mode 100644
index 0000000..3a1533c
--- /dev/null
+++ b/internal/importer/importer_test.go
@@ -0,0 +1,150 @@
+package importer
+
+import (
+	"log"
+	"reflect"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/tableauio/tableau/format"
+	"github.com/tableauio/tableau/internal/fs"
+)
+
+func init() {
+	err := fs.RangeFilesByFormat("./testdata", format.CSV, func(bookPath string) error {
+		// log.Printf("path: %s", bookPath)
+		imp, err := NewCSVImporter(bookPath, nil, nil)
+		if err != nil {
+			return err
+		}
+		return imp.ExportExcel()
+	})
+	if err != nil {
+		log.Panicf("%+v", err)
+	}
+}
+
+func Test_resolveBookPaths(t *testing.T) {
+	type args struct {
+		primaryBookPath string
+		sheetName       string
+		bookNameGlobs   []string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    map[string]bool
+		wantErr bool
+	}{
+		{
+			name: "xlsx",
+			args: args{
+				primaryBookPath: "testdata/Test.xlsx",
+				sheetName:       "Item",
+				bookNameGlobs:   []string{"Test_*.xlsx"},
+			},
+			want: map[string]bool{
+				"testdata/Test_Second.xlsx": true,
+			},
+		},
+		{
+			name: "csv",
+			args: args{
+				primaryBookPath: "testdata/Test#*.csv",
+				sheetName:       "Item",
+				bookNameGlobs:   []string{"Test_*.csv"},
+			},
+			want: map[string]bool{
+				"testdata/Test_Second#*.csv": true,
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := resolveBookPaths(tt.args.primaryBookPath, tt.args.sheetName, tt.args.bookNameGlobs)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("resolveBookPaths() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("resolveBookPaths() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestGetMergerImporters(t *testing.T) {
+	type args struct {
+		primaryBookPath string
+		sheetName       string
+		bookNameGlobs   []string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    []string // book filenames
+		wantErr bool
+	}{
+		{
+			name: "xlsx",
+			args: args{
+				primaryBookPath: "testdata/Test.xlsx",
+				sheetName:       "Item",
+				bookNameGlobs:   []string{"Test_*.xlsx"},
+			},
+			want: []string{"testdata/Test_Second.xlsx"},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := GetMergerImporters(tt.args.primaryBookPath, tt.args.sheetName, tt.args.bookNameGlobs)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("GetMergerImporters() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			filenames := []string{}
+			for _, imp := range got {
+				filenames = append(filenames, fs.GetCleanSlashPath(imp.Filename()))
+			}
+			assert.ElementsMatch(t, tt.want, filenames, "got book filenames not match")
+		})
+	}
+}
+
+func TestGetScatterImporters(t *testing.T) {
+	type args struct {
+		primaryBookPath string
+		sheetName       string
+		bookNameGlobs   []string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    []string // book filenames
+		wantErr bool
+	}{
+		{
+			name: "csv",
+			args: args{
+				primaryBookPath: "testdata/Test#*.csv",
+				sheetName:       "Item",
+				bookNameGlobs:   []string{"Test_*.csv"},
+			},
+			want: []string{"testdata/Test_Second#*.csv"},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := GetScatterImporters(tt.args.primaryBookPath, tt.args.sheetName, tt.args.bookNameGlobs)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("GetScatterImporters() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			filenames := []string{}
+			for _, imp := range got {
+				filenames = append(filenames, fs.GetCleanSlashPath(imp.Filename()))
+			}
+			assert.ElementsMatch(t, tt.want, filenames, "got book filenames not match")
+		})
+	}
+}
diff --git a/internal/importer/testdata/Test.xml b/internal/importer/testdata/Test.xml
new file mode 100644
index 0000000..e825f97
--- /dev/null
+++ b/internal/importer/testdata/Test.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+<@TABLEAU>
+    <Item Sheet="TestConf" Sep=";" />
+</@TABLEAU>
+
+<TestConf>
+    <SepConf number="[]int32" />
+    <MaxNum>int32</MaxNum>
+</TestConf>
+-->
+
+<TestConf>
+    <SepConf number="1;2;3" />
+    
+    <!-- Test -->
+    <MaxNum>100</MaxNum>
+</TestConf>
diff --git a/internal/importer/testdata/Test_Second#@TABLEAU.csv b/internal/importer/testdata/Test_Second#@TABLEAU.csv
new file mode 100644
index 0000000..f909663
--- /dev/null
+++ b/internal/importer/testdata/Test_Second#@TABLEAU.csv
@@ -0,0 +1,3 @@
+Sheet,Alias,Scatter,Merger
+Item,ItemConf,"Test_*.csv",,
+Hero,HeroConf,,"Test_*.csv"
\ No newline at end of file
diff --git a/internal/importer/testdata/Test_Second#Hero.csv b/internal/importer/testdata/Test_Second#Hero.csv
new file mode 100644
index 0000000..37da3d1
--- /dev/null
+++ b/internal/importer/testdata/Test_Second#Hero.csv
@@ -0,0 +1,4 @@
+first_name,last_name,username
+"Rob","Pike",rob
+Ken,Thompson,ken
+"Robert","Griesemer","gri"
\ No newline at end of file
diff --git a/internal/importer/testdata/Test_Second#Item.csv b/internal/importer/testdata/Test_Second#Item.csv
new file mode 100644
index 0000000..b6e29a8
--- /dev/null
+++ b/internal/importer/testdata/Test_Second#Item.csv
@@ -0,0 +1,3 @@
+ID,Name
+11,"Pike"
+12,Thompson
\ No newline at end of file
diff --git a/internal/importer/xml_test.go b/internal/importer/xml_test.go
index 196cfab..5af1565 100644
--- a/internal/importer/xml_test.go
+++ b/internal/importer/xml_test.go
@@ -2,12 +2,15 @@ package importer
 
 import (
 	"fmt"
+	"os"
+	"path/filepath"
 	"reflect"
 	"strings"
 	"testing"
 
 	"github.com/antchfx/xmlquery"
 	"github.com/tableauio/tableau/internal/importer/book"
+	"github.com/tableauio/tableau/log"
 	"github.com/tableauio/tableau/proto/tableaupb"
 )
 
@@ -1198,3 +1201,50 @@ func Test_matchTag(t *testing.T) {
 		})
 	}
 }
+
+func TestNewXMLImporter(t *testing.T) {
+	type args struct {
+		filename string
+		sheets   []string
+		parser   book.SheetParser
+		mode     ImporterMode
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "test",
+			args: args{
+				filename: "testdata/Test.xml",
+				sheets: []string{
+					"TestConf",
+				},
+				parser: nil,
+				mode:   Protogen,
+			},
+			wantErr: false,
+		},
+	}
+	log.Init(&log.Options{
+		Level: "DEBUG",
+	})
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := NewXMLImporter(tt.args.filename, tt.args.sheets, tt.args.parser, tt.args.mode)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("NewXMLImporter() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			for _, sheet := range tt.args.sheets {
+				if got.Book.GetSheet(sheet) == nil {
+					t.Errorf("NewXMLImporter() GetSheet = nil, want %v", sheet)
+					return
+				}
+				csvName := fmt.Sprintf("%s#%s.csv", strings.TrimSuffix(tt.args.filename, filepath.Ext(tt.args.filename)), sheet)
+				os.Remove(csvName)
+			}
+		})
+	}
+}
diff --git a/log/log_test.go b/log/log_test.go
index f2b06ac..3ce6236 100644
--- a/log/log_test.go
+++ b/log/log_test.go
@@ -53,3 +53,25 @@ func TestInfow(t *testing.T) {
 		})
 	}
 }
+
+func TestLevel(t *testing.T) {
+	tests := []struct {
+		name string
+		want string
+	}{
+		{
+			name: "test",
+			want: "DEBUG",	
+		},
+	}
+	Init(&Options{
+		Level: "DEBUG",
+	})
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := Level(); got != tt.want {
+				t.Errorf("Level() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/test/functest/conf/MetasheetOptionMerger.json b/test/functest/conf/MetasheetOptionMerger.json
new file mode 100644
index 0000000..68bc18e
--- /dev/null
+++ b/test/functest/conf/MetasheetOptionMerger.json
@@ -0,0 +1,19 @@
+{
+    "zoneMap": {
+        "1": {
+            "id": 1,
+            "name": "Infinity",
+            "difficulty": 100
+        },
+        "2": {
+            "id": 2,
+            "name": "Desert",
+            "difficulty": 200
+        },
+        "3": {
+            "id": 3,
+            "name": "Snowfield",
+            "difficulty": 300
+        }
+    }
+}
\ No newline at end of file
diff --git a/test/functest/conf/Scatter1_MetasheetOptionScatter.json b/test/functest/conf/Scatter1_MetasheetOptionScatter.json
new file mode 100644
index 0000000..894173b
--- /dev/null
+++ b/test/functest/conf/Scatter1_MetasheetOptionScatter.json
@@ -0,0 +1,9 @@
+{
+    "zoneMap": {
+        "1": {
+            "id": 1,
+            "name": "Infinity",
+            "difficulty": 100
+        }
+    }
+}
\ No newline at end of file
diff --git a/test/functest/conf/Scatter2_MetasheetOptionScatter.json b/test/functest/conf/Scatter2_MetasheetOptionScatter.json
new file mode 100644
index 0000000..d2633c2
--- /dev/null
+++ b/test/functest/conf/Scatter2_MetasheetOptionScatter.json
@@ -0,0 +1,9 @@
+{
+    "zoneMap": {
+        "2": {
+            "id": 2,
+            "name": "Desert",
+            "difficulty": 200
+        }
+    }
+}
\ No newline at end of file
diff --git a/test/functest/conf/Scatter3_MetasheetOptionScatter.json b/test/functest/conf/Scatter3_MetasheetOptionScatter.json
new file mode 100644
index 0000000..f87a005
--- /dev/null
+++ b/test/functest/conf/Scatter3_MetasheetOptionScatter.json
@@ -0,0 +1,9 @@
+{
+    "zoneMap": {
+        "3": {
+            "id": 3,
+            "name": "Snowfield",
+            "difficulty": 300
+        }
+    }
+}
\ No newline at end of file
diff --git a/test/functest/func_test.go b/test/functest/func_test.go
index 7829e89..9c09488 100644
--- a/test/functest/func_test.go
+++ b/test/functest/func_test.go
@@ -31,6 +31,7 @@ import (
 
 	"github.com/stretchr/testify/require"
 	"github.com/tableauio/tableau/format"
+	"github.com/tableauio/tableau/internal/fs"
 	"github.com/tableauio/tableau/internal/importer"
 	"github.com/tableauio/tableau/xerrors"
 )
@@ -114,7 +115,7 @@ func Test_CompareGeneratedJSON(t *testing.T) {
 }
 
 func Test_Excel2CSV(t *testing.T) {
-	err := rangeFilesByFormat("./testdata", format.Excel, func(bookPath string) error {
+	err := fs.RangeFilesByFormat("./testdata", format.Excel, func(bookPath string) error {
 		// log.Printf("path: %s", bookPath)
 		imp, err := importer.NewExcelImporter(bookPath, nil, nil, 0, false)
 		if err != nil {
@@ -123,12 +124,12 @@ func Test_Excel2CSV(t *testing.T) {
 		return imp.ExportCSV()
 	})
 	if err != nil {
-		t.Errorf("%+v", err)
+		t.Fatalf("%+v", err)
 	}
 }
 
 func Test_CSV2Excel(t *testing.T) {
-	err := rangeFilesByFormat("./testdata", format.CSV, func(bookPath string) error {
+	err := fs.RangeFilesByFormat("./testdata", format.CSV, func(bookPath string) error {
 		// log.Printf("path: %s", bookPath)
 		imp, err := importer.NewCSVImporter(bookPath, nil, nil)
 		if err != nil {
@@ -137,6 +138,6 @@ func Test_CSV2Excel(t *testing.T) {
 		return imp.ExportExcel()
 	})
 	if err != nil {
-		t.Errorf("%+v", err)
+		t.Fatalf("%+v", err)
 	}
 }
diff --git a/test/functest/proto/excel__metasheet__merger_1.proto b/test/functest/proto/excel__metasheet__merger_1.proto
new file mode 100644
index 0000000..d8c9132
--- /dev/null
+++ b/test/functest/proto/excel__metasheet__merger_1.proto
@@ -0,0 +1,22 @@
+// Code generated by tableau (protogen v0.4.3). DO NOT EDIT.
+// clang-format off
+
+syntax = "proto3";
+
+package protoconf;
+
+import "tableau/protobuf/tableau.proto";
+
+option go_package = "github.com/tableauio/tableau/test/functest/protoconf";
+option (tableau.workbook) = {name:"excel/metasheet/Merger1#*.csv"};
+
+message MetasheetOptionMerger {
+  option (tableau.worksheet) = {name:"MetasheetOptionMerger" namerow:1 typerow:2 noterow:3 datarow:4 merger:"Merger*.csv"};
+
+  map<uint32, Zone> zone_map = 1 [(tableau.field) = {key:"ID" layout:LAYOUT_VERTICAL}];
+  message Zone {
+    uint32 id = 1 [(tableau.field) = {name:"ID"}];
+    string name = 2 [(tableau.field) = {name:"Name"}];
+    int32 difficulty = 3 [(tableau.field) = {name:"Difficulty"}];
+  }
+}
diff --git a/test/functest/proto/excel__metasheet__scatter_1.proto b/test/functest/proto/excel__metasheet__scatter_1.proto
new file mode 100644
index 0000000..7490f95
--- /dev/null
+++ b/test/functest/proto/excel__metasheet__scatter_1.proto
@@ -0,0 +1,22 @@
+// Code generated by tableau (protogen v0.4.3). DO NOT EDIT.
+// clang-format off
+
+syntax = "proto3";
+
+package protoconf;
+
+import "tableau/protobuf/tableau.proto";
+
+option go_package = "github.com/tableauio/tableau/test/functest/protoconf";
+option (tableau.workbook) = {name:"excel/metasheet/Scatter1#*.csv"};
+
+message MetasheetOptionScatter {
+  option (tableau.worksheet) = {name:"MetasheetOptionScatter" namerow:1 typerow:2 noterow:3 datarow:4 scatter:"Scatter*.csv"};
+
+  map<uint32, Zone> zone_map = 1 [(tableau.field) = {key:"ID" layout:LAYOUT_VERTICAL}];
+  message Zone {
+    uint32 id = 1 [(tableau.field) = {name:"ID"}];
+    string name = 2 [(tableau.field) = {name:"Name"}];
+    int32 difficulty = 3 [(tableau.field) = {name:"Difficulty"}];
+  }
+}
diff --git a/test/functest/testdata/excel/metasheet/Merger1#@TABLEAU.csv b/test/functest/testdata/excel/metasheet/Merger1#@TABLEAU.csv
new file mode 100644
index 0000000..dcd1d0e
--- /dev/null
+++ b/test/functest/testdata/excel/metasheet/Merger1#@TABLEAU.csv
@@ -0,0 +1,2 @@
+Sheet,Merger
+MetasheetOptionMerger,Merger*.csv
diff --git a/test/functest/testdata/excel/metasheet/Merger1#MetasheetOptionMerger.csv b/test/functest/testdata/excel/metasheet/Merger1#MetasheetOptionMerger.csv
new file mode 100644
index 0000000..0f68977
--- /dev/null
+++ b/test/functest/testdata/excel/metasheet/Merger1#MetasheetOptionMerger.csv
@@ -0,0 +1,4 @@
+ID,Name,Difficulty
+"map<uint32, Zone>",string,int32
+Zone’s ID,Zone’s name,Zone’s difficulty
+1,Infinity,100
diff --git a/test/functest/testdata/excel/metasheet/Merger2#MetasheetOptionMerger.csv b/test/functest/testdata/excel/metasheet/Merger2#MetasheetOptionMerger.csv
new file mode 100644
index 0000000..43cf0eb
--- /dev/null
+++ b/test/functest/testdata/excel/metasheet/Merger2#MetasheetOptionMerger.csv
@@ -0,0 +1,4 @@
+ID,Name,Difficulty
+"map<uint32, Zone>",string,int32
+Zone’s ID,Zone’s name,Zone’s difficulty
+2,Desert,200
diff --git a/test/functest/testdata/excel/metasheet/Merger3#MetasheetOptionMerger.csv b/test/functest/testdata/excel/metasheet/Merger3#MetasheetOptionMerger.csv
new file mode 100644
index 0000000..8934967
--- /dev/null
+++ b/test/functest/testdata/excel/metasheet/Merger3#MetasheetOptionMerger.csv
@@ -0,0 +1,4 @@
+ID,Name,Difficulty
+"map<uint32, Zone>",string,int32
+Zone’s ID,Zone’s name,Zone’s difficulty
+3,Snowfield,300
diff --git a/test/functest/testdata/excel/metasheet/Scatter1#@TABLEAU.csv b/test/functest/testdata/excel/metasheet/Scatter1#@TABLEAU.csv
new file mode 100644
index 0000000..8d2dcdd
--- /dev/null
+++ b/test/functest/testdata/excel/metasheet/Scatter1#@TABLEAU.csv
@@ -0,0 +1,2 @@
+Sheet,Scatter
+MetasheetOptionScatter,Scatter*.csv
diff --git a/test/functest/testdata/excel/metasheet/Scatter1#MetasheetOptionScatter.csv b/test/functest/testdata/excel/metasheet/Scatter1#MetasheetOptionScatter.csv
new file mode 100644
index 0000000..0f68977
--- /dev/null
+++ b/test/functest/testdata/excel/metasheet/Scatter1#MetasheetOptionScatter.csv
@@ -0,0 +1,4 @@
+ID,Name,Difficulty
+"map<uint32, Zone>",string,int32
+Zone’s ID,Zone’s name,Zone’s difficulty
+1,Infinity,100
diff --git a/test/functest/testdata/excel/metasheet/Scatter2#MetasheetOptionScatter.csv b/test/functest/testdata/excel/metasheet/Scatter2#MetasheetOptionScatter.csv
new file mode 100644
index 0000000..43cf0eb
--- /dev/null
+++ b/test/functest/testdata/excel/metasheet/Scatter2#MetasheetOptionScatter.csv
@@ -0,0 +1,4 @@
+ID,Name,Difficulty
+"map<uint32, Zone>",string,int32
+Zone’s ID,Zone’s name,Zone’s difficulty
+2,Desert,200
diff --git a/test/functest/testdata/excel/metasheet/Scatter3#MetasheetOptionScatter.csv b/test/functest/testdata/excel/metasheet/Scatter3#MetasheetOptionScatter.csv
new file mode 100644
index 0000000..8934967
--- /dev/null
+++ b/test/functest/testdata/excel/metasheet/Scatter3#MetasheetOptionScatter.csv
@@ -0,0 +1,4 @@
+ID,Name,Difficulty
+"map<uint32, Zone>",string,int32
+Zone’s ID,Zone’s name,Zone’s difficulty
+3,Snowfield,300
diff --git a/test/functest/util.go b/test/functest/util.go
index 9ba203e..710e0ef 100644
--- a/test/functest/util.go
+++ b/test/functest/util.go
@@ -1,7 +1,6 @@
 package functest
 
 import (
-	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -9,7 +8,6 @@ import (
 	"github.com/tableauio/tableau"
 	"github.com/tableauio/tableau/format"
 	"github.com/tableauio/tableau/internal/fs"
-	"github.com/tableauio/tableau/internal/importer"
 	"github.com/tableauio/tableau/log"
 	"github.com/tableauio/tableau/options"
 )
@@ -119,51 +117,3 @@ func genConf(logLevel string) error {
 		options.Lang("zh"),
 	)
 }
-
-func rangeFilesByFormat(dir string, fmt format.Format, callback func(bookPath string) error) error {
-	dirEntries, err := os.ReadDir(dir)
-	if err != nil {
-		return err
-	}
-
-	// book name -> existence(bool)
-	csvBooks := map[string]bool{}
-	for _, entry := range dirEntries {
-		if entry.IsDir() {
-			// scan and generate subdir recursively
-			subdir := filepath.Join(dir, entry.Name())
-			err = rangeFilesByFormat(subdir, fmt, callback)
-			if err != nil {
-				return err
-			}
-			continue
-		}
-		fileFmt := format.Ext2Format(filepath.Ext(entry.Name()))
-		if fileFmt != fmt {
-			continue
-		}
-		switch fmt {
-		case format.Excel:
-			bookPath := filepath.Join(dir, entry.Name())
-			if err := callback(bookPath); err != nil {
-				return err
-			}
-		case format.CSV:
-			bookName, _, err := importer.ParseCSVFilenamePattern(entry.Name())
-			if err != nil {
-				return err
-			}
-			if _, ok := csvBooks[bookName]; ok {
-				// NOTE: multiple CSV files construct the same book.
-				continue
-			}
-			csvBooks[bookName] = true
-			if err := callback(importer.GenCSVBooknamePattern(dir, bookName)); err != nil {
-				return err
-			}
-		default:
-			return errors.New("unknown fommat: " + string(fmt))
-		}
-	}
-	return nil
-}