Fork me on GitHub

core/span.js

            
            /*global require:false*/
/*global define:false*/

/**
 * @module span
 * @private
 */

define(['mathlive/core/fontMetrics'],
    function(FontMetrics) {

const NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
    useGrouping: false,
    maximumSignificantDigits: 5
});

/**
 * Return a string made up of the concatenated arguments.
 * Each arguments can be either a string, which is unchanged,
 * or a number, which is converted to a string with at most 5 fractional digits.
 * 
 * @param {...string} args
 * @return {string}
 * @memberof module:span
 * @private
 */
function toString() {
    let result = '';
    for (const arg of arguments) {
        if (typeof arg === 'number') {
            result += NUMBER_FORMATTER.format(arg);
        } else if (typeof arg === 'string') {
            result += arg;
        } else if (Array.isArray(arg)) {
            for (const elem of arg) {
                result += toString(elem);
            }
        } else if (arg) {
            result += arg.toString();
        }
    }
    return result;
}


//----------------------------------------------------------------------------
// SPAN
//----------------------------------------------------------------------------

/**
 * A span is the most elementary element that can be rendered.
 * It is composed of an optional body of text and an optional list
 * of children (other spans). Each span can be decorated with
 * CSS classes and style attributes.
 * 
 * @param {string|Span|Span[]} content the items 'contained' by this node
 * @param {string} classes list of classes attributes associated with this node
 * @return {void}
 * @class
 * @global
 * @memberof module:span
 * @property {string} type - For example, `'command'`, `'mrel'`, etc...
 * @property {string} classes - A string of space separated CSS classes 
 * associated with this element
 * @property {Span[]} children - An array, potentially empty, of spans which 
 * this span encloses
 * @property {string} body - Content of this span. Can be empty.
 * @property {Object} style - A set of key/value pairs specifying CSS properties
 * associated with this element.
 * @property {number} height - The measurement from baseline to top, in em.
 * @property {number} depth - The measurement from baseline to bottom, in em.
 * @private
 */
function Span(content, classes) {
    // CLASSES
    this.classes = classes || '';

    console.assert(typeof this.classes === 'string');

    // CONTENT
    if (Array.isArray(content)) {
        // Check if isArray first, since an array is also an object
        // Flatten it (i.e. [[a1, a2], b1, b2] -> [a1, a2, b1, b2]
        this.children = [].concat.apply([], content);
    } else if (typeof content === 'string') {
        this.body = content;
    } else if (content && typeof content === 'object') {
        this.children = [content];
    }

    // STYLE
    // CSS style, as an array of key value pairs. 
    // Use this.setStyle() to modify it.
    this.style = null;

    // Calculate the dimensions of this span based on its children
    this.updateDimensions();
}

/**
 * Update the dimensions of this node based on its children:
 * - height: distance from bottom to top
 * - depth: distance from bottom to baseline
 * - maxFontSize:  
 * @method module:span.Span#updateDimensions
 * @private
 */
Span.prototype.updateDimensions = function() {
    let height = 0;
    let depth = 0;
    let maxFontSize = 0;
    if (this.children) {
        for (const child of this.children) {
            if (child) {
                console.assert(!isNaN(child.height));
                if (child.height > height) height = child.height;
                if (child.depth > depth) depth = child.depth;
                if (child.maxFontSize > maxFontSize) maxFontSize = child.maxFontSize;
            }
        }
    }
    this.height = height;
    this.depth = depth;
    this.maxFontSize = maxFontSize;
}



/**
 * Set the value of a CSS property associated with this span.
 * For example, setStyle('border-right', 5.6, 'em');
 * 
 * @param {string} prop the CSS property to set
 * @param {Object} value a series of strings and numbers that will be concatenated.
 * @return {string}
 * @method module:span.Span#setStyle
 * @private
 */
Span.prototype.setStyle = function(prop, ...value) {
    const v = toString(value);
    if (v.length > 0) {
        if (!this.style) this.style = {};
        this.style[prop] = v;
    }
}

Span.prototype.setTop = function(top) {
    if (top && top !== 0) {
        if (!this.style) this.style = {};
        this.style['top'] = toString(top) + 'em';
        this.height -= top;
        this.depth += top;
    }
}

Span.prototype.setLeft = function(left) {
    if (left && left !== 0) {
        if (!this.style) this.style = {};
        this.style['margin-left'] = toString(left) + 'em';
    }
}

Span.prototype.setRight = function(right) {
    if (right && right !== 0) {
        if (!this.style) this.style = {};
        this.style['margin-right'] = toString(right) + 'em';
    }
}

Span.prototype.setWidth = function(width) {
    if (width && width !== 0) {
        if (!this.style) this.style = {};
        this.style['width'] = toString(width) + 'em';
    }
}

Span.prototype.addMarginRight = function(margin) {
    if (margin && margin !== 0) {
        if (!this.style && 
            !/qquad|quad|enspace|thickspace|mediumspace|thinspace|negativethinspace/.test(this.classes)) {
            // Attempt to use a class instead of an explicit margin
            const cls = {
                '2': 'qquad',
                '1': 'quad',
                '.5': 'enspace',
                '0.277778': 'thickspace',
                '0.222222': 'mediumspace',
                '0.166667': 'thinspace',
                '-0.166667': 'negativethinspace'}[margin.toString()];
            if (cls) {
                this.classes += ' rspace ' + cls;
                return;
            }
        }
        if (!this.style) this.style = {};
        const currentMargin = parseFloat(this.style['margin-right'] || '0');
        this.style['margin-right'] = toString(currentMargin + margin) + 'em'
    }
}



/**
 * Return HTML markup representing this span, its style, classes and
 * children.
 * 
 * @param {number} [hskip] amount of whitespace to insert before this element
 * This is used to adjust the inter-spacing between spans of different types,
 * e.g. 'bin' and 'rel', according to the TeX rules.
 * @alias module:span.INTER_ATOM_SPACING
 * @private
 */
const INTER_ATOM_SPACING = {
    'mord+mop':            3,
    'mord+mbin':           4,
    'mord+mrel':           5,
    'mord+minner':         3,

    'mop+mord':             3,
    'mop+mop':              3,
    'mop+mbin':             5,
    'mop+minner':           3,

    'mbin+mord':            4,
    'mbin+mop':             4,
    'mbin+mopen':           4,
    'mbin+minner':          4,

    'mrel+mord':            5,
    'mrel+mop':             5,
    'mrel+mopen':           5,
    'mrel+minner':          5,

    'mclose+mop':           3,
    'mclose+mbin':          4,
    'mclose+mrel':          5,
    'mclose+minner':        3,

    'mpunct+mord':          3,
    'mpunct+mop':           3,
    'mpunct+mbin':          4,
    'mpunct+mrel':          5,
    'mpunct+mopen':         3,
    'mpunct+mpunct':        3,
    'mpunct+minner':        3
}

/**
 * 
 * @alias module:span.INTER_ATOM_TIGHT_SPACING
 * @private
 */
const INTER_ATOM_TIGHT_SPACING = {
    'mord+mop':             3,
    'mop+mord':             3,
    'mop+mop':              3,
    'mclose+mop':           3,
    'minner+mop':           3
}

function lastSpanType(span) {
    let result = span.type;
    if (span.classes.indexOf('ML__selected') !== -1) {
        result = span.children[span.children.length - 1].type;
    }
    if (result === 'first') return 'none';
    if (result === 'textord') return 'mord';
    return result;
}

/**
 * Generate the HTML markup to represent this span.
 * 
 * @param {?number} hskip - If a value is provided, it will be added (in ems) to the 
 * left margin.
 * @param {?number} hscale - If a value is provided, the margins are scaled by 
 * this factor. 
 * @return {string} HTML markup
 * @method module:span.Span#toMarkup
 * @private
 */
Span.prototype.toMarkup = function(hskip, hscale) {
    hskip = hskip || 0;
    let result = '';
    let body = this.body || '';
    if (this.children) {
        let previousType = 'none';
        for (const child of this.children) {
            let spacing = 0;
            if (previousType) {
                let type = child.type;
                if (type) {
                    if (type === 'textord') type = 'mord';
                    if (type === 'first') type = 'none';
                    if (child.isTight) { 
                        spacing = (INTER_ATOM_TIGHT_SPACING[previousType + '+' + type] || 0) / 18;
                    } else {
                        spacing = (INTER_ATOM_SPACING[previousType + '+' + type] || 0) / 18;
                    }
                    if (hscale) spacing = hscale * spacing;
                }
            }
            body += child.toMarkup(spacing, hscale);
            previousType = lastSpanType(child);
        }
    }
    const tag = this.tag || 'span';

    if (tag.length === 0) {
        result = body || '';
    } else {
        result = '<' + tag;

        if (this.svgOverlay) {
            if (!this.style) this.style = {};
            this.style['position'] =  'relative';
        }

        if (this.attributes) {
            for (const attribute in this.attributes) {
                if (this.attributes.hasOwnProperty(attribute)) {  
                    result += ' ' + attribute + '="' + this.attributes[attribute] + '"';
                }
            }
        }

        const classes = this.classes.split(' ');

        // Add the type (mbin, mrel, etc...) if specified
        if (this.type) {
            classes.push({
                'command': 'ML__command',
                'placeholder': 'ML__placeholder',
                'error': 'ML__error'
            }[this.type] || '');
        }

        if (this.type === 'command' && this.hasCaret) {
            classes.push('ML__caret');
        }

        // Remove duplicate and empty classes
        // and 'mathrm' which is a no-op
        const classList = classes.filter(function (x, e, a) {
                return x.length > 0 && x !== 'mathrm' && a.indexOf(x) === e;
            }).join(' ');

        if (classList.length > 0) {
            result += ' class="' + classList + '"';
        }

        // If a `hskip` value was provided, add it to the margin-left
        if (hskip) {
            if (!this.style) this.style = {};
            if (!this.style['margin-left']) {
                this.style['margin-left'] = toString(hskip, 'em');
            } else {
                this.style['margin-left'] = toString(
                    (parseInt(this.style['margin-left']) + hskip), 'em');
            }
        }

        if (this.style) {
            let styleString = ''
            for (const style in this.style) {
                if (this.style.hasOwnProperty(style)) {
                    styleString += style + ':' + this.style[style] + ';'
                }
            }

            if (styleString.length > 0) {
                result += ' style="' + styleString + '"';
            }
        }
        result += '>';

        // If there is some SVG markup associated with this span, 
        // include it now
        if (this.svgOverlay) {
            result += body;     // @todo maybe safe encode here...? (< >)
            result += '<svg ';
            // result += ` width="${this.clientWidth()}px"`;
            // result += ` height="${this.clientHeight()}px"`;
            result += ' width="100%" height="100%"';
            result += 'style="position:absolute;left:0;top:0;width:100%;height:100%;z-index:2;"';
            if (this.svgStyle) {
                result += ' style="filter: drop-shadow(0 0 .5px rgba(255, 255, 255, .7)) drop-shadow(1px 1px 2px #333);"';
            }
            result += '>';
            result += this.svgOverlay;
            result += '</svg>';
        } else {
            result += body;     // @todo maybe safe encode here...? (< >)
        }

        result += '</' + tag + '>';
    }

    // Collapse 'empty' spans
    if (result === '<span>\u200b</span>') {
        result = '';
    }

    if (this.hasCaret && this.type !== 'command') {
        result = '<span class="ML__caret">' + result + '</span>';
    }

    return result;    
}


/**
 * Can this span be coalesced with 'span'?
 * This is used to 'coalesce' (i.e. group together) a series of spans that are 
 * identical except for their value, and to avoid generating redundant spans.
 * That is: '12' ->
 *      "<span class='mord mathrm'>12</span>"
 * rather than:
 *      "<span class='mord mathrm'>1</span><span class='mord mathrm'>2</span>"
 * @param {Span} span
 * @return {boolean} 
 * @method module:span.Span#tryCoalesceWith
 * @private
 */
Span.prototype.tryCoalesceWith = function(span) {

    if (this.tag !== span.tag) return false;

    if (this.type !== span.type) return false;

    // Don't coalesce consecutive errors or placeholders
    if (this.type === 'error' || this.type === 'placeholder' || 
        this.type === 'command') return false;

    // If this span or the candidate span have children, we can't
    // coalesce them, but we'll try to coalesce their children
    const hasChildren = this.children && this.children.length > 0;
    const spanHasChildren = span.children && span.children.length > 0;
    if (hasChildren || spanHasChildren) return false;

    // If they have a different number of styles, can't coalesce
    const thisStyleCount = this.style ? this.style.length : 0;
    const spanStyleCount = span.style ? span.style.length : 0;

    if (thisStyleCount !== spanStyleCount) return false;

    // For the purpose of our comparison,  
    // any 'empty' classes (whitespace)
    const classes = this.classes.trim().replace(/\s+/g, ' ')
        .split(' ');
    const spanClasses = span.classes.trim().replace(/\s+/g, ' ')
        .split(' ');


    // If they have a different number of classes, can't coalesce
    if (classes.length !== spanClasses.length) return false;


    // OK, let's do the more expensive comparison now.

    // If they have different classes, can't coalesce
    classes.sort();
    spanClasses.sort();

    for (let i = 0; i < classes.length; i++) {
        // Don't coalesce vertical separators 
        // (used in column formating with {l||r} for example
        if (classes[i] === 'vertical-separator') return false;
        if (classes[i] !== spanClasses[i]) return false;
    }


    // If the styles are different, can't coalesce
    if (this.style && span.style) {
        for (const style in this.style) {
            if (this.style.hasOwnProperty(style) && 
                span.style.hasOwnProperty(style)) {
                if (this.style[style] !== span.style[style]) return false;
            }
        }
    }

    // OK, the attributes of those spans are compatible.
    // Merge span into this
    this.body += span.body;

    this.height = Math.max(this.height, span.height);
    this.depth = Math.max(this.depth, span.depth);

    // The italic correction for the coalesced spans is the 
    // italic correction of the last span.
    this.italic = span.italic;

    return true;
}

/**
 * Attempts to coalesce (merge) spans, for example consecutive text spans.
 * Return a new tree with coalesced spans.
 * 
 * @param {Span[]} spans
 * @return {Span[]} coalesced tree
 * @memberof module:span
 * @private
 */
function coalesce(spans) {
    if (!spans || spans.length === 0) return [];

    spans[0].children = coalesce(spans[0].children);
    const result = [spans[0]];

    for (let i = 1; i < spans.length; i++) {
        if (!result[result.length - 1].tryCoalesceWith(spans[i])) {
            spans[i].children = coalesce(spans[i].children);
            result.push(spans[i]); 
        }
    }
    return result;
}


//----------------------------------------------------------------------------
// UTILITY FUNCTIONS
//----------------------------------------------------------------------------

function height(spans) {
    if (!spans) return 0;
    if (Array.isArray(spans)) {
        let result = 0;
        for (const span of spans) {
            result = Math.max(result, span.height);
        }
        return result;
    }
    return spans.height;
}

function depth(spans) {
    if (!spans) return 0;
    if (Array.isArray(spans)) {
        let result = 0;
        for (const span of spans) {
            result = Math.max(result, span.depth);
        }
        return result;
    }
    return spans.depth;
}


function skew(spans) {
    if (!spans) return 0;
    if (Array.isArray(spans)) {
        let result = 0;
        for (const span of spans) {
            result += span.skew || 0;
        }
        return result;
    }
    return spans.skew;
}

function italic(spans) {
    if (!spans) return 0;
    if (Array.isArray(spans)) {
        return spans[spans.length - 1].italic;
    }
    return spans.italic;
}


/**
 * Make an element made of a sequence of children with classes
 * @param {(string|Span|Span[])} content the items 'contained' by this node
 * @param {string} classes list of classes attributes associated with this node
 * @memberof module:span
 * @private
 */
function makeSpan(content, classes) {
    if (Array.isArray(content)) {
        const c = [];
        for (const s of content) {
            if (s) c.push(s);
        }
        if (c.length === 1) {
            return makeSpan(c[0], classes);
        }
    }
    return new Span(content, classes);
}


/**
 * 
 * @param {string} fontFamily 
 * @param {string} symbol 
 * @param {string} classes 
 * @memberof module:span
 * @private
 */
function makeSymbol(fontFamily, symbol, classes) {
    const result = new Span(symbol, classes);

    const metrics = FontMetrics.getCharacterMetrics(symbol, fontFamily);
    result.height = metrics.height;
    result.depth = metrics.depth;
    result.skew = metrics.skew;
    result.italic = metrics.italic;

    result.setRight(result.italic);

    return result;
}


/**
 * Makes an element placed in each of the vlist elements to ensure that each
 * element has the same max font size. To do this, we create a zero-width space
 * with the correct font size.
//  * Note: without this, even when fontSize = 0, the fraction bar is no 
//  * longer positioned correctly
 * @return {Span}
 * @memberof module:span
 * @private
 */
function makeFontSizer(context, fontSize) {
    const fontSizeAdjustment = fontSize ? fontSize / context.mathstyle.sizeMultiplier : 0;
    const fontSizeInner = new Span('\u200b');    // ZERO WIDTH SPACE

    if (fontSizeAdjustment !== 1) {
        fontSizeInner.setStyle('font-size', 
            fontSizeAdjustment, 
            (fontSizeAdjustment > 0) ? 'em' : '');
        fontSizeInner.attributes = {
            "aria-hidden": true
        }
    }

    if (context.size !== 'size5') { 
        return new Span(fontSizeInner, 
            'fontsize-ensurer reset-' + context.size + ' size5');
    } 
    return (fontSizeAdjustment !== 0) ? fontSizeInner : null;
}

/**
 * 
 * @param {string} type One of 'mbin', 'mop', 'mord', 'mrel' 'mclose', 
 * 'mpunct', 'minner'
 * @param {string|Span[]} content A string or an array of other Spans
 * @param {string} classes CSS classes decorating this span
 * See https://tex.stackexchange.com/questions/81752/
 * for a thorough description of the TeXt atom type and their relevance to 
 * proper kerning.
 * @memberof module:span
 * @private
 */
function makeSpanOfType(type, content, classes) {
    const result = makeSpan(content, classes);
    result.type = type;
    return result;
}

function makeOp(content, classes) {
    return makeSpanOfType('mop', content, classes);
}

function makeOrd(content, classes) {
    return makeSpanOfType('mord', content, classes);
}

function makeRel(content, classes) {
    return makeSpanOfType('mrel', content, classes);
}

function makeClose(content, classes) {
    return makeSpanOfType('mclose', content, classes);
}

function makeOpen(content, classes) {
    return makeSpanOfType('mopen', content, classes);
}

function makeInner(content, classes) {
    return makeSpanOfType('minner', content, classes);
}

function makePunct(content, classes) {
    return makeSpanOfType('mpunct', content, classes);
}


function makeStyleWrap(type, children, fromStyle, toStyle, classes) {
    classes = classes || '';
    classes += ' style-wrap ';

    const result = makeHlist(children, classes + fromStyle.adjustTo(toStyle));
    result.type = type;

    const multiplier = toStyle.sizeMultiplier / fromStyle.sizeMultiplier;

    result.height *= multiplier;
    result.depth *= multiplier;
    result.maxFontSize = toStyle.sizeMultiplier;

    return result;
}

/**
 * Add some SVG markup to be overlaid on top of the span
 * 
 * @param {Span} body 
 * @param {string} svgMarkup 
 */
function makeSVG(body, svgMarkup, svgStyle) {
    body.svgOverlay = svgMarkup;
    body.svgStyle = svgStyle;
    return body;
}

/**
 * 
 * @param {Span|Span[]} children 
 * @param {string} classes 
 * @memberof module:span
 * @private
 */
function makeHlist(children, classes) {
    if (!classes || classes.length === 0) {
        // No decorations...
        if (children instanceof Span) {
            // A single span, use it as the output
            return children;
        } else if (Array.isArray(children) && children.length === 1) {
            // An array, with a single span, use the single span as the output
            return children[0]
        }
    }
    return new Span(children, classes);
}

/**
 * Create a new span of type `vlist`, a set of vertically stacked items
 * @param {Context} context
 * @param {Array.<(number|Span)>} elements 
 * An array of Span and integer. The integer can be either some kerning information
 * or the value of an individual shift of the preceding child if in 'individualShift' mode
 * @param {string} pos The method that will be used to position the elements in the vlist. 
 * 
 * One of:
 * - `"individualShift"`: each child must be followed by a number indicating how much to shift it (i.e. moved downwards)
 * - `"top"`: posData specifies the topmost point of the vlist (>0 move up)
 * - `"bottom"`: posData specifies the bottommost point of the vlist (>0 move down)
 * - `"shift"`: the baseline of the vlist will be positioned posData away from the baseline 
 * of the first child. (>0 moves down)
 * @param {number} posData
 * @memberof module:span
 * @private
 */
function makeVlist(context, elements, pos, posData) {
    let listDepth = 0;
    let currPos = 0;
    pos = pos || 'shift';
    posData = posData || 0;

    // Normalize the elements so that they're all either a number or 
    // a single span. If a child is an array of spans, 
    // wrap it in a span
    for (let i = 0; i < elements.length; i++) {
        if (Array.isArray(elements[i])) {
            if (elements[i].length === 1) {
                // If that's an array made up of a single span, use that span
                elements[i] = elements[i][0];
            } else {
                // Otherwise, wrap it in a span
                elements[i] = makeSpan(elements[i]);
            }
        }
    }

    if (pos === 'shift') {
        listDepth = -elements[0].depth - posData;
    } else if (pos === 'bottom') {
        listDepth = -posData;
    } else if (pos === 'top') {
        let bottom = posData;
        for (const element of elements) {
            if (element instanceof Span) {
                // It's a Span, use the dimension data
                bottom -= element.height + element.depth;
            } else {
                // It's a kern adjustment
                bottom -= element;
            } 
        }
        listDepth = bottom;
    } else if (pos === 'individualShift') {
        // Individual adjustment to each elements.
        // The elements list is made up of a Span followed
        // by a shift adjustment as an integer
        const originalElements = elements;
        elements = [originalElements[0]];

        // Add in kerns to the list of elements to get each element to be
        // shifted to the correct specified shift
        listDepth = -originalElements[1] - originalElements[0].depth;
        currPos = listDepth;
        for (let i = 2; i < originalElements.length; i += 2) {
            const diff = -originalElements[i + 1] - currPos -
                originalElements[i].depth; 
            currPos = currPos + diff;

            const kern = diff -
                (originalElements[i - 2].height + originalElements[i - 2].depth);
 
            elements.push(kern);
            elements.push(originalElements[i]);
        }
    } else {
        console.assert(false, 'makeVList with unknown method: "' + pos + '"');
    }

    // Make the fontSizer
    let maxFontSize = 0;
    for (const element of elements) {
        if (element instanceof Span) {
            maxFontSize = Math.max(maxFontSize, element.maxFontSize);
        }
    }
    const fontSizer = makeFontSizer(context, maxFontSize);

    const newElements = [];
    currPos = listDepth;
    for (const element of elements) {
        if (typeof element === 'number') {
            // It's a kern adjustment
            currPos += element; 
        } else {
            const shift = -element.depth - currPos;
            currPos += element.height + element.depth;
            const childWrap = makeSpan([fontSizer, element]);
            childWrap.setTop(shift);
            newElements.push(childWrap);
        }
    }

    const result = makeSpan(newElements, 'vlist');
    // Fix the final height and depth, in case there were kerns at the ends
    // since makeSpan won't take that into account.
    result.depth = Math.max(listDepth, depth(result) || 0);
    result.height = Math.max(-currPos, height(result) || 0);

    return result;
}

// function makeStrut(base, strutHeight, strutDepth) {
//     const bottomStrut = makeSpan('', 'ML__strut--bottom');
//     if (strutHeight !== undefined) {
//         bottomStrut.setStyle('height', strutHeight + strutDepth, 'em');
//         if (strutDepth) {
//            bottomStrut.setStyle('vertical-align', -strutDepth, 'em');
//         }
//     } else {
//         // const baseDepth = depth(base);
//         // bottomStrut.setStyle('height', height(base) + baseDepth, 'em');
//         // if (baseDepth) {
//         //     bottomStrut.setStyle('vertical-align', -baseDepth, 'em');
//         // }
//     }
//     // bottomStrut.setStyle('border', '1px solid green');

//     if (Array.isArray(base)) {
//         base.unshift(bottomStrut);
//         return base;
//     }
//     return [bottomStrut, base];
// }

// Export the public interface for this module
return { 
    coalesce,
    makeSpan,
    makeOp,
    makeOrd,
    makeRel,
    makeClose,
    makeOpen,
    makeInner,
    makePunct,

    makeSpanOfType,
    makeSymbol,
    makeVlist,
    makeHlist,
    makeStyleWrap,

    // makeStrut,

    makeSVG,

    height,
    depth,
    skew,
    italic
}

})