"use strict";
/**
* PowerPoint class
* @class
*/
class PowerPoint {
#projects;
#aggregators;
#base;
#titlemaster;
#sectionmaster;
#datePresets;
#captionOptions;
#tokens;
static MAX_LABEL_LENGTH = 200;
static COLMODE_USE_GROUPS = 1;
static AGGR_DATA = {
avg: { multivar: true },
count: { multivar: true },
counta: { multivar: true },
"count-distinct": { multivar: true },
dist: { multivar: false },
distx: { multivar: false },
distinct: { multivar: false },
divide: { multivar: true },
gte: { multivar: true },
lte: { multivar: true },
max: { multivar: true },
min: { multivar: true },
nps: { multivar: false },
npsx: { multivar: false },
pcta: { multivar: true },
pctt: { multivar: true },
prop: { multivar: true },
qtile: { multivar: false },
range: { multivar: true },
sd: { multivar: true },
sdp: { multivar: true },
split: { multivar: false },
sum: { multivar: true },
var: { multivar: true },
varp: { multivar: true },
vsatsplit: { multivar: false },
vsattopbox: { multivar: true },
wavg: { multivar: true },
wyn: { multivar: true },
wynd: { multivar: true },
yn: { multivar: true },
ynd: { multivar: true },
};
static DEFAULT_TLF_LOGO_PATH = 'https://static.leadershipfactor.com/img/ppt/tlf-logo.png';
static CHART_TYPE = {
AREA: "area",
BAR: "bar",
BAR3D: "bar3D",
BUBBLE: "bubble",
BUBBLE3D: "bubble3D",
COLUMN: "column",
DOUGHNUT: "doughnut",
LINE: "line",
PIE: "pie",
RADAR: "radar",
SCATTER: "scatter",
};
// Masters with names that start with PUBLIC_ are exposed to the Presentation Editor
static PUBLIC_MASTER_TITLE_ONLY = "Blank";
static PUBLIC_MASTER_TITLE_AND_BODY = "Text";
static PUBLIC_MASTER_IMAGE_LEFT = "Image (with text)";
static PUBLIC_MASTER_ONE_CHART = "Chart";
static PUBLIC_MASTER_ONE_CHART_NO_CAPTION = "Chart (no caption)";
static PUBLIC_MASTER_ONE_CHART_LEFT = "Chart (with text)";
static PUBLIC_MASTER_ONE_CHART_WITH_COMPARE = "Chart (with swing)";
static PUBLIC_MASTER_TWO_CHARTS_HORIZ_WITH_COMPARE = "Two charts with swing (horiz)";
static PUBLIC_MASTER_ONE_CHART_WITH_TOPBOX = "Chart (with top box)";
static PUBLIC_MASTER_ONE_CHART_WITH_SUPPLEMENTARY_CHART = "Chart (with supplementary chart)";
static PUBLIC_MASTER_TWO_CHARTS_HORIZ = "Two charts (horiz)";
static PUBLIC_MASTER_THREE_CHARTS_HORIZ = "Three charts (horiz)";
static PUBLIC_MASTER_FOUR_CHARTS_GRID = "Four charts (grid)";
static PUBLIC_MASTER_FOUR_CHARTS_GRID_NO_CAPTION = "Four charts (grid, no caption)";
static PUBLIC_MASTER_TABLE = "Table";
static PUBLIC_MASTER_TABLE_WITH_CAPTION = "Table (with caption)";
static MASTER_TSM_REPAIRS = "TSM Repairs (custom)";
static MASTER_TSM_REPAIRS_IMPACT = "TSM Repairs Impact (custom)";
static MASTER_TSM_QUESTION = "TSM Questions (custom)";
static MASTER_TSM_CHART_GRID = "TSM Grid (custom)";
static VSAT_LEGEND = (legendOptions, textOptions, palette, bulletOptions = {}) => ({
options: legendOptions,
values: DataAdapter.VERBAL_SAT_SCALE.reduce((arr, text, idx) => {
arr.push({
text: " ■ ",
options: { ...textOptions, ...bulletOptions, color: palette[idx] },
placeholder: "_NONE",
}, {
text,
options: textOptions,
placeholder: "_NONE",
})
return arr;
}, []),
});
/**
* Returns a presentation template that has been configured using the given substitutions.
* @static
* @param {Object} template
* @param {Object} config
*/
static configureTemplate(template, config) {
// Define a reference to the target class where functions are defined
const targetClass = PowerPoint;
// Check if the current item is a placeholder that needs replacing
function getSubstitution(value) {
// Look for a parameter in the string of the form "{{PARAM}}" or "{{PARAM}} #some_token#"
if (typeof value === 'string') {
let expression = value.match(/^{{(.*?)}}(?:\s#[\w_]+#)?$/);
if (!expression) {
return value;
}
expression = expression[1];
// Check if the expression is an immediate function call
const functionMatch = expression.match(/^(\w+)\((.*)\)$/);
if (functionMatch) {
const functionName = functionMatch[1];
let argsString = functionMatch[2];
// Split arguments by commas, but handle nested {{}} placeholders
const args = [];
let depth = 0, currentArg = "";
for (let i = 0; i < argsString.length; i++) {
const char = argsString[i];
if (char === '{') depth++;
if (char === '}') depth--;
if (char === ',' && depth === 0) {
const arg = getSubstitution(currentArg.trim());
args.push(typeof arg === 'string' ? JSON.parse(arg) : arg);
currentArg = "";
} else {
currentArg += char;
}
}
// Add the last argument
const lastArg = getSubstitution(currentArg.trim());
if (currentArg) args.push(typeof lastArg === 'string' ? JSON.parse(lastArg) : lastArg);
// Substitute placeholders within arguments
const substitutedArgs = args.map(arg => getSubstitution(arg));
// Call the function on the target class with substituted arguments
if (typeof targetClass[functionName] === 'function') {
return targetClass[functionName](...substitutedArgs);
} else {
console.warn(`Function ${functionName} not found on target class`);
return value;
}
}
// Detect if it contains boolean logic (e.g., &&, ||, !)
const isBooleanExpression = /[!&|]/.test(expression);
// If it's a boolean expression, evaluate it
if (isBooleanExpression) {
// Replace each placeholder with its corresponding value from config
expression = expression.replace(/(!?\w+)/g, (match) => {
const negate = match.startsWith('!');
const key = negate ? match.slice(1) : match;
const substValue = config[key];
if (typeof substValue !== 'undefined') {
return negate ? !substValue : substValue;
}
// Default to false if not found
return false;
});
// Safely evaluate the boolean expression and return the result
try {
return eval(expression);
} catch (e) {
console.warn(`Failed to evaluate expression: ${expression}`);
return value; // Return the original value if evaluation fails
}
} else {
// Handle single variable substitution (with optional negation)
let negate = false;
if (expression.startsWith('!')) {
negate = true;
expression = expression.slice(1).trim();
}
let substValue = config[expression];
// Recursively replace any placeholders in the substitution value if it's a string or array
if (typeof substValue === 'string' && substValue.startsWith('{{') && substValue.endsWith('}}')) {
substValue = getSubstitution(substValue);
}
if (Array.isArray(substValue)) {
substValue = substValue.map(getSubstitution);
}
if (substValue !== undefined) {
return negate ? !substValue : substValue;
}
}
}
// Return the original if no substitution is found
return value;
}
// Function to replace placeholders inside a function's body
function replaceInFunction(fn) {
const fnStr = fn.toString(); // Convert function to string
let foundPlaceholder = false;
const replacedStr = fnStr.replace(/'{{(.*?)}}'/g, (match, key) => {
key = key.trim();
foundPlaceholder = true;
return config[key] !== undefined ? JSON.stringify(config[key]) : match;
});
// Create a new function from the modified string
return foundPlaceholder ? new Function('return ' + replacedStr)() : fn;
}
// Recursive function to traverse and replace values in the object
function traverse(item) {
if (Array.isArray(item)) {
// If the item is an array, traverse each element
return item.map(traverse);
} else if (item instanceof Date) {
// If the item is a Date, return it as is
return item;
} else if (typeof item === 'function') {
// If the item is a function, process its body for placeholders
return replaceInFunction(item);
} else if (typeof item === 'object' && item !== null) {
// If the item is an object, traverse each key-value pair
return Object.keys(item).reduce((acc, key) => {
acc[key] = traverse(item[key]);
return acc;
}, {});
} else if (typeof item === 'string' || typeof item === 'number') {
// Check if it's a placeholder to replace
return getSubstitution(item);
} else {
// For other types (e.g., functions), return as is
return item;
}
}
// Start traversal from the root object
return traverse(template);
}
/**
* Sets the first character of a string to uppercase.
* @param {string} s
* @returns {string}
*/
static toUpperCaseFirst(s) {
return s.charAt(0).toUpperCase() + s.slice(1);
};
/**
* Compares two ranges of the form [min, max]
* @private
* @static
* @param {number[]} range1 - The first range to be compared
* @param {number[]} range2 - The second range to be compared
* @return {number} 0 if the given ranges overlap or share a common endpoint;
* -1 if range2 is entirely below range1;
* 1 if range2 is entirely above range1
* @private
* @static
*/
static #compareRanges(range1, range2) {
const [min1, max1] = range1;
const [min2, max2] = range2;
if (max2 < min1) return -1;
if (min2 > max1) return 1;
return 0;
}
/**
* Truncates a string to the given maximum length by removing characters from the middle of the string.
* It does not break words.
* @param {string} s - The string to be truncated
* @param {number} maxLen - The maximum length of the string
* @return {string} The truncated string
* @private
* @static
*/
static #truncateString(s, maxLen) {
if (s.length <= maxLen) {
return s;
}
const ellipsis = "...";
const maxLenHalf = Math.floor((maxLen - ellipsis.length) / 2);
const words = s.split(/\b/);
let leftHalf = "";
for (let idx = 0; idx <= words.length; idx++) {
const candidate = leftHalf + words[idx];
if (candidate.length > maxLenHalf) break;
leftHalf = candidate;
}
let rightHalf = '';
for (let idx = words.length - 1; idx >= 0; idx--) {
const candidate = words[idx] + rightHalf;
if (candidate.length > maxLenHalf) break;
rightHalf = candidate;
}
return leftHalf.trimEnd() + "..." + rightHalf.trimStart();
}
/**
* Returns the data element associated with the given key.
* It matches if the key starts with the given label. This is necessary because we may append the base to a label.
* @param {object} data - The object containing data elements
* @param {string} lbl - The label of the data element to be returned
* @return {*} The data element associated with the given key or undefined if no match is found
* @private
* @static
*/
static #findDataFromLabel(data, lbl) {
for (let key of Object.keys(data)) {
if (lbl.startsWith(key)) {
return data[key];
}
}
}
/**
* Returns the initial data for a series
* @param {*} d - The data object from which to return the initial series data
* @return {object} An object containing the initial data for a series
* @private
* @static
*/
static #getInitialSeriesData(d) {
const initial = DataAdapter.deepClone(d);
// Reset some key values if they exist in this series and are arrays
['count', 'labels', 'total', 'types', 'values', 'splits', 'rawCategories'].forEach(k => {
if (initial[k] && Array.isArray(initial[k])) initial[k] = [];
});
return initial;
}
/**
* Adds an empty series to the data
* @param {object} data - The data object to which to add an empty series
* @param {string} cat - The category label of the empty series
* @param {boolean} showBase - Whether to show the base in the category label or not
* @private
* @static
*/
static #addEmptySeries(data, cat, showBase) {
['count', 'labels', 'total', 'types', 'values', 'splits', 'rawCategories'].forEach(k => {
if (typeof data[k] !== 'undefined' && Array.isArray(data[k])) {
switch (k) {
case 'labels':
data[k].push(cat + (showBase ? ' (0)' : ''));
break;
case 'types':
data[k].push('_PLACEHOLDER');
break;
default:
data[k].push(0);
}
}
});
}
/**
* Adds a series to the data
* @param {object} data - The data object to which to add a series
* @param {object} series - The series to be added
* @param {number} dataIndex - The index at which to add the series in the values array of the data object
* @private
* @static
*/
static #addSeries(data, series, dataIndex) {
['count', 'labels', 'total', 'types', 'values'].forEach(k => {
if (typeof data[k] !== 'undefined' && Array.isArray(data[k])) {
data[k].push(series[k][dataIndex]);
}
});
}
/**
* Normalizes the given value to an array.
*
* @param val
* @returns {array}
* @private
* @static
*/
static #normalizeToArray(val) {
if (typeof val === "undefined" || val === null) {
return [];
}
return Array.isArray(val) ? val : [val];
};
/**
* Converts the given set of filters into a string.
*
* @param {object} filterMap Map of filter values to be converted.
*
* @returns {object}
*/
static #convertFiltersToString = function (filterMap) {
const filters = [];
Object.keys(filterMap).forEach(function (key) {
if (filterMap[key]) {
filters.push(filterMap[key]);
}
});
return filters.join(" AND ");
};
/**
* Returns a collection of Project Variables properties for variables that are in *any* of the given categories.
* If props is a string a sinple array of scalar values is returned, otherwise it's an array of objects.
*
* @param {object} project Project to inspect
* @param {array|string} categories Categories to inspect (null = get all categories)
* @param {array|string} props Variable properties to return (the id property is always returned)
*
* @returns {array} Project variables in the given categories.
*/
static #getProjectVarsByCategory(project, categories, props) {
if (!project) {
console.error("Missing project");
return;
}
// Short-circuit if the project has no variables
if (!project.vars) {
return [];
}
const wasPropsArray = Array.isArray(props);
props = PowerPoint.#normalizeToArray(props);
categories = PowerPoint.#normalizeToArray(categories);
categories = categories.map(function (c) {
return c.toLowerCase();
});
// Add the ID property if we don't have it as we need it to sort
if (!props.includes("id")) {
props.push("id");
}
const v = Convert.extractFromMap(project.vars, props, function (_key, v) {
return (
categories.length === 0 ||
(v.categories &&
v.categories.some(function (vc) {
return categories.includes(vc);
}))
);
}).sort(Sort.Asc("id"));
return wasPropsArray ? v : Convert.extractFromArray(v, props[0]);
};
/**
* Constructor for the PowerPoint class.
* @param {object} opts - The presentation specification containing various configuration parameters
* @param {Array<object>} projects - An array of project objects
* @param {Array<object>} aggregators - An array of aggregators objects
* @param {Date} baseDate? - The base date from which to calculate dates
* @constructor
*/
constructor(opts = {}, projects = [], aggregators = [], baseDate = new Date()) {
// Private
this.#projects = projects;
this.#aggregators = aggregators;
this.#base = new PptxGenJS();
this.#titlemaster = "_MASTER_TITLE";
this.#sectionmaster = "_MASTER_SECTION";
this.#datePresets = DateTime.getPresets(baseDate, opts.quarterStartMonth);
this.#captionOptions = DataAdapter.deepClone(opts.captionOptions || {});
this.#tokens = {
CLIENT: opts.client,
CLIENT_LABEL: opts.clientLabel,
PROJECT: opts.project,
PROJECT_LABEL: opts.projectLabel,
CLIENT_UPPER: opts.client?.toUpperCase(),
TOTAL: data => data[0]?.total.toLocaleString() || 0,
"TOTAL[1]": data => data[1]?.total.toLocaleString() || 0,
HIGH: data => data[0]?.high.toLocaleString() || 0,
YES: data => data[0]?.yes.toLocaleString() || 0,
DIDNTANSWER: data => data[0]?.didntanswer?.toLocaleString() || 0,
YES_PERCENT: data => ((data[0]?.yes_percent || 0) * 100).toFixed(1),
NO: data => data[0]?.no.toLocaleString() || 0,
NO_PERCENT: data => ((data[0]?.no_percent || 0) * 100).toFixed(1),
HIGH_PERCENT: data => (data[0]?.total ? (((data[0]?.high || 0) * 100) / data[0].total).toFixed(1) : null),
"VSAT_TOPBOX_PERCENT[0]": (data, ds) => DataAdapter.getVsatTopBoxPercent(ds, data[0]),
"VSAT_TOPBOX_PERCENT[1]": (data, ds) => DataAdapter.getVsatTopBoxPercent(ds, data[1]),
DEPENDENT_QUESTION: (data, ds) => this.#getDependentQuestionLabel(data, ds),
LAST_MONTH: DateTime.getLongMonthName(this.#datePresets.lastMonth.ends),
THIS_MONTH: DateTime.getLongMonthName(this.#datePresets.thisMonth.ends),
LAST_QUARTER: `${this.#datePresets.lastQuarter.ends.getFullYear()} Q${DateTime.getQuarterNumber(this.#datePresets.lastQuarter.ends, opts.quarterStartMonth)}`,
THIS_QUARTER: `${this.#datePresets.thisQuarter.ends.getFullYear()} Q${DateTime.getQuarterNumber(this.#datePresets.thisQuarter.ends, opts.quarterStartMonth)}`,
LAST_YEAR: this.#datePresets.lastYear.ends.getFullYear(),
THIS_YEAR: this.#datePresets.thisYear.ends.getFullYear(),
LAST_FINANCIAL_YEAR: this.#datePresets.lastFinancialYear.starts.getFullYear() + '/' + this.#datePresets.lastFinancialYear.ends.getFullYear().toString().substr(-2),
THIS_FINANCIAL_YEAR: this.#datePresets.thisFinancialYear.starts.getFullYear() + '/' + this.#datePresets.thisFinancialYear.ends.getFullYear().toString().substr(-2),
};
// Public
this.opts = opts;
this.adapter = null;
}
/**
* Fetches any dynamic data from the API and integrates it into the slide specs. It then builds the presentation ready for download and calls any callback function.
* @param {function} onprepared - The callback function to be called after preparing the presentation.
* @param {function} onfail - The callback function to be called if the presentation fails to prepare
*/
prepare(onprepared, onfail) {
const self = this;
const dynamic = this.#getDynamicElements();
// Create an array of all the API requests needed to fetch all the data for the dynamic elements
const reqs = this.#buildRequests(dynamic);
if (!reqs || reqs.length === 0) {
// Build the presentation anyway in case there are no dynamic elements
self.#build(onprepared);
return;
};
// Use a DataAdapter to convert the raw API data into PowerPoint format
self.adapter = new DataAdapter(this.#projects, self.#aggregators);
// Handle each API call in turn and convert its raw API data into the appropriate format for PowerPoint
jQuery.when
.apply(
$,
reqs.map(r => r.api)
)
.done(function () {
// Normalize results
const results = arguments[1] === "success" ? [arguments] : [...arguments];
const allOmittedCategories = [];
// Iterate over each dynamic element
dynamic.forEach(dyn => {
dyn.obj.data = [];
// Group and convert all the API results for each element
for (let idx = 0; idx < dyn.reqs.length; idx++) {
const reqIdx = dyn.reqs[idx];
const result = results[reqIdx];
// Configure the DataAdapter
self.adapter.convertToProportions = dyn.obj.showPercent || false;
self.adapter.reverse = dyn.obj.type === PowerPoint.CHART_TYPE.BAR;
self.adapter.categoryOrder = dyn.obj.dataSource.categoryOrder || [];
self.adapter.combineResults = self.#canCombineResults(dyn);
self.adapter.dependentVar = dyn.obj.dataSource.dependentVar || null;
self.adapter.showCount = dyn.obj.showBase || false;
self.adapter.label = reqs[reqIdx].labels?.[`__series${idx + 1}`];
self.adapter.lowBase = dyn.obj.dataSource.omitLowBase ? self.opts.lowBase || 0 : 0;
self.adapter.nonStandardCategories = dyn.obj.dataSource.nonStandardCategories || [];
self.adapter.byQuarter = dyn.obj.dataSource.dateRange?.group === 'quarter' || false;
self.adapter.removeInapplicableResponses = dyn.obj.dataSource.removeInapplicableResponses || false;
self.adapter.removeEmptyCategories = dyn.obj.dataSource.removeEmptyCategories || false;;
self.adapter.addOverall = dyn.obj.dataSource.addOverall || false;
// Inject the API response
self.adapter.response = result[0];
// Get the Adapter's output
const output = self.adapter.output;
// console.log('Dataset:', dyn.obj.dataSource.tag);
// console.log('Adaptor input response = ', self.adapter.response);
// console.log('Adaptor internal data = ', self.adapter.data);
// console.log("Adaptor output = ", output);
// console.log("Omitted = ", self.adapter.omitted);
// console.log('Chart data', (self.adapter.combineResults ? 'combined' : 'NOT combined'), 'for the', dyn.obj.dataSource.aggregator, 'aggregator in ', dyn.obj.dataSource.tag);
// Add the adapter's output to this element's data
if (output) dyn.obj.data.push(...output);
// Add any omitted categories for this slide
if (self.adapter.omitted.length) {
allOmittedCategories[dyn.slide] = allOmittedCategories[dyn.slide] || new Set();
self.adapter.omitted.forEach(om => allOmittedCategories[dyn.slide].add(om));
};
}
if (Array.isArray(dyn.obj.dataSource.series)) {
// Add any static data - get any data order from the first row of the API results
const staticSeries = dyn.obj.dataSource.series.filter(s => s.data);
staticSeries.forEach(s => {
const labels = dyn.obj.data.length ? dyn.obj.data[0].labels : Object.keys(s.data);
const rawCategories = dyn.obj.data.length ? dyn.obj.data[0].rawCategories : Object.keys(s.data);
dyn.obj.data.push({
project: null,
name: s.label,
labels,
values: labels.map(lbl => PowerPoint.#findDataFromLabel(s.data, lbl)),
rawCategories
});
});
// Allow user to modify the raw data (onload may be a function or an array of functions).
// Process any series-level handlers first.
// The series handler is passed each series' data object in turn.
dyn.obj.dataSource.series.forEach((s, idx) => {
let onload = s.onload;
if (onload) {
if (!Array.isArray(onload)) onload = [onload];
onload.forEach(fn => {
if (typeof fn === "function" && Array.isArray(dyn.obj.data)) {
dyn.obj.data[idx] = fn.call(self, dyn.obj.dataSource, dyn.obj.data[idx], idx)[0];
}
});
}
});
// If we have multiple series we need to make sure that their data is in a consistent shape
// as it's possibe for one series to have different data to another.
if (dyn.obj.dataSource?.series?.length > 1) {
// Get a list of all unique categories
const catSet = new Set();
dyn.obj.data.forEach(d => {
d.rawCategories.forEach(c => catSet.add(c));
})
let rawCategories = Array.from(catSet);
// Makw sure that the combined categories have 'overall' at the end. It's *end* here because the data has been reversed ready for PowerPoint.
const indexes = DataAdapter.orderIndexes(rawCategories, ['-overall']);
rawCategories = DataAdapter.orderBy(rawCategories, indexes);
// With multiple series. PowerPoint ignores all bar the first set of categories, and doesn't attempt to
// line-up data across series with different category names.
// So we must recreate new data for each series that includes zeros at the right place for any missing categories.
// The order of the new data must be the same as the categories.
const normalizedData = [];
dyn.obj.data.forEach((d, idx) => {
rawCategories.forEach(cat => {
normalizedData[idx] = normalizedData[idx] || PowerPoint.#getInitialSeriesData(d);
// Find the index of this category in this series
const dataIndex = d.rawCategories.indexOf(cat);
// If not found add an 'empty' series value
if (dataIndex === -1) {
PowerPoint.#addEmptySeries(normalizedData[idx], cat, dyn.obj.showBase);
} else {
PowerPoint.#addSeries(normalizedData[idx], d, dataIndex);
}
});
});
dyn.obj.data = normalizedData;
}
// For showLastSeriesLabels datasets, move the labels from the *last* data series to the *first*.
// PowerPoint only shows the first set of labels and these series are typically
// in date order earliest -> latest. This option ensures that we see the latest bases. not the earliest.
if (dyn.obj?.dataSource?.showLastSeriesLabels && dyn.obj?.dataSource?.dateRange?.group !== false && dyn.obj.data.length > 1) {
dyn.obj.data[0].labels = dyn.obj.data[dyn.obj.data.length - 1].labels;
}
}
// Now apply any global handler.
// The handler is passed the entire data object so it can be processed as a single entity.
if (dyn.obj.dataSource.onload) {
if (!Array.isArray(dyn.obj.dataSource.onload)) dyn.obj.dataSource.onload = [dyn.obj.dataSource.onload];
dyn.obj.dataSource.onload.forEach(fn => {
if (typeof fn === "function") {
dyn.obj.data = fn.call(self, dyn.obj.dataSource, dyn.obj.data);
}
});
}
// Add any footnotes...
// If we have a series type that isn't 'Satisfaction' or a placeholder then add a footnote
let nonStandardSeries = new Set();
dyn.obj.data.forEach(d => {
d.types?.forEach(t => {
if (t && t !== "Satisfaction" && t !== "_PLACEHOLDER") nonStandardSeries.add(t);
});
});
if (nonStandardSeries.size) {
for (const type of nonStandardSeries) {
self.#addFootnote(dyn.slide, `*${type} scale rather than satisfied. `)
}
}
// Add a note if we're highlighting values in a table
if (dyn.obj.highlight === 'hilo') {
self.#addFootnote(dyn.slide, 'Highest/lowest satisfaction scores highlighted in ');
self.#addFootnote(dyn.slide, 'green', { color: '00CC00', bold: true });
self.#addFootnote(dyn.slide, '/');
self.#addFootnote(dyn.slide, 'red', { color: 'E95171', bold: true });
}
if (dyn.obj.highlight === 'significance') {
self.#addFootnote(dyn.slide, 'Scores highlighted by signficance. ');
}
// Add a note if any series' scores are undefined and we have a low base
let haveUndefinedValues = false;
for (const d of dyn.obj.data) {
if (d.values.some(v => Array.isArray(v) ? v.some(arr => arr === undefined) : v === undefined)) {
haveUndefinedValues = true;
break;
}
}
if (haveUndefinedValues && dyn.obj.dataSource.omitLowBase) {
allOmittedCategories[dyn.slide] = allOmittedCategories[dyn.slide] || new Set();
allOmittedCategories[dyn.slide].add('some scores');
};
// Truncate long labels to a maximum of MAX_LABEL_LENGTH characters and add a '*'
// if the series is not the standard 'Satisfaction' scale.
dyn.obj.data.forEach(d => {
if (d.labels) {
d.labels = d.labels.map((lbl, idx) => {
let truncated = PowerPoint.#truncateString(lbl, PowerPoint.MAX_LABEL_LENGTH);
if (d.types && d.types[idx] && d.types[idx] !== 'Satisfaction' && d.types[idx] !== '_PLACEHOLDER') truncated = '*' + truncated;
return PowerPoint.toUpperCaseFirst(truncated);
})
}
});
});
// Add notes about any omitted categories
allOmittedCategories.forEach((om, slideIdx) => {
self.#addFootnote(slideIdx, `${self.#formatList(Array.from(om))} omitted due to sample size < ${self.opts.lowBase}. `);
});
// Build the presentation
self.#build(onprepared);
})
.fail(function (e) {
console.error("Failed to fetch data for dynamic elements -", e.message || e.responseText || e.statusText);
if (onfail) onfail(e);
});
}
/**
* Adds slides to the PowerPoint object
* @param {Array<object>} slides - An array of slide objects
*/
addSlides(slides) {
slides.forEach(s => {
this.addSlide(s);
});
}
/**
* Adds a single slide to the PowerPoint object
* @param {object} slideOpts - The options object for the slide
*/
addSlide(slideOpts) {
const s = DataAdapter.deepClone(slideOpts);
// Hack - add any transform' functions from any table columns
slideOpts?.tables?.forEach((t, tIdx) => {
if (Array.isArray(t.cols)) {
t.cols?.forEach((col, cIdx) => {
if (col.transform) s.tables[tIdx].cols[cIdx].transform = col.transform;
});
}
});
// Rename some props to fit the API of the underlying library
s.masterName = s.master;
delete s.master;
s.sectionTitle = s.section;
delete s.section;
// If we're in single-slide mode remove the reference to a section as it won't exist
if (this.opts.singleSlide) delete s.sectionTitle;
s.text = s.text || "";
// Create slide
const slide = this.#base.addSlide(s);
// Add a title
if (s.title) {
// Have to clone this as the PptxGenJS library mututes any objects it's given
const headingOptions = DataAdapter.deepClone(this.opts.headingOptions || {});
let title = this.#replaceTokens(s.title);
title = this.#addDefaultOptions(headingOptions, title);
// Reduce font size for a long title
let titleFontSize = title[0]?.options?.fontSize || headingOptions?.fontSize || 44;
if (title[0].text.length > 78) titleFontSize -= 2;
if (title[0].text.length > 62) titleFontSize -= 2;
if (title[0].text.length > 56) titleFontSize -= 2;
if (title[0].text.length > 47) titleFontSize -= 4;
if (title[0].text.length > 41) titleFontSize -= 4;
title[0].options.fontSize = titleFontSize;
slide.addText(title, { placeholder: "title" });
}
// Add any text
const text = s.text ? DataAdapter.deepClone(s.text) : [{
options: { x: 0.4, y: 7, fontSize: 10 },
values: []
}];
if (Array.isArray(text) && text.length) {
this.#addText(slide, this.#replaceTokens(text));
}
// Add any images
if (s.images) this.#addImages(slide, s.images);
// Add any shapes
if (s.shapes) this.#addShapes(slide, s.shapes);
// Add any tables
if (s.tables) this.#addTables(slide, s.tables);
// Add any charts
if (Array.isArray(s.charts)) {
s.charts.forEach((chart, idx) => {
// Skip any hidden charts
if (chart.hidden) return;
if (chart.type && chart.data) {
// Replace any tokens in the chart data name
chart.data.forEach(d => (d.name = this.#replaceTokens(d.name, chart.data, chart.dataSource)));
const libChartType = chart.type === PowerPoint.CHART_TYPE.COLUMN ? PowerPoint.CHART_TYPE.BAR : chart.type;
slide.addChart(libChartType, chart.data, {
barDir: chart.type === PowerPoint.CHART_TYPE.COLUMN ? "col" : "bar",
barGrouping: chart.barGrouping || "clustered",
barGapWidthPct: typeof chart.barGapWidthPct === "number" ? chart.barGapWidthPct : 70,
barOverlapPct: chart.barOverlapPct || 0,
catAxisHidden: chart.catAxisHidden || false,
catAxisLabelFontSize: chart.catAxisLabelFontSize || 10,
catAxisLineShow: chart.catAxisLineShow || false,
catAxisMaxVal: chart.catAxisMaxVal || null,
catAxisMinVal: chart.catAxisMinVal || null,
catAxisLabelPos: chart.catAxisLabelPos || "nextTo",
catGridLine: chart.catGridLine || false,
catLabelFormatCode: chart.catLabelFormatCode || null,
chartColors: this.opts.chartColors || chart.chartColors,
invertedColors: chart.invertedColors || null,
holeSize: chart.holeSize || 50,
dataBorder: chart.dataBorder || null,
dataLabelColor: chart.dataLabelColor || "000000",
dataLabelPosition: chart.dataLabelPosition || "bestFit",
dataLabelFontSize: chart.dataLabelFontSize || 18,
dataLabelFormatCode: chart.dataLabelFormatCode || (chart.showPercent ? "0.0%" : "#,##0"),
layout: chart.layout || null,
legendPos: chart.legendPos || "r",
legendFontFace: chart.legendFontFace || this.opts.bodyFont || 'sans-serif',
legendFontSize: chart.legendFontSize || 10,
lineSmooth: chart.lineSmooth || false,
placeholder: `chart${idx + 1}`,
serAxisLabelPos: chart.serAxisLabelPos || "nextTo",
showDataTable: Boolean(chart.showDataTable) || false,
showDataTableKeys: Boolean(chart.showDataTableKeys) || false,
showLegend: Boolean(chart.showLegend) || false,
showLabel: Boolean(chart.showLabel) || false,
showPercent: Boolean(chart.showPercent) || false,
showValue: chart.showValue || false,
showValAxisTitle: Boolean(chart.showValAxisTitle) || false,
showTitle: Boolean(chart.title) || false,
title: chart.title,
titleColor: chart.titleColor,
titleFontSize: chart.titleFontSize,
titlePos: chart.titlePos || null,
valAxisLabelPos: chart.valAxisLabelPos || "nextTo",
valAxisHidden: Boolean(chart.valAxisHidden) || false,
valAxisMaxVal: chart.valAxisMaxVal || null,
valAxisMinVal: chart.valAxisMinVal || null,
valAxisMajorUnit: chart.valAxisMajorUnit || null,
valAxisTitle: chart.valAxisTitle || null,
valAxisLabelFontSize: chart.valAxisLabelFontSize || 11,
valAxisLabelFormatCode: chart.valAxisLabelFormatCode || (chart.showPercent ? "##%" : "#,##0"),
valAxisLineShow: chart.valAxisLineShow || false,
valGridLine: chart.valGridLine || { color: 'FFFFFF' },
valueBarColors: chart.valueBarColors || false,
});
}
// Add any chart caption
let caption = ' ';
if (chart.caption) {
caption = this.#replaceTokens(chart.caption, chart.data, chart.dataSource);
caption = this.#addDefaultOptions(this.#captionOptions, caption);
}
// Add the chart caption
slide.addText(caption, { placeholder: `caption${idx + 1}` });
// Handle any text associated with this chart
if (chart.text) {
chart.text.forEach(t => {
let text = this.#replaceTokens(t.values, chart.data, chart.dataSource);
slide.addText(text, { ...(t.options || {}), placeholder: "_NONE" });
});
}
// Handle any footnotes associated with this chart
if (chart.footnotes) {
chart.footnotes.forEach(n => {
const clone = DataAdapter.deepClone(n);
clone.text = this.#replaceTokens(clone.text, chart.data, chart.dataSource);
// If a chart footnote has an 'if' method, only add the footnote if it returns true
if (typeof n.if !== 'function' || n.if(chart.data)) {
this.#addFootnote(s, clone.text, clone.options);
}
});
}
});
}
// Add all footnotes to the slide
this.#addFootnotes(slide, s.footnotes || [{ text: ' ' }]);
}
/**
* Adds sections to the PowerPoint object
* @param {Array<string>} sectionNames - An array of section names
*/
addSection(section, withSlide = true) {
this.#base.addSection({ title: section.name });
if (withSlide) {
// A section can be split into a name and a subname.
// An index after a '::' can be used so sections can have a different name (required)
// but still show the same text.
const [slidename, _] = section.name.split("::");
const [mainname, subname] = slidename.toUpperCase().split("|");
const text = [{ values: [{ text: mainname + '\n', options: { fontFace: this.opts.sectionFont || this.opts.bodyFont || 'sans-serif', color: 'FFFFFF', align: section.textAlign || 'left' }, placeholder: "section_name" }] }];
if (subname) {
text[0].values.push({ text: subname, options: { fontFace: this.opts.sectionFont || this.opts.bodyFont || 'sans-serif', color: 'E10620', align: section.textAlign || 'left' }, placeholder: "section_name" });
}
// Auto-generate a new section slide
this.addSlide({
master: this.#sectionmaster,
section: section.name,
text,
images: section.hero || null,
shapes: section.shapes || null
});
}
}
/**
* Downloads the generated PowerPoint presentation as a file with the given name.
* @param {string} name - The name of the downloaded file. If not provided, defaults to the slide title and subject.
* @return {Promise<File>} A Promise that resolves with the File object representing the downloaded PowerPoint presentation
*/
download(name = `${this.#base.title} ${this.#base.subject}`) {
name = this.#replaceTokens(name);
return this.#base.writeFile({ fileName: name, compression: true });
}
/**
* Generates a new PowerPoint presentation file and returns it as a Blob.
* @return {Promise<Blob>} A Promise that resolves with the Blob object representing the generated PowerPoint presentation
*/
getBlob() {
return this.#base.write('blob');
}
/**
* Builds the PowerPoint presentation
* @param {function} [onbuild] - A callback function that will be called after the presentation is built.
* @private
*/
#build(onbuild) {
// Define top-level presentation attributes
this.#base.company = this.opts.company || "TLF Research";
this.#base.title = this.opts.clientLabel || this.opts.client || "No client specified";
this.#base.subject = this.opts.projectLabel || "No project specified";
this.#base.author = this.opts.cm || "Client Manager";
this.#base.layout = this.opts.layout || "LAYOUT_WIDE"; // 13.3 x 7.5 inches
this.#base.theme = { headFontFace: this.opts.headerFont || "sans-serif", bodyFontFace: this.opts.bodyFont || "sans-serif" };
// Add all the slide Masters
this.#addMasters();
// Deal with single-slide mode (typically used by the presentation editor's Preview view)
if (this.opts.singleSlide) {
if (this.opts.slides.length > 0) {
this.addSlide(this.opts.slides[0]);
}
if (typeof onbuild === "function") {
onbuild();
}
return;
}
const sections = [];
const startSection = { name: '_START', withSlide: false };
// Add the _START section
sections.push(startSection);
// Add all the presentation sections
if (this.opts.sections) sections.push(...this.opts.sections);
// Auto-generate a Title slide (to be placed in the _START section)
const titleSlide = {
master: this.#titlemaster,
title: '',
section: startSection.name,
text: [
{
values: [
{ text: this.#base.title.toUpperCase(), placeholder: "client_name" },
{ text: this.#base.subject.toUpperCase(), placeholder: "project_name" },
{
text: new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
}).format(new Date()),
options: { bold: true },
placeholder: "created",
},
],
},
],
};
// Although sections are supported by the PptxGenJS library, from my testing you can't reply on adding slides to
// sections in an arbitrary way. It only works reliably if you create each section in
// order and then add all the slides in that section.
sections.forEach(section => {
this.addSection(section, section.withSlide !== false);
if (section.name === startSection.name) {
this.addSlide(titleSlide);
}
if (this.opts.slides) this.addSlides(this.opts.slides.filter(s => !s.hidden && s.section === section.name));
});
if (typeof onbuild === "function") {
onbuild();
}
}
/**
* getDependentQuestionLabel returns the label associated with the dependent variable
* associated with the adapter.
* @param {Object} data - Raw data used for calculation.
* @param {Object} ds - Dataset information object.
* @returns {string} - The label associated with the dependent variable
* @private
*/
#getDependentQuestionLabel(data, ds) {
if (!ds.dependentVar) return "#ERROR - No dependent variable specified";
if (!Array.isArray(data) || !data.length) return "#ERROR - Data is not an array";
const proj = this.#projects.find(p => p.id === data[0].project);
const v = proj.vars[ds.dependentVar];
return v ? v.label : `#ERROR - Cannot find dependent variable '${ds.dependentVar}'`;
}
/**
* Adds a footnote to the given slide
* @param {number|Object} slideOrIdx - The slide index or slide object to add the footnote to
* @param {string} note - The footnote text
* @param {Object} [opts] - Options for the footnote
* @returns {void}
* @private
*/
#addFootnote(slideOrIdx, note, opts) {
const slideOpts = typeof slideOrIdx === "number" ? this.opts.slides[slideOrIdx] : slideOrIdx;
if (!slideOpts.footnotes) {
slideOpts.footnotes = [];
};
// Don't add the same footnote twice
if (slideOpts.footnotes.find(f => f.text === note)) return;
slideOpts.footnotes.push({ text: note, options: opts });
}
/**
* Adds footnotes to the given slide
* @param {Object} slide - The slide object to add the footnote to
* @param {Array<string>} notes - An array of footnotes
* @returns {void}
* @private
*/
#addFootnotes(slide, notes) {
slide.addText(notes, { placeholder: 'footnotes' });
}
/**
* Adds text to the given slide
* @param {Object} slide - The slide object to add the text to
* @param {Array<Object>} text - An array of text objects
* @returns {void}
* @private
*/
#addText(slide, text) {
// Short-circuit if the text is just a simple string
if (!Array.isArray(text)) {
slide.addText(text, { placeholder: "body" });
return;
}
// If it's an array it must be an array of objects, as the library doesn't allow
// added text to be a simple object
text.forEach(t => {
const groups = {};
t.values.forEach(obj => {
const ph = obj.placeholder || "body";
delete obj.placeholder;
if (typeof groups[ph] === "undefined") groups[ph] = [];
groups[ph].push(obj);
});
for (const placeholder in groups) {
slide.addText(groups[placeholder], { ...(t.options || {}), placeholder });
}
});
}
/**
* Adds default options to the given text specification
* @param {object} defaultOpts - The default options
* @param {string|Array<object>} textSpec - The text specification to add default options to
* @returns {string|Array<object>} The text specification with default options
* @private
*/
#addDefaultOptions(defaultOpts, textSpec) {
if (Array.isArray(textSpec)) {
textSpec.forEach(t => this.#addDefaultOptions(t));
return textSpec;
}
if (typeof textSpec === "object") textSpec.options = { ...defaultOpts, ...textSpec.options };
if (typeof textSpec === "string") textSpec = [{ text: textSpec, options: defaultOpts || {} }];
return textSpec;
}
/**
* Replaces any token in the given text specification.
* Replacements are done in-place.
* @param {string|Array<object>} textSpec - The string or array of objects to be replaced
* @param {Array<object>} data - The data object from which to replace tokens
* @param {object|undefined} ds - The data source object (optional)
* @return {string|Array<object>} The input argument with all the tokens replaced
* @private
*/
#replaceTokens(textSpec, data, ds) {
if (!textSpec) return textSpec;
if (Array.isArray(textSpec)) {
textSpec.forEach(t => this.#replaceTokens(t, data, ds));
return textSpec;
}
if (Array.isArray(textSpec.values)) {
textSpec.values.forEach(t => this.#replaceTokens(t, data, ds));
return textSpec;
}
if (typeof textSpec === "string") textSpec = this.#replace(textSpec, data, ds);
if (typeof textSpec === "object" && textSpec.text) textSpec.text = this.#replace(textSpec.text, data, ds);
return textSpec;
}
/**
* Replaces all occurrences of a token in the given string.
* @param {string} target - The input string
* @param {string} search - The token to be replaced
* @param {Array<object>} data - The data object from which to replace tokens
* @return {string} The input string with all occurrences of the token replaced
* @private
*/
#replace(target, data, ds) {
if (!target) return target;
return String(target).replace(/#([^#]+)#/g, (_, submatch) => {
// If the replacer is a method then return its result
if (typeof this.#tokens[submatch] === "function") {
if (!data) {
console.warn(`Cannot replace calculated placeholder '${submatch}' without data`);
return "ERROR: NO DATA";
}
return this.#tokens[submatch](data, ds);
}
return this.#tokens[submatch] || "NOT_FOUND";
});
}
// Method to replace tokens like #TOKEN# in a string using the given data
#replaceTokensFromData(string, data) {
if (!string || !data) return string
return string.replace(/#([^#]+)#/g, (_, submatch) => {
return data[submatch] || 'NOT_FOUND'
})
}
/**
* Returns the English equivalent of a list as a single string.
* @param {Array<string>} list - The input list
* @return {string} A single string representation of the input list
* @private
*/
#formatList(list) {
let result = '';
list.forEach((item, idx) => {
switch (idx) {
case 0:
result += item.charAt(0).toUpperCase() + item.substr(1);
break;
case list.length - 1:
result += ' and ' + item;
break;
default:
result += ', ' + item;
}
});
return result;
}
/**
* Adds images to a slide
* @param {object} slide - The slide object to which to add images
* @param {Array<string>|string} images - An array of image URLs or a single image URL
* @private
*/
#addImages(slide, images) {
// Short-circuit if this is just a string
if (!Array.isArray(images)) {
slide.addImage(images);
return;
}
// If it's an array it must be an array of objects, as the library doesn't allow
// added image to be a simple object
const groups = images.reduce((g, obj) => {
const ph = obj.placeholder || "body";
delete obj.placeholder;
if (typeof g[ph] === "undefined") g[ph] = [];
g[ph].push(obj);
return g;
}, {});
for (const placeholder in groups) {
groups[placeholder].forEach(img => slide.addImage(img));
}
}
/**
* Adds shapes to a slide
* @param {object} slide - The slide object to which to add shapes
* @param {Array<object>|object} shapes - An array of shape objects or a single shape object
* @private
*/
#addShapes(slide, shapes) {
// Short-circuit if this is just a string
if (!Array.isArray(shapes)) {
slide.addShape(shapes.type, shapes);
return;
}
// If it's an array it must be an array of objects, as the library doesn't allow
// added shape to be a simple object
const groups = shapes.reduce((g, obj) => {
const ph = obj.placeholder || "body";
delete obj.placeholder;
if (typeof g[ph] === "undefined") g[ph] = [];
g[ph].push(obj);
return g;
}, {});
for (const placeholder in groups) {
groups[placeholder].forEach(shape => slide.addShape(shape.type, shape.options));
}
}
/**
* Determines if the given dynamic object supports combining results.
* @param {object} dyn - The dynamic object
* @return {boolean} True if the dynamic object can combine results
* @private
*/
#canCombineResults(dyn) {
const ds = dyn.obj.dataSource;
if (dyn.table) {
if (ds.aggregator === 'distinct') return false;
const visibleSeries = ds.series ? ds.series.filter(s => !s.hidden) : null;
if (visibleSeries?.every(s => s.aggregator === 'distinct')) return false;
return true;
}
return ds.combine || this.#combineResults(ds);
}
/**
* Adds tables to a slide
* @private
* @param {object} slide - The slide object to which to add tables
* @param {Array<object>|object} tables - An array of table objects or a single table object
*/
#addTables(slide, tables) {
// Normalise tables to an array
if (!Array.isArray(tables)) {
tables = [tables];
}
tables.forEach((table, idx) => {
// Add caption text
let caption = ' ';
if (table.caption) {
caption = this.#replaceTokens(table.caption, table.data, table.dataSource);
caption = this.#addDefaultOptions(this.#captionOptions, caption);
}
slide.addText(caption, { placeholder: `caption${idx + 1}` });
const defaultHeaderOptions = { bold: true };
// Add headers
let headers = [];
// If we're in GROUP mode then the columns are derived fom the results
if (table.cols === PowerPoint.COLMODE_USE_GROUPS) {
// First header is the categoryHeader
headers.push({
text: this.#replaceTokens(table.categoryHeader || 'Category'),
options: { ...defaultHeaderOptions, ...this.#combineOptions(table.headerOptions, 0) },
})
// The splits from the first result row determine the number of columns in the table
headers.push(...table.data[0].splits.map((split, sIdx) => ({
text: this.#replaceTokens(split),
options: { ...defaultHeaderOptions, ...this.#combineOptions(table.headerOptions, sIdx + 1) },
})));
}
// If we have an array of column specs then convert them to headers
if (Array.isArray(table.cols)) {
headers = table.cols.map((col, cIdx) => ({
text: this.#replaceTokens(col.header || `Column ${cIdx + 1}`),
options: { ...defaultHeaderOptions, ...this.#combineOptions(table.headerOptions, cIdx), ...col.headerOptions },
}));
}
// Append an asterisk to any header with an associated data column that contains a non-standard series type.
// But don't do this for a table whose columns are derived from splits rather than series.
if (table.cols !== PowerPoint.COLMODE_USE_GROUPS) {
headers.forEach((h, hIdx) => {
if (hIdx !== 0) {
if (table.data?.[hIdx - 1]?.types.some(t => t !== 'Satisfaction')) h.text += '*';
}
});
}
// Create table rows array
let rows = [];
// The labels come from the first hard-coded cols 'data' property, or the first result row
// for a table associated with a data source. Table queries are always combined so there's always only one data row.
const labels = table.cols?.[0]?.data || table.data?.[0]?.labels || [];
if (table.addBasesRow) {
// Add a top row containing each split's base
labels.unshift(table.addBasesRow.label || 'Base');
}
labels.forEach((lbl, lblIdx) => {
let row = [];
// If we're in COLMODE_USE_GROUPS mode then the columns are derived fom the results
if (table.cols === PowerPoint.COLMODE_USE_GROUPS) {
// First value in each row is the relevant label
const stripe = table.striped ? { fill: lblIdx % 2 ? 'FFFFFF' : 'F5F5F6' } : {};
// If we have a base row then add it first
if (table.addBasesRow) {
if (lblIdx === 0) {
// Fetch base data from the 'bases' property
row.push({ text: table.addBasesRow.label || 'Base', options: { ...this.#combineOptions(table.colOptions, 0), ...{ bold: true }, ...stripe } });
row.push(...table.data[0].bases[0].map((val, colIndex) => this.#getTableCell(table, lblIdx, colIndex, val, { format: 'n0', color: '808080' })));
rows.push(row);
return;
} else {
lblIdx--;
}
}
// Add the label column
row.push({
text: lbl,
options: { ...this.#combineOptions(table.colOptions, 0), ...stripe },
});
// Fetch the remining row data from the returned data values
row.push(...table.data[0].values[lblIdx].map((val, colIndex) => this.#getTableCell(table, lblIdx, colIndex, val)));
}
// Handle a hard-coded list of columns
if (Array.isArray(table.cols)) {
row = table.cols.map((col, cIdx) => {
if (typeof col.series === 'undefined' && typeof col.data === 'undefined') {
console.error(`Table column '${col.header}' must have either a series or data key`);
return;
}
const prop = col.showCategory ? "labels" : col.field || "values";
const vstripe = table.vstriped ? { fill: cIdx % 2 ? 'FFFFFF' : 'F5F5F6' } : {};
const opts = { ...this.#combineOptions(table.colOptions, cIdx), ...(col.options || {}) };
let lblText = ' ', lblOpts = {};
let errOpts = {};
// Handle a table row with a series
if (typeof col.series !== 'undefined' && typeof table.data?.[col.series]?.[prop] === 'undefined') {
console.error(`No '${prop}' key exists in the data for series ${col.series} - did you pick the right one?`);
lblText = '!ERROR!';
errOpts.color = 'FF0000';
} else {
// Text may come from a hard-coded data array or the data from the data source
if (Array.isArray(col.data)) {
if (typeof col.data[lblIdx] === 'string') {
lblText = col.data[lblIdx];
} else if (typeof col.data[lblIdx] === 'object') {
({ text: lblText, options: lblOpts } = col.data[lblIdx]);
}
} else {
lblText = table.data[col.series][prop][lblIdx];
if (Array.isArray(lblText)) lblText = lblText[0];
}
if (opts.format && lblText) lblText = kendo.toString(lblText, opts.format);
if (typeof col.transform === 'function') lblText = col.transform(lblText);
lblText = lblText || '-';
}
return ({ text: lblText, options: { ...col.options, ...lblOpts, ...vstripe, ...errOpts } });
});
}
// If required highlight the biggest/smallest value in each table row
if (table.highlight === 'hilo') {
let colMin = { idx: -1, val: Number.MAX_SAFE_INTEGER };
let colMax = { idx: -1, val: -Number.MAX_SAFE_INTEGER };
row.forEach((data, idx) => {
let text = data.text;
if (String(text).endsWith('%')) text = text.slice(0, -1);
if (isNaN(text)) return;
const val = Number(text);
if (val < colMin.val) colMin = { idx, val };
if (val > colMax.val) colMax = { idx, val };
})
if (colMin.idx !== -1) row[colMin.idx].options.color = 'E95171';
if (colMax.idx !== -1) row[colMax.idx].options.color = '00BA00';
}
rows.push(row);
});
// If required highlight values using their significance
if (table.highlight === 'significance') {
rows.forEach((row, rIdx) => {
let dataIdx = rIdx
// Skip the first (bases) row if we added one (and correct the row index for all other rows)
if (table.addBasesRow) {
if (rIdx === 0) return;
dataIdx--;
}
const rowValues = table.data[0].raw[dataIdx];
const rowBases = table.data[0].bases[dataIdx];
row.forEach((_, cIdx) => {
// Ignore the first column in each row (as it contains text labels not data)
if (cIdx === 0) return;
// // Calculate the confidence for this row's overall value (assumed to be the first column in the table)
// const overallValue = table.data[0].values[rIdx][0];
// const overallConf = table.data[0].confidences[rIdx][0];
// const overallRange = [overallValue - overallConf, overallValue + overallConf];
// // Calculate the confidence for the current value
// const score = table.data[0].values[rIdx][cIdx - 1];
// const conf = table.data[0].confidences[rIdx][cIdx - 1];
// // Compare the value to the overall range
// const significance = DataAdapter.#compareRanges(overallRange, [score - conf, score + conf]);
// For the z-test compare the Overall value for each row (in the first column) to all the other columns in the row
const overall = { count: rowValues[0], base: rowBases[0] };
const subset = { count: rowValues[cIdx - 1], base: rowBases[cIdx - 1] };
const significance = DataAdapter.zTestOverall(overall, subset);
// Apply the significance by changing the style of this table cell
switch (significance) {
case -1:
rows[rIdx][cIdx].options.color = 'E95171';
break;
case 1:
rows[rIdx][cIdx].options.color = '00BA00';
break;
}
});
});
}
// If required merge similar rows
if (Array.isArray(table.cols)) {
table.cols.forEach((col, cIdx) => {
if (col.mergeSimilarRows) {
let firstRowToMerge = null;
let prev = null;
let sameRowsCount = 0;
rows.forEach((row, rIdx) => {
if (row[cIdx].text === prev) {
sameRowsCount++;
return;
}
if (sameRowsCount) {
// Remove the column from all duplicate rows
for (let idx = firstRowToMerge + 1; idx < rIdx; idx++) {
rows[idx].splice(cIdx, 1);
}
const mergedRows = rows.slice(firstRowToMerge + 1, rIdx).filter(r => r.length);
if (mergedRows.length !== 0) {
// Span the merged columns
rows[firstRowToMerge][cIdx].options.rowspan = sameRowsCount + 1;
}
}
firstRowToMerge = rIdx;
prev = row[cIdx].text;
sameRowsCount = 0;
});
if (sameRowsCount) {
// Remove the column from all duplicate rows
for (let idx = firstRowToMerge + 1; idx < rows.length; idx++) {
rows[idx].splice(cIdx, 1);
}
const mergedRows = rows.slice(firstRowToMerge + 1, rows.length).filter(r => r.length);
if (mergedRows.length !== 0) {
// Span the merged columns
rows[firstRowToMerge][cIdx].options.rowspan = sameRowsCount + 1;
}
}
}
});
}
// Remove any completely empty rows
rows = rows.filter(r => r.length);
// Stripe rows horizontally
if (table.striped) {
let visualRowIndex = 0;
let rowsToSkip = 0;
rows.forEach((row, rIdx) => {
// Set fill based in the current visualRowIndex;
const fill = visualRowIndex % 2 ? 'FFFFFF' : 'F5F5F6';
row.forEach(col => col.options.fill = fill);
// If we have a rowspan then set the rowsToBeSkipped
if (row[0].options.rowspan) {
rowsToSkip = row[0].options.rowspan;
}
// If we've skipped all the rows we need to then move to the next visual row
if (--rowsToSkip <= 0) visualRowIndex++;
});
}
slide.addTable([headers, ...rows], { w: 12.5, placeholder: `table${idx + 1}`, ...(table.options || {}) });
// Handle any text associated with this table
if (table.text) {
table.text.forEach(tx => {
let text = this.#replaceTokens(tx.values, table.data, table.dataSource);
slide.addText(text, { ...(tx.options || {}), placeholder: "_NONE" });
});
}
});
}
/**
* Get a table cell
* @param {object} table Table to be used
* @param {number} rowIdx Row index
* @param {number} colIndex Column index
* @param {any} val Value of cell
* @param {object} options Custom widget options
*
* @returns {object}
*/
#getTableCell(table, rowIdx, colIndex, val, options = {}) {
const stripe = table.striped ? { fill: rowIdx % 2 ? 'FFFFFF' : 'F5F5F6' } : {};
const vstripe = table.vstriped ? { fill: colIndex % 2 ? 'FFFFFF' : 'F5F5F6' } : {};
const opts = { ...this.#combineOptions(table.colOptions, colIndex + 1), ...options };
if (val && opts.format) val = kendo.toString(val, opts.format);
return {
text: val || '-',
options: { ...opts, ...vstripe, ...stripe },
}
}
/**
* Combines an array of options
* @param {object|Array(object)} opts Options
* @param {number} idx Index
* @private
*/
#combineOptions(opts, idx) {
// If opts is a plain object then just return it
if (!Array.isArray(opts)) return opts || {};
// If opts is an array then the options are a combination of all the options in the array
// up to the given index (or up to the end of the opts array).
return opts.slice(0, idx + 1).reduce((obj, opt) => {
obj = { ...obj, ...opt };
return obj;
});
}
/**
* Builds a list of all dynamic elements in the presentation.
* @private
*/
#getDynamicElements() {
const dynamic = [];
this.opts.slides?.forEach((slide, sIdx) => {
if (!slide.hidden) {
// Deal with a chart builder function
if (typeof slide.charts === "function") slide.charts = slide.charts(this.#projects);
const visibleCharts = slide.charts && slide.charts.filter(ch => !ch.hidden);
// Enumerate all the dynamic objects in the slide
if (visibleCharts) dynamic.push(...visibleCharts.filter(ch => ch.dataSource).map(ch => ({ slide: sIdx, type: "chart", obj: ch })));
if (slide.tables) dynamic.push(...slide.tables.filter(t => t.dataSource).map(t => ({ slide: sIdx, type: "table", obj: t })));
}
});
return dynamic;
}
/**
* Builds an array of all the API requests needed to fetch all the data for the given dynamic elements.
* @param {Array<object>} dynamic - An array of dynamic objects
* @returns {Array<object>} - An array of API requests
* @private
*/
#buildRequests(dynamic) {
const reqs = [];
// Build all the API calls needed to populate the given dynamic elements
dynamic.forEach(dyn => {
const ds = dyn.obj.dataSource;
dyn.reqs = [];
dyn.obj.proj = this.#projects.find(p => p.name === ds.projectName);
if (!dyn.obj.proj) {
throw new Error(`Project '${ds.projectName}' not found`);
}
// Filter out hidden series
const visibleSeries = ds.series ? ds.series.filter(s => !s.hidden) : null;
const haveSeries = Boolean(visibleSeries && visibleSeries.length);
const haveCategories = Boolean(ds.categories && ds.categories.length);
const haveCols = Boolean(dyn.obj.cols);
// Must specify at least type
if (!haveSeries && !haveCategories && !haveCols) {
console.error(
`You must specify ONE OF either a 'series', 'categories' or 'cols' array for the '${ds.tag || "MISSING_TAG"
}' dataSource - skipping`
);
return;
}
// If you're grouping a 'dist' aggregator you also need a 'categoryOrder' (so you know all the categories you need to match
// when parsing the results of the dist)
const haveDist = ds.aggregator === 'dist' || ds.aggregator === 'distx' || haveSeries && visibleSeries.some(s => s.aggregator === 'dist' || s.aggregator === 'distx');
if (ds.group && haveDist && !ds.categoryOrder) {
console.error(`You must specify a categoryOrder for a grouped dataset if you're using a 'dist' aggregator - skipping`);
return;
}
let commonFilters = {};
// Set default date range using any DateSource-level range
const defaultDateRange = this.#getDateRange(ds.dateRange, dyn.obj.proj.dateVar);
if (defaultDateRange) commonFilters[dyn.obj.proj.dateVar] = defaultDateRange;
// Add any dataSource-level filters
commonFilters = { ...commonFilters, ...(this.#convertFiltersToObject(ds.filters) || {}) };
// Vars may be explicitly defined in a series array, or via a generic variable category
let varsToFetch = [];
let varsToFetchLabels = null;
if (haveCategories) {
varsToFetch = PowerPoint.#getProjectVarsByCategory(dyn.obj.proj, ds.categories, "name");
// Remove any explicit range of categories
if (ds.categoryRange) {
const range = ds.categoryRange.match(/(\d+)-(\d+)/);
if (!range) {
console.error(`Invalid categoryRange '${ds.categoryRange}' for ${ds.tag} - skipping`);
return;
}
varsToFetch = varsToFetch.slice(Number(range[1]), Number(range[2]) + 1);
}
// Remove any explicit range of variable names
if (ds.excludeVars) {
varsToFetch = varsToFetch.filter(v => !ds.excludeVars.includes(v));
}
}
// Remove any series with hard-coded data here as they don't need an API call
const seriesToFetch = haveSeries ? visibleSeries.filter(s => !s.data) : [];
if (haveSeries) {
varsToFetch = seriesToFetch.reduce((vars, s) => {
if (s.variable) {
vars.push(s.variable);
}
if (s.categories) {
vars.push(...PowerPoint.#getProjectVarsByCategory(dyn.obj.proj, s.categories, "name"));
}
return vars;
}, []);
// Remove any explicit range of variable names
if (ds.excludeVars) {
varsToFetch = varsToFetch.filter(v => !ds.excludeVars.includes(v));
}
varsToFetchLabels = seriesToFetch.reduce((labels, s, sIdx) => {
const label = this.#replaceTokens(s.label) || dyn.obj.proj.vars[s.variable]?.caption || s.variable;
labels[`__series${sIdx + 1}`] = label || `Series ${sIdx + 1}`;
return labels;
}, {});
}
if (varsToFetch.length === 0) {
console.error(`No variables for the '${ds.tag || "MISSING_TAG"}' dataSource - API call omitted`);
return [];
}
// If we have a daterange establish any sorting or grouping
let dateGroup = null;
let dateSort = null;
if (ds.dateRange) {
switch (ds.dateRange.group) {
case false:
break;
case 'quarter':
dateGroup = 'YEAR(DATE_ADD({month},interval -3 month)) as year, QUARTER(DATE_ADD({month},interval -3 month)) AS quarter';
dateSort = 'year ASC, quarter ASC';
break;
default:
dateGroup = 'DATE_FORMAT({month}, "%Y-%m") AS date';
dateSort = 'date ASC';
}
}
// Handle compatible series as a single API call
if (this.#canCombineAPICalls(ds)) {
// For compatible series use the first series (these attributes must all be the same as they are combinable)
const aggr = haveSeries ? seriesToFetch[0].aggregator || ds.aggregator : ds.aggregator;
let sFilters = haveSeries ? this.#convertFiltersToObject(seriesToFetch[0].filters) : {};
const rangeFilter = haveSeries ? { [dyn.obj.proj.dateVar]: this.#getDateRange(seriesToFetch[0].dateRange, dyn.obj.proj.dateVar) } : {};
const filter = PowerPoint.#convertFiltersToString({ ...commonFilters, ...sFilters, ...rangeFilter });
dyn.reqs.push(reqs.length);
if (aggr) {
reqs.push({
labels: varsToFetchLabels,
api: server.getResponsesAggregate(
ds.tag,
dyn.obj.proj,
aggr,
varsToFetch,
filter,
ds.group || dateGroup,
ds.sort || dateSort,
Boolean(ds.addOverall),
Boolean(ds.cube),
ds.having,
ds.rolling,
ds.base,
ds.splits,
ds.cache !== false
),
});
} else {
reqs.push({
labels: varsToFetchLabels,
api: server.getResponses(
dyn.obj.proj,
varsToFetch,
ds.sort,
0,
Number.MAX_SAFE_INTEGER,
0,
filter,
ds.cache !== false
),
});
}
} else {
// Handle incompatible data series (processed as separate API calls)
if (haveSeries) {
seriesToFetch.forEach((s, idx) => {
const aggr = s.aggregator || ds.aggregator;
const sFilters = this.#convertFiltersToObject(s.filters) || {};
const rangeFilter = s.dateRange ? { [dyn.obj.proj.dateVar]: this.#getDateRange(s.dateRange, dyn.obj.proj.dateVar) } : {};
const filter = PowerPoint.#convertFiltersToString({ ...commonFilters, ...sFilters, ...rangeFilter });
const combineResults = this.#canCombineResults(dyn);
dyn.reqs.push(reqs.length);
if (aggr) {
reqs.push({
labels: varsToFetchLabels,
api: server.getResponsesAggregate(
s.tag || ds.tag,
dyn.obj.proj,
aggr,
combineResults ? varsToFetch : varsToFetch[idx],
filter,
s.group || ds.group || dateGroup,
s.sort || ds.sort || dateSort,
Boolean(s.addOverall || ds.addOverall),
Boolean(s.cube || ds.cube),
s.having || ds.having,
s.rolling || ds.rolling,
s.base || ds.base,
s.splits || ds.splits,
(s.cache || ds.cache) !== false
),
});
} else {
reqs.push({
labels: varsToFetchLabels,
api: server.getResponses(
dyn.obj.proj,
combineResults ? varsToFetch : varsToFetch[idx],
s.sort || ds.sort || dateSort,
0,
Number.MAX_SAFE_INTEGER,
0,
filter,
(s.cache || ds.cache) !== false
),
});
}
});
}
// Handle variable categories
if (haveCategories) {
const filter = PowerPoint.#convertFiltersToString(commonFilters);
varsToFetch.forEach(vName => {
const tag = ds.tag.replace("#VAR_NAME#", vName);
dyn.reqs.push(reqs.length);
if (ds.aggregator) {
reqs.push({
labels: varsToFetchLabels,
api: server.getResponsesAggregate(
tag,
dyn.obj.proj,
ds.aggregator,
vName,
filter,
ds.group || dateGroup,
ds.sort || dateSort,
Boolean(ds.addOverall),
Boolean(ds.cube),
ds.having,
ds.rolling,
ds.base,
ds.splits,
ds.cache !== false
),
});
} else {
reqs.push({
labels: varsToFetchLabels,
api: server.getResponses(
dyn.obj.proj,
vName,
ds.sort,
0,
Number.MAX_SAFE_INTEGER,
0,
filter,
ds.cache !== false
),
});
}
});
}
}
});
return reqs;
}
// Returns true if the API calls for this DataSource can be combined into a single call.
#canCombineAPICalls(ds) {
const visibleSeries = ds.series ? ds.series.filter(s => !s.hidden) : null;
const haveSeries = Boolean(visibleSeries && visibleSeries.length);
const haveCategories = Boolean(ds.categories && ds.categories.length);
// Combine the top-level and any series aggregators
const aggregators = [];
if (ds.aggregator) aggregators.push(ds.aggregator);
if (haveSeries) {
aggregators.push(
...visibleSeries.reduce((a, s) => {
if (s.aggregator) a.push(s.aggregator);
return a;
}, [])
);
}
const canCombineAggrs = aggregators.length === 0 || this.#aggregatorsSupportMultipleVars(aggregators);
return Boolean(
canCombineAggrs && (haveCategories || (haveSeries && this.#allSeriesAreCompatible(visibleSeries)))
);
}
#combineResults(ds) {
const visibleSeries = ds.series ? ds.series.filter(s => !s.hidden) : null;
switch (ds.aggregator) {
case "count":
return this.#allSeriesAreCompatible(visibleSeries);
default:
return false;
}
}
// Returns true if all the given aggregators support multiple variables.
#aggregatorsSupportMultipleVars(aggr) {
return aggr.every(a => Boolean(PowerPoint.AGGR_DATA[a]?.multivar));
}
// 'Compatible' series are those with just a different label and variable key.
// The results od such series can be merged into a single API call.
#allSeriesAreCompatible(series) {
// Short-circuit for an empty or single-element array
if (!series || series.length < 2) return true;
// If any series has a different aggregator, group or filters then they are incompatible
if (!this.#similar(series, ["aggregator", "group", "filters"])) return false;
const refKeySet = this.#getSeriesKeys(series[0]);
for (let c = 1; c < series.length; c++) {
// Series with hard-coded data are never compatible
if (series[c].data) return false;
const testKeyset = this.#getSeriesKeys(series[c]);
if (!this.#setsEqual(refKeySet, testKeyset)) return false;
}
return true;
}
// #similar returns true if all elements in the given series array have the same values for all
// the given attributes
#similar(series, attrs = []) {
const set = series.reduce((st, ser) => {
const serialised = attrs.reduce((s, a) => (s += ser[a] ? JSON.stringify(ser[a]) + "|" : ""));
if (serialised) st.add(serialised);
return st;
}, new Set());
return set.size < 2;
}
/**
* Gets the date range based on the given date range string and the project's date variable
* @private
* @param {string} dateRange - The date range string
* @param {string} dateVar - The name of the project's date variable
* @return {string|null} A SQL-compatible date range expression or null if no valid date range is found
*/
#getDateRange(dateRange, dateVar) {
if (!dateRange) return null;
let starts, ends;
// Handle a custom range
if (dateRange.range === "custom") {
starts = dateRange.starts;
ends = dateRange.ends;
}
const rangeName = typeof dateRange === "string" ? dateRange : dateRange.range;
// Handle a named range (as defined by DateTime.getPresets)
const namedRange = this.#datePresets[rangeName];
if (namedRange) {
starts = namedRange.starts;
ends = namedRange.ends;
}
if (starts && ends) {
return `{${dateVar}} ${DateTime.getSQLRange(starts, ends)}`;
}
}
#getSeriesKeys(seriesObj) {
// variable and label values can be ignored
const keySet = new Set(Object.keys(seriesObj));
keySet.delete("variable");
keySet.delete("label");
return keySet;
}
#setsEqual(setA, setB) {
return setA.size === setB.size && (setA.size === 0 || [Array.from(setA)].every(x => setB.has(x)));
}
// Converts an array filter of the form '[ '{tenant_type}="LCRA"', '{had_repair}="Yes", ... ]' into an object of the form
// { tenant_type: '{tenant_type}="LCRA"', had_repair: '{had_repair}="Yes"', ...}
#convertFiltersToObject(filters) {
if (!Array.isArray(filters)) return filters;
return filters.reduce((obj, item) => {
const [key, _] = item.split('=');
const trimmedKey = key.trim().replace('{', '').replace('}', '');
obj[trimmedKey] = item;
return obj;
}, {});
}
#addMasters() {
// Define common slide items.
// If you use these you have to clone them as PptxGenJS seems to modify them internally (so they can't be reused).
const TLF_LOGO_SECTION = {
image: { x: 0.709, y: 0.709, w: 0.71, h: 0.71, path: this.opts.tlfLogo ?? PowerPoint.DEFAULT_TLF_LOGO_PATH },
};
const TLF_LOGO_HEADING = {
image: { x: 12.9 - 0.3, y: 0.4, w: 0.3, h: 0.3, path: this.opts.tlfLogo ?? PowerPoint.DEFAULT_TLF_LOGO_PATH }
};
const MARGIN = [0.5, 0.75, 0.5, 0.75];
const BACKGROUND_LIGHT_THEME = { color: "FFFFFF" };
const BACKGROUND_DARK_THEME = { color: "282828" };
const SLIDE_TITLE_LIGHT_THEME = {
placeholder: {
options: {
name: "title",
type: "title",
x: 0.4,
y: 0.22,
h: 0.57,
color: "000000",
bold: true,
fontSize: this.opts.headingOptions?.fontSize ?? 44,
align: "left",
valign: "middle",
},
text: "Title",
},
};
const BODY_LIGHT_THEME = {
name: "body",
type: "body",
color: "000000",
x: 0.4,
y: 1.0,
w: 12.5,
h: 6,
fontSize: 12,
paraSpaceBefore: 6,
paraSpaceAfter: 12,
lineSpacingMultiple: 1.25,
};
const SLIDE_FOOTER = [
{ placeholder: { options: { name: "footnotes", type: "body", x: 0.4, y: 6.8, h: 0.401, w: 12.5, fontSize: 10 } } },
{ rect: { x: 0.0, y: 7.24, w: "100%", h: 0.26, fill: { color: "1D1D1D" } } },
{ text: { text: "CONFIDENTIAL", options: { x: 0.4, y: 7.36, color: "FFFFFF", fontSize: 7 } } },
{ text: { text: "© TLF RESEARCH", options: { x: 0.0, y: 7.36, w: "100%", align: "center", color: "FFFFFF", fontSize: 7 } } },
];
const SLIDE_NUMBER = { x: 12.9, y: 7.27, fontSize: 7, color: "FFFFFF" };
// Define a Title Page Master
const titlePageMaster = {
title: this.#titlemaster,
background: DataAdapter.deepClone(BACKGROUND_DARK_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [],
};
if (TLF_LOGO_SECTION) {
titlePageMaster.objects.push(DataAdapter.deepClone(TLF_LOGO_SECTION));
}
if (this.opts.titleHero) {
titlePageMaster.objects.push({ image: this.opts.titleHero });
}
titlePageMaster.objects.push(...[{
placeholder: {
options: {
name: "client_name",
type: "body",
x: 0.709,
y: 1.9,
h: 1.12,
color: "E10620",
fontFace: this.opts.titleFont ?? 'sans-serif',
fontSize: this.opts.titleFontSize ?? 66,
bold: true,
charSpacing: -1.5,
},
text: "Client Name",
},
},
{
placeholder: {
options: {
name: "project_name",
type: "body",
x: 0.709,
y: 3.0,
w: 5.6,
h: 2.4,
color: "FFFFFF",
fontFace: this.opts.titleFont ?? 'sans-serif',
fontSize: this.opts.titleFontSize ?? 66,
bold: true,
charSpacing: -1.5,
lineSpacing: 52,
},
text: "Survey Name",
},
},
{
placeholder: {
options: {
name: "created",
type: "body",
x: 0.709,
y: 6.65,
h: 0.4,
color: 'FFFFFF',
fontSize: 20,
bold: false
},
text: "",
},
},
]);
this.#base.defineSlideMaster(titlePageMaster);
// Define a Section Page Master
const sectionTitleMaster = {
title: this.#sectionmaster,
background: DataAdapter.deepClone(BACKGROUND_DARK_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [],
};
if (TLF_LOGO_SECTION) {
sectionTitleMaster.objects.push(DataAdapter.deepClone(TLF_LOGO_SECTION));
}
sectionTitleMaster.objects.push({
placeholder: {
options: {
name: "section_name",
type: "body",
x: 0.709,
y: 3,
h: 1.22,
w: 11.5,
fontSize: this.opts.sectionFontSize ?? 60,
bold: true,
lineSpacing: 35,
charSpacing: -1.5,
},
text: "Section Name",
},
});
this.#base.defineSlideMaster(sectionTitleMaster);
// Define a Body Title Only Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_TITLE_ONLY,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title And Body Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_TITLE_AND_BODY,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: { ...DataAdapter.deepClone(BODY_LIGHT_THEME), ...{ x: 0.4, w: 12.5 } },
text: "Body Text",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body with a Title and Image Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_IMAGE_LEFT,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: { name: "img1", type: "image", x: 0.6, y: 1, w: 2, h: 6 },
text: "Image",
},
},
{
placeholder: {
options: { ...DataAdapter.deepClone(BODY_LIGHT_THEME), ...{ x: 2.85, w: 10 } },
text: "Body Text",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With One Chart Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_ONE_CHART,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: {
name: "caption1",
type: "body",
x: 0.4,
y: 1.0,
w: 12.5,
h: 0.73,
},
text: "Caption",
},
},
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1.89, w: 12.5, h: 5, fontSize: 10 },
text: "Chart",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With One Chart Page Master (with no space for a caption)
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_ONE_CHART_NO_CAPTION,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1, w: 12.5, h: 6, fontSize: 10 },
text: "Chart",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With One Chart To the Left Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_ONE_CHART_LEFT,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: {
name: "caption1",
type: "body",
x: 0.4,
y: 1.0,
w: 12.5,
h: 0.73,
},
text: "Caption",
},
},
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1.89, w: 6, h: 4.89, fontSize: 10 },
text: "Chart",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With One Chart and a Comparison Chart Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_ONE_CHART_WITH_COMPARE,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: {
name: "caption1",
type: "body",
x: 0.4,
y: 1.614,
w: 9.6,
h: 0.264,
bold: true,
fontSize: 10
},
text: "Caption 1",
},
},
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1.89, w: 9.6, h: 5, fontSize: 10 },
text: "Chart 1",
},
},
{
placeholder: {
options: {
name: "caption2",
type: "body",
x: 10.4,
y: 1.5,
w: 2.5,
h: 0.73,
fontSize: 9,
align: "center",
bold: false,
},
text: "Change",
},
},
{
placeholder: {
options: { name: "chart2", type: "chart", x: 10.4, y: 1.89, w: 2.5, h: 4.68, fontSize: 8 },
text: "Chart 2",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With One Chart and a TopBox analysis
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_ONE_CHART_WITH_TOPBOX,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1.39, w: 10, h: 5, fontSize: 10 },
text: "Chart 1",
},
},
{
placeholder: {
options: {
name: "caption2",
type: "body",
x: 10.4,
y: 1.45,
w: 2.5,
h: 0.73,
fontSize: 9,
align: "center",
bold: false,
},
text: "% Very & Fairly Satisfied",
},
},
{
placeholder: {
options: { name: "chart2", type: "chart", x: 10.4, y: 1.72, w: 2.5, h: 4.68, fontSize: 8 },
text: "Chart 2",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With One Chart and a Supplementary Chart Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_ONE_CHART_WITH_SUPPLEMENTARY_CHART,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: {
name: "caption1",
type: "body",
x: 0.4,
y: 1.0,
w: 12.5,
h: 0.73,
},
text: "Caption 1",
},
},
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1.89, w: 9.6, h: 5, fontSize: 10 },
text: "Chart 1",
},
},
{
placeholder: {
options: {
name: "caption2",
type: "body",
x: 10.4,
y: 1.5,
w: 2.5,
h: 0.73,
fontSize: 9,
align: "center",
bold: false,
},
},
},
{
placeholder: {
options: { name: "chart2", type: "chart", x: 10.4, y: 1.89, w: 2.5, h: 4.68, fontSize: 8 },
text: "Chart 2",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With Two Charts, each with a Comparison Chart Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_TWO_CHARTS_HORIZ_WITH_COMPARE,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: {
name: "caption1",
type: "body",
x: 0.4,
y: 1.89,
w: 3.3,
h: 0.73,
bold: true,
},
text: "Caption 1",
},
},
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1.89, w: 3.3, h: 5, fontSize: 10 },
text: "Chart 1",
},
},
{
placeholder: {
options: {
name: "caption2",
type: "body",
x: 4,
y: 1.5,
w: 2,
h: 0.73,
fontSize: 9,
align: "center",
bold: false,
},
text: "Change",
},
},
{
placeholder: {
options: { name: "chart2", type: "chart", x: 3.8, y: 1.89, w: 2.531, h: 4.72, fontSize: 10 },
text: "Chart 2",
},
},
{
placeholder: {
options: {
name: "caption3",
type: "body",
x: 7,
y: 1.89,
w: 3.3,
h: 0.73,
bold: true,
fontSize: 9,
color: '000000',
},
text: "Caption 3",
},
},
{
placeholder: {
options: { name: "chart3", type: "chart", x: 7, y: 1.89, w: 3.3, h: 5, fontSize: 10 },
text: "Chart 3",
},
},
{
placeholder: {
options: {
name: "caption4",
type: "body",
x: 10.4,
y: 1.5,
w: 2,
h: 0.73,
fontSize: 9,
align: "center",
bold: false,
},
text: "Change",
},
},
{
placeholder: {
options: { name: "chart4", type: "chart", x: 10.4, y: 1.89, w: 2.531, h: 4.68, fontSize: 10 },
text: "Chart 4",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With Two Charts Side by Side Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_TWO_CHARTS_HORIZ,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: {
name: "caption1",
type: "body",
x: 0.4,
y: 1.0,
w: 6,
h: 0.73,
},
text: "Caption 1",
},
},
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1.89, w: 6, h: 5, fontSize: 10 },
text: "Chart 1",
},
},
{
placeholder: {
options: {
name: "caption2",
type: "body",
x: 6.9,
y: 1.0,
w: 6,
h: 0.73,
},
text: "Caption 2",
},
},
{
placeholder: {
options: { name: "chart2", type: "chart", x: 6.9, y: 1.89, w: 6, h: 5, fontSize: 10 },
text: "Chart 2",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With Three Charts Side by Side Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_THREE_CHARTS_HORIZ,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: {
name: "caption1",
type: "body",
align: 'center',
bold: true,
x: 0.4,
y: 1.0,
w: 4,
h: 0.73,
},
text: "Caption 1",
},
},
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1.6, w: 4, h: 5, fontSize: 10 },
text: "Chart 1",
},
},
{ line: { x: 4.6, y: 1.6, w: 0, h: 5, line: { color: "CCC9C9", width: 1 } } },
{
placeholder: {
options: {
name: "caption2",
type: "body",
x: 4.65,
y: 1.0,
w: 4,
h: 0.73,
},
text: "Caption 2",
},
},
{
placeholder: {
options: { name: "chart2", type: "chart", x: 4.65, y: 1.6, w: 4, h: 5, fontSize: 10 },
text: "Chart 2",
},
},
{ line: { x: 8.85, y: 1.6, w: 0, h: 5, line: { color: "CCC9C9", width: 1 } } },
{
placeholder: {
options: {
name: "caption3",
type: "body",
x: 8.9,
y: 1.0,
w: 4,
h: 0.73,
},
text: "Caption 3",
},
},
{
placeholder: {
options: { name: "chart3", type: "chart", x: 8.9, y: 1.6, w: 4, h: 5, fontSize: 10 },
text: "Chart 3",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With Four Charts In A 2 x 2 Grid Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_FOUR_CHARTS_GRID,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
// Row 1
{
placeholder: {
options: {
name: "caption1",
type: "body",
x: 0.4,
y: 1.0,
w: 6,
h: 0.33,
},
text: "Caption 1",
},
},
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1.4, w: 6, h: 2.3, fontSize: 10 },
text: "Chart 1",
},
},
{
placeholder: {
options: {
name: "caption2",
type: "body",
x: 6.9,
y: 1.0,
w: 6,
h: 0.33,
},
text: "Caption 2",
},
},
{
placeholder: {
options: { name: "chart2", type: "chart", x: 6.9, y: 1.4, w: 6, h: 2.3, fontSize: 10 },
text: "Chart 2",
},
},
// Row 2
{
placeholder: {
options: {
name: "caption3",
type: "body",
x: 0.4,
y: 4.1,
w: 6,
h: 0.33,
},
text: "Caption 2",
},
},
{
placeholder: {
options: { name: "chart3", type: "chart", x: 0.4, y: 4.5, w: 6, h: 2.3, fontSize: 10 },
text: "Chart3",
},
},
{
placeholder: {
options: {
name: "caption4",
type: "body",
x: 6.9,
y: 4.1,
w: 6,
h: 0.33,
},
text: "Caption 4",
},
},
{
placeholder: {
options: { name: "chart4", type: "chart", x: 6.9, y: 4.5, w: 6, h: 2.3, fontSize: 10 },
text: "Chart 4",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With Four Charts In A 2 x 2 Grid Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_FOUR_CHARTS_GRID_NO_CAPTION,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
// Row 1
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1, w: 6, h: 2.3, fontSize: 10 },
text: "Chart 1",
},
},
{
placeholder: {
options: { name: "chart2", type: "chart", x: 6.9, y: 1, w: 6, h: 2.3, fontSize: 10 },
text: "Chart 2",
},
},
// Row 2
{
placeholder: {
options: { name: "chart3", type: "chart", x: 0.4, y: 4.1, w: 6, h: 2.3, fontSize: 10 },
text: "Chart3",
},
},
{
placeholder: {
options: { name: "chart4", type: "chart", x: 6.9, y: 4.1, w: 6, h: 2.3, fontSize: 10 },
text: "Chart 4",
},
},
{ line: { x: 6.65, y: 1.76, w: 0, h: 5, line: { color: "CCCCCC", width: 1 } } },
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With A TSM Chart Grid Page Master
this.#base.defineSlideMaster({
title: PowerPoint.MASTER_TSM_CHART_GRID,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1.5, w: 1.79, h: 1.79, fontSize: 10 },
text: "Chart 1",
},
},
{
placeholder: {
options: { name: "chart2", type: "chart", x: 0.4 + 2.148, y: 1.5, w: 1.79, h: 1.79, fontSize: 10 },
text: "Chart 2",
},
},
{
placeholder: {
options: { name: "chart3", type: "chart", x: 0.4 + 2.148 * 2, y: 1.5, w: 1.79, h: 1.79, fontSize: 10 },
text: "Chart 3",
},
},
{
placeholder: {
options: { name: "chart4", type: "chart", x: 0.4 + 2.148 * 3, y: 1.5, w: 1.79, h: 1.79, fontSize: 10 },
text: "Chart 4",
},
},
{
placeholder: {
options: { name: "chart5", type: "chart", x: 0.4 + 2.148 * 4, y: 1.5, w: 1.79, h: 1.79, fontSize: 10 },
text: "Chart 5",
},
},
{
placeholder: {
options: { name: "chart6", type: "chart", x: 0.4 + 2.148 * 5, y: 1.5, w: 1.79, h: 1.79, fontSize: 10 },
text: "Chart 6",
},
},
{
placeholder: {
options: { name: "chart7", type: "chart", x: 0.4, y: 3.85, w: 1.79, h: 1.79, fontSize: 10 },
text: "Chart 7",
},
},
{
placeholder: {
options: { name: "chart8", type: "chart", x: 0.4 + 2.148, y: 3.85, w: 1.79, h: 1.79, fontSize: 10 },
text: "Chart 8",
},
},
{
placeholder: {
options: { name: "chart9", type: "chart", x: 0.4 + 2.148 * 2, y: 3.85, w: 1.79, h: 1.79, fontSize: 10 },
text: "Chart 9",
},
},
{
placeholder: {
options: { name: "chart10", type: "chart", x: 0.4 + 2.148 * 3, y: 3.85, w: 1.79, h: 1.79, fontSize: 10 },
text: "Chart 10",
},
},
{
placeholder: {
options: { name: "chart11", type: "chart", x: 0.4 + 2.148 * 4, y: 3.85, w: 1.79, h: 1.79, fontSize: 10 },
text: "Chart 11",
},
},
{
placeholder: {
options: { name: "chart12", type: "chart", x: 0.4 + 2.148 * 5, y: 3.85, w: 1.79, h: 1.79, fontSize: 10 },
text: "Chart 12",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With a custom layout for a Repairs overview
this.#base.defineSlideMaster({
title: PowerPoint.MASTER_TSM_REPAIRS,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
// Row 1
{
placeholder: {
options: {
name: "caption1", type: "body", x: 0.4, y: 0.9, w: 4.5, h: 0.5,
},
text: "Caption 1",
},
},
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1.4, w: 4.5, h: 2.3, fontSize: 10 },
text: "Chart 1",
},
},
{
placeholder: {
options: {
name: "caption2", type: "body", x: 5.9, y: 0.9, w: 4.5, h: 0.5,
},
text: "Caption 2",
},
},
{
placeholder: {
options: { name: "chart2", type: "chart", x: 4.7, y: 1.4, w: 7.08, h: 2.3, fontSize: 10 },
text: "Chart 2",
},
},
{
placeholder: {
options: {
name: "caption3", type: "body", x: 11.63, y: 1.5, w: 1.6, h: 0.73,
},
text: "Caption 3",
},
},
{
placeholder: {
options: { name: "chart3", type: "chart", x: 11.63, y: 1.71, w: 1.6, h: 1.98, fontSize: 10 },
text: "Chart 3",
},
},
// Row 2
{
placeholder: {
options: {
name: "caption4",
type: "body",
x: 0.4,
y: 3.9,
w: 4.5,
h: 0.5,
},
text: "Caption 4",
},
},
{
placeholder: {
options: { name: "chart4", type: "chart", x: 0.4, y: 4.4, w: 4.5, h: 2.3, fontSize: 10 },
text: "Chart 4",
},
},
{
placeholder: {
options: {
name: "caption5",
type: "body",
x: 6.9,
y: 3.9,
w: 6,
h: 0.5,
},
text: "Caption 5",
},
},
{
placeholder: {
options: { name: "chart5", type: "chart", x: 6.9, y: 4.4, w: 6, h: 2.3, fontSize: 10 },
text: "Chart 5",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With a custom layout for a Repairs impact analysis
this.#base.defineSlideMaster({
title: PowerPoint.MASTER_TSM_REPAIRS_IMPACT,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1.5, w: 4.5, h: 5, fontSize: 10 },
text: "Chart 1",
},
},
{
placeholder: {
options: {
name: "caption1",
type: "body",
x: 0.4,
y: 1.5,
w: 4.5,
h: 0.73,
color: '000000'
},
text: "Caption 1",
},
},
{
placeholder: {
options: { name: "chart2", type: "chart", x: 4.7, y: 1.5, w: 2, h: 5, fontSize: 10 },
text: "Chart 2",
},
},
{
placeholder: {
options: {
name: "caption2",
type: "body",
x: 4.7,
y: 1.79,
w: 2.22,
h: 0.73,
},
text: "Caption 2",
},
},
{ line: { x: 6.65, y: 1.5, w: 0, h: 4.6, line: { color: "000000", width: 1 } } },
{
placeholder: {
options: { name: "chart3", type: "chart", x: 7, y: 1.5, w: 4.5, h: 5, fontSize: 10 },
text: "Chart 3",
},
},
{
placeholder: {
options: {
name: "caption3",
type: "body",
x: 7,
y: 1.5,
w: 4.5,
h: 0.73,
color: '000000'
},
text: "Caption 3",
},
},
{
placeholder: {
options: { name: "chart4", type: "chart", x: 11.4, y: 1.5, w: 2, h: 5, fontSize: 10 },
text: "Chart 4",
},
},
{
placeholder: {
options: {
name: "caption4",
type: "body",
x: 11.4,
y: 1.79,
w: 2.22,
h: 0.73,
},
text: "Caption 4",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With Four Charts In A 2 x 2 Grid Page Master
this.#base.defineSlideMaster({
title: PowerPoint.MASTER_TSM_QUESTION,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: {
name: "caption1", type: "body", x: 0.4, y: 0.9, w: 4.5, h: 0.5,
},
text: "Caption 1",
},
},
{
placeholder: {
options: { name: "chart1", type: "chart", x: 0.4, y: 1.4, w: 4.5, h: 2.3, fontSize: 10 },
text: "Chart 1",
},
},
{
placeholder: {
options: {
name: "caption2", type: "body", x: 6.9, y: 0.9, w: 5, h: 0.5,
},
text: "Caption 2",
},
},
{
placeholder: {
options: { name: "chart2", type: "chart", x: 6.67, y: 2.37, w: 5, h: 2.3, fontSize: 10 },
text: "Chart 2",
},
},
{
placeholder: {
options: {
name: "caption3", type: "body", x: 11.67, y: 2.12, w: 1.6, h: 0.33,
},
text: "Caption 3",
},
},
{
placeholder: {
options: { name: "chart3", type: "chart", x: 11.67, y: 2.37, w: 1.6, h: 2.3, fontSize: 10 },
text: "Chart 3",
},
},
{
placeholder: {
options: {
name: "caption4", type: "body", x: 0.4, y: 4.15, w: 6, h: 0.33,
},
text: "Caption 4",
},
},
{
placeholder: {
options: { name: "chart4", type: "chart", x: 0.4, y: 4.4, w: 6, h: 2.3, fontSize: 10 },
text: "Chart4",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With A Table Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_TABLE,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: { name: "table1", type: "table", x: 0.4, y: 1, fontSize: 10 },
text: "Table",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
// Define a Body Title With A Table and Caption Page Master
this.#base.defineSlideMaster({
title: PowerPoint.PUBLIC_MASTER_TABLE_WITH_CAPTION,
background: DataAdapter.deepClone(BACKGROUND_LIGHT_THEME),
margin: DataAdapter.deepClone(MARGIN),
objects: [
DataAdapter.deepClone(TLF_LOGO_HEADING),
{ line: { x: 0.4, y: 0.76, w: 12.5, h: 0, line: { color: "000000", width: 1 } } },
DataAdapter.deepClone(SLIDE_TITLE_LIGHT_THEME),
{
placeholder: {
options: {
name: "caption1",
type: "body",
x: 0.4,
y: 1.0,
w: 12.5,
h: 0.5,
},
text: "Caption",
},
},
{
placeholder: {
options: { name: "table1", type: "table", x: 0.4, y: 1.66, fontSize: 10 },
text: "Table",
},
},
...DataAdapter.deepClone(SLIDE_FOOTER),
],
slideNumber: DataAdapter.deepClone(SLIDE_NUMBER),
});
}
}