powerpoint.js


"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),
        });
    }
}