imsc
Version:
Renders IMSC documents to HTML5 fragments
830 lines (540 loc) • 26 kB
JavaScript
/*
* 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
);