UNPKG

node-webodf-ilkkah

Version:

WebODF - JavaScript Document Engine http://webodf.org/

745 lines (675 loc) 34.3 kB
/** * Copyright (C) 2010-2014 KO GmbH <copyright@kogmbh.com> * * @licstart * This file is part of WebODF. * * WebODF is free software: you can redistribute it and/or modify it * under the terms of the GNU Affero General Public License (GNU AGPL) * as published by the Free Software Foundation, either version 3 of * the License, or (at your option) any later version. * * WebODF is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with WebODF. If not, see <http://www.gnu.org/licenses/>. * @licend * * @source: http://www.webodf.org/ * @source: https://github.com/kogmbh/WebODF/ */ /*global odf, core, runtime*/ (function () { "use strict"; var /**@const @type{!string}*/ fons = odf.Namespaces.fons, /**@const @type{!string}*/ stylens = odf.Namespaces.stylens, /**@const @type{!string}*/ textns = odf.Namespaces.textns, /**@const @type{!string}*/ xmlns = odf.Namespaces.xmlns, /**@const @type{!string}*/ helperns = "urn:webodf:names:helper", /**@const @type{!string}*/ listCounterIdSuffix = "webodf-listLevel", /**@const @type{!Object.<string,string>}*/ stylemap = { '1': 'decimal', 'a': 'lower-latin', 'A': 'upper-latin', 'i': 'lower-roman', 'I': 'upper-roman' }; /** * Appends the rule into the stylesheets and logs any errors that occur * @param {!CSSStyleSheet} styleSheet * @param {!string} rule * @return {undefined} */ function appendRule(styleSheet, rule) { try { styleSheet.insertRule(rule, styleSheet.cssRules.length); } catch (/**@type{!DOMException}*/e) { runtime.log("cannot load rule: " + rule + " - " + e); } } /** * Holds the current state of parsing the text:list elements in the DOM * @param {!Object.<!string, !string>} contentRules * @param {!Array.<!string>} continuedCounterIdStack * @constructor * @struct */ function ParseState(contentRules, continuedCounterIdStack) { /** * The number of list counters created for a list * This is just a number appended to the list counter identifier to make it unique within the list * @type {!number} */ this.listCounterCount = 0; /** * The CSS generated content rule keyed by list level * @type {!Object.<!string, !string>} */ this.contentRules = contentRules; /** * The stack of counters for the list being processed * @type {!Array.<!string>} */ this.counterIdStack = []; /** * The stack of counters the list should continue from if any * @type {!Array.<!string>} */ this.continuedCounterIdStack = continuedCounterIdStack; } /** * Assigns globally unique CSS list counters to each text:list element in the document. * The reason a global list counter is required is due to how the scope of CSS counters works * which is described here http://www.w3.org/TR/CSS21/generate.html#scope * * The relevant part is that the scope of the counter applies to the element that the counter-reset rule * was applied to and any children or siblings of that element. Applying another counter-reset rule to the * same counter resets this scope and previous values of the counter are lost. These values are also inaccessible * if we inspect the value of the counter outside of the scope and we simply get the default value of zero. * * The above is important for the case of continued numbering combined with multi-level list numbering. * Multi-level lists use a separate counter for each list level and joins each counter value together. * Continued numbering takes the list counter from the list we want to continue and uses it for the list * that is being continued. Combining these two we get the approach of taking the list counter at each list level * from the list that is being continued and then using these counters at each level in the continued list. * * However the scope rules prevent us from continuing counters at any level deeper than the first level and * this behaviour is illustrated in an example of some list content below. * <office:document> * <text:list> * <text:list-item> counter: level1 value: 1 * <text:list> * <text:list-item><text:p>Item</text:p></text:list-item> counter: level2 value: 1 * </text:list> * </text:list-item> * </text:list> * other doc content * <text:list text:continue-numbering="true"> * <text:list-item> * <text:list> * <text:list-item><text:p>Item</text:p></text:list-item> counter: level2 value: 0 * </text:list> * </text:list-item> * </text:list> * </office:document> * * The solution to this was to hoist the counter initialisation up to the document level so that the counter * scope applies to all lists in the document. Then each text:list element is given a unique counter by default. * Having unique counters is only really required for continuing a list based on its xml:id but having it for * all lists makes the code simpler and reduces the amount of CSS rules being overridden. Hence we end up with a * list counter setup as below. * <office:document> counter-reset: list1-1 list1-2 * <text:list> * <text:list-item> counter: list1-1 value: 1 * <text:list> * <text:list-item><text:p>Item</text:p></text:list-item> counter: list1-2 value: 1 * </text:list> * </text:list-item> * </text:list> * other doc content * <text:list text:continue-numbering="true"> * <text:list-item> * <text:list> * <text:list-item><text:p>Item</text:p></text:list-item> counter: list1-2 value: 2 * </text:list> * </text:list-item> * </text:list> * </office:document> * * @param {!CSSStyleSheet} styleSheet * @constructor */ function UniqueListCounter(styleSheet) { var /**@type{!number}*/ customListIdIndex = 0, /**@type{!string}*/ globalCounterResetRule = "", /**@type{!Object.<!string,!Array.<!string>>}*/ counterIdStacks = {}; /** * Gets the stack of list counters for the given list. * Counter stacks are keyed by the list counter id of the first list level. * Returns a deep copy of the counter stack so it can be modified * @param {!Element|undefined} list * @return {!Array.<!string>} */ function getCounterIdStack(list) { var counterId, stack = []; if (list) { counterId = list.getAttributeNS(helperns, "counter-id"); stack = counterIdStacks[counterId].slice(0); } return stack; } /** * Assigns a unique global CSS list counter to this text:list element * @param {!string} topLevelListId This is used to generate a unique identifier for this element * @param {!Element} listElement This is always a text:list element * @param {!number} listLevel * @param {!ParseState} parseState * @return {undefined} */ function createCssRulesForList(topLevelListId, listElement, listLevel, parseState) { var /**@type{!string}*/ newListSelectorId, newListCounterId, newRule, contentRule, i; // increment counters and create a new identifier for this text:list element // this identifier will be used as the CSS counter name if this list is not continuing another list parseState.listCounterCount += 1; newListSelectorId = topLevelListId + "-level" + listLevel + "-" + parseState.listCounterCount; listElement.setAttributeNS(helperns, "counter-id", newListSelectorId); // if we need to continue from a previous list then get the counter from the stack // of the continued list and use it as the counter for this list element newListCounterId = parseState.continuedCounterIdStack.shift(); if (!newListCounterId) { newListCounterId = newListSelectorId; // add the newly created counter to the counter reset rule so it can be // initialised later once we have parsed all the lists in the document. // In the case of a multi-level list with no items the counter increment rule // will not apply. To fix this issue we initialise the counters to a value of 1 // instead of the default of 0. globalCounterResetRule += newListSelectorId + ' 1 '; // CSS counters increment the value before displaying the rendered list label. This is not an issue but as // we initialise the counters to a value of 1 above to handle lists with no list items it means that // lists that actually have list items will all start with the counter value of 2 which is not desirable. // To fix this we apply another CSS rule here that overrides the counter increment rule above and // prevents incrementing the counter on the FIRST list item that has content (AKA a visible list label). newRule = 'text|list[webodfhelper|counter-id="' + newListSelectorId + '"]'; newRule += ' > text|list-item:first-child > :not(text|list):first-child:before'; newRule += '{'; // Due to https://bugs.webkit.org/show_bug.cgi?id=84985 a value of "none" is ignored by some version of WebKit // (specifically the ones shipped with the Cocoa frameworks on OSX 10.7 + 10.8). // Override the counter-increment on this counter by name to workaround this newRule += 'counter-increment: ' + newListCounterId + ' 0;'; newRule += '}'; appendRule(styleSheet, newRule); } // remove any counters from the stack that are deeper than the current list level // and push the newly created or continued counter on to the stack while (parseState.counterIdStack.length >= listLevel) { parseState.counterIdStack.pop(); } parseState.counterIdStack.push(newListCounterId); // substitute the unique list counters in for each level up to the current one // this only replaces the first occurrence in the string as the generated rule // will have a different counter for each list level and multi level counter rules // are created by joining counters from different levels together contentRule = parseState.contentRules[listLevel.toString()] || ""; for (i = 1; i <= listLevel; i += 1) { contentRule = contentRule.replace(i + listCounterIdSuffix, parseState.counterIdStack[i - 1]); } // Apply the counter increment to EVERY list item in this list that has content (AKA a visible list label) newRule = 'text|list[webodfhelper|counter-id="' + newListSelectorId + '"]'; newRule += ' > text|list-item > :not(text|list):first-child:before'; newRule += '{'; newRule += contentRule; newRule += 'counter-increment: ' + newListCounterId + ';'; newRule += '}'; appendRule(styleSheet, newRule); } /** * Takes an element and parses it and its subtree for any text:list elements. * The text:list elements then have CSS rules applied that give each one * a unique global CSS counter for the purpose of list numbering. * @param {!string} topLevelListId * @param {!Element} element * @param {!number} listLevel * @param {!ParseState} parseState * @return {undefined} */ function iterateOverChildListElements(topLevelListId, element, listLevel, parseState) { var isListElement = element.namespaceURI === textns && element.localName === "list", isListItemElement = element.namespaceURI === textns && element.localName === "list-item", childElement; // don't continue iterating over elements that aren't text:list or text:list-item if (!isListElement && !isListItemElement) { parseState.continuedCounterIdStack = []; return; } if (isListElement) { listLevel += 1; createCssRulesForList(topLevelListId, element, listLevel, parseState); } childElement = element.firstElementChild; while (childElement) { iterateOverChildListElements(topLevelListId, childElement, listLevel, parseState); childElement = childElement.nextElementSibling; } } /** * Takes a text:list element and creates CSS counter rules used for numbering * @param {!Object.<!string, !string>} contentRules * @param {!Element} list * @param {!Element=} continuedList * @return {undefined} */ this.createCounterRules = function (contentRules, list, continuedList) { var /**@type{!string}*/ listId = list.getAttributeNS(xmlns, "id"), currentParseState = new ParseState(contentRules, getCounterIdStack(continuedList)); // ensure there is a unique identifier for each list if it does not have one if (!listId) { customListIdIndex += 1; listId = "X" + customListIdIndex; } else { listId = "Y" + listId; } iterateOverChildListElements(listId, list, 0, currentParseState); counterIdStacks[listId + "-level1-1"] = currentParseState.counterIdStack; }; /** * Initialises all CSS counters created so far by this UniqueListCounter with a counter-reset rule. * Calling this method twice will cause the previous counter reset CSS rule to be overridden * @return {undefined} */ this.initialiseCreatedCounters = function () { var newRule; newRule = 'office|document'; newRule += '{'; newRule += 'counter-reset: ' + globalCounterResetRule + ';'; newRule += "}"; appendRule(styleSheet, newRule); }; } /** * @constructor */ odf.ListStyleToCss = function ListStyleToCss() { var cssUnits = new core.CSSUnits(), odfUtils = odf.OdfUtils; /** * Takes a value with a valid CSS unit and converts it to a CSS pixel value * @param {!string} value * @return {!number} */ function convertToPxValue(value) { var parsedLength = odfUtils.parseLength(value); if (!parsedLength) { runtime.log("Could not parse value '" + value + "'."); // Return 0 as fallback, might have least bad results if used return 0; } return cssUnits.convert(parsedLength.value, parsedLength.unit, "px"); } /** * Return the supplied value with any backslashes escaped, and double-quotes escaped * @param {!string} value * @return {!string} */ function escapeCSSString(value) { return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\""); } /** * Determines whether the list element style-name matches the style-name we require * @param {!Element|undefined} list * @param {!string} matchingStyleName * @return {!boolean} */ function isMatchingListStyle(list, matchingStyleName) { var styleName; if (list) { styleName = list.getAttributeNS(textns, "style-name"); } return styleName === matchingStyleName; } /** * Gets the CSS content for a numbered list * @param {!Element} node * @return {!string} */ function getNumberRule(node) { var style = node.getAttributeNS(stylens, "num-format"), /**@type{!string}*/ suffix = node.getAttributeNS(stylens, "num-suffix") || "", /**@type{!string}*/ prefix = node.getAttributeNS(stylens, "num-prefix") || "", /**@type{!string}*/ content = "", textLevel = node.getAttributeNS(textns, "level"), displayLevels = node.getAttributeNS(textns, "display-levels"); if (prefix) { // Content needs to be on a new line if it contains slashes due to a bug in older versions of webkit // E.g., the one used in the qt runtime tests - https://bugs.webkit.org/show_bug.cgi?id=35010 content += '"' + escapeCSSString(prefix) + '"\n'; } if (stylemap.hasOwnProperty(style)) { textLevel = textLevel ? parseInt(textLevel, 10) : 1; displayLevels = displayLevels ? parseInt(displayLevels, 10) : 1; // as we might want to display a subset of the counters // we assume a different counter for each list level // and concatenate them for multi level lists // https://wiki.openoffice.org/wiki/Number_labels while (displayLevels > 0) { content += " counter(" + (textLevel - displayLevels + 1) + listCounterIdSuffix + "," + stylemap[style] + ")"; if (displayLevels > 1) { content += '"."'; } displayLevels -= 1; } } else if (style) { content += ' "' + style + '"'; } else { content += ' ""'; } return 'content:' + content + ' "' + escapeCSSString(suffix) + '"'; } /** * Gets the CSS content for a image bullet list * @return {!string} */ function getImageRule() { return "content: none"; } /** * Gets the CSS content for a bullet list * @param {!Element} node * @return {!string} */ function getBulletRule(node) { var bulletChar = node.getAttributeNS(textns, "bullet-char"); return 'content: "' + escapeCSSString(bulletChar) + '"'; } /** * Gets the CSS generated content rule for the list style * @param {!Element} node * @return {!string} */ function getContentRule(node) { var contentRule = "", listLevelProps, listLevelPositionSpaceMode, listLevelLabelAlign, followedBy; if (node.localName === "list-level-style-number") { contentRule = getNumberRule(node); } else if (node.localName === "list-level-style-image") { contentRule = getImageRule(); } else if (node.localName === "list-level-style-bullet") { contentRule = getBulletRule(node); } listLevelProps = /**@type{!Element}*/(node.getElementsByTagNameNS(stylens, "list-level-properties")[0]); if (listLevelProps) { listLevelPositionSpaceMode = listLevelProps.getAttributeNS(textns, "list-level-position-and-space-mode"); if (listLevelPositionSpaceMode === "label-alignment") { listLevelLabelAlign = /**@type{!Element}*/(listLevelProps.getElementsByTagNameNS(stylens, "list-level-label-alignment")[0]); if (listLevelLabelAlign) { followedBy = listLevelLabelAlign.getAttributeNS(textns, "label-followed-by"); } if (followedBy === "space") { contentRule += ' "\\a0"'; } } } // Content needs to be on a new line if it contains slashes due to a bug in older versions of webkit // E.g., the one used in the qt runtime tests - https://bugs.webkit.org/show_bug.cgi?id=35010 return '\n' + contentRule + ';\n'; } /** * Takes a text:list-style element and returns the generated CSS * content rules for each list level in the list style * @param {!Element} listStyleNode * @return {!Object.<!string, !string>} */ function getAllContentRules(listStyleNode) { var childNode = listStyleNode.firstElementChild, level, rules = {}; while (childNode) { level = childNode.getAttributeNS(textns, "level"); level = level && parseInt(level, 10); rules[level] = getContentRule(childNode); childNode = childNode.nextElementSibling; } return rules; } /** * In label-width-and-position mode of specifying list layout the margin and indent specified in * the paragraph style is additive to the layout specified in the list style. * * fo:margin-left text:space-before fo:text-indent +-----------+ * +---------------->+------------------>+----------------->| label | LIST TEXT * +-----------+ * +---------------->+------------------>+-------------------->LIST TEXT LIST TEXT LIST TEXT * text:min-label-width * * To get this additive behaviour we calculate an offset from the left side of the page which is * the space-before + min-label-width. We then apply this offset to each text:list-item * element and apply the negative value of the offset to each text:list element. This allows the positioning * provided in the list style to apply relative to the paragraph style as we desired. Then on each * ::before pseudo-element which holds the label we apply the negative value of the min-label-width to complete * the alignment from the left side of the page. We then apply the min-label-distance as padding to the right * of the ::before psuedo-element to complete the list label placement. * * For the label-alignment mode the paragraph style overrides the list style but we specify offsets for * the text:list and text:list-item elements to keep the code consistent between the modes * * Diagram and implementation based on: https://wiki.openoffice.org/wiki/Number_layout * * @param {!CSSStyleSheet} styleSheet * @param {!string} name * @param {!Element} node * @return {undefined} */ function addListStyleRule(styleSheet, name, node) { var selector = 'text|list[text|style-name="' + name + '"]', level = node.getAttributeNS(textns, "level"), selectorLevel, listItemRule, listLevelProps, listLevelPositionSpaceMode, listLevelLabelAlign, listIndent, textAlign, bulletWidth, labelDistance, bulletIndent, followedBy, leftOffset; // style:list-level-properties is an optional element. Since the rest of this function // depends on its existence, return from it if it is not found. listLevelProps = /**@type{!Element|undefined}*/(node.getElementsByTagNameNS(stylens, "list-level-properties")[0]); listLevelPositionSpaceMode = listLevelProps && listLevelProps.getAttributeNS(textns, "list-level-position-and-space-mode"); listLevelLabelAlign = /**@type{!Element|undefined}*/(listLevelProps) && /**@type{!Element|undefined}*/(listLevelProps.getElementsByTagNameNS(stylens, "list-level-label-alignment")[0]); // calculate CSS selector based on list level level = level && parseInt(level, 10); selectorLevel = level; while (selectorLevel > 1) { selector += ' > text|list-item > text|list'; selectorLevel -= 1; } // TODO: fo:text-align is only an optional attribute with <style:list-level-properties>, // needs to be found what should be done if not present. For now falling back to "left" textAlign = (listLevelProps && listLevelProps.getAttributeNS(fons, "text-align")) || "left"; // convert the start and end text alignments to left and right as // IE does not support the start and end values for text alignment switch (textAlign) { case "end": textAlign = "right"; break; case "start": textAlign = "left"; break; } // get relevant properties from the style based on the list label positioning mode if (listLevelPositionSpaceMode === "label-alignment") { // TODO: fetch the margin and indent from the paragraph style if it is defined there // http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#element-style_list-level-label-alignment // for now just fallback to "0px" if not defined on <style:list-level-label-alignment> listIndent = (listLevelLabelAlign && listLevelLabelAlign.getAttributeNS(fons, "margin-left")) || "0px"; bulletIndent = (listLevelLabelAlign && listLevelLabelAlign.getAttributeNS(fons, "text-indent")) || "0px"; followedBy = listLevelLabelAlign && listLevelLabelAlign.getAttributeNS(textns, "label-followed-by"); leftOffset = convertToPxValue(listIndent); } else { // this block is entered if list-level-position-and-space-mode // has the value label-width-and-position or is not present // TODO: fallback values should be read from parent styles or (system) defaults listIndent = (listLevelProps && listLevelProps.getAttributeNS(textns, "space-before")) || "0px"; bulletWidth = (listLevelProps && listLevelProps.getAttributeNS(textns, "min-label-width")) || "0px"; labelDistance = (listLevelProps && listLevelProps.getAttributeNS(textns, "min-label-distance")) || "0px"; leftOffset = convertToPxValue(listIndent) + convertToPxValue(bulletWidth); } listItemRule = selector + ' > text|list-item'; listItemRule += '{'; listItemRule += 'margin-left: ' + leftOffset + 'px;'; listItemRule += '}'; appendRule(styleSheet, listItemRule); listItemRule = selector + ' > text|list-item > text|list'; listItemRule += '{'; listItemRule += 'margin-left: ' + (-leftOffset) + 'px;'; listItemRule += '}'; appendRule(styleSheet, listItemRule); // insert the list label before every immediate child of the list-item, except for lists listItemRule = selector + ' > text|list-item > :not(text|list):first-child:before'; listItemRule += '{'; listItemRule += 'text-align: ' + textAlign + ';'; listItemRule += 'display: inline-block;'; if (listLevelPositionSpaceMode === "label-alignment") { listItemRule += 'margin-left: ' + bulletIndent + ';'; if (followedBy === "listtab") { // TODO: remove this padding once text:label-followed-by="listtab" is implemented // http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#attribute-text_label-followed-by listItemRule += 'padding-right: 0.2cm;'; } } else { listItemRule += 'min-width: ' + bulletWidth + ';'; listItemRule += 'margin-left: ' + (parseFloat(bulletWidth) === 0 ? '' : '-') + bulletWidth + ';'; listItemRule += 'padding-right: ' + labelDistance + ';'; } listItemRule += '}'; appendRule(styleSheet, listItemRule); } /** * Adds a CSS rule for every ODF list style * @param {!CSSStyleSheet} styleSheet * @param {!string} name * @param {!Element} node * @return {undefined} */ function addRule(styleSheet, name, node) { var n = node.firstElementChild; while (n) { if (n.namespaceURI === textns) { addListStyleRule(styleSheet, name, n); } n = n.nextElementSibling; } } /** * Adds new CSS rules based on any properties in * the ODF list content if they affect the final style * @param {!CSSStyleSheet} styleSheet * @param {!Element} odfBody * @param {!Object.<!string, !odf.StyleTreeNode>} listStyles * @return {undefined} */ function applyContentBasedStyles(styleSheet, odfBody, listStyles) { var lists = odfBody.getElementsByTagNameNS(textns, "list"), listCounter = new UniqueListCounter(styleSheet), list, previousList, continueNumbering, continueListXmlId, xmlId, styleName, contentRules, listsWithXmlId = {}, i; for (i = 0; i < lists.length; i += 1) { list = /**@type{!Element}*/(lists.item(i)); styleName = list.getAttributeNS(textns, "style-name"); // TODO: Handle default list style // lists that have no text:style-name attribute defined and do not have a parent text:list that // defines a style name use a default implementation defined style as per the spec // http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#attribute-text_style-name_element-text_list // lists that have no text:style-name attribute defined but do have a parent list that defines a // style name will inherit that style and will be handled correctly as any text:list with a style defined // will have CSS rules applied to its child text:list elements if (styleName) { continueNumbering = list.getAttributeNS(textns, "continue-numbering"); continueListXmlId = list.getAttributeNS(textns, "continue-list"); xmlId = list.getAttributeNS(xmlns, "id"); // store the list keyed by the xml:id if (xmlId) { listsWithXmlId[xmlId] = list; } contentRules = getAllContentRules(listStyles[styleName].element); // lists with different styles cannot be continued // https://tools.oasis-open.org/issues/browse/OFFICE-3558 if (continueNumbering && !continueListXmlId && isMatchingListStyle(previousList, styleName)) { listCounter.createCounterRules(contentRules, list, previousList); } else if (continueListXmlId && isMatchingListStyle(listsWithXmlId[continueListXmlId], styleName)) { listCounter.createCounterRules(contentRules, list, listsWithXmlId[continueListXmlId]); } else { listCounter.createCounterRules(contentRules, list); } previousList = list; } } listCounter.initialiseCreatedCounters(); } /** * Creates CSS styles from the given ODF list styles and applies them to the stylesheet * @param {!CSSStyleSheet} styleSheet * @param {!odf.StyleTree.Tree} styleTree * @param {!Element} odfBody * @return {undefined} */ this.applyListStyles = function (styleSheet, styleTree, odfBody) { var styleFamilyTree, node; /*jslint sub:true*/ // The available families are defined in StyleUtils.familyNamespacePrefixes. styleFamilyTree = (styleTree["list"]); /*jslint sub:false*/ if (styleFamilyTree) { Object.keys(styleFamilyTree).forEach(function (styleName) { node = /**@type{!odf.StyleTreeNode}*/(styleFamilyTree[styleName]); addRule(styleSheet, styleName, node.element); }); } applyContentBasedStyles(styleSheet, odfBody, styleFamilyTree); }; }; }());