diff --git a/__tests__/fixtures/template-charts.pptx b/__tests__/fixtures/template-charts.pptx new file mode 100644 index 00000000..498b2687 Binary files /dev/null and b/__tests__/fixtures/template-charts.pptx differ diff --git a/__tests__/template-chart.test.js b/__tests__/template-chart.test.js new file mode 100644 index 00000000..260db9ad --- /dev/null +++ b/__tests__/template-chart.test.js @@ -0,0 +1,170 @@ +const PPTX = require('../index.js'); +const fs = require('fs'); +const tmpDir = `${__dirname}/tmp`; +const tmpFile = 'charts-new-add-chart-apply-template.pptx' +const categories1 = ['Category 1', 'Category 2', 'Category 3', 'Category 4'] +const barChartData1 = [ + { + name: 'Series 1', + labels: categories1, + values: [6.3, 4.5, 2.5, 4.5], + }, + { + name: 'Series 2', + labels: categories1, + values: [3.4, 1.4, 1.8, 2.8], + }, + { + name: 'Series 3', + labels: categories1, + values: [2.0, 2.0, 1.0, 55.5], + }, +]; + +const pieChartData = [ + { + name: 'Series 1', + labels: categories1, + values: [20, 30, 40, 10], + } +]; + + +const pptxTemplateFile = `${__dirname}/fixtures/template-charts.pptx` +describe('Charts Module', () => { + beforeAll(() => { + prepareTmpDir(tmpDir); + }); + + test('should be able to create a simple chart and apply a bar chart template', async () => { + try { + expect.assertions(1); + + let pptx = new PPTX.Composer(); + let promise = (await pptx.compose(async pres => { + await pres.layout('LAYOUT_4x3').addSlide(async slide => { + await slide.addChart(chart => { + chart + .template({ + pptxFile: pptxTemplateFile, + xmlFile: 'ppt/charts/chart1.xml' + }) + .data(barChartData1) + .x(100) + .y(100) + .cx(400) + .cy(300); + }); + }); + + })).save(`${tmpDir}/${tmpFile}`); + + await promise; + + expect(fs.existsSync(`${tmpDir}/${tmpFile}`)).toBe(true); + } catch (err) { + console.warn(err); + throw err; + } + }); + + test('should be able to create a chart and apply a pie chart template', async () => { + try { + expect.assertions(1); + + let pptx = new PPTX.Composer(); + let promise = (await pptx.compose(async pres => { + await pres.layout('LAYOUT_4x3').addSlide(async slide => { + await slide.addChart(chart => { + chart + .template({ + pptxFile: pptxTemplateFile, + xmlFile: 'ppt/charts/chart2.xml' + }) + .data(pieChartData) + .x(100) + .y(100) + .cx(400) + .cy(300); + }); + }); + + })).save(`${tmpDir}/${tmpFile}`); + + await promise; + + expect(fs.existsSync(`${tmpDir}/${tmpFile}`)).toBe(true); + } catch (err) { + console.warn(err); + throw err; + } + }); + + test('should be able to create a chart and apply a combo chart template', async () => { + try { + expect.assertions(1); + + let pptx = new PPTX.Composer(); + let promise = (await pptx.compose(async pres => { + await pres.layout('LAYOUT_4x3').addSlide(async slide => { + await slide.addChart(chart => { + chart + .template({ + pptxFile: pptxTemplateFile, + xmlFile: 'ppt/charts/chart3.xml', + callback: (template, { newChartSpaceBlock, TemplateHelper, chart }) => { + // First we replace chartSpace by our template + TemplateHelper.applyChartSpace(template, newChartSpaceBlock) + + // We need to extract the different series templates using their chart type names + let seriesTemplateBar = TemplateHelper.getSeriesTemplate(template, 'barChart') + let seriesTemplateLine = TemplateHelper.getSeriesTemplate(template, 'lineChart') + + // Combined, the templates will be passed to the creator + seriesTemplate = [ + seriesTemplateBar[0], seriesTemplateBar[1], seriesTemplateLine[0] + ] + + // The series have to be calculated based on the passed series templates + let series = TemplateHelper.createSeriesFromTemplate(chart.chartData, seriesTemplate) + + // We will override the template series with our created ones + TemplateHelper.applySeriesToChart([series[0], series[1]], newChartSpaceBlock, 'barChart') + TemplateHelper.applySeriesToChart([series[2]], newChartSpaceBlock, 'lineChart') + } + }) + .data(barChartData1) + .x(100) + .y(100) + .cx(400) + .cy(300) + }); + }); + + })).save(`${tmpDir}/${tmpFile}`); + + await promise; + + expect(fs.existsSync(`${tmpDir}/${tmpFile}`)).toBe(true); + } catch (err) { + console.warn(err); + throw err; + } + }); +}); + +function prepareTmpDir(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir); + } else { + emptyDir(dir); + } +} + +function emptyDir(dir) { + if (fs.existsSync(`${__dirname}/tmp/${tmpFile}`)) { + fs.unlink(`${__dirname}/tmp/${tmpFile}`, err => { + if (err) throw err; + }); + } +} diff --git a/lib/chart.js b/lib/chart.js index 5abadbd0..4152c602 100644 --- a/lib/chart.js +++ b/lib/chart.js @@ -27,6 +27,12 @@ class Chart extends ElementProperties { this.content = content; super.setPropertyContent(this.content['p:graphicFrame'][0]['p:xfrm'][0]); } + + template(templateParams) { + this.templateParams = templateParams; + + return this; + } } module.exports.Chart = Chart; diff --git a/lib/factories/ppt/index.js b/lib/factories/ppt/index.js index bf87acd3..d9cc452d 100644 --- a/lib/factories/ppt/index.js +++ b/lib/factories/ppt/index.js @@ -113,7 +113,7 @@ class PptFactory { } async addChart(slide, chart) { - this.slideFactory.addChartToSlideRelationship(slide, chart.name); + this.slideFactory.addChartToSlideRelationship(slide, chart); let workbookJSZip = await ExcelHelper.createWorkbook(chart.chartData); let workbookContentZipBinary = await workbookJSZip.generateAsync({ type: 'arraybuffer' }); diff --git a/lib/factories/ppt/slides.js b/lib/factories/ppt/slides.js index fb517a42..3ef1b788 100644 --- a/lib/factories/ppt/slides.js +++ b/lib/factories/ppt/slides.js @@ -2,6 +2,7 @@ let { PptFactoryHelper } = require('../../helpers/ppt-factory-helper'); let { PptxContentHelper } = require('../../helpers/pptx-content-helper'); +let { TemplateHelper } = require('../../helpers/template-helper'); class SlideFactory { constructor(parentFactory, args) { @@ -43,7 +44,6 @@ class SlideFactory { }; slideContent = JSON.parse(JSON.stringify(slideContent)); - this.content[slideKey] = slideContent; return slideContent; @@ -128,7 +128,8 @@ class SlideFactory { return rId; } - addChartToSlideRelationship(slide, chartName) { + addChartToSlideRelationship(slide, chart) { + let chartName = chart.name let relsKey = `ppt/slides/_rels/${slide.name}.xml.rels`; let rId = `rId${this.content[relsKey]['Relationships']['Relationship'].length + 1}`; @@ -140,7 +141,7 @@ class SlideFactory { }, }); - return rId; + chart.rId = rId } addImage(slide, image, imageObjectName, rId) { @@ -306,19 +307,30 @@ class SlideFactory { return newShapeBlock; } - addChart(slide, chart) { + async addChart(slide, chart) { let slideKey = `ppt/slides/${slide.name}.xml`; let chartKey = `ppt/charts/${chart.name}.xml`; - let newGraphicFrameBlock = PptFactoryHelper.createBaseChartFrameBlock(chart.x(), chart.y(), chart.cx(), chart.cy()); // goes onto the slide + let newGraphicFrameBlock = PptFactoryHelper.createBaseChartFrameBlock(chart); // goes onto the slide let newChartSpaceBlock = PptFactoryHelper.createBaseChartSpaceBlock(); // goes into the chart XML - let seriesDataBlock = PptFactoryHelper.createSeriesDataBlock(chart.chartData); - - newChartSpaceBlock['c:chartSpace']['c:chart'][0]['c:plotArea'][0]['c:barChart'][0]['c:ser'] = seriesDataBlock['c:ser']; + + if(chart.hasOwnProperty('templateParams')) { + // If templateParams are set, a chart template will be loaded and applied to newChartSpaceBlock. + await TemplateHelper.applyTemplateXml(chart.templateParams, { newChartSpaceBlock, chart }) + } else { + let seriesDataBlock = PptFactoryHelper.createSeriesDataBlock(chart.chartData); + newChartSpaceBlock['c:chartSpace']['c:chart'][0]['c:plotArea'][0]['c:barChart'][0]['c:ser'] = seriesDataBlock['c:ser']; + } this.content[chartKey] = newChartSpaceBlock; - this.content[slideKey]['p:sld']['p:cSld'][0]['p:spTree'][0]['p:graphicFrame'] = newGraphicFrameBlock['p:graphicFrame']; + let tree = this.content[slideKey]['p:sld']['p:cSld'][0]['p:spTree'][0] + if(!tree.hasOwnProperty('p:graphicFrame')) { + tree['p:graphicFrame'] = new Array + } + + tree['p:graphicFrame'].push(newGraphicFrameBlock['p:graphicFrame'][0]) + return newGraphicFrameBlock; } diff --git a/lib/helpers/ppt-factory-helper.js b/lib/helpers/ppt-factory-helper.js index e97bd235..f219751e 100644 --- a/lib/helpers/ppt-factory-helper.js +++ b/lib/helpers/ppt-factory-helper.js @@ -5,6 +5,7 @@ const XmlNode = require('../xmlnode'); let { ExcelHelper } = require('./excel-helper'); let { PptxUnitHelper } = require('./unit-helper'); +let { TemplateHelper } = require('./template-helper'); const HyperlinkType = { TEXT: 'text', @@ -85,7 +86,7 @@ class PptFactoryHelper { // TODO: this block is taken straight from won21 (except I had to change some objects to an array of objects to support our existing // block structure); I don't like the defaults it's using and there are some slight differences from an actual PowerPoint-generated // p:graphicFrame block. Once basic charts are done, revisit this and see if this block can be made better. - static createBaseChartFrameBlock(x, y, cx, cy) { + static createBaseChartFrameBlock(chart) { return { 'p:graphicFrame': [ { @@ -118,8 +119,8 @@ class PptFactoryHelper { ], 'p:xfrm': [ { - 'a:off': [{ $: { x: x, y: y } }], - 'a:ext': [{ $: { cx: cx, cy: cy } }], + 'a:off': [{ $: { x: chart.x(), y: chart.y() } }], + 'a:ext': [{ $: { cx: chart.cx(), cy: chart.cy() } }], }, ], 'a:graphic': [ @@ -134,7 +135,7 @@ class PptFactoryHelper { $: { 'xmlns:c': 'http://schemas.openxmlformats.org/drawingml/2006/chart', 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', - 'r:id': 'rId2', + 'r:id': chart.rId, }, }, ], @@ -625,6 +626,7 @@ class PptFactoryHelper { } // this will return all the child nodes that belong under a node (it will NOT contain the root) + // Passing an array in this.template will return series created by TemplateHelper. static createSingleSeriesDataNode(series, i) { let rc2a = ExcelHelper.rowColToSheetAddress; let strRef = PptFactoryHelper.createStrRefNode; @@ -634,13 +636,23 @@ class PptFactoryHelper { let sheetCellRangeForCategories = `Sheet1!${rc2a(2, 1, true, true)}:${rc2a(2 + series.labels.length - 1, 1, true, true)}`; let sheetCellAddressForSeriesName = `Sheet1!${rc2a(1, 2 + i, true, true)}`; + let tx = strRef(sheetCellAddressForSeriesName, [series.name]) + let cat = strRef(sheetCellRangeForCategories, series.labels) + let val = numRef(sheetCellRangeForValues, series.values, 'General') + + if(typeof this.template !== 'undefined' && typeof this.template[i] !== 'undefined') { + let series = TemplateHelper.setTemplateSeriesDefault(this.template[i]) + TemplateHelper.setTemplateSeriesData(series, {i, tx, cat, val}) + return series + } + let serChildNodes = XmlNode() .addChild('c:idx', XmlNode().attr('val', i)) .addChild('c:order', XmlNode().attr('val', i)) - .addChild('c:tx', strRef(sheetCellAddressForSeriesName, [series.name])) .addChild('c:invertIfNegative', XmlNode().attr('val', 0)) - .addChild('c:cat', strRef(sheetCellRangeForCategories, series.labels)) - .addChild('c:val', numRef(sheetCellRangeForValues, series.values, 'General')); + .addChild('c:tx', tx) + .addChild('c:cat', cat) + .addChild('c:val', val); if (series.color) { let colorBlock = PptFactoryHelper.createColorBlock(series.color); diff --git a/lib/helpers/template-helper.js b/lib/helpers/template-helper.js new file mode 100644 index 00000000..a864df88 --- /dev/null +++ b/lib/helpers/template-helper.js @@ -0,0 +1,115 @@ +/* eslint-disable no-prototype-builtins */ +const xml2js = require('xml2js'); +const JSZip = require('jszip'); +const fs = require('fs'); + +// This helper class bundles functions needed to read, transform and apply xml templates. +class TemplateHelper { + + // We need to load, extract and parse xml data from a given pptx file + // Currently, only charts can be edited this way. + // TODO: also non-zipped xml data should be processed. + static async applyTemplateXml(params, target) { + const parser = new xml2js.Parser({}); + const zip = new JSZip(); + + const content = fs.readFileSync(params.pptxFile) + const unzipped = await zip.loadAsync(content) + const xmlBuffer = await unzipped.files[params.xmlFile].async('nodebuffer') + const callback = TemplateHelper.getTemplateCallback(params, target) + + parser.parseString(xmlBuffer, function (err, templateXml) { + callback(templateXml, target) + }) + } + + // When there is no callback defined, we will apply a default callback. + // The default callback will depend on the type of shape; atm only charts are supported. + static getTemplateCallback(params, target) { + if(params.callback === undefined) { + if(target.chart) { + return TemplateHelper.applyChartTemplate + } + } else { + target.TemplateHelper = TemplateHelper + return params.callback + } + } + + // This is the default callback to inject template xml data into a newChartSpaceBlock + static applyChartTemplate(template, {newChartSpaceBlock, chart, chartType}) { + chartType = (chartType === undefined) + ? TemplateHelper.detectChartType(template) + : chartType + + TemplateHelper.applyChartSpace(template, newChartSpaceBlock) + + let seriesTemplate = TemplateHelper.getSeriesTemplate(template, chartType) + let seriesData = TemplateHelper.createSeriesFromTemplate(chart.chartData, seriesTemplate) + + TemplateHelper.applySeriesToChart(seriesData, newChartSpaceBlock, chartType) + } + + // This will refer to getPptFactoryHelper.createSingleSeriesDataNode. + // A check for this.template will fork back here and call TemplateHelper.setTemplateSeriesData + static createSeriesFromTemplate(chartData, seriesTemplate) { + const callback = TemplateHelper.getPptFactoryHelper().createSingleSeriesDataNode + return chartData.map(callback, { template: seriesTemplate }) + } + + // If we don't have to deal with combo charts, the chart type can be auto-detected. + // The first parent of a series will return the cart type. + static detectChartType(template) { + const plotArea = template['c:chartSpace']['c:chart'][0]['c:plotArea'][0] + for(let tag in plotArea) { + if(plotArea[tag][0]['c:ser']) { + return tag.replace('c:', '') + } + } + return 'barChart' + } + + // This will replace the entire chartSpace by the given template xml. + static applyChartSpace(template, newChartSpaceBlock) { + newChartSpaceBlock['c:chartSpace'] = template['c:chartSpace'] + } + + // We want to pick the series by chart type. + static getSeriesTemplate(template, chartType) { + return template['c:chartSpace']['c:chart'][0]['c:plotArea'][0][`c:${chartType}`][0]['c:ser'] + } + + // This is to replace the template series by a calculated one. + static applySeriesToChart(series, newChartSpaceBlock, chartType) { + newChartSpaceBlock['c:chartSpace']['c:chart'][0]['c:plotArea'][0][`c:${chartType}`][0]['c:ser'] = series + } + + // We have to clone the template series get a fresh copy. + static setTemplateSeriesDefault(templateSeries) { + let series = JSON.parse(JSON.stringify(templateSeries)) + if(typeof series['c:cat'] === undefined) { + series['c:cat'] = [ '' ] + } + return series + } + + // This will apply the data from PptFactoryHelper.createSingleSeriesDataNode + static setTemplateSeriesData(series, {i, tx, cat, val}) { + series['c:idx'][0]['$']['val'] = i + series['c:order'][0]['$']['val'] = i + series['c:tx'][0] = tx + series['c:cat'][0] = cat + series['c:val'][0] = val + } + + // We need this, because + // const { PptFactoryHelper } = require('./ppt-factory-helper') + // on top of this file will return undefined and I have no clue why. + static getPptFactoryHelper() { + const { PptFactoryHelper } = require('./ppt-factory-helper'); + return PptFactoryHelper + } + +} + +module.exports.TemplateHelper = TemplateHelper; \ No newline at end of file diff --git a/readme.md b/readme.md index 0363cc7c..0e1e0bf4 100644 --- a/readme.md +++ b/readme.md @@ -354,6 +354,52 @@ await pptx }) .save('./chart.pptx'); ``` +#### Chart Templates + +Although, you can use custom chart templates. Those will be imported from any pptx file and applied using an optional callback function. So, you don't have to wait for further chart types to be implemented, but reuse any existing chart. All you need to know is the chart-id inside the pptx file. Please refer to template-chart.test.js for further examples and handling combo chart types. + +```javascript +const PPTX = require('nodejs-pptx'); +let pptx = new PPTX.Composer(); + +let barChartData1 = [ + { + name: 'Series 1', + labels: ['Category 1', 'Category 2', 'Category 3', 'Category 4'], + values: [4.3, 2.5, 3.5, 4.5], + }, + { + name: 'Series 2', + labels: ['Category 1', 'Category 2', 'Category 3', 'Category 4'], + values: [2.4, 4.4, 1.8, 2.8], + }, + { + name: 'Series 3', + labels: ['Category 1', 'Category 2', 'Category 3', 'Category 4'], + values: [2.0, 2.0, 3.0, 5.0], + }, +]; + +await pptx + .compose(async pres => { + await pres.layout('LAYOUT_4x3').addSlide(async slide => { + await slide.addChart(chart => { + chart + .template({ + pptxFile: 'path/to/template.pptx', + xmlFile: 'ppt/charts/chart1.xml', + //callback: (template, { newChartSpaceBlock, TemplateHelper, chart }) => { ... } + }) + .data(barChartData1) + .x(100) + .y(100) + .cx(400) + .cy(300) + }); + }); + }) + .save('./template-chart.pptx'); +``` ### Images