Browse Source

Add Stream writing

Ryan Hollis 8 years ago
parent
commit
287c5011ff
5 changed files with 954 additions and 10 deletions
  1. 4 10
      .travis.yml
  2. 4 0
      cell.go
  3. 217 0
      stream_file.go
  4. 245 0
      stream_file_builder.go
  5. 484 0
      stream_test.go

+ 4 - 10
.travis.yml

@@ -1,18 +1,12 @@
 language: go
 
-install:
-  - go get -d -t -v ./... && go build -v ./...
-
 go:
-  - 1.5
-  - 1.6
-  - 1.7
-  - 1.8
-  - tip
+  - 1.7.x
+  - 1.8.x
 
 script:
   - go vet ./...
-  - go test -v -coverprofile=coverage.txt -covermode=atomic
+  - go test -v -coverprofile=coverage.txt -covermode=atomic .
 
 after_success:
-  - bash <(curl -s https://codecov.io/bash)
+  - bash <(curl -s https://codecov.io/bash)

+ 4 - 0
cell.go

@@ -23,6 +23,10 @@ const (
 	CellTypeGeneral
 )
 
+func (ct CellType) Ptr() *CellType {
+	return &ct
+}
+
 // Cell is a high level structure intended to provide user access to
 // the contents of Cell within an xlsx.Row.
 type Cell struct {

+ 217 - 0
stream_file.go

@@ -0,0 +1,217 @@
+package xlsx
+
+import (
+	"archive/zip"
+	"encoding/xml"
+	"errors"
+	"io"
+	"strconv"
+)
+
+type StreamFile struct {
+	xlsxFile       *File
+	sheetXmlPrefix []string
+	sheetXmlSuffix []string
+	zipWriter      *zip.Writer
+	currentSheet   *streamSheet
+	styleIds       [][]int
+	err            error
+}
+
+type streamSheet struct {
+	// sheetIndex is the XLSX sheet index, which starts at 1
+	index int
+	// The number of rows that have been written to the sheet so far
+	rowCount int
+	// The number of columns in the sheet
+	columnCount int
+	// The writer to write to this sheet's file in the XLSX Zip file
+	writer   io.Writer
+	styleIds []int
+}
+
+var (
+	NoCurrentSheetError     = errors.New("no Current Sheet")
+	WrongNumberOfRowsError  = errors.New("invalid number of cells passed to Write. All calls to Write on the same sheet must have the same number of cells")
+	AlreadyOnLastSheetError = errors.New("NextSheet() called, but already on last sheet")
+)
+
+// Write will write a row of cells to the current sheet. Every call to Write on the same sheet must contain the
+// same number of cells as the header provided when the sheet was created or an error will be returned. This function
+// will always trigger a flush on success. Currently the only supported data type is string data.
+func (sf *StreamFile) Write(cells []string) error {
+	if sf.err != nil {
+		return sf.err
+	}
+	err := sf.write(cells)
+	if err != nil {
+		sf.err = err
+		return err
+	}
+	return sf.zipWriter.Flush()
+}
+
+func (sf *StreamFile) WriteAll(records [][]string) error {
+	if sf.err != nil {
+		return sf.err
+	}
+	for _, row := range records {
+		err := sf.write(row)
+		if err != nil {
+			sf.err = err
+			return err
+		}
+	}
+	return sf.zipWriter.Flush()
+}
+
+func (sf *StreamFile) write(cells []string) error {
+	if sf.currentSheet == nil {
+		return NoCurrentSheetError
+	}
+	if len(cells) != sf.currentSheet.columnCount {
+		return WrongNumberOfRowsError
+	}
+	sf.currentSheet.rowCount++
+	if err := sf.currentSheet.write(`<row r="` + strconv.Itoa(sf.currentSheet.rowCount) + `">`); err != nil {
+		return err
+	}
+	for colIndex, cellData := range cells {
+		// documentation for the c.t (cell.Type) attribute:
+		// b (Boolean): Cell containing a boolean.
+		// d (Date): Cell contains a date in the ISO 8601 format.
+		// e (Error): Cell containing an error.
+		// inlineStr (Inline String): Cell containing an (inline) rich string, i.e., one not in the shared string table.
+		// If this cell type is used, then the cell value is in the is element rather than the v element in the cell (c element).
+		// n (Number): Cell containing a number.
+		// s (Shared String): Cell containing a shared string.
+		// str (String): Cell containing a formula string.
+		cellCoordinate := GetCellIDStringFromCoords(colIndex, sf.currentSheet.rowCount-1)
+		cellType := "inlineStr"
+		cellOpen := `<c r="` + cellCoordinate + `" t="` + cellType + `"`
+		// Add in the style id if the cell isn't using the default style
+		if colIndex < len(sf.currentSheet.styleIds) && sf.currentSheet.styleIds[colIndex] != 0 {
+			cellOpen += ` s="` + strconv.Itoa(sf.currentSheet.styleIds[colIndex]) + `"`
+		}
+		cellOpen += `><is><t>`
+		cellClose := `</t></is></c>`
+
+		if err := sf.currentSheet.write(cellOpen); err != nil {
+			return err
+		}
+		if err := xml.EscapeText(sf.currentSheet.writer, []byte(cellData)); err != nil {
+			return err
+		}
+		if err := sf.currentSheet.write(cellClose); err != nil {
+			return err
+		}
+	}
+	if err := sf.currentSheet.write(`</row>`); err != nil {
+		return err
+	}
+	return sf.zipWriter.Flush()
+}
+
+// Error reports any error that has occurred during a previous Write or Flush.
+func (sf *StreamFile) Error() error {
+	return sf.err
+}
+
+func (sf *StreamFile) Flush() {
+	if sf.err != nil {
+		sf.err = sf.zipWriter.Flush()
+	}
+}
+
+// NextSheet will switch to the next sheet. Sheets are selected in the same order they were added.
+// Once you leave a sheet, you cannot return to it.
+func (sf *StreamFile) NextSheet() error {
+	if sf.err != nil {
+		return sf.err
+	}
+	var sheetIndex int
+	if sf.currentSheet != nil {
+		if sf.currentSheet.index >= len(sf.xlsxFile.Sheets) {
+			sf.err = AlreadyOnLastSheetError
+			return AlreadyOnLastSheetError
+		}
+		if err := sf.writeSheetEnd(); err != nil {
+			sf.currentSheet = nil
+			sf.err = err
+			return err
+		}
+		sheetIndex = sf.currentSheet.index
+	}
+	sheetIndex++
+	sf.currentSheet = &streamSheet{
+		index:       sheetIndex,
+		columnCount: len(sf.xlsxFile.Sheets[sheetIndex-1].Cols),
+		styleIds:    sf.styleIds[sheetIndex-1],
+		rowCount:    1,
+	}
+	sheetPath := sheetFilePathPrefix + strconv.Itoa(sf.currentSheet.index) + sheetFilePathSuffix
+	fileWriter, err := sf.zipWriter.Create(sheetPath)
+	if err != nil {
+		sf.err = err
+		return err
+	}
+	sf.currentSheet.writer = fileWriter
+
+	if err := sf.writeSheetStart(); err != nil {
+		sf.err = err
+		return err
+	}
+	return nil
+}
+
+// Close closes the Stream File.
+// Any sheets that have not yet been written to will have an empty sheet created for them.
+func (sf *StreamFile) Close() error {
+	if sf.err != nil {
+		return sf.err
+	}
+	// If there are sheets that have not been written yet, call NextSheet() which will add files to the zip for them.
+	// XLSX readers may error if the sheets registered in the metadata are not present in the file.
+	if sf.currentSheet != nil {
+		for sf.currentSheet.index < len(sf.xlsxFile.Sheets) {
+			if err := sf.NextSheet(); err != nil {
+				sf.err = err
+				return err
+			}
+		}
+		// Write the end of the last sheet.
+		if err := sf.writeSheetEnd(); err != nil {
+			sf.err = err
+			return err
+		}
+	}
+	err := sf.zipWriter.Close()
+	if err != nil {
+		sf.err = err
+	}
+	return err
+}
+
+// writeSheetStart will write the start of the Sheet's XML
+func (sf *StreamFile) writeSheetStart() error {
+	if sf.currentSheet == nil {
+		return NoCurrentSheetError
+	}
+	return sf.currentSheet.write(sf.sheetXmlPrefix[sf.currentSheet.index-1])
+}
+
+// writeSheetEnd will write the end of the Sheet's XML
+func (sf *StreamFile) writeSheetEnd() error {
+	if sf.currentSheet == nil {
+		return NoCurrentSheetError
+	}
+	if err := sf.currentSheet.write(endSheetDataTag); err != nil {
+		return err
+	}
+	return sf.currentSheet.write(sf.sheetXmlSuffix[sf.currentSheet.index-1])
+}
+
+func (ss *streamSheet) write(data string) error {
+	_, err := ss.writer.Write([]byte(data))
+	return err
+}

+ 245 - 0
stream_file_builder.go

@@ -0,0 +1,245 @@
+// Authors: Ryan Hollis (ryanh@)
+
+// The purpose of StreamFileBuilder and StreamFile is to allow streamed writing of XLSX files.
+// Directions:
+// 1. Create a StreamFileBuilder with NewStreamFileBuilder() or NewStreamFileBuilderForPath().
+// 2. Add the sheets and their first row of data by calling AddSheet().
+// 3. Call Build() to get a StreamFile. Once built, all functions on the builder will return an error.
+// 4. Write to the StreamFile with Write(). Writes begin on the first sheet. New rows are always written and flushed
+// to the io. All rows written to the same sheet must have the same number of cells as the header provided when the sheet
+// was created or an error will be returned.
+// 5. Call NextSheet() to proceed to the next sheet. Once NextSheet() is called, the previous sheet can not be edited.
+// 6. Call Close() to finish.
+
+// Future work suggestions:
+// Currently the only supported cell type is string, since the main reason this library was written was to prevent
+// strings from being interpreted as numbers. It would be nice to have support for numbers and money so that the exported
+// files could better take advantage of XLSX's features.
+// All text is written with the same text style. Support for additional text styles could be added to highlight certain
+// data in the file.
+// The current default style uses fonts that are not on Macs by default so opening the XLSX files in Numbers causes a
+// pop up that says there are missing fonts. The font could be changed to something that is usually found on Mac and PC.
+
+package xlsx
+
+import (
+	"archive/zip"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"strconv"
+	"strings"
+)
+
+type StreamFileBuilder struct {
+	built              bool
+	xlsxFile           *File
+	zipWriter          *zip.Writer
+	cellTypeToStyleIds map[CellType]int
+	maxStyleId         int
+	styleIds           [][]int
+}
+
+const (
+	sheetFilePathPrefix = "xl/worksheets/sheet"
+	sheetFilePathSuffix = ".xml"
+	endSheetDataTag     = "</sheetData>"
+	dimensionTag        = `<dimension ref="%s"></dimension>`
+	// This is the index of the max style that this library will insert into XLSX sheets by default.
+	// This allows us to predict what the style id of styles that we add will be.
+	// TestXlsxStyleBehavior tests that this behavior continues to be what we expect.
+	initMaxStyleId = 1
+)
+
+var BuiltStreamFileBuilderError = errors.New("StreamFileBuilder has already been built, functions may no longer be used")
+
+// NewStreamFileBuilder creates an StreamFileBuilder that will write to the the provided io.writer
+func NewStreamFileBuilder(writer io.Writer) *StreamFileBuilder {
+	return &StreamFileBuilder{
+		zipWriter:          zip.NewWriter(writer),
+		xlsxFile:           NewFile(),
+		cellTypeToStyleIds: make(map[CellType]int),
+		maxStyleId:         initMaxStyleId,
+	}
+}
+
+// NewStreamFileBuilderForPath takes the name of an XLSX file and returns a builder for it.
+// The file will be created if it does not exist, or truncated if it does.
+func NewStreamFileBuilderForPath(path string) (*StreamFileBuilder, error) {
+	file, err := os.Create(path)
+	if err != nil {
+		return nil, err
+	}
+	return NewStreamFileBuilder(file), nil
+}
+
+// AddSheet will add sheets with the given name with the provided headers. The headers cannot be edited later, and all
+// rows written to the sheet must contain the same number of cells as the header. Sheet names must be unique, or an
+// error will be thrown.
+func (sb *StreamFileBuilder) AddSheet(name string, headers []string, cellTypes []*CellType) error {
+	if sb.built {
+		return BuiltStreamFileBuilderError
+	}
+	if len(cellTypes) > len(headers) {
+		return errors.New("cellTypes is longer than headers")
+	}
+	sheet, err := sb.xlsxFile.AddSheet(name)
+	if err != nil {
+		// Set built on error so that all subsequent calls to the builder will also fail.
+		sb.built = true
+		return err
+	}
+	sb.styleIds = append(sb.styleIds, []int{})
+	row := sheet.AddRow()
+	if count := row.WriteSlice(&headers, -1); count != len(headers) {
+		// Set built on error so that all subsequent calls to the builder will also fail.
+		sb.built = true
+		return errors.New("failed to write headers")
+	}
+	for i, cellType := range cellTypes {
+		var cellStyleIndex int
+		var ok bool
+		if cellType != nil {
+			// The cell type is one of the attributes of a Style.
+			// Since it is the only attribute of Style that we use, we can assume that cell types
+			// map one to one with Styles and their Style ID.
+			// If a new cell type is used, a new style gets created with an increased id, if an existing cell type is
+			// used, the pre-existing style will also be used.
+			cellStyleIndex, ok = sb.cellTypeToStyleIds[*cellType]
+			if !ok {
+				sb.maxStyleId++
+				cellStyleIndex = sb.maxStyleId
+				sb.cellTypeToStyleIds[*cellType] = sb.maxStyleId
+			}
+			sheet.Cols[i].SetType(*cellType)
+		}
+		sb.styleIds[len(sb.styleIds)-1] = append(sb.styleIds[len(sb.styleIds)-1], cellStyleIndex)
+	}
+	return nil
+}
+
+// Build begins streaming the XLSX file to the io, by writing all the XLSX metadata. It creates a StreamFile struct
+// that can be used to write the rows to the sheets.
+func (sb *StreamFileBuilder) Build() (*StreamFile, error) {
+	if sb.built {
+		return nil, BuiltStreamFileBuilderError
+	}
+	sb.built = true
+	parts, err := sb.xlsxFile.MarshallParts()
+	if err != nil {
+		return nil, err
+	}
+	es := &StreamFile{
+		zipWriter:      sb.zipWriter,
+		xlsxFile:       sb.xlsxFile,
+		sheetXmlPrefix: make([]string, len(sb.xlsxFile.Sheets)),
+		sheetXmlSuffix: make([]string, len(sb.xlsxFile.Sheets)),
+		styleIds:       sb.styleIds,
+	}
+	for path, data := range parts {
+		// If the part is a sheet, don't write it yet. We only want to write the XLSX metadata files, since at this
+		// point the sheets are still empty. The sheet files will be written later as their rows come in.
+		if strings.HasPrefix(path, sheetFilePathPrefix) {
+			if err := sb.processEmptySheetXML(es, path, data); err != nil {
+				return nil, err
+			}
+			continue
+		}
+		metadataFile, err := sb.zipWriter.Create(path)
+		if err != nil {
+			return nil, err
+		}
+		_, err = metadataFile.Write([]byte(data))
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	if err := es.NextSheet(); err != nil {
+		return nil, err
+	}
+	return es, nil
+}
+
+// processEmptySheetXML will take in the path and XML data of an empty sheet, and will save the beginning and end of the
+// XML file so that these can be written at the right time.
+func (sb *StreamFileBuilder) processEmptySheetXML(sf *StreamFile, path, data string) error {
+	// Get the sheet index from the path
+	sheetIndex, err := getSheetIndex(sf, path)
+	if err != nil {
+		return err
+	}
+
+	// Remove the Dimension tag. Since more rows are going to be written to the sheet, it will be wrong.
+	// It is valid to for a sheet to be missing a Dimension tag, but it is not valid for it to be wrong.
+	data, err = removeDimensionTag(data, sf.xlsxFile.Sheets[sheetIndex])
+	if err != nil {
+		return err
+	}
+
+	// Split the sheet at the end of its SheetData tag so that more rows can be added inside.
+	prefix, suffix, err := splitSheetIntoPrefixAndSuffix(data)
+	if err != nil {
+		return err
+	}
+	sf.sheetXmlPrefix[sheetIndex] = prefix
+	sf.sheetXmlSuffix[sheetIndex] = suffix
+	return nil
+}
+
+// getSheetIndex parses the path to the XLSX sheet data and returns the index
+// The files that store the data for each sheet must have the format:
+// xl/worksheets/sheet123.xml
+// where 123 is the index of the sheet. This file path format is part of the XLSX file standard.
+func getSheetIndex(sf *StreamFile, path string) (int, error) {
+	indexString := path[len(sheetFilePathPrefix) : len(path)-len(sheetFilePathSuffix)]
+	sheetXLSXIndex, err := strconv.Atoi(indexString)
+	if err != nil {
+		return -1, errors.New("Unexpected sheet file name from xlsx package")
+	}
+	if sheetXLSXIndex < 1 || len(sf.sheetXmlPrefix) < sheetXLSXIndex ||
+		len(sf.sheetXmlSuffix) < sheetXLSXIndex || len(sf.xlsxFile.Sheets) < sheetXLSXIndex {
+		return -1, errors.New("Unexpected sheet index")
+	}
+	sheetArrayIndex := sheetXLSXIndex - 1
+	return sheetArrayIndex, nil
+}
+
+// removeDimensionTag will return the passed in XLSX Spreadsheet XML with the dimension tag removed.
+// data is the XML data for the sheet
+// sheet is the Sheet struct that the XML was created from.
+// Can return an error if the XML's dimension tag does not match was is expected based on the provided Sheet
+func removeDimensionTag(data string, sheet *Sheet) (string, error) {
+	x := len(sheet.Cols) - 1
+	y := len(sheet.Rows) - 1
+	if x < 0 {
+		x = 0
+	}
+	if y < 0 {
+		y = 0
+	}
+	var dimensionRef string
+	if x == 0 && y == 0 {
+		dimensionRef = "A1"
+	} else {
+		endCoordinate := GetCellIDStringFromCoords(x, y)
+		dimensionRef = "A1:" + endCoordinate
+	}
+	dataParts := strings.Split(data, fmt.Sprintf(dimensionTag, dimensionRef))
+	if len(dataParts) != 2 {
+		return "", errors.New("unexpected Sheet XML: dimension tag not found")
+	}
+	return dataParts[0] + dataParts[1], nil
+}
+
+// splitSheetIntoPrefixAndSuffix will split the provided XML sheet into a prefix and a suffix so that
+// more spreadsheet rows can be inserted in between.
+func splitSheetIntoPrefixAndSuffix(data string) (string, string, error) {
+	// Split the sheet at the end of its SheetData tag so that more rows can be added inside.
+	sheetParts := strings.Split(data, endSheetDataTag)
+	if len(sheetParts) != 2 {
+		return "", "", errors.New("unexpected Sheet XML: SheetData close tag not found")
+	}
+	return sheetParts[0], sheetParts[1], nil
+}

+ 484 - 0
stream_test.go

@@ -0,0 +1,484 @@
+package xlsx
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"reflect"
+	"strings"
+	"testing"
+)
+
+const (
+	TestsShouldMakeRealFiles = false
+)
+
+func TestTestsShouldMakeRealFilesShouldBeFalse(t *testing.T) {
+	if TestsShouldMakeRealFiles {
+		t.Fatal("TestsShouldMakeRealFiles should only be true for local debugging. Don't forget to switch back before commiting.")
+	}
+}
+
+func TestXlsxStreamWrite(t *testing.T) {
+	// When shouldMakeRealFiles is set to true this test will make actual XLSX files in the file system.
+	// This is useful to ensure files open in Excel, Numbers, Google Docs, etc.
+	// In case of issues you can use "Open XML SDK 2.5" to diagnose issues in generated XLSX files:
+	// https://www.microsoft.com/en-us/download/details.aspx?id=30425
+	testCases := []struct {
+		testName      string
+		sheetNames    []string
+		workbookData  [][][]string
+		headerTypes   [][]*CellType
+		expectedError error
+	}{
+		{
+			testName: "One Sheet",
+			sheetNames: []string{
+				"Sheet1",
+			},
+			workbookData: [][][]string{
+				{
+					{"Token", "Name", "Price", "SKU"},
+					{"123", "Taco", "300", "0000000123"},
+				},
+			},
+			headerTypes: [][]*CellType{
+				{nil, CellTypeString.Ptr(), nil, CellTypeString.Ptr()},
+			},
+		},
+		{
+			testName: "One Column",
+			sheetNames: []string{
+				"Sheet1",
+			},
+			workbookData: [][][]string{
+				{
+					{"Token"},
+					{"123"},
+				},
+			},
+		},
+		{
+			testName: "Several Sheets, with different numbers of columns and rows",
+			sheetNames: []string{
+				"Sheet 1", "Sheet 2", "Sheet3",
+			},
+			workbookData: [][][]string{
+				{
+					{"Token", "Name", "Price", "SKU"},
+					{"123", "Taco", "300", "0000000123"},
+				},
+				{
+					{"Token", "Name", "Price", "SKU", "Stock"},
+					{"456", "Salsa", "200", "0346", "1"},
+					{"789", "Burritos", "400", "754", "3"},
+				},
+				{
+					{"Token", "Name", "Price"},
+					{"9853", "Guacamole", "500"},
+					{"2357", "Margarita", "700"},
+				},
+			},
+		},
+		{
+			testName: "Two Sheets with same the name",
+			sheetNames: []string{
+				"Sheet 1", "Sheet 1",
+			},
+			workbookData: [][][]string{
+				{
+					{"Token", "Name", "Price", "SKU"},
+					{"123", "Taco", "300", "0000000123"},
+				},
+				{
+					{"Token", "Name", "Price", "SKU", "Stock"},
+					{"456", "Salsa", "200", "0346", "1"},
+					{"789", "Burritos", "400", "754", "3"},
+				},
+			},
+			expectedError: fmt.Errorf("duplicate sheet name '%s'.", "Sheet 1"),
+		},
+		{
+			testName: "One Sheet Registered, tries to write to two",
+			sheetNames: []string{
+				"Sheet 1",
+			},
+			workbookData: [][][]string{
+				{
+					{"Token", "Name", "Price", "SKU"},
+					{"123", "Taco", "300", "0000000123"},
+				},
+				{
+					{"Token", "Name", "Price", "SKU"},
+					{"456", "Salsa", "200", "0346"},
+				},
+			},
+			expectedError: AlreadyOnLastSheetError,
+		},
+		{
+			testName: "One Sheet, too many columns in row 1",
+			sheetNames: []string{
+				"Sheet 1",
+			},
+			workbookData: [][][]string{
+				{
+					{"Token", "Name", "Price", "SKU"},
+					{"123", "Taco", "300", "0000000123", "asdf"},
+				},
+			},
+			expectedError: WrongNumberOfRowsError,
+		},
+		{
+			testName: "One Sheet, too few columns in row 1",
+			sheetNames: []string{
+				"Sheet 1",
+			},
+			workbookData: [][][]string{
+				{
+					{"Token", "Name", "Price", "SKU"},
+					{"123", "Taco", "300"},
+				},
+			},
+			expectedError: WrongNumberOfRowsError,
+		},
+		{
+			testName: "Lots of Sheets, only writes rows to one, only writes headers to one, should not error and should still create a valid file",
+			sheetNames: []string{
+				"Sheet 1", "Sheet 2", "Sheet 3", "Sheet 4", "Sheet 5", "Sheet 6",
+			},
+			workbookData: [][][]string{
+				{
+					{"Token", "Name", "Price", "SKU"},
+					{"123", "Taco", "300", "0000000123"},
+				},
+				{{}},
+				{{"Id", "Unit Cost"}},
+				{{}},
+				{{}},
+				{{}},
+			},
+		},
+		{
+			testName: "Two Sheets, only writes to one, should not error and should still create a valid file",
+			sheetNames: []string{
+				"Sheet 1", "Sheet 2",
+			},
+			workbookData: [][][]string{
+				{
+					{"Token", "Name", "Price", "SKU"},
+					{"123", "Taco", "300", "0000000123"},
+				},
+				{{}},
+			},
+		},
+		{
+			testName: "Larger Sheet",
+			sheetNames: []string{
+				"Sheet 1",
+			},
+			workbookData: [][][]string{
+				{
+					{"Token", "Name", "Price", "SKU", "Token", "Name", "Price", "SKU", "Token", "Name", "Price", "SKU", "Token", "Name", "Price", "SKU", "Token", "Name", "Price", "SKU", "Token", "Name", "Price", "SKU"},
+					{"123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123"},
+					{"456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346"},
+					{"789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754"},
+					{"123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123"},
+					{"456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346"},
+					{"789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754"},
+					{"123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123"},
+					{"456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346"},
+					{"789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754"},
+					{"123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123"},
+					{"456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346"},
+					{"789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754"},
+					{"123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123"},
+					{"456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346"},
+					{"789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754"},
+					{"123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123"},
+					{"456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346"},
+					{"789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754"},
+					{"123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123"},
+					{"456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346"},
+					{"789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754"},
+					{"123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123", "123", "Taco", "300", "0000000123"},
+					{"456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346", "456", "Salsa", "200", "0346"},
+					{"789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754", "789", "Burritos", "400", "754"},
+				},
+			},
+		},
+		{
+			testName: "UTF-8 Characters. This XLSX File loads correctly with Excel, Numbers, and Google Docs. It also passes Microsoft's Office File Format Validator.",
+			sheetNames: []string{
+				"Sheet1",
+			},
+			workbookData: [][][]string{
+				{
+					// String courtesy of https://github.com/minimaxir/big-list-of-naughty-strings/
+					// Header row contains the tags that I am filtering on
+					{"Token", endSheetDataTag, "Price", fmt.Sprintf(dimensionTag, "A1:D1")},
+					// Japanese and emojis
+					{"123", "パーティーへ行かないか", "300", "🍕🐵 🙈 🙉 🙊"},
+					// XML encoder/parser test strings
+					{"123", `<?xml version="1.0" encoding="ISO-8859-1"?>`, "300", `<?xml version="1.0" encoding="ISO-8859-1"?><!DOCTYPE foo [ <!ELEMENT foo ANY ><!ENTITY xxe SYSTEM "file:///etc/passwd" >]><foo>&xxe;</foo>`},
+					// Upside down text and Right to Left Arabic text
+					{"123", `˙ɐnbᴉlɐ ɐuƃɐɯ ǝɹolop ʇǝ ǝɹoqɐl ʇn ʇunpᴉpᴉɔuᴉ ɹodɯǝʇ poɯsnᴉǝ op pǝs 'ʇᴉlǝ ƃuᴉɔsᴉdᴉpɐ ɹnʇǝʇɔǝsuoɔ 'ʇǝɯɐ ʇᴉs ɹolop ɯnsdᴉ ɯǝɹo˥
+					00˙Ɩ$-`, "300", `ﷺ`},
+					{"123", "Taco", "300", "0000000123"},
+				},
+			},
+		},
+	}
+	for i, testCase := range testCases {
+		t.Run(testCase.testName, func(t *testing.T) {
+			var filePath string
+			var buffer bytes.Buffer
+			if TestsShouldMakeRealFiles {
+				filePath = fmt.Sprintf("Workbook%d.xlsx", i)
+			}
+			err := writeStreamFile(filePath, &buffer, testCase.sheetNames, testCase.workbookData, testCase.headerTypes, TestsShouldMakeRealFiles)
+			if err != testCase.expectedError && err.Error() != testCase.expectedError.Error() {
+				t.Fatalf("Error differs from expected error. Error: %v, Expected Error: %v ", err, testCase.expectedError)
+			}
+			if testCase.expectedError != nil {
+				return
+			}
+			// read the file back with the xlsx package
+			var bufReader *bytes.Reader
+			var size int64
+			if !TestsShouldMakeRealFiles {
+				bufReader = bytes.NewReader(buffer.Bytes())
+				size = bufReader.Size()
+			}
+			actualSheetNames, actualWorkbookData := readXLSXFile(t, filePath, bufReader, size, TestsShouldMakeRealFiles)
+			// check if data was able to be read correctly
+			if !reflect.DeepEqual(actualSheetNames, testCase.sheetNames) {
+				t.Fatal("Expected sheet names to be equal")
+			}
+			if !reflect.DeepEqual(actualWorkbookData, testCase.workbookData) {
+				t.Fatal("Expected workbook data to be equal")
+			}
+		})
+	}
+}
+
+// The purpose of TestXlsxStyleBehavior is to ensure that initMaxStyleId has the correct starting value
+// and that the logic in AddSheet() that predicts Style IDs is correct.
+func TestXlsxStyleBehavior(t *testing.T) {
+	file := NewFile()
+	sheet, err := file.AddSheet("Sheet 1")
+	if err != nil {
+		t.Fatal(err)
+	}
+	row := sheet.AddRow()
+	rowData := []string{"testing", "1", "2", "3"}
+	if count := row.WriteSlice(&rowData, -1); count != len(rowData) {
+		t.Fatal("not enough cells written")
+	}
+	parts, err := file.MarshallParts()
+	styleSheet, ok := parts["xl/styles.xml"]
+	if !ok {
+		t.Fatal("no style sheet")
+	}
+	// Created an XLSX file with only the default style.
+	// We expect that the number of styles is one more than our max index constant.
+	// This means the library adds two styles by default.
+	if !strings.Contains(styleSheet, fmt.Sprintf(`<cellXfs count="%d">`, initMaxStyleId+1)) {
+		t.Fatal("Expected sheet to have two styles")
+	}
+
+	file = NewFile()
+	sheet, err = file.AddSheet("Sheet 1")
+	if err != nil {
+		t.Fatal(err)
+	}
+	row = sheet.AddRow()
+	rowData = []string{"testing", "1", "2", "3", "4"}
+	if count := row.WriteSlice(&rowData, -1); count != len(rowData) {
+		t.Fatal("not enough cells written")
+	}
+	sheet.Cols[0].SetType(CellTypeString)
+	sheet.Cols[1].SetType(CellTypeString)
+	sheet.Cols[3].SetType(CellTypeNumeric)
+	sheet.Cols[4].SetType(CellTypeString)
+	parts, err = file.MarshallParts()
+	styleSheet, ok = parts["xl/styles.xml"]
+	if !ok {
+		t.Fatal("no style sheet")
+	}
+	// Created an XLSX file with two distinct cell types, which should create two new styles.
+	// The same cell type was added three times, this should be coalesced into the same style rather than
+	// recreating the style. This XLSX stream library depends on this behavior when predicting the next style id.
+	if !strings.Contains(styleSheet, fmt.Sprintf(`<cellXfs count="%d">`, initMaxStyleId+1+2)) {
+		t.Fatal("Expected sheet to have four styles")
+	}
+}
+
+// writeStreamFile will write the file using this stream package
+func writeStreamFile(filePath string, fileBuffer io.Writer, sheetNames []string, workbookData [][][]string, headerTypes [][]*CellType, shouldMakeRealFiles bool) error {
+	var file *StreamFileBuilder
+	var err error
+	if shouldMakeRealFiles {
+		file, err = NewStreamFileBuilderForPath(filePath)
+		if err != nil {
+			return err
+		}
+	} else {
+		file = NewStreamFileBuilder(fileBuffer)
+	}
+	for i, sheetName := range sheetNames {
+		header := workbookData[i][0]
+		var sheetHeaderTypes []*CellType
+		if i < len(headerTypes) {
+			sheetHeaderTypes = headerTypes[i]
+		}
+		err := file.AddSheet(sheetName, header, sheetHeaderTypes)
+		if err != nil {
+			return err
+		}
+	}
+	streamFile, err := file.Build()
+	if err != nil {
+		return err
+	}
+	for i, sheetData := range workbookData {
+		if i != 0 {
+			err = streamFile.NextSheet()
+			if err != nil {
+				return err
+			}
+		}
+		for i, row := range sheetData {
+			if i == 0 {
+				continue
+			}
+			err = streamFile.Write(row)
+			if err != nil {
+				return err
+			}
+		}
+	}
+	err = streamFile.Close()
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// readXLSXFile will read the file using the xlsx package.
+func readXLSXFile(t *testing.T, filePath string, fileBuffer io.ReaderAt, size int64, shouldMakeRealFiles bool) ([]string, [][][]string) {
+	var readFile *File
+	var err error
+	if shouldMakeRealFiles {
+		readFile, err = OpenFile(filePath)
+		if err != nil {
+			t.Fatal(err)
+		}
+	} else {
+		readFile, err = OpenReaderAt(fileBuffer, size)
+		if err != nil {
+			t.Fatal(err)
+		}
+	}
+	var actualWorkbookData [][][]string
+	var sheetNames []string
+	for _, sheet := range readFile.Sheets {
+		sheetData := [][]string{}
+		for _, row := range sheet.Rows {
+			data := []string{}
+			for _, cell := range row.Cells {
+				str, err := cell.FormattedValue()
+				if err != nil {
+					t.Fatal(err)
+				}
+				data = append(data, str)
+			}
+			sheetData = append(sheetData, data)
+		}
+		sheetNames = append(sheetNames, sheet.Name)
+		actualWorkbookData = append(actualWorkbookData, sheetData)
+	}
+	return sheetNames, actualWorkbookData
+}
+
+func TestAddSheetErrorsAfterBuild(t *testing.T) {
+	file := NewStreamFileBuilder(bytes.NewBuffer(nil))
+
+	err := file.AddSheet("Sheet1", []string{"Header"}, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = file.AddSheet("Sheet2", []string{"Header2"}, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	_, err = file.Build()
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = file.AddSheet("Sheet3", []string{"Header3"}, nil)
+	if err != BuiltStreamFileBuilderError {
+		t.Fatal(err)
+	}
+}
+
+func TestBuildErrorsAfterBuild(t *testing.T) {
+	file := NewStreamFileBuilder(bytes.NewBuffer(nil))
+
+	err := file.AddSheet("Sheet1", []string{"Header"}, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = file.AddSheet("Sheet2", []string{"Header2"}, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	_, err = file.Build()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_, err = file.Build()
+	if err != BuiltStreamFileBuilderError {
+		t.Fatal(err)
+	}
+}
+
+func TestCloseWithNothingWrittenToSheets(t *testing.T) {
+	buffer := bytes.NewBuffer(nil)
+	file := NewStreamFileBuilder(buffer)
+
+	sheetNames := []string{"Sheet1", "Sheet2"}
+	workbookData := [][][]string{
+		{{"Header1", "Header2"}},
+		{{"Header3", "Header4"}},
+	}
+	err := file.AddSheet(sheetNames[0], workbookData[0][0], nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = file.AddSheet(sheetNames[1], workbookData[1][0], nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	stream, err := file.Build()
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = stream.Close()
+	if err != nil {
+		t.Fatal(err)
+	}
+	bufReader := bytes.NewReader(buffer.Bytes())
+	size := bufReader.Size()
+
+	actualSheetNames, actualWorkbookData := readXLSXFile(t, "", bufReader, size, false)
+	// check if data was able to be read correctly
+	if !reflect.DeepEqual(actualSheetNames, sheetNames) {
+		t.Fatal("Expected sheet names to be equal")
+	}
+	if !reflect.DeepEqual(actualWorkbookData, workbookData) {
+		t.Fatal("Expected workbook data to be equal")
+	}
+}