@ngoctay/nodejs-pptx
Version:
Generate PPTX files on the server-side with JavaScript.
1,166 lines (1,044 loc) • 52.3 kB
JavaScript
/* eslint-disable no-prototype-builtins */
const SCHEME_COLORS = require('../color-types').SchemeColors;
const { Xml } = require('../xmlnode');
let { ExcelHelper } = require('./excel-helper');
let { PptxUnitHelper } = require('./unit-helper');
const HyperlinkType = {
TEXT: 'text',
IMAGE: 'image',
};
class PptFactoryHelper {
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 Xml.createTree({
'p:nvSpPr': {
'p:cNvPr': { [Xml.ATTR_KEY]: { id: objectId, name: `${objectName} ${objectId}` } },
'p:cNvSpPr': null,
'p:nvPr': null,
},
'p:spPr': {
'a:xfrm': {
'a:off': { [Xml.ATTR_KEY]: { x, y } },
'a:ext': { [Xml.ATTR_KEY]: { cx, cy } },
},
'a:prstGeom': {
[Xml.ATTR_KEY]: { prst: 'rect' },
'a:avLst': null,
},
},
'p:txBody': {
'a:bodyPr': null,
'a:lstStyle': null,
'a:p': null,
}
});
}
// 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 Xml.createTree({
'p:graphicFrame': {
'p:nvGraphicFramePr': {
'p:cNvPr': { [Xml.ATTR_KEY]: { id: '5', name: 'Chart 4' } },
'p:cNvGraphicFramePr': null,
'p:nvPr': {
'p:extLst': {
'p:ext': {
[Xml.ATTR_KEY]: { uri: '{D42A27DB-BD31-4B8C-83A1-F6EECF244321}' },
'p14:modId': {
[Xml.ATTR_KEY]: {
'xmlns:p14': 'http://schemas.microsoft.com/office/powerpoint/2010/main',
val: '3543180680',
},
},
},
},
},
},
'p:xfrm': {
'a:off': { [Xml.ATTR_KEY]: { x: x, y: y } },
'a:ext': { [Xml.ATTR_KEY]: { cx: cx, cy: cy } },
},
'a:graphic': {
'a:graphicData': {
[Xml.ATTR_KEY]: {
uri: 'http://schemas.openxmlformats.org/drawingml/2006/chart',
},
'c:chart': {
[Xml.ATTR_KEY]: {
'xmlns:c': 'http://schemas.openxmlformats.org/drawingml/2006/chart',
'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
'r:id': 'rId2',
},
},
},
},
},
})[0];
}
static createBaseChartSpaceBlock() {
return Xml.createTree({
// NOTE: c:chartSpace is not an array here because it gets inserted at the root
'c:chartSpace': {
[Xml.ATTR_KEY]: {
'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': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
'c:lang': { [Xml.ATTR_KEY]: { val: 'en-US' } },
'c:roundedCorners': { [Xml.ATTR_KEY]: { val: '0' } },
// this AlternateContent node is optional: it just makes the bars look a little 3D-like instead of flat-shaded
'mc:AlternateContent': {
[Xml.ATTR_KEY]: {
'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
},
'mc:Choice': {
[Xml.ATTR_KEY]: {
Requires: 'c14',
'xmlns:c14': 'http://schemas.microsoft.com/office/drawing/2007/8/2/chart',
},
'c14:style': {
[Xml.ATTR_KEY]: {
val: '118',
},
},
},
'mc:Fallback': {
'c:style': {
[Xml.ATTR_KEY]: {
val: '18',
},
},
},
},
'c:chart': {
'c:autoTitleDeleted': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
'c:plotArea': {
'c:layout': null,
'c:barChart': {
'c:barDir': {
[Xml.ATTR_KEY]: {
val: 'bar',
},
},
'c:grouping': {
[Xml.ATTR_KEY]: {
val: 'clustered',
},
},
'c:varyColors': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
'c:ser': null, // insert generated c:ser here
'c:dLbls': {
'c:showLegendKey': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
'c:showVal': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
'c:showCatName': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
'c:showSerName': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
'c:showPercent': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
'c:showBubbleSize': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
},
'c:gapWidth': {
[Xml.ATTR_KEY]: {
val: '150',
},
},
'c:axId': {
[Xml.ATTR_KEY]: {
val: '-2074751000',
},
},
},
'c:catAx': {
'c:axId': {
[Xml.ATTR_KEY]: {
val: '2067994824',
},
},
'c:scaling': {
'c:orientation': {
[Xml.ATTR_KEY]: {
val: 'minMax',
},
},
},
'c:delete': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
'c:axPos': {
[Xml.ATTR_KEY]: {
val: 'l',
},
},
'c:majorTickMark': {
[Xml.ATTR_KEY]: {
val: 'out',
},
},
'c:minorTickMark': {
[Xml.ATTR_KEY]: {
val: 'none',
},
},
'c:tickLblPos': {
[Xml.ATTR_KEY]: {
val: 'nextTo',
},
},
'c:crossAx': {
[Xml.ATTR_KEY]: {
val: '-2074751000',
},
},
'c:crosses': {
[Xml.ATTR_KEY]: {
val: 'autoZero',
},
},
'c:auto': {
[Xml.ATTR_KEY]: {
val: '1',
},
},
'c:lblAlgn': {
[Xml.ATTR_KEY]: {
val: 'ctr',
},
},
'c:lblOffset': {
[Xml.ATTR_KEY]: {
val: '100',
},
},
'c:noMultiLvlLbl': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
},
'c:valAx': {
'c:axId': {
[Xml.ATTR_KEY]: {
val: '-2074751000',
},
},
'c:scaling': {
'c:orientation': {
[Xml.ATTR_KEY]: {
val: 'minMax',
},
},
},
'c:delete': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
'c:axPos': {
[Xml.ATTR_KEY]: {
val: 'b',
},
},
'c:majorGridlines': null,
'c:numFmt': {
[Xml.ATTR_KEY]: {
formatCode: 'General',
sourceLinked: '1',
},
},
'c:majorTickMark': {
[Xml.ATTR_KEY]: {
val: 'out',
},
},
'c:minorTickMark': {
[Xml.ATTR_KEY]: {
val: 'none',
},
},
'c:tickLblPos': {
[Xml.ATTR_KEY]: {
val: 'nextTo',
},
},
'c:crossAx': {
[Xml.ATTR_KEY]: {
val: '2067994824',
},
},
'c:crosses': {
[Xml.ATTR_KEY]: {
val: 'autoZero',
},
},
'c:crossBetween': {
[Xml.ATTR_KEY]: {
val: 'between',
},
},
},
},
'c:legend': {
'c:legendPos': {
[Xml.ATTR_KEY]: {
val: 'r',
},
},
'c:layout': null,
'c:overlay': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
},
'c:plotVisOnly': {
[Xml.ATTR_KEY]: {
val: '1',
},
},
'c:dispBlanksAs': {
[Xml.ATTR_KEY]: {
val: 'gap',
},
},
'c:showDLblsOverMax': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
},
'c:txPr': {
'a:bodyPr': null,
'a:lstStyle': null,
'a:p': {
'a:pPr': {
'a:defRPr': {
[Xml.ATTR_KEY]: {
sz: '1800',
},
},
},
'a:endParaRPr': {
[Xml.ATTR_KEY]: {
lang: 'en-US',
},
},
},
},
'c:externalData': {
[Xml.ATTR_KEY]: {
'r:id': 'rId1',
},
'c:autoUpdate': {
[Xml.ATTR_KEY]: {
val: '0',
},
},
},
},
})[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';
const colorInfo = PptFactoryHelper.validateColor(color, DEFAULT_FONT_COLOR);
const tagName = colorInfo.isRgb ? 'srgbClr' : 'schemeClr';
return Xml.create(tagName, null, { val: colorInfo.color });
}
// 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 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)}`;
const seriesNodes = [
Xml.create('c:idx', null, { val: i }),
Xml.create('c:order', null, { val: i }),
Xml.create('c:tx', [strRef(sheetCellAddressForSeriesName, [series.name])]),
Xml.create('c:invertIfNegative', null, { val: 0 }),
Xml.create('c:cat', [strRef(sheetCellRangeForCategories, series.labels)]),
Xml.create('c:val', [numRef(sheetCellRangeForValues, series.values, 'General')]),
];
if (series.color) {
let colorBlock = PptFactoryHelper.createColorBlock(series.color);
seriesNodes.push('c:spPr', Xml.create('a:solidFill', [colorBlock]));
}
return seriesNodes;
}
static createStrRefNode(region, stringArray) {
return Xml.create('c:strRef', [
Xml.create('c:f', [Xml.createText(region)]),
Xml.create('c:strCache', PptFactoryHelper.createCacheChildNodes(stringArray)),
]);
}
static createNumRefNode(region, numArray, formatCode) {
return Xml.create('c:numRef', [
Xml.create('c:f', [Xml.createText(region)]),
Xml.create('c:numCache', [
Xml.create('c:formatCode', [Xml.createText(formatCode)]),
...PptFactoryHelper.createCacheChildNodes(numArray),
]),
]);
}
static createCacheChildNodes(arr) {
const countNode = Xml.create('c:ptCount', null, { val: arr.length });
const valNodes = arr.map((val, i) => {
return Xml.create('c:pt', [
Xml.create('c:v', [Xml.createText(val.toString())])
], { idx: i });
});
return [countNode, ...valNodes];
}
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.push(Xml.create('a:latin', null, fontAttributes));
block.push(Xml.create('a:cs', null, fontAttributes));
}
static setTextRunProperties(textRunPropertyBlock, options) {
if (typeof options.fontSize === 'number') {
textRunPropertyBlock.setAttr('sz', `${Math.round(options.fontSize)}00`);
}
if (options.fontBold !== undefined && options.fontBold === true) {
textRunPropertyBlock.setAttr('b', '1');
} else if (options.fontBold === false) {
textRunPropertyBlock.setAttr('b', '0');
}
if (options.fontItalic !== undefined && options.fontItalic === true) {
textRunPropertyBlock.setAttr('i', '1');
} else if (options.fontItalic === false) {
textRunPropertyBlock.setAttr('i', '0');
}
if (options.fontUnderline !== undefined && options.fontUnderline === true) {
textRunPropertyBlock.setAttr('u', 'sng');
} else if (options.fontUnderline === false) {
textRunPropertyBlock.removeAttr('u');
}
if (options.fontSubscript !== undefined && options.fontSubscript === true) {
textRunPropertyBlock.setAttr('baseline', '-40000');
} else if (options.fontSuperscript !== undefined && options.fontSuperscript === true) {
textRunPropertyBlock.setAttr('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.get('a:pPr')) {
// 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.get('a:pPr').setAttr('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.setAttr('lIns', margin.left * PT);
if (margin.top !== undefined && Number.isInteger(margin.top)) textBodyPropertyBlock.setAttr('tIns', margin.top * PT);
if (margin.right !== undefined && Number.isInteger(margin.right)) textBodyPropertyBlock.setAttr('rIns', margin.right * PT);
if (margin.bottom !== undefined && Number.isInteger(margin.bottom)) textBodyPropertyBlock.setAttr('bIns', margin.bottom * PT);
} else if (Number.isInteger(margin)) {
textBodyPropertyBlock.setAttr('lIns', margin * PT);
textBodyPropertyBlock.setAttr('tIns', margin * PT);
textBodyPropertyBlock.setAttr('rIns', margin * PT);
textBodyPropertyBlock.setAttr('bIns', margin * PT);
}
}
static setTextWrapOnTextBody(textBodyPropertyBlock, options) {
textBodyPropertyBlock.setAttr('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.setAttr('anchor', alignment);
}
static setAutoFitOnTextBody(textBodyPropertyBlock, parentObject, options) {
if (options.autoFit !== undefined && options.autoFit === true) {
textBodyPropertyBlock.push(Xml.create('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.push(Xml.create('a:normAutofit', null, { 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.push(Xml.create('a:normAutofit', null, { 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.push(Xml.create('a:ln', [
// TODO: this will validate the color again, even though it's been validated already, think of another way...
Xml.create('a:solidFill', [PptFactoryHelper.createColorBlock(colorInfo.color)]),
], {
w: lineProperties.width * PptxUnitHelper.Units.ONE_POINT,
}));
if (lineProperties.dashType) {
block.get('a:ln').push(Xml.create('a:prstDash', null, { val: lineProperties.dashType }));
}
}
static addAvLstToBlock(block, avLst) {
if (avLst === undefined) return;
for (let prop in avLst) {
if (avLst.hasOwnProperty(prop)) {
block.get('a:avLst').push(Xml.create('a:gd', null, { name: prop, fmla: `val ${avLst[prop]}` }));
}
}
}
static addRotationPropertiesToBlock(block, options) {
if (typeof options.rotation === 'number') {
if (!block.get('a:xfrm')) {
block.push(Xml.create('a:xfrm'));
}
block.get('a:xfrm').setAttr('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.get('a:prstGeom'), 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 textValues = textBox.textValue || textBox.bulletPoints;
if (Array.isArray(textValues)) {
for (let i = 0; i < textValues.length; i++) {
PptFactoryHelper.addBulletPointsToBlock(block.get('a:p'), textValues[i], 0, options);
}
} else if (typeof textValues === 'string' || typeof textValues === 'number') {
textValues = textValues.replace(/\r*\n/g, CRLF);
const textLines = textValues.split(CRLF);
textLines.forEach(t => PptFactoryHelper.createParagraphBlock(block, t, options));
} else if (typeof textValues === 'object') {
PptFactoryHelper.addTextSegmentsBlock(block.get('a:p'), textValues, options);
}
}
static createEmptyParagraphPropertiesBlock() {
return Xml.create('a:pPr');
}
static setupTextRunPropertiesBlock(textRunPropertyBlock, options) {
if (options.textColor !== undefined) textRunPropertyBlock.push(Xml.create('a:solidFill', [PptFactoryHelper.createColorBlock(options.textColor)]));
if (options.fontFace !== undefined) PptFactoryHelper.addFontFaceToBlock(options.fontFace, textRunPropertyBlock);
PptFactoryHelper.setTextRunProperties(textRunPropertyBlock, options);
}
static createParagraphBlock(block, textValue, options) {
const paragraphBlock = block.get('a:p');
paragraphBlock.push(PptFactoryHelper.createEmptyParagraphPropertiesBlock());
paragraphBlock.push(Xml.create('a:r', [
Xml.create('a:rPr', null, { lang: 'en-US', smtClean: '0' }),
Xml.create('a:t', [Xml.createText(textValue)]),
]));
let textRunPropertyBlock = paragraphBlock.get('a:r/a:rPr');
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) {
const bulletLvl0Margin = 228600; // 228600 dxa = 0.25" = 18 pixels (on 72dpi)
const customIndent = options.indentSize !== undefined ? PptxUnitHelper.fromPixels(options.indentSize) : undefined;
const bulletTextGap = options.bulletToTextGapSize !== undefined ? PptxUnitHelper.fromPixels(options.bulletToTextGapSize) : bulletLvl0Margin;
const pPrNode = PptFactoryHelper.createEmptyParagraphPropertiesBlock();
masterParagraphNode.push(pPrNode);
PptFactoryHelper.addParagraphPropertiesToBlock(masterParagraphNode, 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.
pPrNode.setAttr('marL', customIndent === undefined ? bulletLvl0Margin * (indentLevel * 2 + 1) : customIndent);
pPrNode.setAttr('indent', `-${bulletTextGap}`);
let bullet = options.bulletType;
if (bullet === undefined) {
// alphaLcParenR = alphabetic letter, Lc = lower case, ParenR = parenthesis on right (ex: "a)", "b)", "c)", etc.)
// arabicParenR = arabic numeral, ParenR = parenthesis on right (ex: "1)", "2)", "3)", etc.)
// All the types are here: http://www.datypic.com/sc/ooxml/t-a_ST_TextAutonumberScheme.html
// And a great article on how bullet numbering styles work: http://www.brandwares.com/bestpractices/2017/06/xml-hacking-powerpoint-numbering-styles/
pPrNode.push(Xml.create('a:buFont', null, { typeface: '+mj-lt' }));
pPrNode.push(Xml.create('a:buAutoNum', null,
indentLevel % 2 || indentLevel === 0 ? { type: 'arabicParenR' } : { type: 'alphaLcParenR' }));
if (options.startAt !== undefined && typeof options.startAt === 'number') {
pPrNode.get('a:buAutoNum').setAttr('startAt', options.startAt);
}
} else {
pPrNode.push(Xml.create('a:buSzPct', null, { val: '100000' })); // if we want to change the size of a bullet, do it here (100000 = 100% size of text [default])
pPrNode.push(Xml.create('a:buFont', null, { typeface: bullet.font, pitchFamily: bullet.pitchFamily, charset: bullet.charset }));
pPrNode.push(Xml.create('a:buChar', null, { char: bullet.char }));
}
if (indentLevel > 0) pPrNode.setAttr('lvl', indentLevel);
let textSegmentsArray = [];
if (typeof textObject === 'string' || typeof textObject === 'number') {
// simulate a text-run with just one piece of text so we don't have to repeat code
textSegmentsArray.push({ text: textObject });
} else if (typeof textObject === 'object' && textObject.textSegments !== undefined) {
textSegmentsArray = textObject.textSegments;
}
for (let i = 0; i < textSegmentsArray.length; i++) {
const segment = textSegmentsArray[i];
PptFactoryHelper.convertTextPropertiesToOptionsAndMerge(segment, options);
const textRunNode = Xml.create('a:r', [
Xml.create('a:rPr', null, { lang: 'en-US', smtClean: '0' }),
Xml.create('a:t', [Xml.createText(segment.text)]),
]);
masterParagraphNode.push(textRunNode);
PptFactoryHelper.setupTextRunPropertiesBlock(textRunNode.get('a:rPr'), segment.options);
}
}
static addTextSegmentsBlock(masterParagraphNode, textValue, options) {
const CRLF = '\r\n';
if (typeof textValue === 'object') {
PptFactoryHelper.convertTextPropertiesToOptionsAndMerge(textValue, options);
if (textValue.textSegments !== undefined) {
for (let segment of textValue.textSegments) {
let line = segment.text.replace(/\r*\n/g, CRLF);
let splitLines = line.indexOf(CRLF) > -1 ? line.split(CRLF) : [line];
let textSegments = [];
splitLines.forEach(t => {
if (t) textSegments.push({ textSegments: [Object.assign(segment, { text: t })], options: textValue.options });
});
textSegments.forEach(ts => PptFactoryHelper.createMultiFormattedText(masterParagraphNode, ts, ts.options));
}
}
}
}
static createMultiFormattedText(masterParagraphNode, textObject, options) {
masterParagraphNode.push(PptFactoryHelper.createEmptyParagraphPropertiesBlock());
PptFactoryHelper.addParagraphPropertiesToBlock(masterParagraphNode, options);
const textSegmentsArray = textObject.textSegments;
for (let i = 0; i < textSegmentsArray.length; i++) {
const segment = textSegmentsArray[i];
PptFactoryHelper.convertTextPropertiesToOptionsAndMerge(segment, options);
const textRunNode = Xml.create('a:r', [
Xml.create('a:rPr', null, { lang: 'en-US', smtClean: '0' }),
Xml.create('a:t', [Xml.createText(segment.text)]),
]);
masterParagraphNode.push(textRunNode);
PptFactoryHelper.setupTextRunPropertiesBlock(textRunNode.get('a:rPr'), segment.options);
}
}
static convertTextPropertiesToOptionsAndMerge(textObject, options) {
textObject.options = {};
for (let prop in textObject) {
if (textObject.hasOwnProperty(prop) && !['text', 'textSegments', 'options', 'x', 'y', 'cx', 'cy'].includes(prop)) {
textObject.options[prop] = textObject[prop];
}
}
for (let prop in options) {
if (options.hasOwnProperty(prop)) {
// don't override text-segment specific properties
if (!textObject.hasOwnProperty(prop)) {
textObject.options[prop] = options[prop];
}
}
}
}
static calcStringWidthPoints(text, fontName, fontSize) {
let stringWidth = 0;
let charWidths = this.getCharWidthsForFont(fontName);
if (charWidths === false) return -1;
for (let i = 0; i < text.length; i++) {
let ascii = text.charCodeAt(i);
if (ascii >= 32) {
stringWidth += fontSize * charWidths[ascii];
}
}
return stringWidth;
}
static calcStringWidthPixels(text, fontName, fontSize) {
return PptxUnitHelper.toPixels(PptxUnitHelper.fromPoints(PptFactoryHelper.calcStringWidthPoints(text, fontName, fontSize)));
}
static getCharWidthsForCalibri() {
let charWidths = [];
for (let i = 32; i <= 127; i++) {
switch (true) {
case [32, 39, 44, 46, 73, 105, 106, 108].includes(i):
charWidths[i] = 0.2526;
break;
case [40, 41, 45, 58, 59, 74, 91, 93, 96, 102, 123, 125].includes(i):
charWidths[i] = 0.3144;
break;
case [33, 114, 116].includes(i):
charWidths[i] = 0.3768;
break;
case [34, 47, 76, 92, 99, 115, 120, 122].includes(i):
charWidths[i] = 0.4392;
break;
case [35, 42, 43, 60, 61, 62, 63, 69, 70, 83, 84, 89, 90, 94, 95, 97, 101, 103, 107, 118, 121, 124, 126].includes(i):
charWidths[i] = 0.501;
break;
case [36, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 66, 67, 75, 80, 82, 88, 98, 100, 104, 110, 111, 112, 113, 117, 127].includes(i):
charWidths[i] = 0.5634;
break;
case [65, 68, 86].includes(i):
charWidths[i] = 0.6252;
break;
case [71, 72, 78, 79, 81, 85].includes(i):
charWidths[i] = 0.6876;
break;
case [37, 38, 119].includes(i):
charWidths[i] = 0.7494;
break;
case i === 109:
charWidths[i] = 0.8742;
break;
case [64, 77, 87].includes(i):
charWidths[i] = 0.936;
break;
default:
break;
}
}
return charWidths;
}
static getCharWidthsForLucidaConsole() {
let charWidths = [];
for (let i = 32; i <= 127; i++) {
charWidths[i] = 0.6252;
}
return charWidths;
}
/* eslint-disable complexity */
static getCharWidthsForTimesNewRoman() {
// even this function surpassed es-lint's complexity limit, so had to disable
let charWidths = [];
for (let i = 32; i <= 127; i++) {
switch (true) {
case [39, 124].includes(i):
charWidths[i] = 0.1902;
break;
case [32, 44, 46, 59].includes(i):
charWidths[i] = 0.2526;
break;
case [33, 34, 47, 58, 73, 91, 92, 93, 105, 106, 108, 116].includes(i):
charWidths[i] = 0.3144;
break;
case [40, 41, 45, 96, 102, 114].includes(i):
charWidths[i] = 0.3768;
break;
case [63, 74, 97, 115, 118, 122].includes(i):
charWidths[i] = 0.4392;
break;
case [94, 98, 99, 100, 101, 103, 104, 107, 110, 112, 113, 117, 120, 121, 123, 125].includes(i):
charWidths[i] = 0.501;
break;
case [35, 36, 42, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 70, 83, 84, 95, 111, 126].includes(i):
charWidths[i] = 0.5634;
break;
case [43, 60, 61, 62, 69, 76, 80, 90].includes(i):
charWidths[i] = 0.6252;
break;
case [65, 66, 67, 82, 86, 89, 119].includes(i):
charWidths[i] = 0.6876;
break;
case [68, 71, 72, 75, 78, 79, 81, 85, 88].includes(i):
charWidths[i] = 0.7494;
break;
case [38, 109, 127].includes(i):
charWidths[i] = 0.8118;
break;
case i === 37:
charWidths[i] = 0.8742;
break;
case [64, 77].includes(i):
charWidths[i] = 0.936;
break;
case i === 87:
charWidths[i] = 0.9984;
break;
default:
break;
}
}
return charWidths;
}
static getCharWidthsForTahoma() {
let charWidths = [];
for (let i = 32; i <= 127; i++) {
switch (true) {
case [39, 105, 108].includes(i):
charWidths[i] = 0.2526;
break;
case [32, 44, 46, 102, 106].includes(i):
charWidths[i] = 0.3144;
break;
case [33, 45, 58, 59, 73, 114, 116].includes(i):
charWidths[i] = 0.3768;
break;
case [34, 40, 41, 47, 74, 91, 92, 93, 124].includes(i):
charWidths[i] = 0.4392;
break;
case [63, 76, 99, 107, 115, 118, 120, 121, 122, 123, 125].includes(i):
charWidths[i] = 0.501;
break;
case [36, 42, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 70, 80, 83, 95, 96].includes(i):
case [97, 98, 100, 101, 103, 104, 110, 111, 112, 113, 117].includes(i): // broke up big array to avoid pretiier's ugly auto-formatting
charWidths[i] = 0.5634;
break;
case [66, 67, 69, 75, 84, 86, 88, 89, 90].includes(i):
charWidths[i] = 0.6252;
break;
case [38, 65, 71, 72, 78, 82, 85].includes(i):
charWidths[i] = 0.6876;
break;
case [35, 43, 60, 61, 62, 68, 79, 81, 94, 126].includes(i):
charWidths[i] = 0.7494;
break;
case [77, 119].includes(i):
charWidths[i] = 0.8118;
break;
case i === 109:
charWidths[i] = 0.8742;
break;
case [64, 87].includes(i):
charWidths[i] = 0.936;
break;
case [37, 127].includes(i):
charWidths[i] = 1.0602;
break;
default:
break;
}
}
return charWidths;
}
static getCharWidthsForArial() {
let charWidths = [];
for (let i = 32; i <= 127; i++) {
switch (true) {
case [39, 106, 108].includes(i):
charWidths[i] = 0.1902;
break;
case [105, 116].includes(i):
charWidths[i] = 0.2526;
break;
case [32, 33, 44, 46, 47, 58, 59, 73, 91, 92, 93, 102, 124].includes(i):
charWidths[i] = 0.3144;
break;
case [34, 40, 41, 45, 96, 114, 123, 125].includes(i):