// author vtloc // date 2014Jan09 // author GradualStudent // date 2015jan06 // This module's purpose is to transform var _ = require('lodash') // replacing underscore to get merge function var xmlBuilder = require('xmlbuilder') var _chartSpecs = require('./charts') function OfficeChart(chartInfo) { if (chartInfo instanceof OfficeChart) { return chartInfo } return { chartSpec: null, // Javascript object that represents the XML tree for the PowerPoint chart toXML: function() { return xmlBuilder .create(this.chartSpec, { version: '1.0', encoding: 'UTF-8', standalone: true }) .end({ pretty: true, indent: ' ', newline: '\n' }) }, toJSON: function() { return this.chartSpec }, getClass: function() { return 'OfficeChart' }, // @brief Create XML representation of chart object // @param chartInfo {object} // { // [ // title: 'eSurvey chart', // data: [ // array of series // { // name: 'Income', // labels: ['2005', '2006', '2007', '2008', '2009'], // values: [23.5, 26.2, 30.1, 29.5, 24.6], // color: 'ff0000' // } // ], // overlap: "0", // gapWidth: "150" // } initialize: function(chartInfo) { if (chartInfo.getClass && chartInfo.getClass() === 'OfficeChart') { return chartInfo } // overlap ["50"] is handled as an option within the chartbase // gapWidth ["150"] is handled as an option within the chartbase // valAxisCrossAtMaxCategory [true|false] is handled as an option within the chart base // catAxisReverseOrder [true|false] is handled as an option within the chart base this.chartSpec = OfficeChart.getChartBase(chartInfo) // get foundation XML for the chart type // Below are methods for handling options with more complex XML to mix in this.setData(chartInfo['data']) this.setTitle(chartInfo.title || chartInfo.name) this.setValAxisTitle(chartInfo.valAxisTitle) this.setCatAxisTitle(chartInfo.catAxisTitle) this.setValAxisNumFmt(chartInfo.valAxisNumFmt) this.setValAxisScale(chartInfo.valAxisMinValue, chartInfo.valAxisMaxValue) this.setTextSize(chartInfo.fontSize) this.mergeChartXml(chartInfo.xml) this.setValAxisMajorGridlines(chartInfo.valAxisMajorGridlines) this.setValAxisMinorGridlines(chartInfo.valAxisMinorGridlines) return this }, setTextSize: function(textSize) { if (textSize !== undefined) { var textRef = this._text(textSize) _.merge(this.chartSpec['c:chartSpace'], textRef) } }, setTitle: function(title) { if (title !== undefined) { var titleRef = this._title(chartInfo.title || chartInfo.name) _.merge(this.chartSpec['c:chartSpace']['c:chart'], titleRef) } }, setValAxisTitle: function(title) { if (title) { var titleRef = this._title(title) _.merge( this.chartSpec['c:chartSpace']['c:chart']['c:plotArea']['c:valAx'], titleRef ) } }, setCatAxisTitle: function(title) { if (title) { var titleRef = this._title(title) _.merge( this.chartSpec['c:chartSpace']['c:chart']['c:plotArea']['c:catAx'], titleRef ) } }, setValAxisNumFmt: function(format) { if (format !== undefined) { var numFmtRef = this._numFmt(format) _.merge( this.chartSpec['c:chartSpace']['c:chart']['c:plotArea']['c:valAx'], numFmtRef ) } }, setValAxisScale: function(min, max) { if (min !== undefined || max !== undefined) { var scalingRef = this._scaling(min, max) _.merge( this.chartSpec['c:chartSpace']['c:chart']['c:plotArea']['c:valAx'], scalingRef ) } }, mergeChartXml: function(xml) { if (xml !== undefined) { _.merge(this.chartSpec['c:chartSpace'], xml) } }, setValAxisMajorGridlines: function(boolean) { if (boolean) { this.chartSpec['c:chartSpace']['c:chart']['c:plotArea']['c:valAx'][ 'c:majorGridlines' ] = {} } }, setValAxisMinorGridlines: function(boolean) { if (boolean) { this.chartSpec['c:chartSpace']['c:chart']['c:plotArea']['c:valAx'][ 'c:minorGridlines' ] = {} } }, setData: function(data) { var seriesDataRef if (data) { this.data = data // Mix in data series if ( this.chartSpec['c:chartSpace']['c:chart']['c:plotArea']['c:lineChart'] ) { seriesDataRef = this.chartSpec['c:chartSpace']['c:chart'][ 'c:plotArea' ]['c:lineChart']['#text'] } else { if ( this.chartSpec['c:chartSpace']['c:chart']['c:plotArea'][ 'c:areaChart' ] ) { seriesDataRef = this.chartSpec['c:chartSpace']['c:chart'][ 'c:plotArea' ]['c:areaChart']['#text'] } else { if ( this.chartSpec['c:chartSpace']['c:chart']['c:plotArea'][ 'c:barChart' ] ) { seriesDataRef = this.chartSpec['c:chartSpace']['c:chart'][ 'c:plotArea' ]['c:barChart']['#text'] } else if ( this.chartSpec['c:chartSpace']['c:chart']['c:plotArea'][ 'c:doughnutChart' ] ) { seriesDataRef = this.chartSpec['c:chartSpace']['c:chart'][ 'c:plotArea' ]['c:doughnutChart']['#text'] } else if ( this.chartSpec['c:chartSpace']['c:chart']['c:plotArea'][ 'c:lineChart' ] ) { seriesDataRef = this.chartSpec['c:chartSpace']['c:chart'][ 'c:plotArea' ]['c:lineChart']['#text'] } else if ( this.chartSpec['c:chartSpace']['c:chart']['c:plotArea'][ 'c:pieChart' ] ) { seriesDataRef = this.chartSpec['c:chartSpace']['c:chart'][ 'c:plotArea' ]['c:pieChart']['#text'] } else { throw new Error( "Can't add data to chart that is not initialized or not a recognized type" ) } // Endif. } // Endif. } // Endif. // the barChart/pieChart objects has other attributes too, so we push the series data on, not replace it if (chartInfo['data']) { var seriesData = this.getSeriesRef(chartInfo['data']) for (var i = 0; i < seriesData.length; i++) { seriesDataRef.push(seriesData[i]) } } } return this }, /** * Transform an array of string into an office's compliance structure * * @param region {string} The reference cell of the string, for example: $A$1. * @param stringArr {Array} An array of string, for example: ['foo', 'bar']. */ _strRef: function(region, stringArr) { var obj = { 'c:strRef': { 'c:f': region, 'c:strCache': function() { var result = {} result['c:ptCount'] = { '@val': stringArr.length } result['#text'] = [] for (var i = 0; i < stringArr.length; i++) { result['#text'].push({ 'c:pt': { '@idx': i, 'c:v': stringArr[i] } }) } // End of for loop. return result } } } return obj }, /** * Transform an array of numbers into an office's compliance structure * * @param region {string} The reference cell of the string, for example: $A$1. * @param numArr {Array} An array of numArr, for example: [4, 7, 8]. * @param formatCode {string} A string describe the number's format. Example: General. */ _numRef: function(region, numArr, formatCode) { var obj = { 'c:numRef': { 'c:f': region, 'c:numCache': { 'c:formatCode': formatCode, 'c:ptCount': { '@val': '' + numArr.length }, '#text': function() { var result = [] for (var i = 0; i < numArr.length; i++) { result.push({ 'c:pt': { '@idx': i, 'c:v': numArr[i].toString() } }) } // End of for loop. return result } } } } return obj }, _numFmt: function(formatCode) { return { 'c:numFmt': { '@formatCode': formatCode || 'General', '@sourceLinked': formatCode ? '0' : '1' } } }, /** * Transform an array of string into an office's compliance structure * * @param colorArr {Array} An array of colorArr, for example: ['ff0000', '00ff00', '0000ff']. */ _colorRef: function(colorArr) { var arr = [] for (var i = 0; i < colorArr.length; i++) { arr.push({ 'c:dPt': { 'c:idx': { '@val': i }, 'c:bubble3D': { '@val': 0 }, 'c:spPr': { 'a:solidFill': { 'a:srgbClr': { '@val': colorArr[i].toString() } } } } }) } // End of for loop. return arr }, /** * @brief Transform an array of string into an office's compliance structure * * @param row {number} Row index. * @param col {number} Col index. * @param isRowAbsolute {boolean} Will add $ into cell's address if this parameter is true. * @param isColAbsolute {boolean} Will add $ into cell's address if this parameter is true. */ _rowColToSheetAddress: function(row, col, isRowAbsolute, isColAbsolute) { var address = '' if (isColAbsolute) { address += '$' } // Endif. // these lines of code will transform the number 1-26 into A->Z // used in excel's cell's coordination while (col > 0) { var num = col % 26 col = (col - num) / 26 address += String.fromCharCode(65 + num - 1) } // End of while loop. if (isRowAbsolute) { address += '$' } // Endif. address += row return address }, // returns XML snippet for a chart dataseries _ser: function(serie, i) { var rc2a = this._rowColToSheetAddress // shortcut var serieData = { 'c:ser': { 'c:idx': { '@val': i }, 'c:order': { '@val': i }, 'c:tx': this._strRef('Sheet1!' + rc2a(1, 2 + i, true, true), [ serie.name ]), // serie's value 'c:invertIfNegative': { '@val': '0' }, 'c:cat': this._strRef( 'Sheet1!' + rc2a(2, 1, true, true) + ':' + rc2a(2 + serie.labels.length - 1, 1, true, true), serie.labels ), 'c:val': this._numRef( 'Sheet1!' + rc2a(2, 2 + i, true, true) + ':' + rc2a(2 + serie.labels.length - 1, 2 + i, true, true), serie.values, 'General' ) } } if (serie.color) { serieData['c:ser']['c:spPr'] = { 'a:solidFill': { 'a:srgbClr': { '@val': serie.color } } } } else if (serie.schemeColor) { serieData['c:ser']['c:spPr'] = { 'a:solidFill': { 'a:schemeClr': { '@val': serie.schemeColor } } } } // Endif. if (serie.xml) { serieData['c:ser'] = _.merge(serieData['c:ser'], serie.xml) } // Endif. // for pie charts if (serie.colors) { serieData['c:ser']['#text'] = this._colorRef(serie.colors) } // Endif. return serieData }, // returns XML snippet for a chart dataseries getSeriesRef: function(data) { return data.map(this._ser, this) }, // returns XML snippet for axis number format e.g. "$0" for US currency, "0%" for percentages xmlValAxisFormat: function(formatCode) { return { 'c:chartSpace': { 'c:chart': { 'c:plotArea': { 'c:valAx': { 'c:majorGridlines': {}, 'c:numFmt': { '@formatCode': formatCode, '@sourceLinked': '0' } } } } } } }, // returns XML snippet for an axis scale // currently just min/max are supported /* */ _scaling: function(minValue, maxValue) { var ref = { 'c:scaling': { 'c:orientation': { '@val': 'minMax' } } } if (maxValue !== undefined) { ref['c:scaling']['c:max'] = { '@val': '' + (maxValue || '') } } // Endif. if (minValue !== undefined) { ref['c:scaling']['c:min'] = { '@val': '' + (minValue || '') } } // Endif. return ref }, _text: function(textSize) { return { 'c:txPr': { 'a:bodyPr': {}, 'a:listStyle': {}, 'a:p': { 'a:pPr': { 'a:defRPr': { '@sz': textSize } }, 'a:endParaRPr': { '@lang': 'en-US' } } } } }, // @brief returns XML snippet for an axis title _title: function(title) { if (typeof title === 'object') return title // assume it's an XML representations return { 'c:title': { 'c:tx': { 'c:rich': { 'a:bodyPr': {}, 'a:lstStyle': {}, 'a:p': { 'a:pPr': { 'a:defRPr': {} }, 'a:r': { 'a:rPr': { '@lang': 'en-US', '@dirty': '0', '@smtClean': '0' }, 'a:t': title }, 'a:endParaRPr': { '@lang': 'en-US', '@dirty': '0' } } } }, 'c:layout': {}, 'c:overlay': { '@val': '0' } } } } }.initialize(chartInfo) } OfficeChart.getChartBase = function(chartInfo) { var chartBase if (typeof chartInfo === 'string') { chartBase = _chartSpecs[chartInfo]() } else if (typeof chartInfo.renderType === 'string') { chartBase = _chartSpecs[chartInfo.renderType](chartInfo) } else if (chartInfo.xml) { chartBase = chartInfo.xml } else { throw new Error('invalid chart type') } // return deep copy so can create multiple charts from same base within one PowerPoint document return JSON.parse(JSON.stringify(chartBase)) } module.exports = OfficeChart /* // Column chart new OfficeChart({ title: 'eSurvey chart', renderType: 'column', data: [ // array of series { name: 'Income', labels: ['2005', '2006', '2007', '2008', '2009'], values: [23.5, 26.2, 30.1, 29.5, 24.6], colors: ['ff0000', '00ff00', '0000ff', 'ffff00', '00ffff'] // optional }, { name: 'Expense', labels: ['2005', '2006', '2007', '2008', '2009'], values: [18.1, 22.8, 23.9, 25.1, 25], colors: ['ff0000', '00ff00', '0000ff', 'ffff00', '00ffff'] // optional } ] }); // Pie chart new OfficeChart({ title: 'eSurvey chart', renderType: 'pie', data: [ // array of series { name: 'Income', labels: ['2005', '2006', '2007', '2008', '2009'], values: [23.5, 26.2, 30.1, 29.5, 24.6], colors: ['ff0000', '00ff00', '0000ff', 'ffff00', '00ffff'] // optional } ] }); // Clustered bar chat new OfficeChart({ title: 'eSurvey chart', renderType: 'group-bar', data: [ // array of series { name: 'Income', labels: ['2005', '2006', '2007', '2008', '2009'], values: [23.5, 26.2, 30.1, 29.5, 24.6], colors: ['ff0000', '00ff00', '0000ff', 'ffff00', '00ffff'] // optional } ] }); */