UNPKG

er-nodejs-pptx

Version:

Generate PPTX files on the server-side with JavaScript.

1,069 lines (968 loc) 64.8 kB
/* eslint-disable no-prototype-builtins */ const SCHEME_COLORS = require('../color-types').SchemeColors; const XmlNode = require('../xmlnode'); let { ExcelHelper } = require('./excel-helper'); let { PptxUnitHelper } = require('./unit-helper'); const HyperlinkType = { TEXT: 'text', IMAGE: 'image', }; class PptFactoryHelper { // TODO: this function might not be used anymore, check... static assignDefaults(defaults = {}, options = {}) { let settings = Object.assign(defaults, options); options.x = PptxUnitHelper.fromPixels(settings.x); options.y = PptxUnitHelper.fromPixels(settings.y); options.cx = PptxUnitHelper.fromPixels(settings.cx); options.cy = PptxUnitHelper.fromPixels(settings.cy); return options; } static handleHyperlinkOptions(pptFactory, type, slide, options) { if (!options || !options.url) return; if (type !== HyperlinkType.IMAGE && type !== HyperlinkType.TEXT) throw new Error('Invalid hyperlink type.'); if (type === HyperlinkType.IMAGE) { // if this is not a link to another slide if (options.url[0] !== '#') { // interestingly enough, you can't just give PowerPoint a simple URL like "www.google.com" - it // MUST contain the protocol prefix; so we'll put "https://" if the caller didn't specify it if (!options.url.startsWith('http')) { options.url = `https://${options.url}`; } } } if (options.url[0] === '#') { let slideNum = options.url.substr(1); options.rIdForHyperlink = pptFactory.slideFactory.addSlideTargetRelationship(slide, `slide${slideNum}.xml`); } else { options.rIdForHyperlink = pptFactory.slideFactory.addHyperlinkToSlideRelationship(slide, options.url); } } static createBaseShapeBlock(objectId, objectName, x, y, cx, cy) { return { 'p:nvSpPr': [ { 'p:cNvPr': [{ $: { id: objectId, name: `${objectName} ${objectId}` } }], 'p:cNvSpPr': [{}], 'p:nvPr': [{}], }, ], 'p:spPr': [ { 'a:xfrm': [ { 'a:off': [{ $: { x: x, y: y } }], 'a:ext': [{ $: { cx: cx, cy: cy } }], }, ], 'a:prstGeom': [ { $: { prst: 'rect' }, 'a:avLst': [{}], }, ], }, ], 'p:txBody': [ { 'a:bodyPr': [{}], 'a:lstStyle': [{}], 'a:p': [{}], }, ], }; } // 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) { return { 'p:graphicFrame': [ { 'p:nvGraphicFramePr': [ { 'p:cNvPr': [{ $: { id: '5', name: 'Chart 4' } }], 'p:cNvGraphicFramePr': [{}], 'p:nvPr': [ { 'p:extLst': [ { 'p:ext': [ { $: { uri: '{D42A27DB-BD31-4B8C-83A1-F6EECF244321}' }, 'p14:modId': [ { $: { 'xmlns:p14': 'http://schemas.microsoft.com/office/powerpoint/2010/main', val: '3543180680', }, }, ], }, ], }, ], }, ], }, ], 'p:xfrm': [ { 'a:off': [{ $: { x: x, y: y } }], 'a:ext': [{ $: { cx: cx, cy: cy } }], }, ], 'a:graphic': [ { 'a:graphicData': [ { $: { uri: 'http://schemas.openxmlformats.org/drawingml/2006/chart', }, 'c:chart': [ { $: { 'xmlns:c': 'http://schemas.openxmlformats.org/drawingml/2006/chart', 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'r:id': 'rId2', }, }, ], }, ], }, ], }, ], }; } static createBaseChartSpaceBlock() { return { // NOTE: c:chartSpace is not an array here because it gets inserted at the root 'c:chartSpace': { $: { 'xmlns:c': 'http://schemas.openxmlformats.org/drawingml/2006/chart', 'xmlns:a': 'http://schemas.openxmlformats.org/drawingml/2006/main', 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', }, 'c:date1904': [ { $: { val: '0', }, }, ], 'c:lang': [{ $: { val: 'en-US' } }], 'c:roundedCorners': [{ $: { val: '0' } }], // this AlternateContent node is optional: it just makes the bars look a little 3D-like instead of flat-shaded 'mc:AlternateContent': [ { $: { 'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006', }, 'mc:Choice': [ { $: { Requires: 'c14', 'xmlns:c14': 'http://schemas.microsoft.com/office/drawing/2007/8/2/chart', }, 'c14:style': [ { $: { val: '118', }, }, ], }, ], 'mc:Fallback': [ { 'c:style': [ { $: { val: '18', }, }, ], }, ], }, ], 'c:chart': [ { 'c:autoTitleDeleted': [ { $: { val: '0', }, }, ], 'c:plotArea': [ { 'c:layout': [{}], 'c:barChart': [ { 'c:barDir': [ { $: { val: 'bar', }, }, ], 'c:grouping': [ { $: { val: 'clustered', }, }, ], 'c:varyColors': [ { $: { val: '0', }, }, ], 'c:ser': [], // insert generated c:ser here 'c:dLbls': [ { 'c:showLegendKey': [ { $: { val: '0', }, }, ], 'c:showVal': [ { $: { val: '0', }, }, ], 'c:showCatName': [ { $: { val: '0', }, }, ], 'c:showSerName': [ { $: { val: '0', }, }, ], 'c:showPercent': [ { $: { val: '0', }, }, ], 'c:showBubbleSize': [ { $: { val: '0', }, }, ], }, ], 'c:gapWidth': [ { $: { val: '150', }, }, ], 'c:axId': [ { $: { val: '2067994824', }, }, { $: { val: '-2074751000', }, }, ], }, ], 'c:catAx': [ { 'c:axId': [ { $: { val: '2067994824', }, }, ], 'c:scaling': [ { 'c:orientation': [ { $: { val: 'minMax', }, }, ], }, ], 'c:delete': [ { $: { val: '0', }, }, ], 'c:axPos': [ { $: { val: 'l', }, }, ], 'c:majorTickMark': [ { $: { val: 'out', }, }, ], 'c:minorTickMark': [ { $: { val: 'none', }, }, ], 'c:tickLblPos': [ { $: { val: 'nextTo', }, }, ], 'c:crossAx': [ { $: { val: '-2074751000', }, }, ], 'c:crosses': [ { $: { val: 'autoZero', }, }, ], 'c:auto': [ { $: { val: '1', }, }, ], 'c:lblAlgn': [ { $: { val: 'ctr', }, }, ], 'c:lblOffset': [ { $: { val: '100', }, }, ], 'c:noMultiLvlLbl': [ { $: { val: '0', }, }, ], }, ], 'c:valAx': [ { 'c:axId': [ { $: { val: '-2074751000', }, }, ], 'c:scaling': [ { 'c:orientation': [ { $: { val: 'minMax', }, }, ], }, ], 'c:delete': [ { $: { val: '0', }, }, ], 'c:axPos': [ { $: { val: 'b', }, }, ], 'c:majorGridlines': [{}], 'c:numFmt': [ { $: { formatCode: 'General', sourceLinked: '1', }, }, ], 'c:majorTickMark': [ { $: { val: 'out', }, }, ], 'c:minorTickMark': [ { $: { val: 'none', }, }, ], 'c:tickLblPos': [ { $: { val: 'nextTo', }, }, ], 'c:crossAx': [ { $: { val: '2067994824', }, }, ], 'c:crosses': [ { $: { val: 'autoZero', }, }, ], 'c:crossBetween': [ { $: { val: 'between', }, }, ], }, ], }, ], 'c:legend': [ { 'c:legendPos': [ { $: { val: 'r', }, }, ], 'c:layout': [{}], 'c:overlay': [ { $: { val: '0', }, }, ], }, ], 'c:plotVisOnly': [ { $: { val: '1', }, }, ], 'c:dispBlanksAs': [ { $: { val: 'gap', }, }, ], 'c:showDLblsOverMax': [ { $: { val: '0', }, }, ], }, ], 'c:txPr': [ { 'a:bodyPr': [{}], 'a:lstStyle': [{}], 'a:p': [ { 'a:pPr': [ { 'a:defRPr': [ { $: { sz: '1800', }, }, ], }, ], 'a:endParaRPr': [ { $: { lang: 'en-US', }, }, ], }, ], }, ], 'c:externalData': [ { $: { 'r:id': 'rId1', }, 'c:autoUpdate': [ { $: { val: '0', }, }, ], }, ], }, }; } // fallbackRgbColor = the color to use if the user-supplied "color" variable is invalid (MUST be in RGB format) static validateColor(color, fallbackRgbColor) { if (color === undefined || color === '') { // this is OK, the color wasn't even specified (blank or undefined), so don't throw warnings, just use fallback return { isRgb: true, color: fallbackRgbColor }; } let isRgb = /^[0-9a-fA-F]{6}$/.test(color); let schemeColorValues = Object.keys(SCHEME_COLORS).map(function(key) { return SCHEME_COLORS[key]; }); if (!isRgb && !schemeColorValues.includes(color)) { console.warn(`"${color}" is not a valid scheme color or RGB value. Using default color: "${fallbackRgbColor}"`); console.warn('Use a RGB value or one of these scheme color values:', schemeColorValues.join(', ')); return { isRgb: isRgb, color: fallbackRgbColor, }; } return { isRgb: isRgb, color: color, }; } static createColorBlock(color) { const DEFAULT_FONT_COLOR = '000000'; let colorInfo = PptFactoryHelper.validateColor(color, DEFAULT_FONT_COLOR); let tagName = colorInfo.isRgb ? 'srgbClr' : 'schemeClr'; let colorObject = {}; colorObject[`a:${tagName}`] = [{ $: { val: colorInfo.color } }]; return colorObject; } // this will return an array of data blocks representing all the <c:ser> nodes which get inserted as children // under the corresponding "chart" node (e.g., <c:barChart> for bar charts, <c:pieChart> for pie charts, etc.) static createSeriesDataBlock(data) { return { 'c:ser': data.map(this.createSingleSeriesDataNode, this) }; } // this will return all the child nodes that belong under a <c:ser> node (it will NOT contain the <c:ser> root) static createSingleSeriesDataNode(series, i) { let rc2a = ExcelHelper.rowColToSheetAddress; let strRef = PptFactoryHelper.createStrRefNode; let numRef = PptFactoryHelper.createNumRefNode; let sheetCellRangeForValues = `Sheet1!${rc2a(2, 2 + i, true, true)}:${rc2a(2 + series.labels.length - 1, 2 + i, true, true)}`; 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 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')); if (series.color) { let colorBlock = PptFactoryHelper.createColorBlock(series.color); let colorChildNodeType = colorBlock['a:srgbClr'] ? 'a:srgbClr' : 'a:schemeClr'; let colorNode = XmlNode().addChild(colorChildNodeType, colorBlock[colorChildNodeType][0]); // will either be <a:srgbClr> or <a:schemeClr> serChildNodes.addChild('c:spPr', XmlNode().addChild('a:solidFill', colorNode)); } return serChildNodes.el; } static createStrRefNode(region, stringArray) { let node = XmlNode().addChild( 'c:strRef', XmlNode() .addChild('c:f', region) .addChild('c:strCache', PptFactoryHelper.createPtCountNode(stringArray)) ); return node.el; } static createPtCountNode(stringArray) { let node = XmlNode().addChild('c:ptCount', XmlNode().attr('val', stringArray.length)); for (let i = 0; i < stringArray.length; i++) { node.addChild( 'c:pt', XmlNode() .attr('idx', i) .addChild('c:v', stringArray[i]) ); } return node; } static createNumRefNode(region, numArray, formatCode) { let numCache = XmlNode() .addChild('c:formatCode', formatCode) .addChild('c:ptCount', XmlNode().attr('val', numArray.length)); for (let i = 0; i < numArray.length; i++) { numCache.addChild( 'c:pt', XmlNode() .attr('idx', i) .addChild('c:v', numArray[i].toString()) ); } let node = XmlNode().addChild( 'c:numRef', XmlNode() .addChild('c:f', region) .addChild('c:numCache', numCache) ); return node.el; } static addFontFaceToBlock(fontFace, block) { // TODO: not completely sure how pitchFamily is calculated (PowerPoint defaulted to "34" on "Arial" and "2" on "Alien Encounters") // // After looking at some .NET code for enumerating available fonts on Windows, this enum stuck out at me: // // [Flags] // public enum LogFontPitchAndFamily : byte { // Default = 0, // DontCare = 0, // Fixed = 1, // Variable = 2, // Roman = 16, // Swiss = 32, // Modern = 48, // Script = 64, // Decorative = 80, // } // // So it looks like PowerPoint's default of "34" for arial could've been a bitfield combination of Swiss and Variable (0010 0010) (i.e. 32 OR'ed with 2). // While PowerPoint's selection of "2" for alien encounters probably just means Variable (it is indeed a variable width font, same as arial). // // Since it looks like "0" can be used as a safe default, I'm going to revisit this later. let fontAttributes = [{ $: { typeface: fontFace, pitchFamily: '0', charset: '0' } }]; block['a:latin'] = fontAttributes; block['a:cs'] = fontAttributes; } static setTextRunProperties(textRunPropertyBlock, options) { if (typeof options.fontSize === 'number') { textRunPropertyBlock['$'].sz = `${Math.round(options.fontSize)}00`; } if (options.fontBold !== undefined && options.fontBold === true) { textRunPropertyBlock['$'].b = '1'; } else if (options.fontBold === false) { textRunPropertyBlock['$'].b = '0'; } if (options.fontItalic !== undefined && options.fontItalic === true) { textRunPropertyBlock['$'].i = '1'; } else if (options.fontItalic === false) { textRunPropertyBlock['$'].i = '0'; } if (options.fontUnderline !== undefined && options.fontUnderline === true) { textRunPropertyBlock['$'].u = 'sng'; } else if (options.fontUnderline === false) { delete textRunPropertyBlock['$'].u; } if (options.fontSubscript !== undefined && options.fontSubscript === true) { textRunPropertyBlock['$'].baseline = '-40000'; } else if (options.fontSuperscript !== undefined && options.fontSuperscript === true) { textRunPropertyBlock['$'].baseline = '30000'; } } static setTextBodyProperties(textBodyPropertyBlock, parentObject, options) { PptFactoryHelper.setMarginsOnTextBody(textBodyPropertyBlock, options.margin); PptFactoryHelper.setTextWrapOnTextBody(textBodyPropertyBlock, options); PptFactoryHelper.setVerticalAlignmentOnTextBody(textBodyPropertyBlock, options); PptFactoryHelper.setAutoFitOnTextBody(textBodyPropertyBlock, parentObject, options); } static addParagraphPropertiesToBlock(paragraphBlock, options) { if (options.textAlign) { let alignment = ''; // text will default to left alignment if no <a:pPr> node is created if (options.textAlign) { switch (options.textAlign) { case 'l': case 'left': alignment = 'l'; break; case 'r': case 'right': alignment = 'r'; break; case 'c': case 'ctr': case 'cntr': case 'center': alignment = 'ctr'; break; case 'j': case 'justify': alignment = 'just'; break; default: alignment = ''; } } if (alignment) { if (paragraphBlock['a:pPr'] === undefined) { // better to throw an error here than to create a new <a:pPr> block which could potentially be // out of order - if it's out of order the resulting PowerPoint will be corrupt throw new Error("Paragraph properties block, <a:pPr>, doesn't exist."); } paragraphBlock['a:pPr'][0]['$'].algn = alignment; } } } static setMarginsOnTextBody(textBodyPropertyBlock, margin) { const PT = PptxUnitHelper.Units.ONE_POINT; if (margin === undefined) { return; } else if (typeof margin === 'object') { if (margin.left !== undefined && Number.isInteger(margin.left)) textBodyPropertyBlock['$'].lIns = margin.left * PT; if (margin.top !== undefined && Number.isInteger(margin.top)) textBodyPropertyBlock['$'].tIns = margin.top * PT; if (margin.right !== undefined && Number.isInteger(margin.right)) textBodyPropertyBlock['$'].rIns = margin.right * PT; if (margin.bottom !== undefined && Number.isInteger(margin.bottom)) textBodyPropertyBlock['$'].bIns = margin.bottom * PT; } else if (Number.isInteger(margin)) { textBodyPropertyBlock['$'].lIns = margin * PT; textBodyPropertyBlock['$'].tIns = margin * PT; textBodyPropertyBlock['$'].rIns = margin * PT; textBodyPropertyBlock['$'].bIns = margin * PT; } } static setTextWrapOnTextBody(textBodyPropertyBlock, options) { textBodyPropertyBlock['$'].wrap = options.textWrap ? options.textWrap : 'square'; } static setVerticalAlignmentOnTextBody(textBodyPropertyBlock, options) { let alignment = 'ctr'; if (options.textVerticalAlign) { switch (options.textVerticalAlign) { case 't': case 'top': alignment = 't'; break; case 'c': case 'ctr': case 'cntr': case 'center': alignment = 'ctr'; break; case 'b': case 'bottom': alignment = 'b'; break; default: alignment = 'ctr'; } } textBodyPropertyBlock['$'].anchor = alignment; } static setAutoFitOnTextBody(textBodyPropertyBlock, parentObject, options) { if (options.autoFit !== undefined && options.autoFit === true) { textBodyPropertyBlock['a:spAutoFit'] = [{}]; } else if (options.shrinkText !== undefined && options.shrinkText === true) { let approxNumLines = this.calcNumTextLinesInShape(parentObject); if (approxNumLines === -1) { // can't calculate the number of text lines, so just set an appropriate default and return textBodyPropertyBlock['a:normAutofit'] = [{ $: { fontScale: '70000', lnSpcReduction: '20000' } }]; return; } // the default line spacing is 20% of the font size; TODO: if we add an option to specify line spacing size in the future, will need to read from that option here let lineSpacingPixels = PptxUnitHelper.toPixels(PptxUnitHelper.fromPoints(parentObject.options.fontSize * 0.2)); let fontHeightPixels = PptxUnitHelper.toPixels(PptxUnitHelper.fromPoints(parentObject.options.fontSize)); // only at 72 DPI will points == pixels let totalTextBlockHeightPixels = approxNumLines * fontHeightPixels + (approxNumLines - 1) * lineSpacingPixels; let shapeHeight = parentObject.cy(); if (totalTextBlockHeightPixels > shapeHeight) { let overflowAmountPixels = totalTextBlockHeightPixels - shapeHeight; let textOverflowAmountPercent = overflowAmountPixels / shapeHeight; let fontScale = 100 - textOverflowAmountPercent * 40; if (fontScale < 60) { fontScale = 60; } fontScale = Math.floor(fontScale * 1000); textBodyPropertyBlock['a:normAutofit'] = [{ $: { fontScale: fontScale, lnSpcReduction: '20000' } }]; } } } static calcNumTextLinesInShape(shapeObject) { let currentLine = ''; let lineWidth = 0; let approxNumLines = 1; let fontFace = shapeObject.options.fontFace; let fontSize = shapeObject.options.fontSize; let wordsArray = shapeObject.textValue.split(' '); let internalLeftRightMargin = PptxUnitHelper.toPoints(PptxUnitHelper.fromInches(0.1)) * 2; // default internal margin is 0.10 inches on both sides let textAreaWidthPoints = PptxUnitHelper.toPoints(PptxUnitHelper.fromPixels(shapeObject.cx())) - internalLeftRightMargin; // the width of the _drawable_ text area in a shape if (shapeObject.shapeType.name === 'chevron') { // since a chevron is a non-rectangle weird shape, PowerPoint doesn't allow a text line to go all the way to the right edge before breaking into a new line; // seems to be padding of about 20% on the right side, so we decrease the drawable text area width by this amount textAreaWidthPoints *= 0.8; } for (let word of wordsArray) { currentLine += ` ${word}`; lineWidth = this.calcStringWidthPoints(currentLine, fontFace, fontSize); if (lineWidth === -1) return -1; // font type isn't supported, no need to go further if (lineWidth > textAreaWidthPoints) { approxNumLines++; currentLine = word; lineWidth = this.calcStringWidthPoints(word, fontFace, fontSize); } } return approxNumLines; } static addLinePropertiesToBlock(block, lineProperties) { if (lineProperties === undefined) return; const DEFAULT_LINE_COLOR = '000000'; let colorInfo = PptFactoryHelper.validateColor(lineProperties.color, DEFAULT_LINE_COLOR); if (!lineProperties.width) lineProperties.width = 1; block['a:ln'] = [{}]; block['a:ln'][0]['$'] = { w: lineProperties.width * PptxUnitHelper.Units.ONE_POINT }; block['a:ln'][0]['a:solidFill'] = PptFactoryHelper.createColorBlock(colorInfo.color); // TODO: this will validate the color again, even though it's been validated arleady, think of another way... if (lineProperties.dashType) { block['a:ln'][0]['a:prstDash'] = [{ $: { val: lineProperties.dashType } }]; } } static addAvLstToBlock(block, avLst) { if (avLst === undefined) return; block['a:avLst'] = [{ 'a:gd': [] }]; for (let prop in avLst) { if (avLst.hasOwnProperty(prop)) { block['a:avLst'][0]['a:gd'].push({ $: { name: prop, fmla: `val ${avLst[prop]}` } }); } } } static addRotationPropertiesToBlock(block, options) { if(typeof options.rotation === 'number') { if(block['a:xfrm'] === undefined) block['a:xfrm'] = [{}]; if(block['a:xfrm'][0]['$'] === undefined) block['a:xfrm'][0]['$'] = {}; block['a:xfrm'][0]['$'].rot = options.rotation*60000; } } // avLst is optional - doesn't apply to textboxes, only to actual shapes static setShapeProperties(block, options, avLst = undefined) { PptFactoryHelper.addAvLstToBlock(block['a:prstGeom'][0], avLst); PptFactoryHelper.addLinePropertiesToBlock(block, options.line); PptFactoryHelper.addRotationPropertiesToBlock(block, options); } // block should be the <p:txBody> node static addTextValuesToBlock(block, textBox, options) { const CRLF = '\r\n'; let textLines; let textValues = textBox.textValue || textBox.bulletPoints; if (Array.isArray(textValues)) { block['a:p'] = []; for (let i = 0; i < textValues.length; i++) { PptFactoryHelper.addBulletPointsToBlock(block['a:p'], textValues[i], 0, options); } } else if (typeof textValues === 'string' || typeof textValues === 'number') { block['a:p'] = []; textValues = textValues.replace(/\r*\n/g, CRLF); textLines = textValues.indexOf(CRLF) > -1 ? textValues.split(CRLF) : [textValues]; textLines.forEach(t => block['a:p'].push(PptFactoryHelper.createParagraphBlock(block, t, options))); } else if (typeof textValues === 'object') { block['a:p'] = []; PptFactoryHelper.addTextSegmentsBlock(block['a:p'], textValues, options); } } static createEmptyParagraphPropertiesBlock() { return [{ 'a:pPr': [{ $: {} }] }]; } static setupTextRunPropertiesBlock(textRunPropertyBlock, options) { if (options.textColor !== undefined) textRunPropertyBlock['a:solidFill'] = [PptFactoryHelper.createColorBlock(options.textColor)]; if (options.fontFace !== undefined) PptFactoryHelper.addFontFaceToBlock(options.fontFace, textRunPropertyBlock); PptFactoryHelper.setTextRunProperties(textRunPropertyBlock, options); } static createParagraphBlock(block, textValue, options) { let paragraphBlock = PptFactoryHelper.createEmptyParagraphPropertiesBlock()[0]; paragraphBlock['a:r'] = [{ 'a:rPr': [{ $: { lang: 'en-US', smtClean: '0' } }], 'a:t': textValue }]; let textRunPropertyBlock = paragraphBlock['a:r'][0]['a:rPr'][0]; PptFactoryHelper.setupTextRunPropertiesBlock(textRunPropertyBlock, options); PptFactoryHelper.addParagraphPropertiesToBlock(paragraphBlock, options); return paragraphBlock; } static addBulletPointsToBlock(masterParagraphNode, textValue, indentLevel, options) { if (typeof textValue === 'string' || typeof textValue === 'number') { PptFactoryHelper.createBulletPointAndText(masterParagraphNode, textValue, indentLevel, options); } else if (Array.isArray(textValue)) { let lines = textValue; for (let i = 0; i < lines.length; i++) { PptFactoryHelper.addBulletPointsToBlock(masterParagraphNode, lines[i], indentLevel + 1, options); } } else if (typeof textValue === 'object') { // if textValue is an object, it can be one of two things: 1) just a string with formatting attributes, or // 2) an array of text segments each which have their own formatting attributes (this is how you would make // word-level formatting instead of line-level formatting) let textObject = textValue; PptFactoryHelper.convertTextPropertiesToOptionsAndMerge(textObject, options); if (textObject.text !== undefined) { PptFactoryHelper.addBulletPointsToBlock(masterParagraphNode, textObject.text, indentLevel, textObject.options); } else if (textObject.textSegments !== undefined) { PptFactoryHelper.createBulletPointAndText(masterParagraphNode, textObject, indentLevel, textObject.options); } } } static createBulletPointAndText(masterParagraphNode, textObject, indentLevel, options) { let bulletLvl0Margin = 228600; // 228600 dxa = 0.25" = 18 pixels (on 72dpi) let customIndent = options.indentSize !== undefined ? PptxUnitHelper.fromPixels(options.indentSize) : undefined; let bulletTextGap = options.bulletToTextGapSize !== undefined ? PptxUnitHelper.fromPixels(options.bulletToTextGapSize) : bulletLvl0Margin; masterParagraphNode.push({}); let paragraphNodeIndex = masterParagraphNode.length - 1; let paragraphBlock = (masterParagraphNode[paragraphNodeIndex] = PptFactoryHelper.createEmptyParagraphPropertiesBlock()[0]); PptFactoryHelper.addParagraphPropertiesToBlock(paragraphBlock, options); // Don't be fooled here - in the desccription JSON, I refer to the amount of bullet indentation as "indentSize" while // PowerPoint calls this "marL" (marginLeft I assume); but PowerPoint also has an "indent" attribute which refers to // the amount of space between a bullet and the first letter of text - I call this the "bulletToTextGapSize" in the JSON. // Important note: the bullet-to-text gap _includes_ the marL width; for example: if "marL" is 1 inch and "indent" is 0.25, // this means the first letter of text will be 1 inch from the left margin while the bullet will be a quarter of an inch // to the left of the _text_, or 0.75 inches from the left margin. So you can think of it like the "indent" value "pushes" // the bullet away from the text to the left side. paragraphBlock['a:pPr'][0]['$'].marL = customIndent === undefined ? bulletLvl0Margin * (indentLevel * 2 + 1) : customIndent; paragraphBlock['a:pPr'][0]['$'].indent = `-${bulletTextGap}`; let bullet = options.bulletType; if (bullet === undefined) { // alphaLcParenR = alphabetic letter, Lc = lower case, ParenR = parenthesis on right (ex: "a)", "b)", "c)", etc.)