text.js

const LineBreaker = require('linebreak');
const { Word, Line, Column } = require('./text.helper');
const { htmlToTextObjects } = require('./htmlToTextObjects');
const { Color, xObjectForm } = require('./xObjectForm');

//  Table indicating how to specify coloration of elements
//  -------------------------------------------------------------------
// |Color | HexColor   | DecimalColor                   | PercentColor |
// |Space | (string)   | (array)                        | (string)     |
// |------+------------+--------------------------------+--------------|
// | Gray | #GG        | [gray]                         | %G           |
// |  RGB | #rrggbb    | [red, green, blue]             | %G           |
// | CMYK | #ccmmyykk  | [cyan, magenta, yellow, black] | %c,m,y,k     |
//  -------------------------------------------------------------------
//
//   HexColor component values (two hex digits) range from 00 to FF.
//   DecimalColor component values range from 0 to 255.
//   PercentColor component values range from 1 to 100.

// function merge (target, source) {
//     // Iterate through `source` properties and if an `Object` set property to merge of `target` and `source` properties
//     for (const key of Object.keys(source)) {
//         if (Array.isArray(source[key])) {
//             target[key] = source[key];  // don't want to merge elements, just accept new values.
//         } else if (source[key] instanceof Object && key in target) {
//             Object.assign(source[key], merge(target[key], source[key]));
//         }
//     }

//     // Join `target` and modified `source`
//     // Forcing non-object to become one. This allows
//     // things like hilite:true become hilite:{color:'red'}
//     if (! (target instanceof Object)) {
//         target = {};
//     }
//     Object.assign(target, source)
//     return target
// }

exports._merge = function merge(target, source) {
    // Iterate through `source` properties and if an `Object` set property to merge of `target` and `source` properties
    for (const key of Object.keys(source)) {
        if (Array.isArray(source[key])) {
            target[key] = source[key]; // don't want to merge elements, just accept new values.
        } else if (source[key] instanceof Object && target instanceof Object && key in target) {
            Object.assign(source[key], merge(target[key], source[key]));
        }
    }

    // Join `target` and modified `source`
    // Forcing non-object to become one. This allows
    // things like hilite:true become hilite:{color:'red'}
    if (!(target instanceof Object)) {
        target = {};
    }
    return Object.assign({}, target, source);
};


function _initOptions(self, x = {}, y, options = {}) {
    // This allows user to skip providing x/y coordinates
    if (typeof x === 'object') {
        options = x;
        self._textOptions = self._textOptions || { textBox: {} };
        self._flow = (options.flow === undefined) ? true : options.flow;

        if (options.layout) {
            self._columns = self._layouts[options.layout];
            self.x = self._columns[0].x;
            self.y = self._columns[0].y;
            self.box = { x: self.x, y: self.y };
            self._firstLineHeight = 0;
        }

        // The following only happens when 'text' is called
        // for the first time without any position coordinates.
        if (self.box === undefined) {
            if (!options.layout) {
                self.x = self._margin.left;
                self.y = self._margin.top;
            }

            self.box = { x: self.x, y: self.y };
            self._firstLineHeight = 0;
        }

    } else {
        // Update the current position, when provided.
        [x, y] = self._centrify(x, y);
        self.x = x;
        self.y = y;
        self.box = { x, y };
        self._firstLineHeight = 0; // indicates not set yet, determined later.
        self._textOptions = { textBox: {} };
        self._previousTextObjects = [];
        self._flow = options.flow || false;

        if (options.layout) {

            self._columns = self._layouts[options.layout];

            // Only tinker with the column coordinates when the incoming
            // [x,y] coordinates differ from the first layout element.

            if (x !== self._columns[0].x || y !== self._columns[0].y) {
                // Override layout values.
                adjustcolumnPosition(self._columns, x, y);
            }
        }
    }

    self._previousTextObjects = self._previousTextObjects || [];

    // Merge any previous options with new options
    const mergedOpts = self._merge(self._textOptions, options);

    if (self._flow && mergedOpts.textBox.width === undefined) {
        mergedOpts.textBox.width = Math.max(0, self.metadata[self.pageNumber].width - self.x - self._margin.right);
    }

    if (options.layout) {
        self._columns = self._layouts[options.layout];
        mergedOpts.textBox.width = self._columns[0].width;
        mergedOpts.textBox.height = self._columns[0].height;
    }

    if (options.overflow) {
        self._overflowNotifier = options.overflow;
    }

    return mergedOpts;
}

function isEmpty(obj) {
    return !obj || Object.keys(obj).length === 0 && obj.constructor === Object;
}

exports._makeTextObject = function _makeTextObject(text, size, options) {
    return [{
        value: text,
        tag: null,
        font: options.font,
        isBold: options.bold,
        isItalic: options.italic,
        attributes: [],
        styles: {},
        needsLineBreaker: false,
        size: size,
        sizeRatio: 1,
        sizeRatios: [1],
        link: null,
        childs: []
    }];
};

exports._makeTextBox = function _makeTextBox(options) {
    return (isEmpty(options.textBox)) ? {
        // use page width with padding and margin
        isSimpleText: true,
        width: null,
        lineHeight: 0,
        padding: 0,
        minHeight: 0,
        wrap: 'auto'
    } : {
        width: options.textBox.width || 100,
        lineHeight: options.textBox.lineHeight,
        height: options.textBox.height,
        padding: (options.textBox.padding || 0),
        minHeight: options.textBox.minHeight || 0,
        style: options.textBox.style,
        textAlign: options.textBox.textAlign,
        wrap: (options.textBox.wrap !== undefined) ? options.textBox.wrap : 'auto'
    };
};

/**
 * Write text elements
 * @name text
 * @function
 * @todo support break words
 * @memberof Recipe
 * @param {string} text - The text content
 * @param {number} x - The coordinate x
 * @param {number} y - The coordinate y
 * @param {Object} [options] - The options
 * @param {string|number[]} [options.color] - Text color (HexColor, PercentColor or DecimalColor)
 * @param {number} [options.opacity=1] - opacity
 * @param {number} [options.rotation=0] - Accept: +/- 0 through 360.
 * @param {number[]} [options.rotationOrigin=[x,y]] - [originX, originY]
 * @param {string} [options.font=Helvetica] - The font. 'Arial', 'Helvetica'...
 * @param {number} [options.size=14] - The font size
 * @param {number} [options.charSpace=0] - space to be added between characters, units in points.
 * @param {string} [options.align='left top'] - This is the alignment of the text in relationship to its position
 * coordinates, specified as 'horizontal vertical', where horizontal is either 'left', 'center' or 'right
 * and vertical is either 'top', 'center' or bottom.
 * @param {Object|Boolean} [options.highlight] - Text markup annotation.
 * @param {Object|Boolean} [options.underline] - Text markup annotation.
 * @param {Object|Boolean} [options.strikeOut] - Text markup annotation.
 * @param {Boolean} [options.flow=false] - Used to activate/deactivate text flow which is the
 * ability to use multiple calls to 'text' to create an overall text box.
 * @param {number|string} [options.layout] - An identifier of the layout to be associated with given text.
 * @param {function} [options.overflow] - Called when the text is going to exceed the area
 * of the given text object. Intended for column layouts. Its parameter is (self) where 'self' is the recipe handle so
 * that other recipe interfaces can be called. The return value can be 'true' which indicates that text processing
 * should stop, or 'false' which indicates that the text should continue being processed with the original [x,y]
 * coordinates, or it can be an object containing a 'column' property indicating either a layout column index
 * or a set of [x,y] coordinates where the next set of layout columns should be positioned for the remaining text.
 * @param {Boolean|Object} [options.hilite=false] - Used to hilite given text.
 * @param {string|number[]} [options.hilite.color=yellow] - text hilite color (HexColor, PercentColor or DecimalColor)
 * @param {number} [options.hilite.opacity=.5] - text hilite color opacity
 * @param {Object} [options.textBox] - Text Box to fit in.
 * @param {number} [options.textBox.width=100] - Text Box width
 * @param {number} [options.textBox.height] - Text Box fixed height
 * @param {number} [options.textBox.minHeight=0] - Text Box minimum height
 * @param {number|number[]} [options.textBox.padding=0] - Text Box padding, [top, right, bottom, left]
 * @param {number} [options.textBox.lineHeight=0] - Text Box line height
 * @param {string|Boolean} [options.textBox.wrap='auto'] - Text wrapping mechanism, may be true, false,
 * 'auto', 'clip', 'trim', 'ellipsis'. All the option values that are not equivalent to 'auto' dictate
 *  how the text which does not fit on a line is to be truncated. True is equivalent to 'auto'. False is equivalent to 'ellipsis'.
 * @param {string} [options.textBox.textAlign='left top'] - Alignment inside text box, specified as 'horizontal vertical',
 * where horizontal is one of: 'left', 'center', 'right', 'justify' and veritical is one of: 'top', 'center', 'bottom'.
 * @param {Object} [options.textBox.style] - Text Box styles
 * @param {number} [options.textBox.style.lineWidth=2] - Text Box border width
 * @param {string|number[]} [options.textBox.style.stroke] - Text Box border color  (HexColor, PercentColor or DecimalColor)
 * @param {number[]} [options.textBox.style.dash=[]] - Text Box border border dash style [number, number]
 * @param {string|number[]} [options.textBox.style.fill] - Text Box border background color (HexColor, PercentColor or DecimalColor)
 * @param {number} [options.textBox.style.opacity=1] - Text Box border background opacity
 * @param {boolean|number|number[]} [options.textBox.style.borderRadius=0] - Border radius to apply to get rounded corners.
 * When true is given, the default radius size for all corners is 5. A four number array may be used to give specific sizees to each
 * corner. The numbering starts from the top, left corner, and goes clockwise around the text box.
 */
exports.text = function text(text = '', x, y, options = {}) {
    if (!this.pageContext) {
        return this;
    }
    options = _initOptions(this, x, y, options);

    const targetAnnotations = options;
    const originCoord = this._calibrateCoordinate(this.x, this.y, 0, 0, this.pageNumber);
    const pathOptions = this._getPathOptions(options, originCoord.nx, originCoord.ny);
    pathOptions.html = options.html;
    pathOptions.hilite = options.hilite;

    // save text state for continued text?
    this._textOptions = (this._flow) ? options : { textBox: {} };

    const textObjects = (options.html) ? htmlToTextObjects(text) : this._makeTextObject(text, pathOptions.size, options);
    const textBox = this._makeTextBox(options);

    let { toWriteTextObjects } = this._layoutText(textObjects, textBox, pathOptions);

    if (!textBox.width) {
        textBox.width = toWriteTextObjects[0].lineWidth;
    }

    textBox.firstLineHeight = this._firstLineHeight;

    // need to collect all the text that is 'flowing' before processing.
    if (this._flow) {
        this._previousTextObjects = [...toWriteTextObjects];
    } else {
        textBox.textHeight = getTextBoxHeight(toWriteTextObjects);
        let [nx, ny] = getTextBoxPosition(this, textBox, pathOptions);
        let textYpos = ny;

        if (textBox.style) {
            drawTextBox(this, nx, ny, textBox, pathOptions);

            // Determine vertical starting position of text within textBox
            switch (toWriteTextObjects[0].writeOptions.alignVertical) {
                case 'center':
                    textYpos -= (textBox.height - textBox.textHeight) / 2;
                    break;
                case 'bottom':
                    textYpos -= (textBox.height - textBox.textHeight - textBox.paddingBottom);
                    break;
            }
        }

        let context = this.pageContext;
        let currentY = textYpos - textBox.paddingTop;
        let boxTop = currentY;
        let currentLineID;
        let currentLineWidth = 0;
        let toWriteContents = [];
        let columnIndex = 1;

        toWriteTextObjects.some((toWriteTextObject, index) => {
            const isHTML = toWriteTextObject.writeOptions.html;
            const {
                text,
                lineHeight,
                lineWidth,
                lineID,
                spaceWidth
            } = toWriteTextObject;

            currentLineID = currentLineID || lineID;
            const isContinued = (currentLineID == lineID) ? true : false;

            const getStartX = (startX, content) => {
                let spaceWidth = content.text.endsWith(' ') ? content.spaceWidth : 0;
                let offsetX;
                switch (content.writeOptions.alignHorizontal) {
                    case 'center':
                        offsetX = (textBox.width - currentLineWidth) / 2;
                        break;
                    case 'right':
                        offsetX = (textBox.width - textBox.paddingRight - currentLineWidth) + spaceWidth;
                        break;
                    default:
                        offsetX = textBox.paddingLeft;
                        break;
                }

                return startX + offsetX;
            };

            const addUnderline = (x, y, ctx, options) => {
                // underline implementation
                if (options.underline) {
                    // console.log(textWidth, lineWidth, currentLineWidth)
                    const underlineY = y - options.textHeight * 0.1;
                    //  TODO: fix the line with calculation
                    const width = currentLineWidth - ((isHTML || toWriteTextObject.text.endsWith(' ')) ? spaceWidth : 0);
                    ctx
                        .q()
                        .drawPath(x, underlineY, x + width, underlineY, options)
                        .Q();
                }
            };

            const addTextTraits = (ctx, options) => {
                ctx.Tf(options.font, options.size);
                ctx.Tc(options.charSpace);
                if (options.colorModel.xObject) {
                    options.colorModel.xObject.fill(options.colorModel);
                } else {
                    Color.fill(ctx, options.colorModel);
                }
            };

            const emitText = (word, x, y, ctx) => {
                ctx.Tm(1, 0, 0, 1, x, y);
                ctx.Tj(word);
            };

            const emitTextObject = (text, x, y, ctx, options) => {
                ctx.BT();
                addTextTraits(ctx, options);
                emitText(text, x, y, ctx);
                ctx.ET();

                addUnderline(x, y, ctx, options);
            };

            const justifyText = (left, x, wto, textBox, ctx, options, callback) => {
                ctx.BT();
                addTextTraits(ctx, options);
                let next_x = justify(left, x, wto, textBox, callback);
                ctx.ET();
                return next_x;
            };

            const updateTextBox = (columns) => {
                textBox.width = columns.width; // in case user changed column layout
                textBox.height = columns.height;
                [nx, ny] = getTextBoxPosition(this, textBox, pathOptions);
                boxTop = currentY = ny;
                if (textBox.style) {
                    drawTextBox(this, nx, currentY, textBox, pathOptions);
                }
            };

            const writeText = (context, x, y, wto) => {
                const options = wto.writeOptions;
                const { lineHeight, text, baseline } = wto;
                let next_x = 0;

                if (text === '') { // nothing to write, so simply escape.
                    return next_x;
                }

                // Produce a hilite under words?
                if (options.hilite) {
                    const bgColor = options.hilite.color || '#ffff00';
                    const bgOpacity = options.hilite.opacity || .5;
                    let bxWidth = wto.lineWidth;

                    // The hiliting rectangle cannot use the text box line
                    // width when justification is activated because the
                    // spaces between words is calculated dynamically.
                    if (options.alignHorizontal === 'justify') {
                        bxWidth = justify(nx, x, wto, textBox) - x;

                        // Except for 'right' alignment cases, have to consider
                        // text on line ending with spaces to tweak box width.
                    } else if (options.alignHorizontal !== 'right') {
                        if (text.endsWith(' ')) {
                            bxWidth += wto.spaceWidth;
                        }
                    }

                    this.rectangle(x, y, bxWidth, options.textHeight, {
                        useGivenCoords: true,
                        rotation: pathOptions.rotation,
                        rotationOrigin: [pathOptions.originX, pathOptions.originY],
                        fill: bgColor,
                        opacity: bgOpacity
                    });
                }

                // Note that the last line of a text box ignores justification.
                const _justify = options.alignHorizontal === 'justify' && !wto.lastLine;

                // write directly to page when not dealing with opacity, rotation and special colorspace.
                if (options.opacity === 1 && options.colorspace !== 'separation' &&
                    (options.rotation === 0 || options.rotation === undefined)) {

                    context.q();

                    if (_justify) {
                        next_x =
                            justifyText(nx, x, wto, textBox, context, options, (word, xx) => {
                                emitText(word.value, xx, y + baseline, context);
                            });
                    } else {
                        if (textBox.wrap !== 'auto') { // This applies a clipping region around the text
                            context
                                .m(nx, y + lineHeight)
                                .l(nx + textBox.width, y + lineHeight)
                                .l(nx + textBox.width, y)
                                .l(nx, y)
                                .h().W().n();
                        }

                        emitTextObject(text, x, y + baseline, context, options);
                    }

                    context.Q();
                } else {

                    this.pauseContext();

                    // https://github.com/galkahana/HummusJS/wiki/Use-the-pdf-drawing-operators
                    const xObject = new xObjectForm(this.writer, textBox.width, lineHeight);
                    const xObjectCtx = xObject.getContentContext();
                    if (options.colorModel) {
                        options.colorModel.xObject = xObject;
                    }

                    xObjectCtx.q();
                    xObjectCtx.gs(xObject.getGsName(options.fillGsId)); // set graphic state (here opacity)

                    if (_justify) {
                        next_x =
                            justifyText(nx, x, wto, textBox, xObjectCtx, options, (word, xx) => {
                                emitText(word.value, xx - nx, baseline, xObjectCtx, options);
                            });
                    } else {
                        emitTextObject(text, x - nx, baseline, xObjectCtx, options);
                    }

                    xObjectCtx.Q();
                    xObject.end();

                    // To get proper alignment, reset back to textbox
                    // coordinate in case text segment encountered.
                    x = nx;

                    this.resumeContext();

                    this.pageContext.q();
                    this._setRotationContext(this.pageContext, x, y, options);
                    this.pageContext
                        .doXObject(xObject)
                        .Q();
                }

                const { textHeight } = options;

                for (let key in targetAnnotations) {
                    const subtype = this._getTextMarkupAnnotationSubtype(key);
                    if (subtype) {
                        const markupOption = (typeof(targetAnnotations[key]) != 'object') ? {} : targetAnnotations[key];
                        Object.assign(markupOption, {
                            height: textHeight * 1.4,
                            width: currentLineWidth,
                            text: markupOption.text || '',
                            _textHeight: textHeight
                        });
                        const { ox, oy } = this._reverseCoordinate(x, y - textHeight * 0.2);
                        this.annot(ox, oy, subtype, markupOption);
                    }
                }

                return next_x;
            };

            if (!isContinued) { // flush out current line before processing next one
                let next_x = 0;
                toWriteContents.forEach((content) => {
                    const x = next_x || getStartX(content.startX, content);
                    const y = currentY;
                    next_x = writeText(context, x, y, content);
                });
                // The line offset from the last line in the
                // group determines Y positioning for next line.
                let lineOffset = (toWriteContents.length) ? toWriteContents[toWriteContents.length - 1].lineOffset : 1;
                let yDiff = lineHeight * lineOffset;
                let overflow = false;
                let updateVertical = true;
                toWriteContents = [];
                currentLineID = lineID;
                currentLineWidth = 0;

                if (this._columns) {
                    const boxHeight = boxTop - currentY + yDiff;
                    if (boxHeight >= textBox.height - lineHeight) {
                        if (columnIndex >= this._columns.length) {
                            overflow = true;
                        } else {
                            [this.x, this.y] = this._columns[columnIndex].position;
                            updateTextBox(this._columns[columnIndex]);
                            columnIndex++;
                            updateVertical = false;
                        }
                    }
                }

                if (overflow && this._overflowNotifier) {
                    let orders = this._overflowNotifier(this);
                    if (orders === true) {
                        return true; // stop processing remaining text.
                    }
                    if (orders.layout !== undefined) {
                        this._columns = this._layouts[orders.layout];
                        if (!this._columns) {
                            throw new Error(`Layout '${orders.layout}' is undefined.`);
                        }
                        if (!orders.column) {
                            orders.column = 0;
                        }
                    }

                    if (orders.column !== undefined) {
                        if (Array.isArray(orders.column)) {
                            let [xx, yy] = orders.column;
                            [this.x, this.y] = orders.column;
                            adjustcolumnPosition(this._columns, xx, yy);
                            columnIndex = 1;
                        } else {
                            columnIndex = orders.column;
                            [this.x, this.y] = this._columns[columnIndex++].position;
                        }
                        updateTextBox(this._columns[columnIndex - 1]);
                        updateVertical = false;
                    }
                    context = this.pageContext; // in case user changed page
                }

                if (updateVertical) {
                    currentY -= yDiff;
                    this.y += yDiff;
                }

                this._previousTextObjects.shift();
            }

            const startX = nx + currentLineWidth;

            // This text will only ever have this strange tag when the
            // HTML option is being used and an explicit linebreak encountered.
            if (text != '[@@DONOT_RENDER_THIS@@]') {
                toWriteTextObject.startX = toWriteTextObject.startX || startX;
                toWriteContents.push(toWriteTextObject);
            }

            // To handle text that has been split in middle of word,
            // need to decide if current text ends with a space.
            currentLineWidth += lineWidth + ((isHTML || toWriteTextObject.text.endsWith(' ')) ? spaceWidth : 0);

            // Processing last text object?
            if (index === toWriteTextObjects.length - 1) {
                if (this._flow) {
                    if (toWriteTextObject.lineComplete) {
                        this._previousTextObjects = [];
                    } else {
                        this._previousTextObjects = [...toWriteContents];
                        toWriteContents = [];
                    }
                }

                let next_x = 0;
                for (let ii = 0; ii < toWriteContents.length; ii++) {
                    const content = toWriteContents[ii];
                    const x = next_x || getStartX(content.startX, content);
                    const y = currentY;
                    next_x = writeText(context, x, y, content);
                }

                // Flush any left over text objects.
                if (!this._flow) {
                    this._previousTextObjects = [];
                }
            }
        });
    }

    return this;
};

exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
    let totalHeight = 0;
    // allow user to treat wrap as boolean
    if (textBox.wrap === true) {
        textBox.wrap = 'auto';
    } else if (textBox.wrap === false) {
        textBox.wrap = 'ellipsis';
    }

    // Allows user to enter a single number which will be used for all text box sides,
    // or an array of values [top, right, bottom, left] with any combination of missing sides.
    // Default value for a missing side is the value of the text box's opposite side (see below).
    //
    //               padding[0]
    //   padding[3]              padding[1]
    //               padding[2]

    textBox.padding = (Array.isArray(textBox.padding)) ? textBox.padding : [textBox.padding];

    Object.assign(textBox, {
        paddingTop: textBox.padding[0],
        paddingRight: (textBox.padding[1] !== undefined) ? textBox.padding[1] : textBox.padding[0],
        paddingBottom: (textBox.padding[2] !== undefined) ? textBox.padding[2] : textBox.padding[0],
        paddingLeft: (textBox.padding[3] !== undefined) ? textBox.padding[3] : (textBox.padding[1] !== undefined) ? textBox.padding[1] : textBox.padding[0]
    });

    let firstLineHeight;
    let toWriteTextObjects = [];

    const writeValue = (textObject) => {
        textObject.lineID = textObject.lineID || Date.now() * Math.random();
        textObject.lineID = (textObject.needsLineBreaker) ? Date.now() * Math.random() : textObject.lineID;
        // Want to allow empty string to pass through. Undefined and null elements, stay out!
        if (textObject.value !== undefined && textObject.value !== null) {
            textObject.styles.color = (textObject.styles.color) ?
                this._transformColor(textObject.styles.color) : pathOptions.color;
            const {
                toWriteTextObjects: newToWriteObjects,
                paragraphHeight
            } = makeTextObjects(this, textObject, pathOptions, textBox);

            toWriteTextObjects = [...toWriteTextObjects, ...newToWriteObjects];

            if (!firstLineHeight) {
                this._lineHeight = firstLineHeight = toWriteTextObjects[0].lineHeight;
                if (!this._firstLineHeight) {
                    this._firstLineHeight = this._lineHeight; // used in textbox coordinate computation
                }
            }
            totalHeight += paragraphHeight;
        }
        if (textObject.tag && textObject.childs.length) {
            // console.log(textObject);
            if (!textObject.size) {
                textObject.size = pathOptions.size * textObject.sizeRatio;
                textObject.sizeRatios = [textObject.sizeRatio];
            }
            textObject.layer = textObject.layer || 0;
            textObject.layer++;

            textObject.currentIndex = 0;

            textObject.childs.forEach((child) => {
                if (textObject.tag == 'ul') {
                    child.prependValue = '* ';
                    child.layer = textObject.layer + 1;
                    // child.indent = 4 * child.layer;
                }
                if (textObject.tag == 'ol') {
                    if (child.tag != 'ol') {
                        textObject.currentIndex++;
                        child.prependValue = `${(textObject.currentIndex).toString()}. `;
                    }
                    child.layer = textObject.layer + 1;
                    // child.indent = 4 * child.layer;
                }
                if (textObject.tag == 'li') {
                    if (child.tag == 'ol' || child.tag == 'ul') {
                        child.layer = textObject.layer - 1;
                    }
                }
                if (textObject.prependValue) {
                    child.prependValue = (!['ol', 'ul'].includes(textObject.tag)) ?
                        textObject.prependValue : child.prependValue;
                    textObject.indent = 2 * textObject.layer;
                }
                if (textObject.indent) {
                    child.indent = child.indent || textObject.indent;
                }
                if (textObject.size) {
                    child.size = textObject.size * child.sizeRatio;
                    child.sizeRatios = [...textObject.sizeRatios, child.sizeRatio];
                }
                child.styles = Object.assign(child.styles, textObject.styles);

                child.isBold = (textObject.isBold) ? textObject.isBold : child.isBold;
                child.isItalic = (textObject.isItalic) ? textObject.isItalic : child.isItalic;
                child.underline = (textObject.underline) ? textObject.underline : child.underline;

                child.lineID = textObject.lineID;
                writeValue(child);
            });
        }
    };
    textObjects.forEach((textObject) => {
        writeValue(textObject);
    });

    return { toWriteTextObjects: toWriteTextObjects, textHeight: totalHeight };
};

function getTextBoxHeight(textObjs) {
    let previousLineID;
    let height = 0;

    // The summation of each text line height determines text box height.
    // The last segment of a line holds the correct offset (influenced by moveDown)
    // so must traverse the list in reverse order.

    for (let index = textObjs.length - 1; index >= 0; index--) {
        const segment = textObjs[index];
        const { lineHeight, lineID } = segment;

        // Keep line segments from same line influencing box height
        if (previousLineID !== lineID) {
            let lineOffset = segment.lineOffset;
            height += (lineHeight * lineOffset);
            previousLineID = lineID;
        }
    }

    return height;
}

function getTextBoxPosition(self, textBox, pathOptions) {

    const { offsetX, offsetY } = self._getTextBoxOffset(textBox, pathOptions);
    const { nx, ny } = self._calibrateCoordinate(self.x, self.y, offsetX, offsetY);
    return [nx, ny];
}

function drawTextBox(self, nx, ny, textBox, pathOptions) {

    const textBoxWidth = textBox.width; //+ textBox.paddingLeft + textBox.paddingRight;
    let borderRadius = (textBox.style) ? textBox.style.borderRadius : 0;
    if (borderRadius === true) { borderRadius = 5; }

    if (!textBox.height) {
        textBox.height = textBox.textHeight + textBox.paddingTop + textBox.paddingBottom;

        if (textBox.minHeight && textBox.minHeight > textBox.height) {
            textBox.height = textBox.minHeight;
        }
    }

    self.rectangle(
        nx,
        ny - textBox.height + self._firstLineHeight,
        textBoxWidth, textBox.height, Object.assign(textBox.style, {
            useGivenCoords: true,
            rotation: pathOptions.rotation,
            rotationOrigin: [pathOptions.originX, pathOptions.originY],
            borderRadius: borderRadius
        })
    );
}

/**
 * Justify text in a line.
 * @param {number} left is position of left hand side of text box
 * @param {number} x is starting position for text placement
 * @param {Object[]} wto is a write object
 * @param {Object} textBox holds text box properties
 * @param {Function} [position] used to place given word at a postion on the line
 */
function justify(left, x, wto, textBox, position) {
    // For some reason, textWidth is smaller than lineWidth. My suspicions lie in the fact
    // that spacing computations appear different depending on where the space is located.
    // What is noted though that if lineWidth is used in the calculations for text
    // justitification, the text goes passed the right boundary. Due to the vagary in space
    // computation, the final wrinkle to make sure the last word in the line smacks up against
    // the right side boundary is to perform a special computation on the last word positioning
    // relative to that right side bounds.
    const wordsInLine = wto.wordsInLine;
    const textWidth = (wto.totalTextWidth) ? wto.totalTextWidth : wto.textWidth;
    const spaceCount = (wto.wordCount) ? wto.wordCount - 1 : wordsInLine.length - 1;
    const spaceBetweenWords = (spaceCount > 0) ? (textBox.width - textBox.paddingLeft - textBox.paddingRight - textWidth) / (spaceCount) : 0;
    const boxEdge = left;
    const lineStart = left + textBox.paddingLeft;
    let word;

    const lastWordPosition = (word) => {
        return boxEdge + textBox.width - textBox.paddingRight - word.dimensions.xMax;
    };

    // There is the possibility that only one word left on segmented line.
    if (wordsInLine.length === 1 && wordsInLine[0].last && x !== lineStart) {
        x = lastWordPosition(wordsInLine[0]);
    }

    for (let index = 0; index < wordsInLine.length; index++) {
        const nextWord = wordsInLine[index + 1];

        word = wordsInLine[index];
        position && position(word, x);

        // Ready to compute last word spacing?
        if (nextWord && nextWord.last) {
            x = lastWordPosition(nextWord);
        } else {
            x += word.dimensions.xMax + spaceBetweenWords;
        }
    }

    // Supply caller with next available line position.
    // Checking to see if last word ended with a space or not
    // to compensate for text fragments that have not been
    // split on whitespace boundaries.
    return word.value.endsWith(' ') ? x : x - spaceBetweenWords;
}

function nextWord(text, brk, previousPosition, pathOptions) {
    let nextWord = text.slice(previousPosition, brk.position);

    if (brk.required) { // effectively saw a '\n' in text.
        nextWord = nextWord.trim();
    }

    return new Word(nextWord, pathOptions);
}

function elideNonFittingText(textBox, line, word, pathOptions) {
    if (textBox.wrap === 'clip') {
        line.addWord(word);
    } else if (textBox.wrap === 'ellipsis') {
        // This is more complicated than the other no-wrap options.
        // It makes an initial attempt to take the word that was
        // too big and make it shrink in size until it and the
        // ellipsis character fit. If that doesn't work, one more
        // attempt is taken by trying to shrink the previous word
        // that fit on the line.
        const ellipsis = '…';
        let usingPreviousWord = false;
        let tooBig = new Word(word.value.slice(0, -2) + ellipsis, pathOptions);

        while (!line.canFit(tooBig)) {

            if (tooBig.value.length > 1) {
                tooBig = new Word(tooBig.value.slice(0, -2) + ellipsis, pathOptions);

                // Try last word that fit in box?
            } else if (!usingPreviousWord) {
                tooBig = new Word(line.words.pop().value.slice(0, -1) + ellipsis, pathOptions);
                usingPreviousWord = true;

            } else {
                break; // give up, only get 2 shots at this.
            }
        }

        line.addWord(tooBig);
    }
}

function makeTextObject(lines, line, lineID, textBox, options = {}) {

    const lineHeight = line.height;
    const spaceSz = (options.lastLine && line.lastWord && line.lastWord.value.endsWith(' ')) ? line.spaceWidth / 2 : 0;
    let lid;

    if (!options.html) {
        lid = line.lineID;
    } else {
        // Use given lineID for very first line.
        // It helps to tie HTML lines together.
        lid = (lines.length) ? line.lineID : lineID;
    }

    lines.push(line);

    return {
        text: line.value,
        lineID: lid,
        lineHeight: lineHeight,
        lineOffset: 1,
        baseline: lineHeight - textBox.baselineHeight,
        lineWidth: line.currentWidth + spaceSz,
        textWidth: line.textWidth, // for justification
        spaceWidth: line.spaceWidth,
        wordsInLine: line.words,
        wordCount: options.wordCount || 0, // for justification
        totalTextWidth: options.totalTextWidth || 0,
        lastLine: (options.lastLine === true),
        writeOptions: options.writeOptions,
        lineComplete: (options.lineComplete === true)
    };
}

function bindTextToLine(line, textObjects, wordCount, totalTextWidth) {
    // Apply justification information to previous text objects.
    if (wordCount > 0) {
        line.markLastWord();
        wordCount += line.words.length;
        totalTextWidth += line.textWidth;

        // If the previous text does not end with a space and
        // the text on the line does not start with a space then
        // assuming that the input was split across the word so
        // we want to decrement the number of words in the line
        // for the proper space calculation when justifying text.
        // For example, 'justif' --- 'ying'.
        const previousLine = textObjects[textObjects.length - 1];

        if (!previousLine.text.endsWith(' ') &&
            line.words[0] && !line.words[0].value.startsWith(' ')) {
            wordCount--;
        }

        line.lineID = previousLine.lineID; // associate this line with previous one

        for (let i = textObjects.length - 1; i >= 0; i--) {
            let textObj = textObjects[i];
            if (textObj.lineID !== line.lineID) { break; }
            textObj.wordCount = wordCount;
            textObj.totalTextWidth = totalTextWidth;
        }
    }
    return [wordCount, totalTextWidth];
}

function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
    const toWriteTextObjects = [...self._previousTextObjects];
    let text = ((textObject.prependValue) ? textObject.prependValue : '') +
        textObject.value +
        ((textObject.appendValue) ? textObject.appendValue : '');

    const size = textObject.size || pathOptions.size;
    // Use the same string to get the same height for each string with the same font.
    // Need lowercase 'gjpqy' so descenders are included in text height.
    // Need special characters '|}' because they have ascenders that go beyond upper case letters.
    const textDimensions = pathOptions.font.calculateTextDimensions(
        'ABCDEFGHIJKLMNOPQRSTUVWXYZgjpqy|}', size
    );
    const textHeight = textDimensions.height;

    pathOptions.textHeight = textHeight;
    textBox.lineHeight = textBox.lineHeight || textHeight;
    textBox.baselineHeight = textDimensions.yMax;

    const [alignHorizontal, alignVertical] = (textBox.textAlign) ? textBox.textAlign.split(' '): [];
    const writeOptions = Object.assign({}, pathOptions, {
        color: textObject.styles.color,
        opacity: parseFloat(textObject.styles.opacity || pathOptions.opacity || 1),
        underline: textObject.underline || pathOptions.underline,
        size: textObject.size,
        alignHorizontal: alignHorizontal,
        alignVertical: alignVertical,
        font: self._getFont(textObject)
    });

    const lineOpts = {
        html: pathOptions.html,
        writeOptions: writeOptions,
        more: self._flow
    };

    const breaker = new LineBreaker(text);
    const lines = [];
    const indent = (textObject.indent || 0);

    const lineMaxWidth = (textBox.width) ? textBox.width - textBox.paddingLeft - textBox.paddingRight - indent : null;
    let remainderWidth = lineMaxWidth;
    let newLine;
    let last = 0;
    let bk = breaker.nextBreak();
    let previousWord;
    let flushLine = false;
    let wordCount = 0; // for justification
    let totalTextWidth = 0; // for justification
    let textLine = '';
    let lineID = textObject.lineID;
    let lineHeight = (textHeight > textBox.lineHeight) ? textHeight : textBox.lineHeight;

    // When text flow is involved, there may be lines that are
    // incomplete. So need to determine previous line word count
    // and remove last line marks because more text is being processed.
    if (toWriteTextObjects.length > 0) {
        let lineWidth = 0;
        let spaceSz = 0;
        const end = toWriteTextObjects.length - 1;
        const previousLine = toWriteTextObjects[end];
        let lineComplete, fini;

        if (text === '' && !self._flow) { // turning off flow with empty text so
            previousLine.lastLine = true; // need to make previous line, the last.
        }

        for (let i = end; i >= 0; i--) {
            let textObj = toWriteTextObjects[i];

            // only collect data while lineID's match.
            if (textObj.lineID !== previousLine.lineID) { break; }
            spaceSz = textObj.spaceWidth;
            textLine = textObj.text + textLine;
            totalTextWidth += textObj.textWidth;
            lineWidth += textObj.lineWidth;
            if (i === end) {
                fini = lineComplete = textObj.lineComplete;
            } else {
                fini = textObj.lineComplete;
            }
            textObj.wordsInLine[textObj.wordsInLine.length - 1].lastWord(fini);
        }

        if (lineComplete) {
            totalTextWidth = 0;
        } else if (textLine) {
            wordCount = textLine.trim().split(/\s+/).length;
            if (!textLine.endsWith(' ')) { spaceSz = 0; }
            remainderWidth = lineMaxWidth - lineWidth - spaceSz;
        }
    }

    newLine = new Line(remainderWidth, lineHeight, size, pathOptions);
    newLine.indent(indent);

    while (bk) {
        let word = nextWord(text, bk, last, pathOptions);

        if (newLine.canFit(word)) {
            newLine.addWord(word);
        } else {
            // Protect against line width being too small to accept any
            // word, which may also happen during text justification.
            if (newLine.words.length === 0) {
                // self.movedown(); // start at front of next line.
                if (wordCount > 0) {
                    // no words applied to previous segment, so drop word count
                    // and mark last word of previous line in case justifying.
                    wordCount = 0;
                    totalTextWidth = 0;
                    markLineComplete(toWriteTextObjects);
                }
            } else {
                // remove any trailing space on previous word so right justification works appropriately
                if (previousWord && textBox.wrap === 'auto') {
                    newLine.replaceLastWord(previousWord.value.trim());
                }

                [wordCount, totalTextWidth] =
                bindTextToLine(newLine, toWriteTextObjects, wordCount, totalTextWidth);

                toWriteTextObjects.push(
                    makeTextObject(lines, newLine, lineID, textBox,
                        Object.assign({}, lineOpts, {
                            wordCount: wordCount,
                            totalTextWidth: totalTextWidth,
                            lineComplete: true
                        })
                    )
                );
                wordCount = 0;
                totalTextWidth = 0;
            }

            // now deal with text line wrap (what happens to text that doesn't fit in line)
            if (textBox.wrap !== 'auto') {
                flushLine = true;
                elideNonFittingText(textBox, newLine, word, pathOptions);
                toWriteTextObjects[toWriteTextObjects.length - 1].text = newLine.value;

            } else {
                // this is the auto wrap section
                newLine = new Line(lineMaxWidth, lineHeight, size, pathOptions);
                newLine.indent(indent);

                if (textObject.prependValue) {
                    const space = Array(textObject.prependValue.length + 1).fill(' ').join('');
                    newLine.addWord(new Word(space, pathOptions));
                }
                newLine.addWord(word);
            }
        }

        if (flushLine) {
            while (bk) {
                if (bk.required) {
                    flushLine = false;
                    newLine = new Line(lineMaxWidth, lineHeight, size, pathOptions);
                    word = null;
                    break;
                }
                bk = breaker.nextBreak();
            }
        } else {
            /**
             * Author: silverma (Marc Silverman)
             * #29 Is it possible to add multi-line text?
             * https://github.com/chunyenHuang/hummusRecipe/issues/29
             */
            if (bk.required) {
                toWriteTextObjects.push(
                    makeTextObject(lines, newLine, lineID, textBox, Object.assign({ lineComplete: true, lastLine: true }, lineOpts)));
                markLineComplete(toWriteTextObjects);
                newLine = new Line(lineMaxWidth, lineHeight, size, pathOptions);
            }
        }

        if (bk) {
            previousWord = word;
            last = bk.position;
            bk = breaker.nextBreak();
        }
    }

    let isLastLine = (alignHorizontal === 'justify' && !self._flow);

    if (!flushLine) {
        [wordCount, totalTextWidth] =
        bindTextToLine(newLine, toWriteTextObjects, wordCount, totalTextWidth);

        toWriteTextObjects.push(
            makeTextObject(lines, newLine, lineID, textBox,
                Object.assign({}, lineOpts, {
                    wordCount: wordCount,
                    totalTextWidth: totalTextWidth,
                    lastLine: isLastLine
                })));
    } else {
        toWriteTextObjects[toWriteTextObjects.length - 1].lastLine = isLastLine;
    }

    let paragraphHeight = lineHeight * lines.length + textBox.paddingTop + textBox.paddingBottom;

    return {
        toWriteTextObjects,
        paragraphHeight
    };
}

function markLineComplete(toWriteTextObjects, lines = null) {
    // Get last element in text objects and mark it.
    const textObj = toWriteTextObjects[toWriteTextObjects.length - 1];
    const lastWordIdx = textObj.wordsInLine.length - 1;

    textObj.wordsInLine[lastWordIdx].lastWord();
    textObj.text = textObj.text.trim();
    textObj.lineComplete = true;

    if (lines) {
        textObj.lineOffset = lines;
    }
}

/** Move text positioning down N lines in text box
 * @name movedown
 * @function
 * @memberof Recipe
 * @param {number} [lines=1] - the number of lines to reposition x and y coordinates
 * @param {Boolean} [returnCoords=false] - indicate whether or not to return [x,y] coordinates
 * @returns {Object|number[]} - when returnCoord false, the recipe object, when true, the new [x,y] coordinates.
 */
exports.movedown = function movedown(lines = 1, returnCoords = false) {

    if (!this._flow || this._previousTextObjects.length === 0) {
        this._previousTextObjects = [];
        this.y += this._lineHeight * lines;
        this.x = this.box.x;
    } else {
        // This handles continuous text positioning
        markLineComplete(this._previousTextObjects, lines);
        this._previousTextObjects[this._previousTextObjects.length - 1].lastLine = true;
    }

    return (returnCoords) ? [this.x, this.y] : this;
};

function adjustcolumnPosition(columns, x, y) {
    const ydiff = y - columns[0].y;
    for (const column of columns) {
        column.position = [x, column.y + ydiff];
        x += column.width + column.gap;
    }
}

/**
 * Define text column layout
 * @name layout
 * @function
 * @memberof Recipe
 * @param {number|string} id - The identifier to be associated with the layout. (See 'text' layout option)
 * @param {number} x - The coordinate x used to position text columns on page. When zero, left margin used.
 * @param {number} y - The coordinate y used to position text columns on page. When zero, top margin used.
 * @param {number} width - The width of a text column. When zero, space between left and right margin used.
 * @param {number} height - The height of a text column. When zero, space between top and bottom margin used.
 * @param {object} [options] - The options.
 * @param {number} [options.columns] - Represents the number of columns in which to divide the given width.
 * @param {number} [options.gap=18] - Defines the separation between layout columns, units in points.
 * @param {boolean} [options.reset] - True indicates that the a new layout should be produced for the given
 * layout id, so any previous layout associated with the given id will be lost.
 */
exports.layout = function layout(id, x, y, width, height, options = {}) {
    this._layouts = this._layouts || {};
    this._layouts[id] = this._layouts[id] || [];

    if (options.reset) {
        this._layouts[id] = [];
    }

    if (!x) {
        x = this._margin.left;
    }
    if (!y) {
        y = this._margin.top;
    }
    if (!width) {
        width = this.page.mediaBox[2] - x - this._margin.right;
    }
    if (!height) {
        height = this.page.mediaBox[3] - y - this._margin.bottom;
    }

    if (!options.columns) {
        this._layouts[id].push(new Column(x, y, width, height));

        // columns as a simple number drives the text multiple columns feature
    } else if (typeof options.columns === 'number') {
        const columns = options.columns;
        const gap = options.gap || 18;
        width = width / columns - (gap / 2);

        for (let i = 0; i < options.columns; i++) {
            const column = new Column(x, y, width, height);
            column.gap = gap;
            this._layouts[id].push(column);
            x += width + gap;
        }
        // columns as an array drives the table feature (internal not documented for user)
    } else if (Array.isArray(options.columns)) {
        for (let i = 0; i < options.columns.length; i++) {
            let element = options.columns[i];
            width = element.width || 100;
            const column = new Column(x, y, width, height, element.text, element.name, element);
            this._layouts[id].push(column);
            x += width;
        }
    }

    return this;
};