Node-XLSX-Stream is written in literate coffeescript. The following is the actual source of the module.
fs = require('fs')
blobs = require('./blobs')
Archiver = require('archiver')
module.exports = class XlsxWriter
The simplest way to use Node-XLSX-Stream is to use the write method.
The callback comes directly from fs.writeFile
and has the arity (err)
# @param {String} out Output file path.
# @param {Array} data Data to write.
# @param {Function} cb Callback to call when done. Fed (err).
@write = (out, data, cb) ->
writer = new XlsxWriter({out: out})
writer.addRows(data)
writer.writeToFile(cb)
Node-XLSX-Stream has more advanced features available for better customization of spreadsheets.
When constructing a writer, pass it an optional file path and customization options.
# Build a writer object.
# @param {Object} [options] Preparation options.
# @param {String} [options.out] Output file path.
# @param {Array} [options.columns] Column definition. Must be added in constructor.
# @example options.columns = [
# { width: 30 }, // width is in 'characters'
# { width: 10 }
# ]
constructor: (options = {}) ->
# Support just passing a string path into the constructor.
if (typeof options == 'string')
options = {out: options}
# Set options.
defaults = {
defaultWidth: 15
zip: {
forceUTC: true # this is required, zips will be unreadable without it
},
columns: []
}
@options = _extend(defaults, options)
# Start sheet.
@_resetSheet()
# Write column definition.
@defineColumns(@options.columns)
# Create Zip.
zipOptions = @options.zip || {}
zipOptions.forceUTC = true # force this on in all cases for now, otherwise we're useless
@zip = Archiver('zip', zipOptions)
# Archiver attaches an exit listener on the process, we don't want this,
# it will fire if this object is never finalized.
@zip.catchEarlyExitAttached = true
# Hook this passthrough into the zip stream.
@zip.append(@sheetStream, {name: 'xl/worksheets/sheet1.xml'})
Rows are easy to add one by one or all at once. Data types within the sheet will be inferred from the data types passed to addRow().
Add a single row.
# @example (javascript)
# writer.addRow({
# "A String Column" : "A String Value",
# "A Number Column" : 12345,
# "A Date Column" : new Date(1999,11,31)
# })
addRow: (row) ->
# Values in header are defined by the keys of the object we've passed in.
# They need to be written the first time they're passed in.
if !@haveHeader
@_write(blobs.sheetDataHeader)
@_startRow()
col = 1
for key of row
@_addCell(key, col)
@cellMap.push(key)
col += 1
@_endRow()
@haveHeader = true
@_startRow()
for key, col in @cellMap
@_addCell((if row[key] == 0 then 0 else row[key] || ""), col + 1)
@_endRow()
Rows can be added in batch.
addRows: (rows) ->
for row in rows
@addRow(row)
Column definitions can be easily added, but it must be done before rows are added to prevent a nasty Excel bug.
# @example (javascript)
# writer.defineColumns([
# { width: 30 }, // width is in 'characters'
# { width: 10 }
# ])
defineColumns: (columns) ->
if @haveHeader
throw new Error """
Columns cannot be added after rows! Unfortunately Excel will crash
if column definitions come after sheet data. Please move your `defineColumns()`
call before any `addRow()` calls, or define options.columns in the XlsxWriter
constructor.
"""
@options.columns = columns
# Write column metadata.
# Would really like to do this at the end so that we don't have to mandate
# it comes first, but Excel pukes if <cols> comes before <sheetData>.
@_write(@_generateColumnDefinition())
Once you are done adding rows & defining columns, you have a few options
for generating the file. The writeToFile
helper is a one-stop-shop for writing
directly to a file using fs.writeFile
; otherwise, you can pack() manually,
which will return a readable stream.
Writes data to a file. Convenience method.
If no filename is specified, will attempt to use the one specified in the
constructor as options.out
.
The callback is fed directly to fs.writeFile
.
# @param {String} [fileName] File path to write.
# @param {Function} cb Callback.
writeToFile: (fileName, cb) ->
if fileName instanceof Function
cb = fileName
fileName = @options.out
if !fileName
throw new Error("Filename required. Supply one in writeToFile() or in options.out.")
# Create zip, pipe it into a file writeStream.
zip = @createReadStream(fileName)
fileStream = fs.createWriteStream(fileName)
fileStream.once 'finish', cb
zip.pipe(fileStream)
@finalize()
# @return {Stream} Readable stream with ZIP data.
createReadStream: () ->
@getReadStream()
Returns a readable stream from this file. You can pipe this directly to a file or response object. Be sure to use 'binary' mode.
You are responsible for indicating that you have finished
the file generation by calling finalize()
, which will end the sheet stream.
# @return {Stream} Readable stream with ZIP data.
getReadStream: () ->
@zip
Finishes up the sheet & generate shared strings. You must call this manually if
you are using createReadStream
.
finalize: () ->
if @finalized
throw new Error "This XLSX was already finalized."
# Mark this as finished
@finalized = true
# If there was data, end sheetData
if @haveHeader
@_write(blobs.sheetDataFooter)
# Write relationships data.
@_write(blobs.worksheetRels(@relationships))
# Generate shared strings
@_generateStrings()
# Generate external rels
@_generateRelationships()
# End sheet
@sheetStream.end(blobs.sheetFooter)
# Add supporting files to zip and finalize it. The readStream (@zip) will soon emit
# an 'end' event.
@_finalizeZip()
Cancel use of this writer and close all streams. This is not needed if you've written to a file.
dispose: () ->
return if @disposed
@sheetStream.end()
@sheetStream.unpipe()
@zip.unpipe()
while(@zip.read()) # drain stream
1; # noop
delete @zip
delete @sheetStream
@disposed = true
Adds a cell to the row in progress.
# @param {String|Number|Date} value Value to write.
# @param {Number} col Column index.
_addCell: (value = '', col) ->
row = @currentRow
cell = @_getCellIdentifier(row, col)
# Hyperlink support
if Object.prototype.toString.call(value) == '[object Object]'
if !value.value || !value.hyperlink
throw new Error("A hyperlink cell must have both 'value' and 'hyperlink' keys.")
@_addCell(value.value, col)
@_createRelationship(cell, value.hyperlink)
return
if typeof value == 'number'
@rowBuffer += blobs.numberCell(value, cell)
else if value instanceof Date
date = @_dateToOADate(value)
@rowBuffer += blobs.dateCell(date, cell)
else
index = @_lookupString(value)
@rowBuffer += blobs.cell(index, cell)
Begins a row. Call this before starting any row. Will start a buffer for all proceeding cells, until @_endRow is called.
_startRow: () ->
@rowBuffer = blobs.startRow(@currentRow)
@currentRow += 1
Ends a row. Will write the row to the sheet.
_endRow: () ->
@_write(@rowBuffer + blobs.endRow)
Given row and column indices, returns a cell identifier, e.g. "E20"
# @param {Number} row Row index.
# @param {Number} cell Cell index.
# @return {String} Cell identifier.
_getCellIdentifier: (row, col) ->
colIndex = ''
if @cellLabelMap[col]
colIndex = @cellLabelMap[col]
else
if col == 0
# Provide a fallback for empty spreadsheets
row = 1
col = 1
input = (+col - 1).toString(26)
while input.length
a = input.charCodeAt(input.length - 1)
colIndex = String.fromCharCode(a + if a >= 48 and a <= 57 then 17 else -22) + colIndex
input = if input.length > 1 then (parseInt(input.substr(0, input.length - 1), 26) - 1).toString(26) else ""
@cellLabelMap[col] = colIndex
return colIndex + row
Creates column definitions, if any definitions exist. This will write column styles, widths, etc.
# @return {String} Column definition.
_generateColumnDefinition: () ->
# <cols/> tag (empty) crashes excel, weeeeee
if !@options.columns || !@options.columns.length
return ''
columnDefinition = ''
columnDefinition += blobs.startColumns
idx = 1
for index, column of @options.columns
columnDefinition += blobs.column(column.width || @options.defaultWidth, idx)
idx += 1
columnDefinition += blobs.endColumns
return columnDefinition
Generates StringMap XML. Used as a finalization step - don't call this while building the xlsx is in progress.
Saves string data to this object so it can be written to the zip.
_generateStrings: () ->
stringTable = ''
for string in @strings
stringTable += blobs.string(@escapeXml(string))
@stringsData = blobs.stringsHeader(@strings.length) + stringTable + blobs.stringsFooter
Looks up a string inside the internal string map. If it doesn't exist, it will be added to the map.
# @param {String} value String to look up.
# @return {Number} Index within the string map where this string is located.
_lookupString: (value) ->
if !@stringMap[value]
@stringMap[value] = @stringIndex
@strings.push(value)
@stringIndex += 1
return @stringMap[value]
Create a relationship. For now, this is always a hyperlink. This writes to a array that will later be used define the rels.
_createRelationship: (cell, target) ->
@relationships.push({cell: cell, target: target})
Generate external relationships data. This is saved into "xl/worksheets/_rels/sheet1.xml.rels".
_generateRelationships: () ->
@relsData = blobs.externalWorksheetRels(@relationships)
Converts a Date to an OADate. See this stackoverflow post
# @param {Date} date Date to convert.
# @return {Number} OADate.
_dateToOADate: (date) ->
epoch = new Date(1899,11,30)
msPerDay = 8.64e7
v = -1 * (epoch - date) / msPerDay
# Deal with dates prior to 1899-12-30 00:00:00
dec = v - Math.floor(v)
if v < 0 and dec
v = Math.floor(v) - dec
return v
Convert an OADate to a Date.
# @param {Number} oaDate OADate.
# @return {Date} Converted date.
_OADateToDate: (oaDate) ->
epoch = new Date(1899,11,30)
msPerDay = 8.64e7
# Deal with -ve values
dec = oaDate - Math.floor(oaDate)
if oaDate < 0 and dec
oaDate = Math.floor(oaDate) - dec
return new Date(oaDate * msPerDay + +epoch)
Resets sheet data. Called on initialization.
_resetSheet: () ->
# Sheet data storage.
@sheetData = ''
@strings = []
@stringMap = {}
@stringIndex = 0
@stringData = null
@currentRow = 0
# Cell data storage
@cellMap = []
@cellLabelMap = {}
# Column data storage
@columns = []
# Rels data storage
@relData = ''
@relationships = []
# Flags
@haveHeader = false
@finalized = false
# Create sheet stream.
PassThrough = require('stream').PassThrough
@sheetStream = new PassThrough()
# Start off the sheet.
@_write(blobs.sheetHeader)
Finalizes this file and adds supporting docs. Should not be called directly.
_finalizeZip: () ->
@zip
.append(blobs.contentTypes, {name: '[Content_Types].xml'})
.append(blobs.rels, {name: '_rels/.rels'})
.append(blobs.workbook, {name: 'xl/workbook.xml'})
.append(blobs.styles, {name: 'xl/styles.xml'})
.append(blobs.workbookRels, {name: 'xl/_rels/workbook.xml.rels'})
.append(@relsData, {name: 'xl/worksheets/_rels/sheet1.xml.rels'})
.append(@stringsData, {name: 'xl/sharedStrings.xml'})
.finalize()
Wrapper around writing sheet data.
# @param {String} data Data to write to the sheet.
_write: (data) ->
@sheetStream.write(data)
Utility method for escaping XML - used within blobs and can be used manually.
# @param {String} str String to escape.
escapeXml: (str = '') ->
return str.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
Simple extend helper.
_extend = (dest, src) ->
for key, val of src
dest[key] = val
dest