table.js

function clone(object) {
    return JSON.parse(JSON.stringify(object));
}

function getCellOptions(options) {
    const cellOptions = clone(options);

    if (cellOptions.cell) { // convert cell options to textBox options
        cellOptions.textBox = cellOptions.cell;
        delete cellOptions['cell'];
    }
    return cellOptions;
}

function getCellHeight(self, text, column, options) {
    let colOptions = self._merge(options, { textBox: { width: column.width } });
    const originCoord = self._calibrateCoordinate(column.x, column.y, 0, 0, self.pageNumber);
    const pathOptions = self._getPathOptions(colOptions, originCoord.nx, originCoord.ny);
    const textObjects = self._makeTextObject(text, pathOptions.size, colOptions);
    const textBox = self._makeTextBox(colOptions);
    const { textHeight } = self._layoutText(textObjects, textBox, pathOptions);

    return textHeight;
}

function drawTableBorder(self, x, y, width, height, rowLines, options) {
    if (options.border) {
        let borderOptions = (options.border === true) ? {} : options.border;

        if (!borderOptions.width) {
            borderOptions = Object.assign({}, borderOptions, { width: .5 });
        }

        self.rectangle(x, y, width, height, borderOptions);
        const columns = self._layouts['_table_'];

        // Draw verticles
        for (let index = 0; index < columns.length - 1; index++) {
            const column = columns[index];
            self.line([
                [column.x + column.width, y],
                [column.x + column.width, y + height]
            ], borderOptions);
        }
        // Draw horizontals
        for (let index = 0; index < rowLines.length - 1; index++) {
            const yPos = rowLines[index];
            self.line([
                [x, yPos],
                [x + width, yPos]
            ], borderOptions);
        }
    }
}

/**
 * Display text data in tabular form
 * @name table
 * @function
 * @memberof Recipe
 * @param {number} x - The coordinate x used to position table on page
 * @param {number} y - The coordinate y used to position table on page
 * @param {object[]} contents - the data to be placed into the table
 * @param {object} [options] - The options
 * @param {number} [options.height] - The height designation of the table
 * @param {string|string[]} [options.order] - Defines the order of the named columns in the table.
 * It can also be used to choose a subset of the actual data found in the given contents.
 * @param {object[]} [options.columns] - Holds the defining options for columns in the table.
 * @param {string} [options.columns[].name] - The name of the content data field to be associated with the column.
 * This field is mandatory when supplying column options.
 * @param {string} [options.columns[].text] - The title to be applied to the column header.
 * When missing, the data field name is used.
 * @param {number} [options.columns[].width=100] - The width of table column.
 * @param {object} [options.columns[].cell] - Holds the options to be applied to a column table cell.
 * All textBox options from the 'text' interface can be used here.
 * @param {string|number[]} [options.columns[].color] - Text color (HexColor, PercentColor or DecimalColor)
 * @param {number} [options.columns[].opacity=1] - opacity
 * @param {string} [options.columns[].font=Helvetica] - The font. 'Arial', 'Helvetica'...
 * @param {number} [options.columns[].size=14] - The font size
 * @param {function} [options.columns[].renderer] - function to be called which can be used to modify the text options for a particular
 * table cell. The function is called with the parameters (text, data), where 'text' is the text to be written in the cell and
 * 'data' is an object holding all the text elements in the table row. The function returns an object with the text attributes that
 * are to be modified for the table cell.
 * @param {object|boolean} [options.header=false] - When true, the column name associated with a column will
 * appear at the top of the column. When presented as an object it is the set of unique options to be applied to column headers.
 * All 'text' interface options can be used.
 * @param {object} [options.header.cell] - All textBox options from the 'text' interface can be used here.
 * @param {object} [options.border] - Used to define table and cell border characteristics
 * @param {number} [options.border.width=.5] - thickness of lines used in border.Array
 * @param {string|number[]} [options.border.stroke] - line color (HexColor, PercentColor or DecimalColor)
 * @param {function} [options.overflow] - Called when the next table entry is going to expand the table
 * beyond the given height or page boundary. Its parameters are (self, row) where 'self' is the recipe handle so
 * that other recipe interfaces can be called, and the row number of the data which caused the data overflow.
 * The return value can be 'true' which indicates that data processing should stop, or 'false' which indicates that
 * the data should continue being processed with the original [x,y] coordinates, or it can be an object containing
 * a 'position' property indicating the [x,y] coordinates where the next table for the remaining data should start.
 * @param {object} [options.row] - text properties to be applied to all cells in a table row.
 * @param {object} [options.row.cell] - All textBox options from the 'text' interface can be used here.
 * @param {string} [options.row.nth] - 'even|odd', indicating that the properties should be applied only to
 * 'even' or 'odd' rows.
 */
exports.table = function table(x, y, contents, options = {}) {

    let tableBottom = (options.height) ? y + options.height : 0;
    let fields = Object.keys(contents[0]);
    let order = options.order || [];
    let columns = [];

    if (order.length !== 0) {
        if (typeof options.order === 'string') {
            order = options.order.split(',');
        }
    } else {
        if (!options.columns || options.columns.length !== fields.length) {
            order = fields; // all fields emitted, columns in order of first record
        } else {
            // columns in order found in options
            for (const column of options.columns) {
                order.push(column.name);
            }
        }
    }

    for (const field of order) {
        if (fields.includes(field)) {
            let found = false;
            if (options.columns) {
                for (const column of options.columns) {
                    if (column.name && column.name === field) {
                        columns.push(column);
                        found = true;
                        break;
                    }
                }
            }

            if (!found) {
                columns.push({ text: field, name: field });
            }
        }
    }
    this.layout('_table_', x, y, 0, 0, { columns: columns, reset: true });

    const tableWidth =
        this._layouts['_table_'].reduce((width, column) => {
            width += column.width;
            return width;
        }, 0);

    let tableHeight = 0;
    let headerHeight = 0;
    let rowLines = [];
    let currentY = y;
    this._previousTextObjects = [];
    let nth;
    let rowOptions = {};

    if (options.header) {
        // Wade through columns to determine minimum header height
        for (const column of this._layouts['_table_']) {
            let colOptions = clone(column.options.header);
            if (typeof options.header === 'object') {
                const cellOptions = getCellOptions(options.header);
                colOptions = this._merge(colOptions, cellOptions);
            }

            const cellHeight = getCellHeight(this, column.text, column, colOptions);
            headerHeight = Math.max(headerHeight, cellHeight);
        }
    }

    if (options.overflow) {
        this._tableFullNotifier = options.overflow;

        const pageBottom = this.pageInfo(this.pageNumber).height - this._margin.bottom;
        if (tableBottom === 0 || tableBottom > pageBottom) {
            tableBottom = pageBottom;
        }
    }

    if (options.border) {
        options.border.lineCap = 'butt'; // keep borders from extending outside of enclosing box.
    }

    if (options.row) {
        rowOptions = getCellOptions(options.row);

        switch (options.row.nth) {
            case 'even':
                nth = (row) => { return (row % 2 === 0); };
                break;
            case 'odd':
                nth = (row) => { return (row % 2 !== 0); };
                break;
            default:
                nth = () => { return 1; }; // apply to all rows
                break;
        }
    }

    let firstTime = true;
    let row = 0;

    for (const record of contents) {
        let rowHeight = 0;
        row++;

        // Wade through row data to determine row height
        for (const column of this._layouts['_table_']) {
            const field = column.field;
            const text = record[field];
            if (text !== undefined) {
                let colOptions = clone(options);
                colOptions = this._merge(colOptions, clone(column.options));
                if (nth && nth(row)) {
                    colOptions = this._merge(colOptions, clone(rowOptions));
                }
                const cellHeight = getCellHeight(this, text, column, colOptions);
                rowHeight = Math.max(rowHeight, cellHeight);
            }
        }

        // When table gets 'full', let user know when overflow callback provided.
        // They can choose to bail out of table filling loop, or keep on going with
        // appropriate variables reset to initial values. This gives the user the
        // opportunity to change to a new page to continue table production with
        // remaining rows of data.
        if (currentY + rowHeight > tableBottom && this._tableFullNotifier) {
            drawTableBorder(this, x, y, tableWidth, tableHeight, rowLines, options);

            const orders = this._tableFullNotifier(this, row);

            if (orders === true) { return this; } // stop processing table data
            if (orders.position) {
                [x, y] = orders.position;
                let xx = x;
                // Make sure x position adjusted in all columns
                for (const column of this._layouts['_table_']) {
                    column.x = xx;
                    xx += column.width;
                }
            }

            firstTime = true;
            currentY = y;
            tableHeight = 0;
            rowLines = [];
        }

        if (firstTime && options.header) {
            // Display table header
            for (const column of this._layouts['_table_']) {
                let colOptions = clone(column.options.header);
                if (typeof options.header === 'object') {
                    const cellOptions = getCellOptions(options.header);
                    colOptions = this._merge(colOptions, clone(cellOptions));
                }
                colOptions = this._merge(colOptions, { textBox: { minHeight: headerHeight, width: column.width } });
                this.text(column.text, column.x, currentY, colOptions);
            }

            currentY += headerHeight;
            tableHeight += headerHeight;
            rowLines.push(y + tableHeight);
        }

        firstTime = false;

        // Now write out table cells for current record
        for (const column of this._layouts['_table_']) {
            const field = column.field;
            let text = record[field];
            if (text === undefined) { text = ''; }
            let colOptions = clone(options);
            colOptions = this._merge(colOptions, clone(column.options));
            if (nth && nth(row)) {
                colOptions = this._merge(colOptions, clone(rowOptions));
            }
            if (column.options.renderer) {
                let renderOptions = column.options.renderer(text, record, field, row);
                if (renderOptions) {
                    colOptions = this._merge(colOptions, renderOptions);
                }
            }
            colOptions = this._merge(colOptions, { textBox: { minHeight: rowHeight, width: column.width } });
            this.text(text, column.x, currentY, colOptions);
        }

        currentY += rowHeight;
        tableHeight += rowHeight;
        rowLines.push(y + tableHeight);
    }

    drawTableBorder(this, x, y, tableWidth, tableHeight, rowLines, options);

    return this;
};