UNPKG

imsc

Version:

Renders IMSC documents to HTML5 fragments

830 lines (540 loc) 26 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. */ /** * @module imscISD */ ; (function (imscISD, imscNames, imscStyles, imscUtils) { // wrapper for non-node envs /** * Creates a canonical representation of an IMSC1 document returned by <pre>imscDoc.fromXML()</pre> * at a given absolute offset in seconds. This offset does not have to be one of the values returned * by <pre>getMediaTimeEvents()</pre>. * * @param {Object} tt IMSC1 document * @param {number} offset Absolute offset (in seconds) * @param {?module:imscUtils.ErrorHandler} errorHandler Error callback * @returns {Object} Opaque in-memory representation of an ISD */ imscISD.generateISD = function (tt, offset, errorHandler) { /* TODO check for tt and offset validity */ /* create the ISD object from the IMSC1 doc */ var isd = new ISD(tt); /* context */ var context = { /*rubyfs: []*/ /* font size of the nearest textContainer or container */ }; /* Filter body contents - Only process what we need within the offset and discard regions not applicable to the content */ var body = {}; var activeRegions = {}; /* gather any regions that might have showBackground="always" and show a background */ var initialShowBackground = tt.head.styling.initials[imscStyles.byName.showBackground.qname]; var initialbackgroundColor = tt.head.styling.initials[imscStyles.byName.backgroundColor.qname]; for (var layout_child in tt.head.layout.regions) { if (tt.head.layout.regions.hasOwnProperty(layout_child)) { var region = tt.head.layout.regions[layout_child]; var showBackground = region.styleAttrs[imscStyles.byName.showBackground.qname] || initialShowBackground; var backgroundColor = region.styleAttrs[imscStyles.byName.backgroundColor.qname] || initialbackgroundColor; activeRegions[region.id] = ( (showBackground === 'always' || showBackground === undefined) && backgroundColor !== undefined && !(offset < region.begin || offset >= region.end) ); } } /* If the body specifies a region, catch it, since no filtered content will */ /* likely specify the region. */ if (tt.body && tt.body.regionID) { activeRegions[tt.body.regionID] = true; } function filter(offset, element) { function offsetFilter(element) { return !(offset < element.begin || offset >= element.end); } if (element.contents) { var clone = {}; for (var prop in element) { if (element.hasOwnProperty(prop)) { clone[prop] = element[prop]; } } clone.contents = []; element.contents.filter(offsetFilter).forEach(function (el) { var filteredElement = filter(offset, el); if (filteredElement.regionID) { activeRegions[filteredElement.regionID] = true; } if (filteredElement !== null) { clone.contents.push(filteredElement); } }); return clone; } else { return element; } } if (tt.body !== null) { body = filter(offset, tt.body); } else { body = null; } /* rewritten TTML will always have a default - this covers it. because the region is defaulted to "" */ if (activeRegions[""] !== undefined) { activeRegions[""] = true; } /* process regions */ for (var regionID in activeRegions) { if (activeRegions[regionID]) { /* post-order traversal of the body tree per [construct intermediate document] */ var c = isdProcessContentElement(tt, offset, tt.head.layout.regions[regionID], body, null, '', tt.head.layout.regions[regionID], errorHandler, context); if (c !== null) { /* add the region to the ISD */ isd.contents.push(c.element); } } } return isd; }; /* set of styles not applicable to ruby container spans */ var _rcs_na_styles = [ imscStyles.byName.color.qname, imscStyles.byName.textCombine.qname, imscStyles.byName.textDecoration.qname, imscStyles.byName.textEmphasis.qname, imscStyles.byName.textOutline.qname, imscStyles.byName.textShadow.qname ]; function isdProcessContentElement(doc, offset, region, body, parent, inherited_region_id, elem, errorHandler, context) { /* prune if temporally inactive */ if (offset < elem.begin || offset >= elem.end) { return null; } /* * set the associated region as specified by the regionID attribute, or the * inherited associated region otherwise */ var associated_region_id = 'regionID' in elem && elem.regionID !== '' ? elem.regionID : inherited_region_id; /* prune the element if either: * - the element is not terminal and the associated region is neither the default * region nor the parent region (this allows children to be associated with a * region later on) * - the element is terminal and the associated region is not the parent region */ /* TODO: improve detection of terminal elements since <region> has no contents */ if (parent !== null /* are we in the region element */ && associated_region_id !== region.id && ( (!('contents' in elem)) || ('contents' in elem && elem.contents.length === 0) || associated_region_id !== '' ) ) return null; /* create an ISD element, including applying specified styles */ var isd_element = new ISDContentElement(elem); /* apply set (animation) styling */ if ("sets" in elem) { for (var i = 0; i < elem.sets.length; i++) { if (offset < elem.sets[i].begin || offset >= elem.sets[i].end) continue; isd_element.styleAttrs[elem.sets[i].qname] = elem.sets[i].value; } } /* * keep track of specified styling attributes so that we * can compute them later */ var spec_attr = {}; for (var qname in isd_element.styleAttrs) { if (! isd_element.styleAttrs.hasOwnProperty(qname)) continue; spec_attr[qname] = true; /* special rule for tts:writingMode (section 7.29.1 of XSL) * direction is set consistently with writingMode only * if writingMode sets inline-direction to LTR or RTL */ if (isd_element.kind === 'region' && qname === imscStyles.byName.writingMode.qname && !(imscStyles.byName.direction.qname in isd_element.styleAttrs)) { var wm = isd_element.styleAttrs[qname]; if (wm === "lrtb" || wm === "lr") { isd_element.styleAttrs[imscStyles.byName.direction.qname] = "ltr"; } else if (wm === "rltb" || wm === "rl") { isd_element.styleAttrs[imscStyles.byName.direction.qname] = "rtl"; } } } /* inherited styling */ if (parent !== null) { for (var j = 0; j < imscStyles.all.length; j++) { var sa = imscStyles.all[j]; /* textDecoration has special inheritance rules */ if (sa.qname === imscStyles.byName.textDecoration.qname) { /* handle both textDecoration inheritance and specification */ var ps = parent.styleAttrs[sa.qname]; var es = isd_element.styleAttrs[sa.qname]; var outs = []; if (es === undefined) { outs = ps; } else if (es.indexOf("none") === -1) { if ((es.indexOf("noUnderline") === -1 && ps.indexOf("underline") !== -1) || es.indexOf("underline") !== -1) { outs.push("underline"); } if ((es.indexOf("noLineThrough") === -1 && ps.indexOf("lineThrough") !== -1) || es.indexOf("lineThrough") !== -1) { outs.push("lineThrough"); } if ((es.indexOf("noOverline") === -1 && ps.indexOf("overline") !== -1) || es.indexOf("overline") !== -1) { outs.push("overline"); } } else { outs.push("none"); } isd_element.styleAttrs[sa.qname] = outs; } else if (sa.qname === imscStyles.byName.fontSize.qname && !(sa.qname in isd_element.styleAttrs) && isd_element.kind === 'span' && isd_element.styleAttrs[imscStyles.byName.ruby.qname] === "textContainer") { /* special inheritance rule for ruby text container font size */ var ruby_fs = parent.styleAttrs[imscStyles.byName.fontSize.qname]; isd_element.styleAttrs[sa.qname] = new imscUtils.ComputedLength( 0.5 * ruby_fs.rw, 0.5 * ruby_fs.rh); } else if (sa.qname === imscStyles.byName.fontSize.qname && !(sa.qname in isd_element.styleAttrs) && isd_element.kind === 'span' && isd_element.styleAttrs[imscStyles.byName.ruby.qname] === "text") { /* special inheritance rule for ruby text font size */ var parent_fs = parent.styleAttrs[imscStyles.byName.fontSize.qname]; if (parent.styleAttrs[imscStyles.byName.ruby.qname] === "textContainer") { isd_element.styleAttrs[sa.qname] = parent_fs; } else { isd_element.styleAttrs[sa.qname] = new imscUtils.ComputedLength( 0.5 * parent_fs.rw, 0.5 * parent_fs.rh); } } else if (sa.inherit && (sa.qname in parent.styleAttrs) && !(sa.qname in isd_element.styleAttrs)) { isd_element.styleAttrs[sa.qname] = parent.styleAttrs[sa.qname]; } } } /* initial value styling */ for (var k = 0; k < imscStyles.all.length; k++) { var ivs = imscStyles.all[k]; /* skip if value is already specified */ if (ivs.qname in isd_element.styleAttrs) continue; /* skip tts:position if tts:origin is specified */ if (ivs.qname === imscStyles.byName.position.qname && imscStyles.byName.origin.qname in isd_element.styleAttrs) continue; /* skip tts:origin if tts:position is specified */ if (ivs.qname === imscStyles.byName.origin.qname && imscStyles.byName.position.qname in isd_element.styleAttrs) continue; /* determine initial value */ var iv = doc.head.styling.initials[ivs.qname] || ivs.initial; if (iv === null) { /* skip processing if no initial value defined */ continue; } /* apply initial value to elements other than region only if non-inherited */ if (isd_element.kind === 'region' || (ivs.inherit === false && iv !== null)) { var piv = ivs.parse(iv); if (piv !== null) { isd_element.styleAttrs[ivs.qname] = piv; /* keep track of the style as specified */ spec_attr[ivs.qname] = true; } else { reportError(errorHandler, "Invalid initial value for '" + ivs.qname + "' on element '" + isd_element.kind); } } } /* compute styles (only for non-inherited styles) */ /* TODO: get rid of spec_attr */ for (var z = 0; z < imscStyles.all.length; z++) { var cs = imscStyles.all[z]; if (!(cs.qname in spec_attr)) continue; if (cs.compute !== null) { var cstyle = cs.compute( /*doc, parent, element, attr, context*/ doc, parent, isd_element, isd_element.styleAttrs[cs.qname], context ); if (cstyle !== null) { isd_element.styleAttrs[cs.qname] = cstyle; } else { /* if the style cannot be computed, replace it by its initial value */ isd_element.styleAttrs[cs.qname] = cs.compute( /*doc, parent, element, attr, context*/ doc, parent, isd_element, cs.parse(cs.initial), context ); reportError(errorHandler, "Style '" + cs.qname + "' on element '" + isd_element.kind + "' cannot be computed"); } } } /* prune if tts:display is none */ if (isd_element.styleAttrs[imscStyles.byName.display.qname] === "none") return null; /* process contents of the element */ var contents = null; if (parent === null) { /* we are processing the region */ if (body === null) { /* if there is no body, still process the region but with empty content */ contents = []; } else { /*use the body element as contents */ contents = [body]; } } else if ('contents' in elem) { contents = elem.contents; } for (var x = 0; contents !== null && x < contents.length; x++) { var c = isdProcessContentElement(doc, offset, region, body, isd_element, associated_region_id, contents[x], errorHandler, context); /* * keep child element only if they are non-null and their region match * the region of this element */ if (c !== null) { isd_element.contents.push(c.element); } } /* remove styles that are not applicable */ for (var qnameb in isd_element.styleAttrs) { if (!isd_element.styleAttrs.hasOwnProperty(qnameb)) continue; /* true if not applicable */ var na = false; /* special applicability of certain style properties to ruby container spans */ /* TODO: in the future ruby elements should be translated to elements instead of kept as spans */ if (isd_element.kind === 'span') { var rsp = isd_element.styleAttrs[imscStyles.byName.ruby.qname]; na = ( rsp === 'container' || rsp === 'textContainer' || rsp === 'baseContainer' ) && _rcs_na_styles.indexOf(qnameb) !== -1; if (! na) { na = rsp !== 'container' && qnameb === imscStyles.byName.rubyAlign.qname; } if (! na) { na = (! (rsp === 'textContainer' || rsp === 'text')) && qnameb === imscStyles.byName.rubyPosition.qname; } } /* normal applicability */ if (! na) { var da = imscStyles.byQName[qnameb]; if ("applies" in da){ na = da.applies.indexOf(isd_element.kind) === -1; } } if (na) { delete isd_element.styleAttrs[qnameb]; } } /* trim whitespace around explicit line breaks */ var ruby = isd_element.styleAttrs[imscStyles.byName.ruby.qname]; if (isd_element.kind === 'p' || (isd_element.kind === 'span' && (ruby === "textContainer" || ruby === "text")) ) { var elist = []; constructSpanList(isd_element, elist); collapseLWSP(elist); pruneEmptySpans(isd_element); } /* keep element if: * * contains a background image * * <br/> * * if there are children * * if it is an image * * if <span> and has text * * if region and showBackground = always */ if ((isd_element.kind === 'div' && imscStyles.byName.backgroundImage.qname in isd_element.styleAttrs) || isd_element.kind === 'br' || isd_element.kind === 'image' || ('contents' in isd_element && isd_element.contents.length > 0) || (isd_element.kind === 'span' && isd_element.text !== null) || (isd_element.kind === 'region' && isd_element.styleAttrs[imscStyles.byName.showBackground.qname] === 'always')) { return { region_id: associated_region_id, element: isd_element }; } return null; } function collapseLWSP(elist) { function isPrevCharLWSP(prev_element) { return prev_element.kind === 'br' || /[\r\n\t ]$/.test(prev_element.text); } function isNextCharLWSP(next_element) { return next_element.kind === 'br' || (next_element.space === "preserve" && /^[\r\n]/.test(next_element.text)); } /* collapse spaces and remove leading LWSPs */ var element; for (var i = 0; i < elist.length;) { element = elist[i]; if (element.kind === "br" || element.space === "preserve") { i++; continue; } var trimmed_text = element.text.replace(/[\t\r\n ]+/g, ' '); if (/^[ ]/.test(trimmed_text)) { if (i === 0 || isPrevCharLWSP(elist[i - 1])) { trimmed_text = trimmed_text.substring(1); } } element.text = trimmed_text; if (trimmed_text.length === 0) { elist.splice(i, 1); } else { i++; } } /* remove trailing LWSPs */ for (i = 0; i < elist.length; i++) { element = elist[i]; if (element.kind === "br" || element.space === "preserve") { i++; continue; } if (/[ ]$/.test(element.text)) { if (i === (elist.length - 1) || isNextCharLWSP(elist[i + 1])) { element.text = element.text.slice(0, -1); } } } } function constructSpanList(element, elist) { if (! ("contents" in element)) { return; } for (var i = 0; i < element.contents.length; i++) { var child = element.contents[i]; var ruby = child.styleAttrs[imscStyles.byName.ruby.qname]; if (child.kind === 'span' && (ruby === "textContainer" || ruby === "text")) { /* skip ruby text and text containers, which are handled on their own */ continue; } else if ('contents' in child) { constructSpanList(child, elist); } else if ((child.kind === 'span' && child.text.length !== 0) || child.kind === 'br') { /* skip empty spans */ elist.push(child); } } } function pruneEmptySpans(element) { if (element.kind === 'br') { return false; } else if ('text' in element) { return element.text.length === 0; } else if ('contents' in element) { var i = element.contents.length; while (i--) { if (pruneEmptySpans(element.contents[i])) { element.contents.splice(i, 1); } } return element.contents.length === 0; } } function ISD(tt) { this.contents = []; this.aspectRatio = tt.aspectRatio; this.lang = tt.lang; } function ISDContentElement(ttelem) { /* assume the element is a region if it does not have a kind */ this.kind = ttelem.kind || 'region'; /* copy lang */ this.lang = ttelem.lang; /* copy id */ if (ttelem.id) { this.id = ttelem.id; } /* deep copy of style attributes */ this.styleAttrs = {}; for (var sname in ttelem.styleAttrs) { if (! ttelem.styleAttrs.hasOwnProperty(sname)) continue; this.styleAttrs[sname] = ttelem.styleAttrs[sname]; } /* copy src and type if image */ if ('src' in ttelem) { this.src = ttelem.src; } if ('type' in ttelem) { this.type = ttelem.type; } /* TODO: clean this! * TODO: ISDElement and document element should be better tied together */ if ('text' in ttelem) { this.text = ttelem.text; } else if (this.kind === 'region' || 'contents' in ttelem) { this.contents = []; } if ('space' in ttelem) { this.space = ttelem.space; } } /* * ERROR HANDLING UTILITY FUNCTIONS * */ function reportInfo(errorHandler, msg) { if (errorHandler && errorHandler.info && errorHandler.info(msg)) throw msg; } function reportWarning(errorHandler, msg) { if (errorHandler && errorHandler.warn && errorHandler.warn(msg)) throw msg; } function reportError(errorHandler, msg) { if (errorHandler && errorHandler.error && errorHandler.error(msg)) throw msg; } function reportFatal(errorHandler, msg) { if (errorHandler && errorHandler.fatal) errorHandler.fatal(msg); throw msg; } })(typeof exports === 'undefined' ? this.imscISD = {} : exports, typeof imscNames === 'undefined' ? require("./names") : imscNames, typeof imscStyles === 'undefined' ? require("./styles") : imscStyles, typeof imscUtils === 'undefined' ? require("./utils") : imscUtils );