UNPKG

imsc

Version:

Renders IMSC documents to HTML5 fragments

2,090 lines • 50.7 kB
/*
 * Copyright (c) 2016, Pierre-Anthony Lemieux <pal@sandflow.com>
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * * Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 * * Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 */

import { reportError, reportFatal, reportWarning } from "./error.js";
import { ns_ebutts, ns_ittp, ns_itts, ns_tt, ns_ttp, ns_tts } from "./names.js";
import { byName, byQName } from "./styles.js";
import { ComputedLength, hasOwnProperty, parseLength } from "./utils.js";

/**
 * @module imscDoc
 */

/**
 * @typedef {import("./error").ErrorHandler} ErrorHandler
 * @typedef {import("./parser").Node} Node
 * @typedef {import("./parser").Parser} Parser
 */

/**
 * Allows a client to provide callbacks to handle children of the <metadata> element
 * @typedef {Object} MetadataHandler
 * @property {?OpenTagCallBack} onOpenTag
 * @property {?CloseTagCallBack} onCloseTag
 * @property {?TextCallBack} onText
 */

/**
 * Called when the opening tag of an element node is encountered.
 * @callback OpenTagCallBack
 * @param {string} ns Namespace URI of the element
 * @param {string} name Local name of the element
 * @param {Record<string, {uri: string, name: string, value: string}>} attributes List of attributes, each consisting of a
 *                              `uri`, `name` and `value`
 */

/**
 * Called when the closing tag of an element node is encountered.
 * @callback CloseTagCallBack
 */

/**
 * Called when a text node is encountered.
 * @callback TextCallBack
 * @param {string} contents Contents of the text node
 */

/**
 * Parses an IMSC1 document into an opaque in-memory representation that exposes
 * a single method <pre>getMediaTimeEvents()</pre> that returns a list of time
 * offsets (in seconds) of the ISD, i.e. the points in time where the visual
 * representation of the document change. `metadataHandler` allows the caller to
 * be called back when nodes are present in <metadata> elements.
 *
 * @param {string} xmlstring XML document
 * @param {ErrorHandler} errorHandler Error callback
 * @param {?MetadataHandler} metadataHandler Callback for <Metadata> elements
 * @param {Parser} parser XML parser
 * @returns {?TTElement} Opaque in-memory representation of an IMSC1 document
 */

export function fromParser(xmlstring, errorHandler, metadataHandler, parser) {
    if (!parser) {
        reportFatal(errorHandler, "No parser provided");
    }

    const estack = [];
    const xmllangstack = [];
    const xmlspacestack = [];
    let metadata_depth = 0;
    let doc = null;

    parser.onclosetag = function () {

        if (estack[0] instanceof RegionElement) {

            /* merge referenced styles */

            if (doc.head !== null && doc.head.styling !== null) {
                mergeReferencedStyles(doc.head.styling, estack[0].styleRefs, estack[0].styleAttrs, errorHandler);
            }

            delete estack[0].styleRefs;

        } else if (estack[0] instanceof StylingElement) {

            /* flatten chained referential styling */

            for (const sid in estack[0].styles) {

                if (!hasOwnProperty(estack[0].styles, sid)) continue;

                mergeChainedStyles(estack[0], estack[0].styles[sid], errorHandler);

            }

        } else if (estack[0] instanceof PElement || estack[0] instanceof SpanElement) {

            /* merge anonymous spans */

            if (estack[0].contents.length > 1) {

                const cs = [estack[0].contents[0]];

                let c;

                for (c = 1; c < estack[0].contents.length; c++) {

                    if (estack[0].contents[c] instanceof AnonymousSpanElement &&
                        cs[cs.length - 1] instanceof AnonymousSpanElement) {

                        cs[cs.length - 1].text += estack[0].contents[c].text;

                    } else {

                        cs.push(estack[0].contents[c]);

                    }

                }

                estack[0].contents = cs;

            }

            // remove redundant nested anonymous spans (9.3.3(1)(c))

            if (estack[0] instanceof SpanElement &&
                estack[0].contents.length === 1 &&
                estack[0].contents[0] instanceof AnonymousSpanElement) {

                estack[0].text = estack[0].contents[0].text;
                delete estack[0].contents;

            }

        } else if (estack[0] instanceof ForeignElement) {

            if (estack[0].node.uri === ns_tt &&
                estack[0].node.local === "metadata") {

                /* leave the metadata element */

                metadata_depth--;

            } else if (metadata_depth > 0 &&
                metadataHandler &&
                "onCloseTag" in metadataHandler) {

                /* end of child of metadata element */

                metadataHandler.onCloseTag();

            }

        }

        // TODO: delete stylerefs?

        // maintain the xml:space stack

        xmlspacestack.shift();

        // maintain the xml:lang stack

        xmllangstack.shift();

        // prepare for the next element

        estack.shift();
    };

    parser.ontext = function (str) {

        if (estack[0] === undefined) {

            /* ignoring text outside of elements */

        } else if (estack[0] instanceof SpanElement || estack[0] instanceof PElement) {

            /* ignore children text nodes in ruby container spans */

            if (estack[0] instanceof SpanElement) {

                const ruby = estack[0].styleAttrs[byName.ruby.qname];

                if (ruby === "container" || ruby === "textContainer" || ruby === "baseContainer") {

                    return;

                }

            }

            /* create an anonymous span */

            const s = new AnonymousSpanElement();

            s.initFromText(doc, estack[0], str, xmllangstack[0], xmlspacestack[0], errorHandler);

            estack[0].contents.push(s);

        } else if (estack[0] instanceof ForeignElement &&
            metadata_depth > 0 &&
            metadataHandler &&
            "onText" in metadataHandler) {

            /* text node within a child of metadata element */

            metadataHandler.onText(str);

        }

    };

    parser.onopentag = function (node) {

        // maintain the xml:space stack

        const xmlspace = node.attributes["xml:space"];

        if (xmlspace) {

            xmlspacestack.unshift(xmlspace.value);

        } else {

            if (xmlspacestack.length === 0) {

                xmlspacestack.unshift("default");

            } else {

                xmlspacestack.unshift(xmlspacestack[0]);

            }

        }

        /* maintain the xml:lang stack */

        const xmllang = node.attributes["xml:lang"];

        if (xmllang) {

            xmllangstack.unshift(xmllang.value);

        } else {

            if (xmllangstack.length === 0) {

                xmllangstack.unshift("");

            } else {

                xmllangstack.unshift(xmllangstack[0]);

            }

        }

        /* process the element */

        if (node.uri === ns_tt) {

            if (node.local === "tt") {

                if (doc !== null) {

                    reportFatal(errorHandler, "Two <tt> elements at (" + this.line + "," + this.column + ")");

                }

                doc = new TTElement();

                doc.initFromNode(node, xmllangstack[0], errorHandler);

                estack.unshift(doc);

            } else if (node.local === "head") {

                if (!(estack[0] instanceof TTElement)) {
                    reportFatal(errorHandler, "Parent of <head> element is not <tt> at (" + this.line + "," + this.column + ")");
                }

                estack.unshift(doc.head);

            } else if (node.local === "styling") {

                if (!(estack[0] instanceof HeadElement)) {
                    reportFatal(errorHandler, "Parent of <styling> element is not <head> at (" + this.line + "," + this.column + ")");
                }

                estack.unshift(doc.head.styling);

            } else if (node.local === "style") {

                let s;

                if (estack[0] instanceof StylingElement) {

                    s = new StyleElement();

                    s.initFromNode(node, errorHandler);

                    /* ignore <style> element missing @id */

                    if (!s.id) {

                        reportError(errorHandler, "<style> element missing @id attribute");

                    } else {

                        doc.head.styling.styles[s.id] = s;

                    }

                    estack.unshift(s);

                } else if (estack[0] instanceof RegionElement) {

                    /* nested styles can be merged with specified styles
                     * immediately, with lower priority
                     * (see 8.4.4.2(3) at TTML1 )
                     */

                    s = new StyleElement();

                    s.initFromNode(node, errorHandler);

                    mergeStylesIfNotPresent(s.styleAttrs, estack[0].styleAttrs);

                    estack.unshift(s);

                } else {

                    reportFatal(errorHandler, "Parent of <style> element is not <styling> or <region> at (" + this.line + "," + this.column + ")");

                }

            } else if (node.local === "initial") {

                let ini;

                if (estack[0] instanceof StylingElement) {

                    ini = new InitialElement();

                    ini.initFromNode(node, errorHandler);

                    for (const qn in ini.styleAttrs) {

                        if (!hasOwnProperty(ini.styleAttrs, qn)) continue;

                        doc.head.styling.initials[qn] = ini.styleAttrs[qn];

                    }

                    estack.unshift(ini);

                } else {

                    reportFatal(errorHandler, "Parent of <initial> element is not <styling> at (" + this.line + "," + this.column + ")");

                }

            } else if (node.local === "layout") {

                if (!(estack[0] instanceof HeadElement)) {

                    reportFatal(errorHandler, "Parent of <layout> element is not <head> at " + this.line + "," + this.column + ")");

                }

                estack.unshift(doc.head.layout);

            } else if (node.local === "region") {

                if (!(estack[0] instanceof LayoutElement)) {
                    reportFatal(errorHandler, "Parent of <region> element is not <layout> at " + this.line + "," + this.column + ")");
                }

                const r = new RegionElement();

                r.initFromNode(doc, node, xmllangstack[0], errorHandler);

                if (!r.id || r.id in doc.head.layout.regions) {

                    reportError(errorHandler, "Ignoring <region> with duplicate or missing @id at " + this.line + "," + this.column + ")");

                } else {

                    doc.head.layout.regions[r.id] = r;

                }

                estack.unshift(r);

            } else if (node.local === "body") {

                if (!(estack[0] instanceof TTElement)) {

                    reportFatal(errorHandler, "Parent of <body> element is not <tt> at " + this.line + "," + this.column + ")");

                }

                if (doc.body !== null) {

                    reportFatal(errorHandler, "Second <body> element at " + this.line + "," + this.column + ")");

                }

                const b = new BodyElement();

                b.initFromNode(doc, node, xmllangstack[0], errorHandler);

                doc.body = b;

                estack.unshift(b);

            } else if (node.local === "div") {

                if (!(estack[0] instanceof DivElement || estack[0] instanceof BodyElement)) {

                    reportFatal(errorHandler, "Parent of <div> element is not <body> or <div> at " + this.line + "," + this.column + ")");

                }

                const d = new DivElement();

                d.initFromNode(doc, estack[0], node, xmllangstack[0], errorHandler);

                /* transform smpte:backgroundImage to TTML2 image element */

                const bi = d.styleAttrs[byName.backgroundImage.qname];

                if (bi) {
                    d.contents.push(new ImageElement(bi));
                    delete d.styleAttrs[byName.backgroundImage.qname];
                }

                estack[0].contents.push(d);

                estack.unshift(d);

            } else if (node.local === "image") {

                if (!(estack[0] instanceof DivElement)) {

                    reportFatal(errorHandler, "Parent of <image> element is not <div> at " + this.line + "," + this.column + ")");

                }

                const img = new ImageElement();

                img.initFromNode(doc, estack[0], node, xmllangstack[0], errorHandler);

                estack[0].contents.push(img);

                estack.unshift(img);

            } else if (node.local === "p") {

                if (!(estack[0] instanceof DivElement)) {

                    reportFatal(errorHandler, "Parent of <p> element is not <div> at " + this.line + "," + this.column + ")");

                }

                const p = new PElement();

                p.initFromNode(doc, estack[0], node, xmllangstack[0], errorHandler);

                estack[0].contents.push(p);

                estack.unshift(p);

            } else if (node.local === "span") {

                if (!(estack[0] instanceof SpanElement || estack[0] instanceof PElement)) {

                    reportFatal(errorHandler, "Parent of <span> element is not <span> or <p> at " + this.line + "," + this.column + ")");

                }

                const ns = new SpanElement();

                ns.initFromNode(doc, estack[0], node, xmllangstack[0], xmlspacestack[0], errorHandler);

                estack[0].contents.push(ns);

                estack.unshift(ns);

            } else if (node.local === "br") {

                if (!(estack[0] instanceof SpanElement || estack[0] instanceof PElement)) {

                    reportFatal(errorHandler, "Parent of <br> element is not <span> or <p> at " + this.line + "," + this.column + ")");

                }

                const nb = new BrElement();

                nb.initFromNode(doc, estack[0], node, xmllangstack[0], errorHandler);

                estack[0].contents.push(nb);

                estack.unshift(nb);

            } else if (node.local === "set") {

                if (!(estack[0] instanceof SpanElement ||
                    estack[0] instanceof PElement ||
                    estack[0] instanceof DivElement ||
                    estack[0] instanceof BodyElement ||
                    estack[0] instanceof RegionElement ||
                    estack[0] instanceof BrElement)) {

                    reportFatal(errorHandler, "Parent of <set> element is not a content element or a region at " + this.line + "," + this.column + ")");

                }

                const st = new SetElement();

                st.initFromNode(doc, estack[0], node, errorHandler);

                estack[0].sets.push(st);

                estack.unshift(st);

            } else {

                /* element in the TT namespace, but not a content element */

                estack.unshift(new ForeignElement(node));
            }

        } else {

            /* ignore elements not in the TTML namespace unless in metadata element */

            estack.unshift(new ForeignElement(node));

        }

        /* handle metadata callbacks */

        if (estack[0] instanceof ForeignElement) {

            if (node.uri === ns_tt &&
                node.local === "metadata") {

                /* enter the metadata element */

                metadata_depth++;

            } else if (
                metadata_depth > 0 &&
                metadataHandler &&
                "onOpenTag" in metadataHandler
            ) {

                /* start of child of metadata element */

                const attrs = [];

                for (const a in node.attributes) {
                    attrs[node.attributes[a].uri + " " + node.attributes[a].local] =
                    {
                        uri: node.attributes[a].uri,
                        local: node.attributes[a].local,
                        value: node.attributes[a].value,
                    };
                }

                metadataHandler.onOpenTag(node.uri, node.local, attrs);

            }

        }

    };

    // parse the document

    parser.write(xmlstring).close();

    // all referential styling has been flatten, so delete styles

    delete doc.head.styling.styles;

    // create default region if no regions specified

    const regions = doc.head.layout.regions;
    let hasRegions = false;

    /* AFAIK the only way to determine whether an object has members */

    for (const i in regions) {

        if (hasOwnProperty(regions, i)) {
            hasRegions = true;
            break;
        }

    }

    if (!hasRegions) {

        /* create default region */

        const dr = RegionElement.prototype.createDefaultRegion(doc.lang);

        regions[dr.id] = dr;

    }

    /* resolve desired timing for regions */

    for (const region_i in regions) {

        if (!hasOwnProperty(regions, region_i)) continue;

        resolveTiming(doc, regions[region_i], null, null);

    }

    /* resolve desired timing for content elements */

    if (doc.body) {
        resolveTiming(doc, doc.body, null, null);
    }

    /* remove undefined spans in ruby containers */

    if (doc.body) {
        cleanRubyContainers(doc.body);
    }

    return doc;
};

function cleanRubyContainers(element) {

    if (!("contents" in element)) return;

    const rubyval = "styleAttrs" in element ? element.styleAttrs[byName.ruby.qname] : null;

    const isrubycontainer = (element.kind === "span" && (rubyval === "container" || rubyval === "textContainer" || rubyval === "baseContainer"));

    for (let i = element.contents.length - 1; i >= 0; i--) {

        if (isrubycontainer && !("styleAttrs" in element.contents[i] && byName.ruby.qname in element.contents[i].styleAttrs)) {

            /* prune undefined <span> in ruby containers */

            delete element.contents[i];

        } else {

            cleanRubyContainers(element.contents[i]);

        }

    }

}

function resolveTiming(doc, element, prev_sibling, parent) {

    /* are we in a seq container? */

    const isinseq = parent && parent.timeContainer === "seq";

    /* determine implicit begin */

    let implicit_begin = 0; /* default */

    if (parent) {

        if (isinseq && prev_sibling) {

            /*
             * if seq time container, offset from the previous sibling end
             */

            implicit_begin = prev_sibling.end;

        } else {

            implicit_begin = parent.begin;

        }

    }

    /* compute desired begin */

    element.begin = element.explicit_begin ? element.explicit_begin + implicit_begin : implicit_begin;

    /* determine implicit end */

    let implicit_end = element.begin;

    let s = null;

    if ("sets" in element) {

        for (let set_i = 0; set_i < element.sets.length; set_i++) {

            resolveTiming(doc, element.sets[set_i], s, element);

            if (element.timeContainer === "seq") {

                implicit_end = element.sets[set_i].end;

            } else {

                implicit_end = Math.max(implicit_end, element.sets[set_i].end);

            }

            s = element.sets[set_i];

        }

    }

    if (!("contents" in element)) {

        /* anonymous spans and regions and <set> and <br>s and spans with only children text nodes */

        if (isinseq) {

            /* in seq container, implicit duration is zero */

            implicit_end = element.begin;

        } else {

            /* in par container, implicit duration is indefinite */

            implicit_end = Number.POSITIVE_INFINITY;

        }

    } else if ("contents" in element) {

        for (let content_i = 0; content_i < element.contents.length; content_i++) {

            resolveTiming(doc, element.contents[content_i], s, element);

            if (element.timeContainer === "seq") {

                implicit_end = element.contents[content_i].end;

            } else {

                implicit_end = Math.max(implicit_end, element.contents[content_i].end);

            }

            s = element.contents[content_i];

        }

    }

    /* determine desired end */
    /* it is never made really clear in SMIL that the explicit end is offset by the implicit begin */

    if (element.explicit_end !== null && element.explicit_dur !== null) {

        element.end = Math.min(element.begin + element.explicit_dur, implicit_begin + element.explicit_end);

    } else if (element.explicit_end === null && element.explicit_dur !== null) {

        element.end = element.begin + element.explicit_dur;

    } else if (element.explicit_end !== null && element.explicit_dur === null) {

        element.end = implicit_begin + element.explicit_end;

    } else {

        element.end = implicit_end;
    }

    delete element.explicit_begin;
    delete element.explicit_dur;
    delete element.explicit_end;

    doc._registerEvent(element);

}

export class ForeignElement {
    constructor(node) {
        this.node = node;
    }
}

export class TTElement {
    constructor() {
        /**
         * @type {number[]}
         */
        this.events = [];
        this.head = new HeadElement();

        /**
         * @type {?BodyElement}
         */
        this.body = null;
    }

    /**
     * @param {Node} node
     * @param {string} xmllang
     * @param {ErrorHandler} errorHandler
     */
    initFromNode(node, xmllang, errorHandler) {

        /* compute cell resolution */

        const cr = extractCellResolution(node, errorHandler);

        this.cellLength = {
            "h": new ComputedLength(0, 1 / cr.h),
            "w": new ComputedLength(1 / cr.w, 0),
        };

        /* extract frame rate and tick rate */

        const frtr = extractFrameAndTickRate(node, errorHandler);

        this.effectiveFrameRate = frtr.effectiveFrameRate;

        this.tickRate = frtr.tickRate;

        /* extract aspect ratio */

        this.aspectRatio = extractAspectRatio(node, errorHandler);

        /* check timebase */

        const attr = findAttribute(node, ns_ttp, "timeBase");

        if (attr !== null && attr !== "media") {

            reportFatal(errorHandler, "Unsupported time base");

        }

        /* retrieve extent */

        const e = extractExtent(node, errorHandler);

        if (e === null) {

            this.pxLength = {
                "h": null,
                "w": null,
            };

        } else {

            if (e.h.unit !== "px" || e.w.unit !== "px") {
                reportFatal(errorHandler, "Extent on TT must be in px or absent");
            }

            this.pxLength = {
                "h": new ComputedLength(0, 1 / e.h.value),
                "w": new ComputedLength(1 / e.w.value, 0),
            };
        }

        /** set root container dimensions to (1, 1) arbitrarily
          * the root container is mapped to actual dimensions at rendering
        **/

        this.dimensions = {
            "h": new ComputedLength(0, 1),
            "w": new ComputedLength(1, 0),

        };

        /* xml:lang */
        /**
         * @type {string}
         */
        this.lang = xmllang;

    }

    /* register a temporal events */
    _registerEvent(elem) {

        /* skip if begin is not < then end */

        if (elem.end <= elem.begin)
            return;

        /* index the begin time of the event */

        const b_i = indexOf(this.events, elem.begin);

        if (!b_i.found) {
            this.events.splice(b_i.index, 0, elem.begin);
        }

        /* index the end time of the event */

        if (elem.end !== Number.POSITIVE_INFINITY) {

            const e_i = indexOf(this.events, elem.end);

            if (!e_i.found) {
                this.events.splice(e_i.index, 0, elem.end);
            }

        }

    }

    /**
     * Retrieves the range of ISD times covered by the document
     *
     * @returns {[number, number]} Array of two elements: min_begin_time and max_begin_time
     *
     */
    getMediaTimeRange() {

        return [this.events[0], this.events[this.events.length - 1]];
    };

    /**
     * Returns list of ISD begin times
     *
     * @returns {number[]}
     */
    getMediaTimeEvents() {

        return this.events;
    };
}

/*
 * Represents a TTML Head element
 */

export class HeadElement {
    constructor() {
        this.styling = new StylingElement();
        this.layout = new LayoutElement();
    }
}

/*
 * Represents a TTML Styling element
 */

export class StylingElement {
    constructor() {
        /**
         * @type {Record<string, StyleElement>}
         */
        this.styles = {};

        /**
         * @type {Record<string, any>}
         */
        this.initials = {};
    }
}

/*
 * Represents a TTML Style element
 */

export class StyleElement {
    constructor() {
        /**
         * @type {string}
         */
        this.id = null;

        /**
         * @type {Record<string, any>}
         */
        this.styleAttrs = null;

        /**
         * @type {string[]}
         */
        this.styleRefs = null;
    }

    /**
     * @param {Node} node
     * @param {ErrorHandler} errorHandler
     */
    initFromNode(node, errorHandler) {
        this.id = elementGetXMLID(node);
        this.styleAttrs = elementGetStyles(node, errorHandler);
        this.styleRefs = elementGetStyleRefs(node);
    };
}

/*
 * Represents a TTML initial element
 */

export class InitialElement {
    constructor() {
        /**
         * @type {Record<string, string>}
         */
        this.styleAttrs = null;
    }

    /**
     * @param {Node} node
     */
    initFromNode(node) {

        this.styleAttrs = {};

        for (const i in node.attributes) {

            if (node.attributes[i].uri === ns_itts ||
                node.attributes[i].uri === ns_ebutts ||
                node.attributes[i].uri === ns_tts) {

                const qname = node.attributes[i].uri + " " + node.attributes[i].local;

                this.styleAttrs[qname] = node.attributes[i].value;

            }
        }

    }
}

/*
 * Represents a TTML Layout element
 *
 */

export class LayoutElement {
    constructor() {
        this.regions = {};
    }
}

export class ContentElement {
    /**
     * @param {string} kind
     */
    constructor(kind) {
        this.kind = kind;
    }
}

/*
 * Represents a TTML image element
 */

export class ImageElement extends ContentElement {
    /**
     * @param {string} src
     * @param {string} type
     */
    constructor(src, type) {
        super("image");

        /**
         * @type {string}
         */
        this.src = src;

        /**
         * @type {string}
         */
        this.type = type;
    }

    /**
     * @param {TTElement} doc
     * @param {Node} parent
     * @param {Node} node
     * @param {string} xmllang
     * @param {ErrorHandler} errorHandler
     */
    initFromNode(doc, parent, node, xmllang, errorHandler) {
        this.src = "src" in node.attributes ? node.attributes.src.value : null;

        if (!this.src) {
            reportError(errorHandler, "Invalid image@src attribute");
        }

        this.type = "type" in node.attributes ? node.attributes.type.value : null;

        if (!this.type) {
            reportError(errorHandler, "Invalid image@type attribute");
        }

        StyledMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        TimedMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        AnimatedMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        LayoutMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);

        this.lang = xmllang;
    }
}

/*
 * TTML element utility functions
 *
 */

export class IdentifiedMixin {
    /**
     * @param {string} id
     */
    constructor(id) {
        this.id = id;
    }

    /**
     * @param {TTElement} doc
     * @param {Node} parent
     * @param {Node} node
     */
    initFromNode(doc, parent, node) {
        this.id = elementGetXMLID(node);
    }
}

export class LayoutMixin {
    /**
     * @param {string} id
     */
    constructor(id) {
        this.regionID = id;
    }

    /**
     * @param {TTElement} doc
     * @param {Node} parent
     * @param {Node} node
     */
    initFromNode(doc, parent, node) {
        this.regionID = elementGetRegionID(node);
    }
}

export class StyledMixin {
    /**
     * @param {Record<string, any>} styleAttrs
     */
    constructor(styleAttrs) {
        this.styleAttrs = styleAttrs;
    }

    /**
     * @param {TTElement} doc
     * @param {Node} parent
     * @param {Node} node
     * @param {ErrorHandler} errorHandler
     */
    initFromNode(doc, parent, node, errorHandler) {

        this.styleAttrs = elementGetStyles(node, errorHandler);

        if (doc.head !== null && doc.head.styling !== null) {
            mergeReferencedStyles(doc.head.styling, elementGetStyleRefs(node), this.styleAttrs, errorHandler);
        }

    }
}

export class AnimatedMixin {
    /**
     * @param {any[]} sets
     */
    constructor(sets) {
        this.sets = sets;
    }

    initFromNode() {
        this.sets = [];
    }
}

export class ContainerMixin {
    /**
     * @param {string} contents
     */
    constructor(contents) {
        this.contents = contents;
    }

    initFromNode() {
        this.contents = [];
    }
}

export class TimedMixin {
    /**
     *
     * @param {number} explicit_begin
     * @param {number} explicit_end
     * @param {number} explicit_dur
     */
    constructor(explicit_begin, explicit_end, explicit_dur) {
        this.explicit_begin = explicit_begin;
        this.explicit_end = explicit_end;
        this.explicit_dur = explicit_dur;
    }

    /**
     * @param {TTElement} doc
     * @param {Node} parent
     * @param {Node} node
     * @param {ErrorHandler} errorHandler
     */
    initFromNode(doc, parent, node, errorHandler) {
        const t = processTiming(doc, parent, node, errorHandler);
        this.explicit_begin = t.explicit_begin;
        this.explicit_end = t.explicit_end;
        this.explicit_dur = t.explicit_dur;

        this.timeContainer = elementGetTimeContainer(node, errorHandler);
    }
}

/*
 * Represents a TTML body element
 */

export class BodyElement extends ContentElement {
    constructor() {
        super("body");
    }

    /**
     * @param {TTElement} doc
     * @param {Node} node
     * @param {string} xmllang
     * @param {ErrorHandler} errorHandler
     */
    initFromNode(doc, node, xmllang, errorHandler) {
        StyledMixin.prototype.initFromNode.call(this, doc, null, node, errorHandler);
        TimedMixin.prototype.initFromNode.call(this, doc, null, node, errorHandler);
        AnimatedMixin.prototype.initFromNode.call(this, doc, null, node, errorHandler);
        LayoutMixin.prototype.initFromNode.call(this, doc, null, node, errorHandler);
        ContainerMixin.prototype.initFromNode.call(this, doc, null, node, errorHandler);

        this.lang = xmllang;
    }
}

/*
 * Represents a TTML div element
 */

export class DivElement extends ContentElement {
    constructor() {
        super("div");
    }

    /**
     * @param {TTElement} doc
     * @param {Node} parent
     * @param {Node} node
     * @param {string} xmllang
     * @param {ErrorHandler} errorHandler
     */
    initFromNode(doc, parent, node, xmllang, errorHandler) {
        StyledMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        TimedMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        AnimatedMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        LayoutMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        ContainerMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);

        this.lang = xmllang;
    }
}

/*
 * Represents a TTML p element
 */

export class PElement extends ContentElement {
    constructor() {
        super("p");
    }

    /**
     * @param {TTElement} doc
     * @param {Node} parent
     * @param {Node} node
     * @param {string} xmllang
     * @param {ErrorHandler} errorHandler
     */
    initFromNode(doc, parent, node, xmllang, errorHandler) {
        StyledMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        TimedMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        AnimatedMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        LayoutMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        ContainerMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);

        this.lang = xmllang;
    }
}

/*
 * Represents a TTML span element
 */

export class SpanElement extends ContentElement {
    constructor() {
        super("span");
    }

    /**
     * @param {TTElement} doc
     * @param {Node} parent
     * @param {Node} node
     * @param {string} xmllang
     * @param {string} xmlspace
     * @param {ErrorHandler} errorHandler
     */
    initFromNode(doc, parent, node, xmllang, xmlspace, errorHandler) {
        StyledMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        TimedMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        AnimatedMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        LayoutMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        ContainerMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);

        this.space = xmlspace;
        this.lang = xmllang;
    }
}

/*
 * Represents a TTML anonymous span element
 */

export class AnonymousSpanElement extends ContentElement {
    constructor() {
        super("span");
    }

    /**
     * @param {TTElement} doc
     * @param {Node} parent
     * @param {string} text
     * @param {string} xmlspace
     * @param {string} xmllang
     * @param {ErrorHandler} errorHandler
     */
    initFromText(doc, parent, text, xmllang, xmlspace, errorHandler) {
        TimedMixin.prototype.initFromNode.call(this, doc, parent, null, errorHandler);

        this.text = text;
        this.space = xmlspace;
        this.lang = xmllang;
    }
}

/*
 * Represents a TTML br element
 */

export class BrElement extends ContentElement {
    constructor() {
        super("br");
    }

    /**
     * @param {TTElement} doc
     * @param {Node} parent
     * @param {Node} node
     * @param {string} xmllang
     * @param {ErrorHandler} errorHandler
     */
    initFromNode(doc, parent, node, xmllang, errorHandler) {
        LayoutMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);
        TimedMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);

        this.lang = xmllang;
    }
}

/*
 * Represents a TTML Region element
 *
 */

export class RegionElement {
    constructor() { }

    /**
     * @param {string} xmllang
     * @returns {RegionElement}
     */
    createDefaultRegion(xmllang) {
        const r = new RegionElement();

        r.id = "";
        r.styleAttrs = {};
        r.sets = [];
        r.explicit_begin = 0;
        r.explicit_end = Number.POSITIVE_INFINITY;
        r.explicit_dur = null;

        this.lang = xmllang;

        return r;
    }

    /**
     * @param {TTElement} doc
     * @param {Node} node
     * @param {string} xmllang
     * @param {ErrorHandler} errorHandler
     */
    initFromNode(doc, node, xmllang, errorHandler) {
        IdentifiedMixin.prototype.initFromNode.call(this, doc, null, node, errorHandler);
        TimedMixin.prototype.initFromNode.call(this, doc, null, node, errorHandler);
        AnimatedMixin.prototype.initFromNode.call(this, doc, null, node, errorHandler);

        /* add specified styles */

        this.styleAttrs = elementGetStyles(node, errorHandler);

        /* remember referential styles for merging after nested styling is processed*/

        this.styleRefs = elementGetStyleRefs(node);

        /* xml:lang */

        this.lang = xmllang;
    }
}

/*
 * Represents a TTML Set element
 *
 */

export class SetElement {
    constructor() {
    }

    /**
     * @param {TTElement} doc
     * @param {Node} parent
     * @param {Node} node
     * @param {ErrorHandler} errorHandler
     */
    initFromNode(doc, parent, node, errorHandler) {

        TimedMixin.prototype.initFromNode.call(this, doc, parent, node, errorHandler);

        this.styles = elementGetStyles(node, errorHandler);

    }
}

/*
 * Utility functions
 *
 */

/**
 * @param {Node} node
 * @returns {string | null}
 */
function elementGetXMLID(node) {
    return node && "xml:id" in node.attributes ? node.attributes["xml:id"].value || null : null;
}

/**
 * @param {Node} node
 * @returns {string}
 */
function elementGetRegionID(node) {
    return node && "region" in node.attributes ? node.attributes.region.value : "";
}

/**
 * @param {Node} node
 * @param {ErrorHandler} errorHandler
 * @returns {string}
 */
function elementGetTimeContainer(node, errorHandler) {

    const tc = node && "timeContainer" in node.attributes ? node.attributes.timeContainer.value : null;

    if ((!tc) || tc === "par") {

        return "par";

    } else if (tc === "seq") {

        return "seq";

    } else {

        reportError(errorHandler, "Illegal value of timeContainer (assuming 'par')");

        return "par";

    }

}

/**
 * @param {Node} node
 * @returns {string[]}
 */
function elementGetStyleRefs(node) {

    return node && "style" in node.attributes ? node.attributes.style.value.split(" ") : [];

}

/**
 * @param {Node} node
 * @param {ErrorHandler} errorHandler
 * @returns {Record<string, any>}
 */
function elementGetStyles(node, errorHandler) {

    const s = {};

    if (node !== null) {

        for (const i in node.attributes) {

            const qname = node.attributes[i].uri + " " + node.attributes[i].local;

            const sa = byQName[qname];

            if (sa !== undefined) {

                const val = sa.parse(node.attributes[i].value);

                if (val !== null) {

                    if (qname in s) {
                        reportWarning(errorHandler, "More than one style attribute for " + qname);
                    }

                    s[qname] = val;

                    /* TODO: consider refactoring errorHandler into parse and compute routines */

                    if (sa === byName.zIndex) {
                        reportWarning(errorHandler, "zIndex attribute present but not used by IMSC1 since regions do not overlap");
                    }

                } else {

                    reportError(errorHandler, "Cannot parse styling attribute " + qname);

                }

            }

        }

    }

    return s;
}

/**
 * @param {Node} node
 * @param {string} ns
 * @param {string} name
 * @returns {string | null}
 */
function findAttribute(node, ns, name) {
    for (const i in node.attributes) {

        if (node.attributes[i].uri === ns &&
            node.attributes[i].local === name) {

            return node.attributes[i].value;
        }
    }

    return null;
}

/**
 * @param {Node} node
 * @param {ErrorHandler} errorHandler
 * @returns {number | null}
 */
function extractAspectRatio(node, errorHandler) {

    let ar = findAttribute(node, ns_ittp, "aspectRatio");

    if (ar === null) {

        ar = findAttribute(node, ns_ttp, "displayAspectRatio");

    }

    let rslt = null;

    if (ar !== null) {

        const ASPECT_RATIO_RE = /(\d+)\s+(\d+)/;

        const m = ASPECT_RATIO_RE.exec(ar);

        if (m !== null) {

            const w = parseInt(m[1]);

            const h = parseInt(m[2]);

            if (w !== 0 && h !== 0) {

                rslt = w / h;

            } else {

                reportError(errorHandler, "Illegal aspectRatio values (ignoring)");
            }

        } else {

            reportError(errorHandler, "Malformed aspectRatio attribute (ignoring)");
        }

    }

    return rslt;

}

/**
 * Returns the cellResolution attribute from a node
 *
 * @param {Node} node
 * @param {ErrorHandler} errorHandler
 * @returns {{w: number, h: number}}
 */
function extractCellResolution(node, errorHandler) {

    const cr = findAttribute(node, ns_ttp, "cellResolution");

    // initial value

    let h = 15;
    let w = 32;

    if (cr !== null) {

        const CELL_RESOLUTION_RE = /(\d+) (\d+)/;

        const m = CELL_RESOLUTION_RE.exec(cr);

        if (m !== null) {

            w = parseInt(m[1]);

            h = parseInt(m[2]);

        } else {

            reportWarning(errorHandler, "Malformed cellResolution value (using initial value instead)");

        }

    }

    return { "w": w, "h": h };

}

/**
 * @param {Node} node
 * @param {ErrorHandler} errorHandler
 * @returns {{ effectiveFrameRate: number, tickRate: number }}
 */
function extractFrameAndTickRate(node, errorHandler) {

    // subFrameRate is ignored per IMSC1 specification

    // extract frame rate

    const fps_attr = findAttribute(node, ns_ttp, "frameRate");

    // initial value

    let fps = 30;

    // match variable

    let m;

    if (fps_attr !== null) {

        const FRAME_RATE_RE = /(\d+)/;

        m = FRAME_RATE_RE.exec(fps_attr);

        if (m !== null) {

            fps = parseInt(m[1]);

        } else {

            reportWarning(errorHandler, "Malformed frame rate attribute (using initial value instead)");
        }

    }

    // extract frame rate multiplier

    const frm_attr = findAttribute(node, ns_ttp, "frameRateMultiplier");

    // initial value

    let frm = 1;

    if (frm_attr !== null) {

        const FRAME_RATE_MULT_RE = /(\d+) (\d+)/;

        m = FRAME_RATE_MULT_RE.exec(frm_attr);

        if (m !== null) {

            frm = parseInt(m[1]) / parseInt(m[2]);

        } else {

            reportWarning(errorHandler, "Malformed frame rate multiplier attribute (using initial value instead)");
        }

    }

    const efps = frm * fps;

    // extract tick rate

    let tr = 1;

    const trattr = findAttribute(node, ns_ttp, "tickRate");

    if (trattr === null) {

        if (fps_attr !== null)
            tr = efps;

    } else {

        const TICK_RATE_RE = /(\d+)/;

        m = TICK_RATE_RE.exec(trattr);

        if (m !== null) {

            tr = parseInt(m[1]);

        } else {

            reportWarning(errorHandler, "Malformed tick rate attribute (using initial value instead)");
        }

    }

    return { effectiveFrameRate: efps, tickRate: tr };

}

/**
 * @param {Node} node
 * @param {ErrorHandler} errorHandler
 * @returns {{w: number, h: number} | null}
 */
function extractExtent(node, errorHandler) {

    const attr = findAttribute(node, ns_tts, "extent");

    if (attr === null)
        return null;

    const s = attr.split(" ");

    if (s.length !== 2) {

        reportWarning(errorHandler, "Malformed extent (ignoring)");

        return null;
    }

    const w = parseLength(s[0]);

    const h = parseLength(s[1]);

    if (!h || !w) {

        reportWarning(errorHandler, "Malformed extent values (ignoring)");

        return null;
    }

    return { "h": h, "w": w };

}

/**
 * @param {number} tickRate
 * @param {number} effectiveFrameRate
 * @param {string} str
 * @returns {number | null}
 */
function parseTimeExpression(tickRate, effectiveFrameRate, str) {

    const CLOCK_TIME_FRACTION_RE = /^(\d{2,}):(\d\d):(\d\d(?:\.\d+)?)$/;
    const CLOCK_TIME_FRAMES_RE = /^(\d{2,}):(\d\d):(\d\d):(\d{2,})$/;
    const OFFSET_FRAME_RE = /^(\d+(?:\.\d+)?)f$/;
    const OFFSET_TICK_RE = /^(\d+(?:\.\d+)?)t$/;
    const OFFSET_MS_RE = /^(\d+(?:\.\d+)?)ms$/;
    const OFFSET_S_RE = /^(\d+(?:\.\d+)?)s$/;
    const OFFSET_H_RE = /^(\d+(?:\.\d+)?)h$/;
    const OFFSET_M_RE = /^(\d+(?:\.\d+)?)m$/;
    let m;
    let r = null;
    if ((m = OFFSET_FRAME_RE.exec(str)) !== null) {

        if (effectiveFrameRate !== null) {

            r = parseFloat(m[1]) / effectiveFrameRate;
        }

    } else if ((m = OFFSET_TICK_RE.exec(str)) !== null) {

        if (tickRate !== null) {

            r = parseFloat(m[1]) / tickRate;
        }

    } else if ((m = OFFSET_MS_RE.exec(str)) !== null) {

        r = parseFloat(m[1]) / 1000.0;

    } else if ((m = OFFSET_S_RE.exec(str)) !== null) {

        r = parseFloat(m[1]);

    } else if ((m = OFFSET_H_RE.exec(str)) !== null) {

        r = parseFloat(m[1]) * 3600.0;

    } else if ((m = OFFSET_M_RE.exec(str)) !== null) {

        r = parseFloat(m[1]) * 60.0;

    } else if ((m = CLOCK_TIME_FRACTION_RE.exec(str)) !== null) {

        r = parseInt(m[1]) * 3600 +
            parseInt(m[2]) * 60 +
            parseFloat(m[3]);

    } else if ((m = CLOCK_TIME_FRAMES_RE.exec(str)) !== null) {

        /* this assumes that HH:MM:SS is a clock-time-with-fraction */

        if (effectiveFrameRate !== null) {

            r = parseInt(m[1]) * 3600 +
                parseInt(m[2]) * 60 +
                parseInt(m[3]) +
                (m[4] === null ? 0 : parseInt(m[4]) / effectiveFrameRate);
        }

    }

    return r;
}

/**
 * @param {TTElement} doc
 * @param {Node} parent
 * @param {Node} node
 * @param {ErrorHandler} errorHandler
 * @returns {{explicit_begin: number, explicit_end: number, explicit_dur: number}}
 */
function processTiming(doc, parent, node, errorHandler) {

    /* determine explicit begin */

    let explicit_begin = null;

    if (node && "begin" in node.attributes) {

        explicit_begin = parseTimeExpression(doc.tickRate, doc.effectiveFrameRate, node.attributes.begin.value);

        if (explicit_begin === null) {

            reportWarning(errorHandler, "Malformed begin value (using 0)");

        }

    }

    /* determine explicit duration */

    let explicit_dur = null;

    if (node && "dur" in node.attributes) {

        explicit_dur = parseTimeExpression(doc.tickRate, doc.effectiveFrameRate, node.attributes.dur.value);

        if (explicit_dur === null) {

            reportWarning(errorHandler, "Malformed dur value (ignoring)");

        }

    }

    /* determine explicit end */

    let explicit_end = null;

    if (node && "end" in node.attributes) {

        explicit_end = parseTimeExpression(doc.tickRate, doc.effectiveFrameRate, node.attributes.end.value);

        if (explicit_end === null) {

            reportWarning(errorHandler, "Malformed end value (ignoring)");

        }

    }

    return {
        explicit_begin: explicit_begin,
        explicit_end: explicit_end,
        explicit_dur: explicit_dur,
    };

}

/**
 * @param {StylingElement} styling
 * @param {StyleElement} style
 * @param {ErrorHandler} errorHandler
 */
function mergeChainedStyles(styling, style, errorHandler) {

    while (style.styleRefs.length > 0) {

        const sref = style.styleRefs.pop();

        if (!(sref in styling.styles)) {
            reportError(errorHandler, "Non-existant style id referenced");
            continue;
        }

        mergeChainedStyles(styling, styling.styles[sref], errorHandler);

        mergeStylesIfNotPresent(styling.styles[sref].styleAttrs, style.styleAttrs);

    }

}

/**
 * @param {StylingElement} styling
 * @param {string[]} stylerefs
 * @param {Record<string, any>} styleattrs
 * @param {ErrorHandler} errorHandler
 */
function mergeReferencedStyles(styling, stylerefs, styleattrs, errorHandler) {

    for (let i = stylerefs.length - 1; i >= 0; i--) {

        const sref = stylerefs[i];

        if (!(sref in styling.styles)) {
            reportError(errorHandler, "Non-existant style id referenced");
            continue;
        }

        mergeStylesIfNotPresent(styling.styles[sref].styleAttrs, styleattrs);

    }

}

/**
 * @param {Record<string, any>} from_styles
 * @param {Record<string, any>} into_styles
 */
function mergeStylesIfNotPresent(from_styles, into_styles) {

    for (const sname in from_styles) {

        if (!hasOwnProperty(from_styles, sname)) continue;

        if (sname in into_styles)
            continue;

        into_styles[sname] = from_styles[sname];

    }

}

/* TODO: validate style format at parsing */

/**
 * Binary search utility function
 *
 * @typedef {Object} BinarySearchResult
 * @property {boolean} found Was an exact match found?
 * @property {number} index Position of the exact match or insert position
 *
 * @param {number[]} arr
 * @param {number} searchval
 * @returns {BinarySearchResult}
 */

function indexOf(arr, searchval) {

    let min = 0;
    let max = arr.length - 1;
    let cur;

    while (min <= max) {

        cur = Math.floor((min + max) / 2);

        const curval = arr[cur];

        if (curval < searchval) {

            min = cur + 1;

        } else if (curval > searchval) {

            max = cur - 1;

        } else {

            return { found: true, index: cur };

        }

    }

    return { found: false, index: min };
}