'use strict'; var xmldom = require('@xmldom/xmldom'); var getProp = require('lodash.get'); var JSON5 = require('json5'); var JSZip = require('jszip'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var JSON5__namespace = /*#__PURE__*/_interopNamespaceDefault(JSON5); function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; } function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; } class ArgumentError extends Error { constructor(message) { super(message); } } class MalformedFileError extends Error { constructor(expectedFileType) { super(`Malformed file detected. Make sure the file is a valid ${expectedFileType} file.`); _defineProperty(this, "expectedFileType", void 0); this.expectedFileType = expectedFileType; } } class MaxXmlDepthError extends Error { constructor(maxDepth) { super(`XML maximum depth reached (max depth: ${maxDepth}).`); _defineProperty(this, "maxDepth", void 0); this.maxDepth = maxDepth; } } class MissingArgumentError extends ArgumentError { constructor(argName) { super(`Argument '${argName}' is missing.`); _defineProperty(this, "argName", void 0); this.argName = argName; } } class MissingCloseDelimiterError extends Error { constructor(openDelimiterText) { super(`Close delimiter is missing from '${openDelimiterText}'.`); _defineProperty(this, "openDelimiterText", void 0); this.openDelimiterText = openDelimiterText; } } class MissingStartDelimiterError extends Error { constructor(closeDelimiterText) { super(`Open delimiter is missing from '${closeDelimiterText}'.`); _defineProperty(this, "closeDelimiterText", void 0); this.closeDelimiterText = closeDelimiterText; } } class TagOptionsParseError extends Error { constructor(tagRawText, parseError) { super(`Failed to parse tag options of '${tagRawText}': ${parseError.message}.`); _defineProperty(this, "tagRawText", void 0); _defineProperty(this, "parseError", void 0); this.tagRawText = tagRawText; this.parseError = parseError; } } class UnclosedTagError extends Error { constructor(tagName) { super(`Tag '${tagName}' is never closed.`); _defineProperty(this, "tagName", void 0); this.tagName = tagName; } } class UnidentifiedFileTypeError extends Error { constructor() { super(`The filetype for this file could not be identified, is this file corrupted?`); } } class UnknownContentTypeError extends Error { constructor(contentType, tagRawText, path) { super(`Content type '${contentType}' does not have a registered plugin to handle it.`); _defineProperty(this, "tagRawText", void 0); _defineProperty(this, "contentType", void 0); _defineProperty(this, "path", void 0); this.contentType = contentType; this.tagRawText = tagRawText; this.path = path; } } class UnopenedTagError extends Error { constructor(tagName) { super(`Tag '${tagName}' is closed but was never opened.`); _defineProperty(this, "tagName", void 0); this.tagName = tagName; } } class UnsupportedFileTypeError extends Error { constructor(fileType) { super(`Filetype "${fileType}" is not supported.`); _defineProperty(this, "fileType", void 0); this.fileType = fileType; } } function pushMany(destArray, items) { Array.prototype.push.apply(destArray, items); } function first(array) { if (!array.length) return undefined; return array[0]; } function last(array) { if (!array.length) return undefined; return array[array.length - 1]; } function toDictionary(array, keySelector, valueSelector) { if (!array.length) return {}; const res = {}; array.forEach((item, index) => { const key = keySelector(item, index); const value = valueSelector ? valueSelector(item, index) : item; if (res[key]) throw new Error(`Key '${key}' already exists in the dictionary.`); res[key] = value; }); return res; } class Base64 { static encode(str) { // browser if (typeof btoa !== 'undefined') return btoa(str); // node // https://stackoverflow.com/questions/23097928/node-js-btoa-is-not-defined-error#38446960 return new Buffer(str, 'binary').toString('base64'); } } function inheritsFrom(derived, base) { // https://stackoverflow.com/questions/14486110/how-to-check-if-a-javascript-class-inherits-another-without-creating-an-obj return derived === base || derived.prototype instanceof base; } function isPromiseLike(candidate) { return !!candidate && typeof candidate === 'object' && typeof candidate.then === 'function'; } const Binary = { // // type detection // isBlob(binary) { return this.isBlobConstructor(binary.constructor); }, isArrayBuffer(binary) { return this.isArrayBufferConstructor(binary.constructor); }, isBuffer(binary) { return this.isBufferConstructor(binary.constructor); }, isBlobConstructor(binaryType) { return typeof Blob !== 'undefined' && inheritsFrom(binaryType, Blob); }, isArrayBufferConstructor(binaryType) { return typeof ArrayBuffer !== 'undefined' && inheritsFrom(binaryType, ArrayBuffer); }, isBufferConstructor(binaryType) { return typeof Buffer !== 'undefined' && inheritsFrom(binaryType, Buffer); }, // // utilities // toBase64(binary) { if (this.isBlob(binary)) { return new Promise(resolve => { const fileReader = new FileReader(); fileReader.onload = function () { const base64 = Base64.encode(this.result); resolve(base64); }; fileReader.readAsBinaryString(binary); }); } if (this.isBuffer(binary)) { return Promise.resolve(binary.toString('base64')); } if (this.isArrayBuffer(binary)) { // https://stackoverflow.com/questions/9267899/arraybuffer-to-base64-encoded-string#42334410 const binaryStr = new Uint8Array(binary).reduce((str, byte) => str + String.fromCharCode(byte), ''); const base64 = Base64.encode(binaryStr); return Promise.resolve(base64); } throw new Error(`Binary type '${binary.constructor.name}' is not supported.`); } }; function isNumber(value) { return Number.isFinite(value); } class Path { static getFilename(path) { const lastSlashIndex = path.lastIndexOf('/'); return path.substr(lastSlashIndex + 1); } static getDirectory(path) { const lastSlashIndex = path.lastIndexOf('/'); return path.substring(0, lastSlashIndex); } static combine(...parts) { return parts.filter(part => part === null || part === void 0 ? void 0 : part.trim()).join('/'); } } class Regex { static escape(str) { // https://stackoverflow.com/questions/1144783/how-to-replace-all-occurrences-of-a-string-in-javascript return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string } } /** * Secure Hash Algorithm (SHA1) * * Taken from here: http://www.webtoolkit.info/javascript-sha1.html * * Recommended here: https://stackoverflow.com/questions/6122571/simple-non-secure-hash-function-for-javascript#6122732 */ function sha1(msg) { msg = utf8Encode(msg); const msgLength = msg.length; let i, j; const wordArray = []; for (i = 0; i < msgLength - 3; i += 4) { j = msg.charCodeAt(i) << 24 | msg.charCodeAt(i + 1) << 16 | msg.charCodeAt(i + 2) << 8 | msg.charCodeAt(i + 3); wordArray.push(j); } switch (msgLength % 4) { case 0: i = 0x080000000; break; case 1: i = msg.charCodeAt(msgLength - 1) << 24 | 0x0800000; break; case 2: i = msg.charCodeAt(msgLength - 2) << 24 | msg.charCodeAt(msgLength - 1) << 16 | 0x08000; break; case 3: i = msg.charCodeAt(msgLength - 3) << 24 | msg.charCodeAt(msgLength - 2) << 16 | msg.charCodeAt(msgLength - 1) << 8 | 0x80; break; } wordArray.push(i); while (wordArray.length % 16 != 14) { wordArray.push(0); } wordArray.push(msgLength >>> 29); wordArray.push(msgLength << 3 & 0x0ffffffff); const w = new Array(80); let H0 = 0x67452301; let H1 = 0xEFCDAB89; let H2 = 0x98BADCFE; let H3 = 0x10325476; let H4 = 0xC3D2E1F0; let A, B, C, D, E; let temp; for (let blockStart = 0; blockStart < wordArray.length; blockStart += 16) { for (i = 0; i < 16; i++) { w[i] = wordArray[blockStart + i]; } for (i = 16; i <= 79; i++) { w[i] = rotateLeft(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); } A = H0; B = H1; C = H2; D = H3; E = H4; for (i = 0; i <= 19; i++) { temp = rotateLeft(A, 5) + (B & C | ~B & D) + E + w[i] + 0x5A827999 & 0x0ffffffff; E = D; D = C; C = rotateLeft(B, 30); B = A; A = temp; } for (i = 20; i <= 39; i++) { temp = rotateLeft(A, 5) + (B ^ C ^ D) + E + w[i] + 0x6ED9EBA1 & 0x0ffffffff; E = D; D = C; C = rotateLeft(B, 30); B = A; A = temp; } for (i = 40; i <= 59; i++) { temp = rotateLeft(A, 5) + (B & C | B & D | C & D) + E + w[i] + 0x8F1BBCDC & 0x0ffffffff; E = D; D = C; C = rotateLeft(B, 30); B = A; A = temp; } for (i = 60; i <= 79; i++) { temp = rotateLeft(A, 5) + (B ^ C ^ D) + E + w[i] + 0xCA62C1D6 & 0x0ffffffff; E = D; D = C; C = rotateLeft(B, 30); B = A; A = temp; } H0 = H0 + A & 0x0ffffffff; H1 = H1 + B & 0x0ffffffff; H2 = H2 + C & 0x0ffffffff; H3 = H3 + D & 0x0ffffffff; H4 = H4 + E & 0x0ffffffff; } temp = cvtHex(H0) + cvtHex(H1) + cvtHex(H2) + cvtHex(H3) + cvtHex(H4); return temp.toLowerCase(); } function rotateLeft(n, s) { const t4 = n << s | n >>> 32 - s; return t4; } function cvtHex(val) { let str = ""; for (let i = 7; i >= 0; i--) { const v = val >>> i * 4 & 0x0f; str += v.toString(16); } return str; } function utf8Encode(str) { str = str.replace(/\r\n/g, "\n"); let utfStr = ""; for (let n = 0; n < str.length; n++) { const c = str.charCodeAt(n); if (c < 128) { utfStr += String.fromCharCode(c); } else if (c > 127 && c < 2048) { utfStr += String.fromCharCode(c >> 6 | 192); utfStr += String.fromCharCode(c & 63 | 128); } else { utfStr += String.fromCharCode(c >> 12 | 224); utfStr += String.fromCharCode(c >> 6 & 63 | 128); utfStr += String.fromCharCode(c & 63 | 128); } } return utfStr; } // Copied from: https://gist.github.com/thanpolas/244d9a13151caf5a12e42208b6111aa6 // And see: https://unicode-table.com/en/sets/quotation-marks/ const nonStandardDoubleQuotes = ['“', // U+201c '”', // U+201d '«', // U+00AB '»', // U+00BB '„', // U+201E '“', // U+201C '‟', // U+201F '”', // U+201D '❝', // U+275D '❞', // U+275E '〝', // U+301D '〞', // U+301E '〟', // U+301F '"' // U+FF02 ]; const standardDoubleQuotes = '"'; // U+0022 const nonStandardDoubleQuotesRegex = new RegExp(nonStandardDoubleQuotes.join('|'), 'g'); function stringValue(val) { if (val === null || val === undefined) { return ''; } return val.toString(); } function normalizeDoubleQuotes(text) { return text.replace(nonStandardDoubleQuotesRegex, standardDoubleQuotes); } class XmlDepthTracker { constructor(maxDepth) { this.maxDepth = maxDepth; _defineProperty(this, "depth", 0); } increment() { this.depth++; if (this.depth > this.maxDepth) { throw new MaxXmlDepthError(this.maxDepth); } } decrement() { this.depth--; } } let XmlNodeType = /*#__PURE__*/function (XmlNodeType) { XmlNodeType["Text"] = "Text"; XmlNodeType["General"] = "General"; XmlNodeType["Comment"] = "Comment"; return XmlNodeType; }({}); const TEXT_NODE_NAME = '#text'; // see: https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeName const COMMENT_NODE_NAME = '#comment'; const XmlNode = { // // factories // createTextNode(text) { return { nodeType: XmlNodeType.Text, nodeName: TEXT_NODE_NAME, textContent: text }; }, createGeneralNode(name) { return { nodeType: XmlNodeType.General, nodeName: name }; }, createCommentNode(text) { return { nodeType: XmlNodeType.Comment, nodeName: COMMENT_NODE_NAME, commentContent: text }; }, // // serialization // /** * Encode string to make it safe to use inside xml tags. * * https://stackoverflow.com/questions/7918868/how-to-escape-xml-entities-in-javascript */ encodeValue(str) { if (str === null || str === undefined) throw new MissingArgumentError("str"); if (typeof str !== 'string') throw new TypeError(`Expected a string, got '${str.constructor.name}'.`); return str.replace(/[<>&'"]/g, c => { switch (c) { case '<': return '<'; case '>': return '>'; case '&': return '&'; case '\'': return '''; case '"': return '"'; } return ''; }); }, serialize(node) { if (this.isTextNode(node)) return this.encodeValue(node.textContent || ''); if (this.isCommentNode(node)) { return ``; } // attributes let attributes = ''; if (node.attributes) { const attributeNames = Object.keys(node.attributes); if (attributeNames.length) { attributes = ' ' + attributeNames.map(name => `${name}="${this.encodeValue(node.attributes[name] || '')}"`).join(' '); } } // open tag const hasChildren = (node.childNodes || []).length > 0; const suffix = hasChildren ? '' : '/'; const openTag = `<${node.nodeName}${attributes}${suffix}>`; let xml; if (hasChildren) { // child nodes const childrenXml = node.childNodes.map(child => this.serialize(child)).join(''); // close tag const closeTag = ``; xml = openTag + childrenXml + closeTag; } else { xml = openTag; } return xml; }, /** * The conversion is always deep. */ fromDomNode(domNode) { let xmlNode; // basic properties switch (domNode.nodeType) { case domNode.TEXT_NODE: { xmlNode = this.createTextNode(domNode.textContent); break; } case domNode.COMMENT_NODE: { var _domNode$textContent; xmlNode = this.createCommentNode((_domNode$textContent = domNode.textContent) === null || _domNode$textContent === void 0 ? void 0 : _domNode$textContent.trim()); break; } case domNode.ELEMENT_NODE: { const generalNode = xmlNode = this.createGeneralNode(domNode.nodeName); const attributes = domNode.attributes; if (attributes) { generalNode.attributes = {}; for (let i = 0; i < attributes.length; i++) { const curAttribute = attributes.item(i); generalNode.attributes[curAttribute.name] = curAttribute.value; } } break; } default: { xmlNode = this.createGeneralNode(domNode.nodeName); break; } } // children if (domNode.childNodes) { xmlNode.childNodes = []; let prevChild; for (let i = 0; i < domNode.childNodes.length; i++) { // clone child const domChild = domNode.childNodes.item(i); const curChild = this.fromDomNode(domChild); // set references xmlNode.childNodes.push(curChild); curChild.parentNode = xmlNode; if (prevChild) { prevChild.nextSibling = curChild; } prevChild = curChild; } } return xmlNode; }, // // core functions // isTextNode(node) { if (node.nodeType === XmlNodeType.Text || node.nodeName === TEXT_NODE_NAME) { if (!(node.nodeType === XmlNodeType.Text && node.nodeName === TEXT_NODE_NAME)) { throw new Error(`Invalid text node. Type: '${node.nodeType}', Name: '${node.nodeName}'.`); } return true; } return false; }, isCommentNode(node) { if (node.nodeType === XmlNodeType.Comment || node.nodeName === COMMENT_NODE_NAME) { if (!(node.nodeType === XmlNodeType.Comment && node.nodeName === COMMENT_NODE_NAME)) { throw new Error(`Invalid comment node. Type: '${node.nodeType}', Name: '${node.nodeName}'.`); } return true; } return false; }, cloneNode(node, deep) { if (!node) throw new MissingArgumentError("node"); if (!deep) { const clone = Object.assign({}, node); clone.parentNode = null; clone.childNodes = node.childNodes ? [] : null; clone.nextSibling = null; return clone; } else { const clone = cloneNodeDeep(node); clone.parentNode = null; return clone; } }, /** * Insert the node as a new sibling, before the original node. * * * **Note**: It is more efficient to use the insertChild function if you * already know the relevant index. */ insertBefore(newNode, referenceNode) { if (!newNode) throw new MissingArgumentError("newNode"); if (!referenceNode) throw new MissingArgumentError("referenceNode"); if (!referenceNode.parentNode) throw new Error(`'${"referenceNode"}' has no parent`); const childNodes = referenceNode.parentNode.childNodes; const beforeNodeIndex = childNodes.indexOf(referenceNode); XmlNode.insertChild(referenceNode.parentNode, newNode, beforeNodeIndex); }, /** * Insert the node as a new sibling, after the original node. * * * **Note**: It is more efficient to use the insertChild function if you * already know the relevant index. */ insertAfter(newNode, referenceNode) { if (!newNode) throw new MissingArgumentError("newNode"); if (!referenceNode) throw new MissingArgumentError("referenceNode"); if (!referenceNode.parentNode) throw new Error(`'${"referenceNode"}' has no parent`); const childNodes = referenceNode.parentNode.childNodes; const referenceNodeIndex = childNodes.indexOf(referenceNode); XmlNode.insertChild(referenceNode.parentNode, newNode, referenceNodeIndex + 1); }, insertChild(parent, child, childIndex) { if (!parent) throw new MissingArgumentError("parent"); if (XmlNode.isTextNode(parent)) throw new Error('Appending children to text nodes is forbidden'); if (!child) throw new MissingArgumentError("child"); if (!parent.childNodes) parent.childNodes = []; // revert to append if (childIndex === parent.childNodes.length) { XmlNode.appendChild(parent, child); return; } if (childIndex > parent.childNodes.length) throw new RangeError(`Child index ${childIndex} is out of range. Parent has only ${parent.childNodes.length} child nodes.`); // update references child.parentNode = parent; const childAfter = parent.childNodes[childIndex]; child.nextSibling = childAfter; if (childIndex > 0) { const childBefore = parent.childNodes[childIndex - 1]; childBefore.nextSibling = child; } // append parent.childNodes.splice(childIndex, 0, child); }, appendChild(parent, child) { if (!parent) throw new MissingArgumentError("parent"); if (XmlNode.isTextNode(parent)) throw new Error('Appending children to text nodes is forbidden'); if (!child) throw new MissingArgumentError("child"); if (!parent.childNodes) parent.childNodes = []; // update references if (parent.childNodes.length) { const currentLastChild = parent.childNodes[parent.childNodes.length - 1]; currentLastChild.nextSibling = child; } child.nextSibling = null; child.parentNode = parent; // append parent.childNodes.push(child); }, /** * Removes the node from it's parent. * * * **Note**: It is more efficient to call removeChild(parent, childIndex). */ remove(node) { if (!node) throw new MissingArgumentError("node"); if (!node.parentNode) throw new Error('Node has no parent'); removeChild(node.parentNode, node); }, removeChild, // // utility functions // /** * Gets the last direct child text node if it exists. Otherwise creates a * new text node, appends it to 'node' and return the newly created text * node. * * The function also makes sure the returned text node has a valid string * value. */ lastTextChild(node) { if (XmlNode.isTextNode(node)) { return node; } // existing text nodes if (node.childNodes) { const allTextNodes = node.childNodes.filter(child => XmlNode.isTextNode(child)); if (allTextNodes.length) { const lastTextNode = last(allTextNodes); if (!lastTextNode.textContent) lastTextNode.textContent = ''; return lastTextNode; } } // create new text node const newTextNode = { nodeType: XmlNodeType.Text, nodeName: TEXT_NODE_NAME, textContent: '' }; XmlNode.appendChild(node, newTextNode); return newTextNode; }, /** * Remove sibling nodes between 'from' and 'to' excluding both. * Return the removed nodes. */ removeSiblings(from, to) { if (from === to) return []; const removed = []; let lastRemoved; from = from.nextSibling; while (from !== to) { const removeMe = from; from = from.nextSibling; XmlNode.remove(removeMe); removed.push(removeMe); if (lastRemoved) lastRemoved.nextSibling = removeMe; lastRemoved = removeMe; } return removed; }, /** * Split the original node into two sibling nodes. Returns both nodes. * * @param parent The node to split * @param child The node that marks the split position. * @param removeChild Should this method remove the child while splitting. * * @returns Two nodes - `left` and `right`. If the `removeChild` argument is * `false` then the original child node is the first child of `right`. */ splitByChild(parent, child, removeChild) { if (child.parentNode != parent) throw new Error(`Node '${"child"}' is not a direct child of '${"parent"}'.`); // create childless clone 'left' const left = XmlNode.cloneNode(parent, false); if (parent.parentNode) { XmlNode.insertBefore(left, parent); } const right = parent; // move nodes from 'right' to 'left' let curChild = right.childNodes[0]; while (curChild != child) { XmlNode.remove(curChild); XmlNode.appendChild(left, curChild); curChild = right.childNodes[0]; } // remove child if (removeChild) { XmlNode.removeChild(right, 0); } return [left, right]; }, findParent(node, predicate) { while (node) { if (predicate(node)) return node; node = node.parentNode; } return null; }, findParentByName(node, nodeName) { return XmlNode.findParent(node, n => n.nodeName === nodeName); }, findChildByName(node, childName) { if (!node) return null; return (node.childNodes || []).find(child => child.nodeName === childName); }, /** * Returns all siblings between 'firstNode' and 'lastNode' inclusive. */ siblingsInRange(firstNode, lastNode) { if (!firstNode) throw new MissingArgumentError("firstNode"); if (!lastNode) throw new MissingArgumentError("lastNode"); const range = []; let curNode = firstNode; while (curNode && curNode !== lastNode) { range.push(curNode); curNode = curNode.nextSibling; } if (!curNode) throw new Error('Nodes are not siblings.'); range.push(lastNode); return range; }, /** * Recursively removes text nodes leaving only "general nodes". */ removeEmptyTextNodes(node) { recursiveRemoveEmptyTextNodes(node); } }; // // overloaded functions // /** * Remove a child node from it's parent. Returns the removed child. * * * **Note:** Prefer calling with explicit index. */ /** * Remove a child node from it's parent. Returns the removed child. */ function removeChild(parent, childOrIndex) { if (!parent) throw new MissingArgumentError("parent"); if (childOrIndex === null || childOrIndex === undefined) throw new MissingArgumentError("childOrIndex"); if (!parent.childNodes || !parent.childNodes.length) throw new Error('Parent node has node children'); // get child index let childIndex; if (typeof childOrIndex === 'number') { childIndex = childOrIndex; } else { childIndex = parent.childNodes.indexOf(childOrIndex); if (childIndex === -1) throw new Error('Specified child node is not a child of the specified parent'); } if (childIndex >= parent.childNodes.length) throw new RangeError(`Child index ${childIndex} is out of range. Parent has only ${parent.childNodes.length} child nodes.`); // update references const child = parent.childNodes[childIndex]; if (childIndex > 0) { const beforeChild = parent.childNodes[childIndex - 1]; beforeChild.nextSibling = child.nextSibling; } child.parentNode = null; child.nextSibling = null; // remove and return return parent.childNodes.splice(childIndex, 1)[0]; } // // private functions // function cloneNodeDeep(original) { const clone = {}; // basic properties clone.nodeType = original.nodeType; clone.nodeName = original.nodeName; if (XmlNode.isTextNode(original)) { clone.textContent = original.textContent; } else { const attributes = original.attributes; if (attributes) { clone.attributes = Object.assign({}, attributes); } } // children if (original.childNodes) { clone.childNodes = []; let prevChildClone; for (const child of original.childNodes) { // clone child const childClone = cloneNodeDeep(child); // set references clone.childNodes.push(childClone); childClone.parentNode = clone; if (prevChildClone) { prevChildClone.nextSibling = childClone; } prevChildClone = childClone; } } return clone; } function recursiveRemoveEmptyTextNodes(node) { if (!node.childNodes) return node; const oldChildren = node.childNodes; node.childNodes = []; for (const child of oldChildren) { if (XmlNode.isTextNode(child)) { // https://stackoverflow.com/questions/1921688/filtering-whitespace-only-strings-in-javascript#1921694 if (child.textContent && child.textContent.match(/\S/)) { node.childNodes.push(child); } continue; } const strippedChild = recursiveRemoveEmptyTextNodes(child); node.childNodes.push(strippedChild); } return node; } class XmlParser { parse(str) { const doc = this.domParse(str); return XmlNode.fromDomNode(doc.documentElement); } domParse(str) { if (str === null || str === undefined) throw new MissingArgumentError("str"); return XmlParser.parser.parseFromString(str, "text/xml"); } serialize(xmlNode) { return XmlParser.xmlHeader + XmlNode.serialize(xmlNode); } } _defineProperty(XmlParser, "xmlHeader", ''); /** * We always use the DOMParser from 'xmldom', even in the browser since it * handles xml namespaces more forgivingly (required mainly by the * RawXmlPlugin). */ _defineProperty(XmlParser, "parser", new xmldom.DOMParser()); class MatchState { constructor() { _defineProperty(this, "delimiterIndex", 0); _defineProperty(this, "openNodes", []); _defineProperty(this, "firstMatchIndex", -1); } reset() { this.delimiterIndex = 0; this.openNodes = []; this.firstMatchIndex = -1; } } class DelimiterSearcher { constructor(docxParser) { this.docxParser = docxParser; _defineProperty(this, "maxXmlDepth", 20); _defineProperty(this, "startDelimiter", "{"); _defineProperty(this, "endDelimiter", "}"); if (!docxParser) throw new MissingArgumentError("docxParser"); } findDelimiters(node) { // // Performance note: // // The search efficiency is o(m*n) where n is the text size and m is the // delimiter length. We could use a variation of the KMP algorithm here // to reduce it to o(m+n) but since our m is expected to be small // (delimiters defaults to 2 characters and even on custom inputs are // not expected to be much longer) it does not worth the extra // complexity and effort. // const delimiters = []; const match = new MatchState(); const depth = new XmlDepthTracker(this.maxXmlDepth); let lookForOpenDelimiter = true; while (node) { // reset state on paragraph transition if (this.docxParser.isParagraphNode(node)) { match.reset(); } // skip irrelevant nodes if (!this.shouldSearchNode(node)) { node = this.findNextNode(node, depth); continue; } // search delimiters in text nodes match.openNodes.push(node); let textIndex = 0; while (textIndex < node.textContent.length) { const delimiterPattern = lookForOpenDelimiter ? this.startDelimiter : this.endDelimiter; const char = node.textContent[textIndex]; // no match if (char !== delimiterPattern[match.delimiterIndex]) { [node, textIndex] = this.noMatch(node, textIndex, match); textIndex++; continue; } // first match if (match.firstMatchIndex === -1) { match.firstMatchIndex = textIndex; } // partial match if (match.delimiterIndex !== delimiterPattern.length - 1) { match.delimiterIndex++; textIndex++; continue; } // full delimiter match [node, textIndex, lookForOpenDelimiter] = this.fullMatch(node, textIndex, lookForOpenDelimiter, match, delimiters); textIndex++; } node = this.findNextNode(node, depth); } return delimiters; } noMatch(node, textIndex, match) { // // go back to first open node // // Required for cases where the text has repeating // characters that are the same as a delimiter prefix. // For instance: // Delimiter is '{!' and template text contains the string '{{!' // if (match.firstMatchIndex !== -1) { node = first(match.openNodes); textIndex = match.firstMatchIndex; } // update state match.reset(); if (textIndex < node.textContent.length - 1) { match.openNodes.push(node); } return [node, textIndex]; } fullMatch(node, textIndex, lookForOpenDelimiter, match, delimiters) { // move all delimiters characters to the same text node if (match.openNodes.length > 1) { const firstNode = first(match.openNodes); const lastNode = last(match.openNodes); this.docxParser.joinTextNodesRange(firstNode, lastNode); textIndex += firstNode.textContent.length - node.textContent.length; node = firstNode; } // store delimiter const delimiterMark = this.createDelimiterMark(match, lookForOpenDelimiter); delimiters.push(delimiterMark); // update state lookForOpenDelimiter = !lookForOpenDelimiter; match.reset(); if (textIndex < node.textContent.length - 1) { match.openNodes.push(node); } return [node, textIndex, lookForOpenDelimiter]; } shouldSearchNode(node) { if (!XmlNode.isTextNode(node)) return false; if (!node.textContent) return false; if (!node.parentNode) return false; if (!this.docxParser.isTextNode(node.parentNode)) return false; return true; } findNextNode(node, depth) { // children if (node.childNodes && node.childNodes.length) { depth.increment(); return node.childNodes[0]; } // siblings if (node.nextSibling) return node.nextSibling; // parent sibling while (node.parentNode) { if (node.parentNode.nextSibling) { depth.decrement(); return node.parentNode.nextSibling; } // go up depth.decrement(); node = node.parentNode; } return null; } createDelimiterMark(match, isOpenDelimiter) { return { index: match.firstMatchIndex, isOpen: isOpenDelimiter, xmlTextNode: match.openNodes[0] }; } } class ScopeData { static defaultResolver(args) { let result; const lastKey = last(args.strPath); const curPath = args.strPath.slice(); while (result === undefined && curPath.length) { curPath.pop(); result = getProp(args.data, curPath.concat(lastKey)); } return result; } constructor(data) { _defineProperty(this, "scopeDataResolver", void 0); _defineProperty(this, "allData", void 0); _defineProperty(this, "path", []); _defineProperty(this, "strPath", []); this.allData = data; } pathPush(pathPart) { this.path.push(pathPart); const strItem = isNumber(pathPart) ? pathPart.toString() : pathPart.name; this.strPath.push(strItem); } pathPop() { this.strPath.pop(); return this.path.pop(); } pathString() { return this.strPath.join("."); } getScopeData() { const args = { path: this.path, strPath: this.strPath, data: this.allData }; if (this.scopeDataResolver) { return this.scopeDataResolver(args); } return ScopeData.defaultResolver(args); } } let TagDisposition = /*#__PURE__*/function (TagDisposition) { TagDisposition["Open"] = "Open"; TagDisposition["Close"] = "Close"; TagDisposition["SelfClosed"] = "SelfClosed"; return TagDisposition; }({}); class TagParser { constructor(docParser, delimiters) { this.docParser = docParser; this.delimiters = delimiters; _defineProperty(this, "tagRegex", void 0); if (!docParser) throw new MissingArgumentError("docParser"); if (!delimiters) throw new MissingArgumentError("delimiters"); const tagOptionsRegex = `${Regex.escape(delimiters.tagOptionsStart)}(?.*?)${Regex.escape(delimiters.tagOptionsEnd)}`; this.tagRegex = new RegExp(`^${Regex.escape(delimiters.tagStart)}(?.*?)(${tagOptionsRegex})?${Regex.escape(delimiters.tagEnd)}`, 'm'); } parse(delimiters) { const tags = []; let openedTag; let openedDelimiter; for (let i = 0; i < delimiters.length; i++) { const delimiter = delimiters[i]; // close before open if (!openedTag && !delimiter.isOpen) { const closeTagText = delimiter.xmlTextNode.textContent; throw new MissingStartDelimiterError(closeTagText); } // open before close if (openedTag && delimiter.isOpen) { const openTagText = openedDelimiter.xmlTextNode.textContent; throw new MissingCloseDelimiterError(openTagText); } // valid open if (!openedTag && delimiter.isOpen) { openedTag = {}; openedDelimiter = delimiter; } // valid close if (openedTag && !delimiter.isOpen) { // normalize the underlying xml structure // (make sure the tag's node only includes the tag's text) this.normalizeTagNodes(openedDelimiter, delimiter, i, delimiters); openedTag.xmlTextNode = openedDelimiter.xmlTextNode; // extract tag info from tag's text this.processTag(openedTag); tags.push(openedTag); openedTag = null; openedDelimiter = null; } } return tags; } /** * Consolidate all tag's text into a single text node. * * Example: * * Text node before: "some text {some tag} some more text" * Text nodes after: [ "some text ", "{some tag}", " some more text" ] */ normalizeTagNodes(openDelimiter, closeDelimiter, closeDelimiterIndex, allDelimiters) { let startTextNode = openDelimiter.xmlTextNode; let endTextNode = closeDelimiter.xmlTextNode; const sameNode = startTextNode === endTextNode; // trim start if (openDelimiter.index > 0) { this.docParser.splitTextNode(startTextNode, openDelimiter.index, true); if (sameNode) { closeDelimiter.index -= openDelimiter.index; } } // trim end if (closeDelimiter.index < endTextNode.textContent.length - 1) { endTextNode = this.docParser.splitTextNode(endTextNode, closeDelimiter.index + this.delimiters.tagEnd.length, true); if (sameNode) { startTextNode = endTextNode; } } // join nodes if (!sameNode) { this.docParser.joinTextNodesRange(startTextNode, endTextNode); endTextNode = startTextNode; } // update offsets of next delimiters for (let i = closeDelimiterIndex + 1; i < allDelimiters.length; i++) { let updated = false; const curDelimiter = allDelimiters[i]; if (curDelimiter.xmlTextNode === openDelimiter.xmlTextNode) { curDelimiter.index -= openDelimiter.index; updated = true; } if (curDelimiter.xmlTextNode === closeDelimiter.xmlTextNode) { curDelimiter.index -= closeDelimiter.index + this.delimiters.tagEnd.length; updated = true; } if (!updated) break; } // update references openDelimiter.xmlTextNode = startTextNode; closeDelimiter.xmlTextNode = endTextNode; } processTag(tag) { var _tagParts$groups, _tagParts$groups2; tag.rawText = tag.xmlTextNode.textContent; const tagParts = this.tagRegex.exec(tag.rawText); const tagName = (((_tagParts$groups = tagParts.groups) === null || _tagParts$groups === void 0 ? void 0 : _tagParts$groups["tagName"]) || '').trim(); // Ignoring empty tags. if (!(tagName !== null && tagName !== void 0 && tagName.length)) { tag.disposition = TagDisposition.SelfClosed; return; } // Tag options. const tagOptionsText = (((_tagParts$groups2 = tagParts.groups) === null || _tagParts$groups2 === void 0 ? void 0 : _tagParts$groups2["tagOptions"]) || '').trim(); if (tagOptionsText) { try { tag.options = JSON5__namespace.parse("{" + normalizeDoubleQuotes(tagOptionsText) + "}"); } catch (e) { throw new TagOptionsParseError(tag.rawText, e); } } // Container open tag. if (tagName.startsWith(this.delimiters.containerTagOpen)) { tag.disposition = TagDisposition.Open; tag.name = tagName.slice(this.delimiters.containerTagOpen.length).trim(); return; } // Container close tag. if (tagName.startsWith(this.delimiters.containerTagClose)) { tag.disposition = TagDisposition.Close; tag.name = tagName.slice(this.delimiters.containerTagClose.length).trim(); return; } // Self-closed tag. tag.disposition = TagDisposition.SelfClosed; tag.name = tagName; } } let MimeType = /*#__PURE__*/function (MimeType) { MimeType["Png"] = "image/png"; MimeType["Jpeg"] = "image/jpeg"; MimeType["Gif"] = "image/gif"; MimeType["Bmp"] = "image/bmp"; MimeType["Svg"] = "image/svg+xml"; return MimeType; }({}); class MimeTypeHelper { static getDefaultExtension(mime) { switch (mime) { case MimeType.Png: return 'png'; case MimeType.Jpeg: return 'jpg'; case MimeType.Gif: return 'gif'; case MimeType.Bmp: return 'bmp'; case MimeType.Svg: return 'svg'; default: throw new UnsupportedFileTypeError(mime); } } static getOfficeRelType(mime) { switch (mime) { case MimeType.Png: case MimeType.Jpeg: case MimeType.Gif: case MimeType.Bmp: case MimeType.Svg: return "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; default: throw new UnsupportedFileTypeError(mime); } } } class TemplatePlugin { constructor() { /** * The content type this plugin handles. */ _defineProperty(this, "contentType", void 0); _defineProperty(this, "utilities", void 0); } /** * Called by the TemplateHandler at runtime. */ setUtilities(utilities) { this.utilities = utilities; } /** * This method is called for each self-closing tag. * It should implement the specific document manipulation required by the tag. */ simpleTagReplacements(tag, data, context) { // noop } /** * This method is called for each container tag. It should implement the * specific document manipulation required by the tag. * * @param tags All tags between the opening tag and closing tag (inclusive, * i.e. tags[0] is the opening tag and the last item in the tags array is * the closing tag). */ containerTagReplacements(tags, data, context) { // noop } } /** * Apparently it is not that important for the ID to be unique... * Word displays two images correctly even if they both have the same ID. * Further more, Word will assign each a unique ID upon saving (it assigns * consecutive integers starting with 1). * * Note: The same principal applies to image names. * * Tested in Word v1908 */ let nextImageId = 1; class ImagePlugin extends TemplatePlugin { constructor(...args) { super(...args); _defineProperty(this, "contentType", 'image'); } async simpleTagReplacements(tag, data, context) { const wordTextNode = this.utilities.docxParser.containingTextNode(tag.xmlTextNode); const content = data.getScopeData(); if (!content || !content.source) { XmlNode.remove(wordTextNode); return; } // Add the image file into the archive const mediaFilePath = await context.docx.mediaFiles.add(content.source, content.format); const relType = MimeTypeHelper.getOfficeRelType(content.format); const relId = await context.currentPart.rels.add(mediaFilePath, relType); await context.docx.contentTypes.ensureContentType(content.format); // Create the xml markup const imageId = nextImageId++; const imageXml = this.createMarkup(imageId, relId, content); XmlNode.insertAfter(imageXml, wordTextNode); XmlNode.remove(wordTextNode); } createMarkup(imageId, relId, content) { // http://officeopenxml.com/drwPicInline.php // // Performance note: // // I've tried to improve the markup generation performance by parsing // the string once and caching the result (and of course customizing it // per image) but it made no change whatsoever (in both cases 1000 items // loop takes around 8 seconds on my machine) so I'm sticking with this // approach which I find to be more readable. // const name = `Picture ${imageId}`; const markupText = ` ${this.docProperties(imageId, name, content)} ${this.pictureMarkup(imageId, relId, name, content)} `; const markupXml = this.utilities.xmlParser.parse(markupText); XmlNode.removeEmptyTextNodes(markupXml); // remove whitespace return markupXml; } docProperties(imageId, name, content) { if (content.altText) { return ``; } return ` `; } pictureMarkup(imageId, relId, name, content) { // http://officeopenxml.com/drwPic.php // Legend: // nvPicPr - non-visual picture properties - id, name, etc. // blipFill - binary large image (or) picture fill - image size, image fill, etc. // spPr - shape properties - frame size, frame fill, etc. return ` ${this.transparencyMarkup(content.transparencyPercent)} `; } transparencyMarkup(transparencyPercent) { if (transparencyPercent === null || transparencyPercent === undefined) { return ''; } if (transparencyPercent < 0 || transparencyPercent > 100) { throw new ArgumentError(`Transparency percent must be between 0 and 100, but was ${transparencyPercent}.`); } const alpha = Math.round((100 - transparencyPercent) * 1000); return ``; } pixelsToEmu(pixels) { // https://stackoverflow.com/questions/20194403/openxml-distance-size-units // https://docs.microsoft.com/en-us/windows/win32/vml/msdn-online-vml-units#other-units-of-measurement // https://en.wikipedia.org/wiki/Office_Open_XML_file_formats#DrawingML // http://www.java2s.com/Code/CSharp/2D-Graphics/ConvertpixelstoEMUEMUtopixels.htm return Math.round(pixels * 9525); } } let ContentPartType = /*#__PURE__*/function (ContentPartType) { ContentPartType["MainDocument"] = "MainDocument"; ContentPartType["DefaultHeader"] = "DefaultHeader"; ContentPartType["FirstHeader"] = "FirstHeader"; ContentPartType["EvenPagesHeader"] = "EvenPagesHeader"; ContentPartType["DefaultFooter"] = "DefaultFooter"; ContentPartType["FirstFooter"] = "FirstFooter"; ContentPartType["EvenPagesFooter"] = "EvenPagesFooter"; return ContentPartType; }({}); /** * http://officeopenxml.com/anatomyofOOXML.php */ class ContentTypesFile { constructor(zip, xmlParser) { this.zip = zip; this.xmlParser = xmlParser; _defineProperty(this, "addedNew", false); _defineProperty(this, "root", void 0); _defineProperty(this, "contentTypes", void 0); } async ensureContentType(mime) { // parse the content types file await this.parseContentTypesFile(); // already exists if (this.contentTypes[mime]) return; // add new const extension = MimeTypeHelper.getDefaultExtension(mime); const typeNode = XmlNode.createGeneralNode('Default'); typeNode.attributes = { "Extension": extension, "ContentType": mime }; this.root.childNodes.push(typeNode); // update state this.addedNew = true; this.contentTypes[mime] = true; } async count() { await this.parseContentTypesFile(); return this.root.childNodes.filter(node => !XmlNode.isTextNode(node)).length; } /** * Save the Content Types file back to the zip. * Called automatically by the holding `Docx` before exporting. */ async save() { // not change - no need to save if (!this.addedNew) return; const xmlContent = this.xmlParser.serialize(this.root); this.zip.setFile(ContentTypesFile.contentTypesFilePath, xmlContent); } async parseContentTypesFile() { if (this.root) return; // parse the xml file const contentTypesXml = await this.zip.getFile(ContentTypesFile.contentTypesFilePath).getContentText(); this.root = this.xmlParser.parse(contentTypesXml); // build the content types lookup this.contentTypes = {}; for (const node of this.root.childNodes) { if (node.nodeName !== 'Default') continue; const genNode = node; const contentTypeAttribute = genNode.attributes['ContentType']; if (!contentTypeAttribute) continue; this.contentTypes[contentTypeAttribute] = true; } } } _defineProperty(ContentTypesFile, "contentTypesFilePath", '[Content_Types].xml'); /** * Handles media files of the main document. */ class MediaFiles { constructor(zip) { this.zip = zip; _defineProperty(this, "hashes", void 0); _defineProperty(this, "files", new Map()); _defineProperty(this, "nextFileId", 0); } /** * Returns the media file path. */ async add(mediaFile, mime) { // check if already added if (this.files.has(mediaFile)) return this.files.get(mediaFile); // hash existing media files await this.hashMediaFiles(); // hash the new file // Note: Even though hashing the base64 string may seem inefficient // (requires extra step in some cases) in practice it is significantly // faster than hashing a 'binarystring'. const base64 = await Binary.toBase64(mediaFile); const hash = sha1(base64); // check if file already exists // note: this can be optimized by keeping both mapping by filename as well as by hash let path = Object.keys(this.hashes).find(p => this.hashes[p] === hash); if (path) return path; // generate unique media file name const extension = MimeTypeHelper.getDefaultExtension(mime); do { this.nextFileId++; path = `${MediaFiles.mediaDir}/media${this.nextFileId}.${extension}`; } while (this.hashes[path]); // add media to zip await this.zip.setFile(path, mediaFile); // add media to our lookups this.hashes[path] = hash; this.files.set(mediaFile, path); // return return path; } async count() { await this.hashMediaFiles(); return Object.keys(this.hashes).length; } async hashMediaFiles() { if (this.hashes) return; this.hashes = {}; for (const path of this.zip.listFiles()) { if (!path.startsWith(MediaFiles.mediaDir)) continue; const filename = Path.getFilename(path); if (!filename) continue; const fileData = await this.zip.getFile(path).getContentBase64(); const fileHash = sha1(fileData); this.hashes[filename] = fileHash; } } } _defineProperty(MediaFiles, "mediaDir", 'word/media'); class Relationship { static fromXml(xml) { var _xml$attributes, _xml$attributes2, _xml$attributes3, _xml$attributes4; return new Relationship({ id: (_xml$attributes = xml.attributes) === null || _xml$attributes === void 0 ? void 0 : _xml$attributes['Id'], type: (_xml$attributes2 = xml.attributes) === null || _xml$attributes2 === void 0 ? void 0 : _xml$attributes2['Type'], target: (_xml$attributes3 = xml.attributes) === null || _xml$attributes3 === void 0 ? void 0 : _xml$attributes3['Target'], targetMode: (_xml$attributes4 = xml.attributes) === null || _xml$attributes4 === void 0 ? void 0 : _xml$attributes4['TargetMode'] }); } constructor(initial) { _defineProperty(this, "id", void 0); _defineProperty(this, "type", void 0); _defineProperty(this, "target", void 0); _defineProperty(this, "targetMode", void 0); Object.assign(this, initial); } toXml() { const node = XmlNode.createGeneralNode('Relationship'); node.attributes = {}; // set only non-empty attributes for (const propKey of Object.keys(this)) { const value = this[propKey]; if (value && typeof value === 'string') { const attrName = propKey[0].toUpperCase() + propKey.substr(1); node.attributes[attrName] = value; } } return node; } } /** * Handles the relationship logic of a single docx "part". * http://officeopenxml.com/anatomyofOOXML.php */ class Rels { constructor(partPath, zip, xmlParser) { this.zip = zip; this.xmlParser = xmlParser; _defineProperty(this, "rels", void 0); _defineProperty(this, "relTargets", void 0); _defineProperty(this, "nextRelId", 0); _defineProperty(this, "partDir", void 0); _defineProperty(this, "relsFilePath", void 0); this.partDir = partPath && Path.getDirectory(partPath); const partFilename = partPath && Path.getFilename(partPath); this.relsFilePath = Path.combine(this.partDir, '_rels', `${partFilename !== null && partFilename !== void 0 ? partFilename : ''}.rels`); } /** * Returns the rel ID. */ async add(relTarget, relType, relTargetMode) { // if relTarget is an internal file it should be relative to the part dir if (this.partDir && relTarget.startsWith(this.partDir)) { relTarget = relTarget.substr(this.partDir.length + 1); } // parse rels file await this.parseRelsFile(); // already exists? const relTargetKey = this.getRelTargetKey(relType, relTarget); let relId = this.relTargets[relTargetKey]; if (relId) return relId; // create rel node relId = this.getNextRelId(); const rel = new Relationship({ id: relId, type: relType, target: relTarget, targetMode: relTargetMode }); // update lookups this.rels[relId] = rel; this.relTargets[relTargetKey] = relId; // return return relId; } async list() { await this.parseRelsFile(); return Object.values(this.rels); } /** * Save the rels file back to the zip. * Called automatically by the holding `Docx` before exporting. */ async save() { // not change - no need to save if (!this.rels) return; // create rels xml const root = this.createRootNode(); root.childNodes = Object.values(this.rels).map(rel => rel.toXml()); // serialize and save const xmlContent = this.xmlParser.serialize(root); this.zip.setFile(this.relsFilePath, xmlContent); } // // private methods // getNextRelId() { let relId; do { this.nextRelId++; relId = 'rId' + this.nextRelId; } while (this.rels[relId]); return relId; } async parseRelsFile() { // already parsed if (this.rels) return; // parse xml let root; const relsFile = this.zip.getFile(this.relsFilePath); if (relsFile) { const xml = await relsFile.getContentText(); root = this.xmlParser.parse(xml); } else { root = this.createRootNode(); } // parse relationship nodes this.rels = {}; this.relTargets = {}; for (const relNode of root.childNodes) { const attributes = relNode.attributes; if (!attributes) continue; const idAttr = attributes['Id']; if (!idAttr) continue; // store rel const rel = Relationship.fromXml(relNode); this.rels[idAttr] = rel; // create rel target lookup const typeAttr = attributes['Type']; const targetAttr = attributes['Target']; if (typeAttr && targetAttr) { const relTargetKey = this.getRelTargetKey(typeAttr, targetAttr); this.relTargets[relTargetKey] = idAttr; } } } getRelTargetKey(type, target) { return `${type} - ${target}`; } createRootNode() { const root = XmlNode.createGeneralNode('Relationships'); root.attributes = { 'xmlns': 'http://schemas.openxmlformats.org/package/2006/relationships' }; root.childNodes = []; return root; } } /** * Represents an xml file that is part of an OPC package. * * See: https://en.wikipedia.org/wiki/Open_Packaging_Conventions */ class XmlPart { constructor(path, zip, xmlParser) { this.path = path; this.zip = zip; this.xmlParser = xmlParser; _defineProperty(this, "rels", void 0); _defineProperty(this, "root", void 0); this.rels = new Rels(this.path, zip, xmlParser); } // // public methods // /** * Get the xml root node of the part. * Changes to the xml will be persisted to the underlying zip file. */ async xmlRoot() { if (!this.root) { const xml = await this.zip.getFile(this.path).getContentText(); this.root = this.xmlParser.parse(xml); } return this.root; } /** * Get the text content of the part. */ async getText() { const xmlDocument = await this.xmlRoot(); // ugly but good enough... const xml = this.xmlParser.serialize(xmlDocument); const domDocument = this.xmlParser.domParse(xml); return domDocument.documentElement.textContent; } async saveChanges() { // save xml if (this.root) { const xmlRoot = await this.xmlRoot(); const xmlContent = this.xmlParser.serialize(xmlRoot); this.zip.setFile(this.path, xmlContent); } // save rels await this.rels.save(); } } /** * Represents a single docx file. */ class Docx { // // static methods // static async open(zip, xmlParser) { const mainDocumentPath = await Docx.getMainDocumentPath(zip, xmlParser); if (!mainDocumentPath) throw new MalformedFileError('docx'); return new Docx(mainDocumentPath, zip, xmlParser); } static async getMainDocumentPath(zip, xmlParser) { var _relations$find; const rootPart = ''; const rootRels = new Rels(rootPart, zip, xmlParser); const relations = await rootRels.list(); return (_relations$find = relations.find(rel => rel.type == Docx.mainDocumentRelType)) === null || _relations$find === void 0 ? void 0 : _relations$find.target; } // // fields // /** * **Notice:** You should only use this property if there is no other way to * do what you need. Use with caution. */ get rawZipFile() { return this.zip; } // // constructor // constructor(mainDocumentPath, zip, xmlParser) { this.zip = zip; this.xmlParser = xmlParser; _defineProperty(this, "mainDocument", void 0); _defineProperty(this, "mediaFiles", void 0); _defineProperty(this, "contentTypes", void 0); _defineProperty(this, "_parts", {}); this.mainDocument = new XmlPart(mainDocumentPath, zip, xmlParser); this.mediaFiles = new MediaFiles(zip); this.contentTypes = new ContentTypesFile(zip, xmlParser); } // // public methods // async getContentPart(type) { switch (type) { case ContentPartType.MainDocument: return this.mainDocument; default: return await this.getHeaderOrFooter(type); } } /** * Returns the xml parts of the main document, headers and footers. */ async getContentParts() { const partTypes = [ContentPartType.MainDocument, ContentPartType.DefaultHeader, ContentPartType.FirstHeader, ContentPartType.EvenPagesHeader, ContentPartType.DefaultFooter, ContentPartType.FirstFooter, ContentPartType.EvenPagesFooter]; const parts = await Promise.all(partTypes.map(p => this.getContentPart(p))); return parts.filter(p => !!p); } async export(outputType) { await this.saveChanges(); return await this.zip.export(outputType); } // // private methods // async getHeaderOrFooter(type) { var _sectionProps$childNo, _attributes; const nodeName = this.headerFooterNodeName(type); const nodeTypeAttribute = this.headerFooterType(type); // find the last section properties // see: http://officeopenxml.com/WPsection.php const docRoot = await this.mainDocument.xmlRoot(); const body = docRoot.childNodes.find(node => node.nodeName == 'w:body'); if (body == null) return null; const sectionProps = last(body.childNodes.filter(node => node.nodeType === XmlNodeType.General)); if (sectionProps.nodeName != 'w:sectPr') return null; // find the header or footer reference const reference = (_sectionProps$childNo = sectionProps.childNodes) === null || _sectionProps$childNo === void 0 ? void 0 : _sectionProps$childNo.find(node => { var _node$attributes; return node.nodeType === XmlNodeType.General && node.nodeName === nodeName && ((_node$attributes = node.attributes) === null || _node$attributes === void 0 ? void 0 : _node$attributes['w:type']) === nodeTypeAttribute; }); const relId = reference === null || reference === void 0 ? void 0 : (_attributes = reference.attributes) === null || _attributes === void 0 ? void 0 : _attributes['r:id']; if (!relId) return null; // return the XmlPart const rels = await this.mainDocument.rels.list(); const relTarget = rels.find(r => r.id === relId).target; if (!this._parts[relTarget]) { const part = new XmlPart("word/" + relTarget, this.zip, this.xmlParser); this._parts[relTarget] = part; } return this._parts[relTarget]; } headerFooterNodeName(contentPartType) { switch (contentPartType) { case ContentPartType.DefaultHeader: case ContentPartType.FirstHeader: case ContentPartType.EvenPagesHeader: return 'w:headerReference'; case ContentPartType.DefaultFooter: case ContentPartType.FirstFooter: case ContentPartType.EvenPagesFooter: return 'w:footerReference'; default: throw new Error(`Invalid content part type: '${contentPartType}'.`); } } headerFooterType(contentPartType) { // https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.headerfootervalues?view=openxml-2.8.1 switch (contentPartType) { case ContentPartType.DefaultHeader: case ContentPartType.DefaultFooter: return 'default'; case ContentPartType.FirstHeader: case ContentPartType.FirstFooter: return 'first'; case ContentPartType.EvenPagesHeader: case ContentPartType.EvenPagesFooter: return 'even'; default: throw new Error(`Invalid content part type: '${contentPartType}'.`); } } async saveChanges() { const parts = [this.mainDocument, ...Object.values(this._parts)]; for (const part of parts) { await part.saveChanges(); } await this.contentTypes.save(); } } _defineProperty(Docx, "mainDocumentRelType", 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument'); class DocxParser { // // constructor // constructor(xmlParser) { this.xmlParser = xmlParser; } // // parse document // load(zip) { return Docx.open(zip, this.xmlParser); } // // content manipulation // /** * Split the text node into two text nodes, each with it's own wrapping node. * Returns the newly created text node. * * @param textNode * @param splitIndex * @param addBefore Should the new node be added before or after the original node. */ splitTextNode(textNode, splitIndex, addBefore) { let firstXmlTextNode; let secondXmlTextNode; // split nodes const wordTextNode = this.containingTextNode(textNode); const newWordTextNode = XmlNode.cloneNode(wordTextNode, true); // set space preserve to prevent display differences after splitting // (otherwise if there was a space in the middle of the text node and it // is now at the beginning or end of the text node it will be ignored) this.setSpacePreserveAttribute(wordTextNode); this.setSpacePreserveAttribute(newWordTextNode); if (addBefore) { // insert new node before existing one XmlNode.insertBefore(newWordTextNode, wordTextNode); firstXmlTextNode = XmlNode.lastTextChild(newWordTextNode); secondXmlTextNode = textNode; } else { // insert new node after existing one const curIndex = wordTextNode.parentNode.childNodes.indexOf(wordTextNode); XmlNode.insertChild(wordTextNode.parentNode, newWordTextNode, curIndex + 1); firstXmlTextNode = textNode; secondXmlTextNode = XmlNode.lastTextChild(newWordTextNode); } // edit text const firstText = firstXmlTextNode.textContent; const secondText = secondXmlTextNode.textContent; firstXmlTextNode.textContent = firstText.substring(0, splitIndex); secondXmlTextNode.textContent = secondText.substring(splitIndex); return addBefore ? firstXmlTextNode : secondXmlTextNode; } /** * Split the paragraph around the specified text node. * * @returns Two paragraphs - `left` and `right`. If the `removeTextNode` argument is * `false` then the original text node is the first text node of `right`. */ splitParagraphByTextNode(paragraph, textNode, removeTextNode) { // input validation const containingParagraph = this.containingParagraphNode(textNode); if (containingParagraph != paragraph) throw new Error(`Node '${"textNode"}' is not a descendant of '${"paragraph"}'.`); const runNode = this.containingRunNode(textNode); const wordTextNode = this.containingTextNode(textNode); // create run clone const leftRun = XmlNode.cloneNode(runNode, false); const rightRun = runNode; XmlNode.insertBefore(leftRun, rightRun); // copy props from original run node (preserve style) const runProps = rightRun.childNodes.find(node => node.nodeName === DocxParser.RUN_PROPERTIES_NODE); if (runProps) { const leftRunProps = XmlNode.cloneNode(runProps, true); XmlNode.appendChild(leftRun, leftRunProps); } // move nodes from 'right' to 'left' const firstRunChildIndex = runProps ? 1 : 0; let curChild = rightRun.childNodes[firstRunChildIndex]; while (curChild != wordTextNode) { XmlNode.remove(curChild); XmlNode.appendChild(leftRun, curChild); curChild = rightRun.childNodes[firstRunChildIndex]; } // remove text node if (removeTextNode) { XmlNode.removeChild(rightRun, firstRunChildIndex); } // create paragraph clone const leftPara = XmlNode.cloneNode(containingParagraph, false); const rightPara = containingParagraph; XmlNode.insertBefore(leftPara, rightPara); // copy props from original paragraph (preserve style) const paragraphProps = rightPara.childNodes.find(node => node.nodeName === DocxParser.PARAGRAPH_PROPERTIES_NODE); if (paragraphProps) { const leftParagraphProps = XmlNode.cloneNode(paragraphProps, true); XmlNode.appendChild(leftPara, leftParagraphProps); } // move nodes from 'right' to 'left' const firstParaChildIndex = paragraphProps ? 1 : 0; curChild = rightPara.childNodes[firstParaChildIndex]; while (curChild != rightRun) { XmlNode.remove(curChild); XmlNode.appendChild(leftPara, curChild); curChild = rightPara.childNodes[firstParaChildIndex]; } // clean paragraphs - remove empty runs if (this.isEmptyRun(leftRun)) XmlNode.remove(leftRun); if (this.isEmptyRun(rightRun)) XmlNode.remove(rightRun); return [leftPara, rightPara]; } /** * Move all text between the 'from' and 'to' nodes to the 'from' node. */ joinTextNodesRange(from, to) { // find run nodes const firstRunNode = this.containingRunNode(from); const secondRunNode = this.containingRunNode(to); const paragraphNode = firstRunNode.parentNode; if (secondRunNode.parentNode !== paragraphNode) throw new Error('Can not join text nodes from separate paragraphs.'); // find "word text nodes" const firstWordTextNode = this.containingTextNode(from); const secondWordTextNode = this.containingTextNode(to); const totalText = []; // iterate runs let curRunNode = firstRunNode; while (curRunNode) { // iterate text nodes let curWordTextNode; if (curRunNode === firstRunNode) { curWordTextNode = firstWordTextNode; } else { curWordTextNode = this.firstTextNodeChild(curRunNode); } while (curWordTextNode) { if (curWordTextNode.nodeName !== DocxParser.TEXT_NODE) { curWordTextNode = curWordTextNode.nextSibling; continue; } // move text to first node const curXmlTextNode = XmlNode.lastTextChild(curWordTextNode); totalText.push(curXmlTextNode.textContent); // next text node const textToRemove = curWordTextNode; if (curWordTextNode === secondWordTextNode) { curWordTextNode = null; } else { curWordTextNode = curWordTextNode.nextSibling; } // remove current text node if (textToRemove !== firstWordTextNode) { XmlNode.remove(textToRemove); } } // next run const runToRemove = curRunNode; if (curRunNode === secondRunNode) { curRunNode = null; } else { curRunNode = curRunNode.nextSibling; } // remove current run if (!runToRemove.childNodes || !runToRemove.childNodes.length) { XmlNode.remove(runToRemove); } } // set the text content const firstXmlTextNode = XmlNode.lastTextChild(firstWordTextNode); firstXmlTextNode.textContent = totalText.join(''); } /** * Take all runs from 'second' and move them to 'first'. */ joinParagraphs(first, second) { if (first === second) return; let childIndex = 0; while (second.childNodes && childIndex < second.childNodes.length) { const curChild = second.childNodes[childIndex]; if (curChild.nodeName === DocxParser.RUN_NODE) { XmlNode.removeChild(second, childIndex); XmlNode.appendChild(first, curChild); } else { childIndex++; } } } setSpacePreserveAttribute(node) { if (!node.attributes) { node.attributes = {}; } if (!node.attributes['xml:space']) { node.attributes['xml:space'] = 'preserve'; } } // // node queries // isTextNode(node) { return node.nodeName === DocxParser.TEXT_NODE; } isRunNode(node) { return node.nodeName === DocxParser.RUN_NODE; } isRunPropertiesNode(node) { return node.nodeName === DocxParser.RUN_PROPERTIES_NODE; } isTableCellNode(node) { return node.nodeName === DocxParser.TABLE_CELL_NODE; } isParagraphNode(node) { return node.nodeName === DocxParser.PARAGRAPH_NODE; } isListParagraph(paragraphNode) { const paragraphProperties = this.paragraphPropertiesNode(paragraphNode); const listNumberProperties = XmlNode.findChildByName(paragraphProperties, DocxParser.NUMBER_PROPERTIES_NODE); return !!listNumberProperties; } paragraphPropertiesNode(paragraphNode) { if (!this.isParagraphNode(paragraphNode)) throw new Error(`Expected paragraph node but received a '${paragraphNode.nodeName}' node.`); return XmlNode.findChildByName(paragraphNode, DocxParser.PARAGRAPH_PROPERTIES_NODE); } /** * Search for the first direct child **Word** text node (i.e. a node). */ firstTextNodeChild(node) { if (!node) return null; if (node.nodeName !== DocxParser.RUN_NODE) return null; if (!node.childNodes) return null; for (const child of node.childNodes) { if (child.nodeName === DocxParser.TEXT_NODE) return child; } return null; } /** * Search **upwards** for the first **Word** text node (i.e. a node). */ containingTextNode(node) { if (!node) return null; if (!XmlNode.isTextNode(node)) throw new Error(`'Invalid argument ${"node"}. Expected a XmlTextNode.`); return XmlNode.findParentByName(node, DocxParser.TEXT_NODE); } /** * Search **upwards** for the first run node. */ containingRunNode(node) { return XmlNode.findParentByName(node, DocxParser.RUN_NODE); } /** * Search **upwards** for the first paragraph node. */ containingParagraphNode(node) { return XmlNode.findParentByName(node, DocxParser.PARAGRAPH_NODE); } /** * Search **upwards** for the first "table row" node. */ containingTableRowNode(node) { return XmlNode.findParentByName(node, DocxParser.TABLE_ROW_NODE); } // // advanced node queries // isEmptyTextNode(node) { var _node$childNodes; if (!this.isTextNode(node)) throw new Error(`Text node expected but '${node.nodeName}' received.`); if (!((_node$childNodes = node.childNodes) !== null && _node$childNodes !== void 0 && _node$childNodes.length)) return true; const xmlTextNode = node.childNodes[0]; if (!XmlNode.isTextNode(xmlTextNode)) throw new Error("Invalid XML structure. 'w:t' node should contain a single text node only."); if (!xmlTextNode.textContent) return true; return false; } isEmptyRun(node) { if (!this.isRunNode(node)) throw new Error(`Run node expected but '${node.nodeName}' received.`); for (const child of (_node$childNodes2 = node.childNodes) !== null && _node$childNodes2 !== void 0 ? _node$childNodes2 : []) { var _node$childNodes2; if (this.isRunPropertiesNode(child)) continue; if (this.isTextNode(child) && this.isEmptyTextNode(child)) continue; return false; } return true; } } /* * Word markup intro: * * In Word text nodes are contained in "run" nodes (which specifies text * properties such as font and color). The "run" nodes in turn are * contained in paragraph nodes which is the core unit of content. * * Example: * * <-- paragraph * <-- run * <-- run properties * <-- bold * * This is text. <-- actual text * * * * see: http://officeopenxml.com/WPcontentOverview.php */ _defineProperty(DocxParser, "PARAGRAPH_NODE", 'w:p'); _defineProperty(DocxParser, "PARAGRAPH_PROPERTIES_NODE", 'w:pPr'); _defineProperty(DocxParser, "RUN_NODE", 'w:r'); _defineProperty(DocxParser, "RUN_PROPERTIES_NODE", 'w:rPr'); _defineProperty(DocxParser, "TEXT_NODE", 'w:t'); _defineProperty(DocxParser, "TABLE_ROW_NODE", 'w:tr'); _defineProperty(DocxParser, "TABLE_CELL_NODE", 'w:tc'); _defineProperty(DocxParser, "NUMBER_PROPERTIES_NODE", 'w:numPr'); class LinkPlugin extends TemplatePlugin { constructor(...args) { super(...args); _defineProperty(this, "contentType", 'link'); } async simpleTagReplacements(tag, data, context) { const wordTextNode = this.utilities.docxParser.containingTextNode(tag.xmlTextNode); const content = data.getScopeData(); if (!content || !content.target) { XmlNode.remove(wordTextNode); return; } // add rel const relId = await context.currentPart.rels.add(content.target, LinkPlugin.linkRelType, 'External'); // generate markup const wordRunNode = this.utilities.docxParser.containingRunNode(wordTextNode); const linkMarkup = this.generateMarkup(content, relId, wordRunNode); // add to document this.insertHyperlinkNode(linkMarkup, wordRunNode, wordTextNode); } generateMarkup(content, relId, wordRunNode) { // http://officeopenxml.com/WPhyperlink.php let tooltip = ''; if (content.tooltip) { tooltip += `w:tooltip="${content.tooltip}" `; } const markupText = ` ${content.text || content.target} `; const markupXml = this.utilities.xmlParser.parse(markupText); XmlNode.removeEmptyTextNodes(markupXml); // remove whitespace // copy props from original run node (preserve style) const runProps = wordRunNode.childNodes.find(node => node.nodeName === DocxParser.RUN_PROPERTIES_NODE); if (runProps) { const linkRunProps = XmlNode.cloneNode(runProps, true); markupXml.childNodes[0].childNodes.unshift(linkRunProps); } return markupXml; } insertHyperlinkNode(linkMarkup, tagRunNode, tagTextNode) { // Links are inserted at the 'run' level. // Therefor we isolate the link tag to it's own run (it is already // isolated to it's own text node), insert the link markup and remove // the run. let textNodesInRun = tagRunNode.childNodes.filter(node => node.nodeName === DocxParser.TEXT_NODE); if (textNodesInRun.length > 1) { const [runBeforeTag] = XmlNode.splitByChild(tagRunNode, tagTextNode, true); textNodesInRun = runBeforeTag.childNodes.filter(node => node.nodeName === DocxParser.TEXT_NODE); XmlNode.insertAfter(linkMarkup, runBeforeTag); if (textNodesInRun.length === 0) { XmlNode.remove(runBeforeTag); } } // already isolated else { XmlNode.insertAfter(linkMarkup, tagRunNode); XmlNode.remove(tagRunNode); } } } _defineProperty(LinkPlugin, "linkRelType", 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'); class LoopListStrategy { constructor() { _defineProperty(this, "utilities", void 0); } setUtilities(utilities) { this.utilities = utilities; } isApplicable(openTag, closeTag) { const containingParagraph = this.utilities.docxParser.containingParagraphNode(openTag.xmlTextNode); return this.utilities.docxParser.isListParagraph(containingParagraph); } splitBefore(openTag, closeTag) { const firstParagraph = this.utilities.docxParser.containingParagraphNode(openTag.xmlTextNode); const lastParagraph = this.utilities.docxParser.containingParagraphNode(closeTag.xmlTextNode); const paragraphsToRepeat = XmlNode.siblingsInRange(firstParagraph, lastParagraph); // remove the loop tags XmlNode.remove(openTag.xmlTextNode); XmlNode.remove(closeTag.xmlTextNode); return { firstNode: firstParagraph, nodesToRepeat: paragraphsToRepeat, lastNode: lastParagraph }; } mergeBack(paragraphGroups, firstParagraph, lastParagraphs) { for (const curParagraphsGroup of paragraphGroups) { for (const paragraph of curParagraphsGroup) { XmlNode.insertBefore(paragraph, lastParagraphs); } } // remove the old paragraphs XmlNode.remove(firstParagraph); if (firstParagraph !== lastParagraphs) { XmlNode.remove(lastParagraphs); } } } class LoopParagraphStrategy { constructor() { _defineProperty(this, "utilities", void 0); } setUtilities(utilities) { this.utilities = utilities; } isApplicable(openTag, closeTag) { return true; } splitBefore(openTag, closeTag) { // gather some info let firstParagraph = this.utilities.docxParser.containingParagraphNode(openTag.xmlTextNode); let lastParagraph = this.utilities.docxParser.containingParagraphNode(closeTag.xmlTextNode); const areSame = firstParagraph === lastParagraph; // split first paragraph let splitResult = this.utilities.docxParser.splitParagraphByTextNode(firstParagraph, openTag.xmlTextNode, true); firstParagraph = splitResult[0]; let afterFirstParagraph = splitResult[1]; if (areSame) lastParagraph = afterFirstParagraph; // split last paragraph splitResult = this.utilities.docxParser.splitParagraphByTextNode(lastParagraph, closeTag.xmlTextNode, true); const beforeLastParagraph = splitResult[0]; lastParagraph = splitResult[1]; if (areSame) afterFirstParagraph = beforeLastParagraph; // disconnect splitted paragraph from their parents XmlNode.remove(afterFirstParagraph); if (!areSame) XmlNode.remove(beforeLastParagraph); // extract all paragraphs in between let middleParagraphs; if (areSame) { middleParagraphs = [afterFirstParagraph]; } else { const inBetween = XmlNode.removeSiblings(firstParagraph, lastParagraph); middleParagraphs = [afterFirstParagraph].concat(inBetween).concat(beforeLastParagraph); } return { firstNode: firstParagraph, nodesToRepeat: middleParagraphs, lastNode: lastParagraph }; } mergeBack(middleParagraphs, firstParagraph, lastParagraph) { let mergeTo = firstParagraph; for (const curParagraphsGroup of middleParagraphs) { // merge first paragraphs this.utilities.docxParser.joinParagraphs(mergeTo, curParagraphsGroup[0]); // add middle and last paragraphs to the original document for (let i = 1; i < curParagraphsGroup.length; i++) { XmlNode.insertBefore(curParagraphsGroup[i], lastParagraph); mergeTo = curParagraphsGroup[i]; } } // merge last paragraph this.utilities.docxParser.joinParagraphs(mergeTo, lastParagraph); // remove the old last paragraph (was merged into the new one) XmlNode.remove(lastParagraph); } } let LoopOver = /*#__PURE__*/function (LoopOver) { LoopOver["Row"] = "row"; LoopOver["Content"] = "content"; return LoopOver; }({}); class LoopTableStrategy { constructor() { _defineProperty(this, "utilities", void 0); } setUtilities(utilities) { this.utilities = utilities; } isApplicable(openTag, closeTag) { const openParagraph = this.utilities.docxParser.containingParagraphNode(openTag.xmlTextNode); if (!openParagraph.parentNode) return false; if (!this.utilities.docxParser.isTableCellNode(openParagraph.parentNode)) return false; const closeParagraph = this.utilities.docxParser.containingParagraphNode(closeTag.xmlTextNode); if (!closeParagraph.parentNode) return false; if (!this.utilities.docxParser.isTableCellNode(closeParagraph.parentNode)) return false; const options = openTag.options; const forceRowLoop = (options === null || options === void 0 ? void 0 : options.loopOver) === LoopOver.Row; // If both tags are in the same cell, assume it's a paragraph loop (iterate content, not rows). if (!forceRowLoop && openParagraph.parentNode === closeParagraph.parentNode) return false; return true; } splitBefore(openTag, closeTag) { const firstRow = this.utilities.docxParser.containingTableRowNode(openTag.xmlTextNode); const lastRow = this.utilities.docxParser.containingTableRowNode(closeTag.xmlTextNode); const rowsToRepeat = XmlNode.siblingsInRange(firstRow, lastRow); // remove the loop tags XmlNode.remove(openTag.xmlTextNode); XmlNode.remove(closeTag.xmlTextNode); return { firstNode: firstRow, nodesToRepeat: rowsToRepeat, lastNode: lastRow }; } mergeBack(rowGroups, firstRow, lastRow) { for (const curRowsGroup of rowGroups) { for (const row of curRowsGroup) { XmlNode.insertBefore(row, lastRow); } } // remove the old rows XmlNode.remove(firstRow); if (firstRow !== lastRow) { XmlNode.remove(lastRow); } } } const LOOP_CONTENT_TYPE = 'loop'; class LoopPlugin extends TemplatePlugin { constructor(...args) { super(...args); _defineProperty(this, "contentType", LOOP_CONTENT_TYPE); _defineProperty(this, "loopStrategies", [new LoopTableStrategy(), new LoopListStrategy(), new LoopParagraphStrategy() // the default strategy ]); } setUtilities(utilities) { this.utilities = utilities; this.loopStrategies.forEach(strategy => strategy.setUtilities(utilities)); } async containerTagReplacements(tags, data, context) { let value = data.getScopeData(); // Non array value - treat as a boolean condition. const isCondition = !Array.isArray(value); if (isCondition) { if (value) { value = [{}]; } else { value = []; } } // vars const openTag = tags[0]; const closeTag = last(tags); // select the suitable strategy const loopStrategy = this.loopStrategies.find(strategy => strategy.isApplicable(openTag, closeTag)); if (!loopStrategy) throw new Error(`No loop strategy found for tag '${openTag.rawText}'.`); // prepare to loop const { firstNode, nodesToRepeat, lastNode } = loopStrategy.splitBefore(openTag, closeTag); // repeat (loop) the content const repeatedNodes = this.repeat(nodesToRepeat, value.length); // recursive compilation // (this step can be optimized in the future if we'll keep track of the // path to each token and use that to create new tokens instead of // search through the text again) const compiledNodes = await this.compile(isCondition, repeatedNodes, data, context); // merge back to the document loopStrategy.mergeBack(compiledNodes, firstNode, lastNode); } repeat(nodes, times) { if (!nodes.length || !times) return []; const allResults = []; for (let i = 0; i < times; i++) { const curResult = nodes.map(node => XmlNode.cloneNode(node, true)); allResults.push(curResult); } return allResults; } async compile(isCondition, nodeGroups, data, context) { const compiledNodeGroups = []; // compile each node group with it's relevant data for (let i = 0; i < nodeGroups.length; i++) { // create dummy root node const curNodes = nodeGroups[i]; const dummyRootNode = XmlNode.createGeneralNode('dummyRootNode'); curNodes.forEach(node => XmlNode.appendChild(dummyRootNode, node)); // compile the new root const conditionTag = this.updatePathBefore(isCondition, data, i); await this.utilities.compiler.compile(dummyRootNode, data, context); this.updatePathAfter(isCondition, data, conditionTag); // disconnect from dummy root const curResult = []; while (dummyRootNode.childNodes && dummyRootNode.childNodes.length) { const child = XmlNode.removeChild(dummyRootNode, 0); curResult.push(child); } compiledNodeGroups.push(curResult); } return compiledNodeGroups; } updatePathBefore(isCondition, data, groupIndex) { // if it's a condition - don't go deeper in the path // (so we need to extract the already pushed condition tag) if (isCondition) { if (groupIndex > 0) { // should never happen - conditions should have at most one (synthetic) child... throw new Error(`Internal error: Unexpected group index ${groupIndex} for boolean condition at path "${data.pathString()}".`); } return data.pathPop(); } // else, it's an array - push the current index data.pathPush(groupIndex); return null; } updatePathAfter(isCondition, data, conditionTag) { // reverse the "before" path operation if (isCondition) { data.pathPush(conditionTag); } else { data.pathPop(); } } } class RawXmlPlugin extends TemplatePlugin { constructor(...args) { super(...args); _defineProperty(this, "contentType", 'rawXml'); } simpleTagReplacements(tag, data) { const value = data.getScopeData(); const replaceNode = value !== null && value !== void 0 && value.replaceParagraph ? this.utilities.docxParser.containingParagraphNode(tag.xmlTextNode) : this.utilities.docxParser.containingTextNode(tag.xmlTextNode); if (typeof (value === null || value === void 0 ? void 0 : value.xml) === 'string') { const newNode = this.utilities.xmlParser.parse(value.xml); XmlNode.insertBefore(newNode, replaceNode); } XmlNode.remove(replaceNode); } } const TEXT_CONTENT_TYPE = 'text'; class TextPlugin extends TemplatePlugin { constructor(...args) { super(...args); _defineProperty(this, "contentType", TEXT_CONTENT_TYPE); } /** * Replace the node text content with the specified value. */ simpleTagReplacements(tag, data) { const value = data.getScopeData(); const lines = stringValue(value).split('\n'); if (lines.length < 2) { this.replaceSingleLine(tag.xmlTextNode, lines.length ? lines[0] : ''); } else { this.replaceMultiLine(tag.xmlTextNode, lines); } } replaceSingleLine(textNode, text) { // set text textNode.textContent = text; // make sure leading and trailing whitespace are preserved const wordTextNode = this.utilities.docxParser.containingTextNode(textNode); this.utilities.docxParser.setSpacePreserveAttribute(wordTextNode); } replaceMultiLine(textNode, lines) { const runNode = this.utilities.docxParser.containingRunNode(textNode); // first line textNode.textContent = lines[0]; // other lines for (let i = 1; i < lines.length; i++) { // add line break const lineBreak = this.getLineBreak(); XmlNode.appendChild(runNode, lineBreak); // add text const lineNode = this.createWordTextNode(lines[i]); XmlNode.appendChild(runNode, lineNode); } } getLineBreak() { return XmlNode.createGeneralNode('w:br'); } createWordTextNode(text) { const wordTextNode = XmlNode.createGeneralNode(DocxParser.TEXT_NODE); wordTextNode.attributes = {}; this.utilities.docxParser.setSpacePreserveAttribute(wordTextNode); wordTextNode.childNodes = [XmlNode.createTextNode(text)]; return wordTextNode; } } function createDefaultPlugins() { return [new LoopPlugin(), new RawXmlPlugin(), new ImagePlugin(), new LinkPlugin(), new TextPlugin()]; } const PluginContent = { isPluginContent(content) { return !!content && typeof content._type === 'string'; } }; /** * The TemplateCompiler works roughly the same way as a source code compiler. * It's main steps are: * * 1. find delimiters (lexical analysis) :: (Document) => DelimiterMark[] * 2. extract tags (syntax analysis) :: (DelimiterMark[]) => Tag[] * 3. perform document replace (code generation) :: (Tag[], data) => Document* * * see: https://en.wikipedia.org/wiki/Compiler */ class TemplateCompiler { constructor(delimiterSearcher, tagParser, plugins, options) { this.delimiterSearcher = delimiterSearcher; this.tagParser = tagParser; this.options = options; _defineProperty(this, "pluginsLookup", void 0); this.pluginsLookup = toDictionary(plugins, p => p.contentType); } /** * Compiles the template and performs the required replacements using the * specified data. */ async compile(node, data, context) { const tags = this.parseTags(node); await this.doTagReplacements(tags, data, context); } parseTags(node) { const delimiters = this.delimiterSearcher.findDelimiters(node); const tags = this.tagParser.parse(delimiters); return tags; } // // private methods // async doTagReplacements(tags, data, context) { for (let tagIndex = 0; tagIndex < tags.length; tagIndex++) { const tag = tags[tagIndex]; data.pathPush(tag); const contentType = this.detectContentType(tag, data); const plugin = this.pluginsLookup[contentType]; if (!plugin) { throw new UnknownContentTypeError(contentType, tag.rawText, data.pathString()); } if (tag.disposition === TagDisposition.SelfClosed) { await this.simpleTagReplacements(plugin, tag, data, context); } else if (tag.disposition === TagDisposition.Open) { // get all tags between the open and close tags const closingTagIndex = this.findCloseTagIndex(tagIndex, tag, tags); const scopeTags = tags.slice(tagIndex, closingTagIndex + 1); tagIndex = closingTagIndex; // replace container tag const job = plugin.containerTagReplacements(scopeTags, data, context); if (isPromiseLike(job)) { await job; } } data.pathPop(); } } detectContentType(tag, data) { // explicit content type const scopeData = data.getScopeData(); if (PluginContent.isPluginContent(scopeData)) return scopeData._type; // implicit - loop if (tag.disposition === TagDisposition.Open || tag.disposition === TagDisposition.Close) return this.options.containerContentType; // implicit - text return this.options.defaultContentType; } async simpleTagReplacements(plugin, tag, data, context) { if (this.options.skipEmptyTags && stringValue(data.getScopeData()) === '') { return; } const job = plugin.simpleTagReplacements(tag, data, context); if (isPromiseLike(job)) { await job; } } findCloseTagIndex(fromIndex, openTag, tags) { let openTags = 0; let i = fromIndex; for (; i < tags.length; i++) { const tag = tags[i]; if (tag.disposition === TagDisposition.Open) { openTags++; continue; } if (tag.disposition == TagDisposition.Close) { openTags--; if (openTags === 0) { return i; } if (openTags < 0) { // As long as we don't change the input to // this method (fromIndex in particular) this // should never happen. throw new UnopenedTagError(tag.name); } continue; } } if (i === tags.length) { throw new UnclosedTagError(openTag.name); } return i; } } class TemplateExtension { constructor() { _defineProperty(this, "utilities", void 0); } /** * Called by the TemplateHandler at runtime. */ setUtilities(utilities) { this.utilities = utilities; } } class JsZipHelper { static toJsZipOutputType(binaryOrType) { if (!binaryOrType) throw new MissingArgumentError("binaryOrType"); let binaryType; if (typeof binaryOrType === 'function') { binaryType = binaryOrType; } else { binaryType = binaryOrType.constructor; } if (Binary.isBlobConstructor(binaryType)) return 'blob'; if (Binary.isArrayBufferConstructor(binaryType)) return 'arraybuffer'; if (Binary.isBufferConstructor(binaryType)) return 'nodebuffer'; throw new Error(`Binary type '${binaryType.name}' is not supported.`); } } class ZipObject { get name() { return this.zipObject.name; } set name(value) { this.zipObject.name = value; } get isDirectory() { return this.zipObject.dir; } constructor(zipObject) { this.zipObject = zipObject; } getContentText() { return this.zipObject.async('text'); } getContentBase64() { return this.zipObject.async('binarystring'); } getContentBinary(outputType) { const zipOutputType = JsZipHelper.toJsZipOutputType(outputType); return this.zipObject.async(zipOutputType); } } class Zip { static async load(file) { const zip = await JSZip.loadAsync(file); return new Zip(zip); } constructor(zip) { this.zip = zip; } getFile(path) { const internalZipObject = this.zip.files[path]; if (!internalZipObject) return null; return new ZipObject(internalZipObject); } setFile(path, content) { this.zip.file(path, content); } isFileExist(path) { return !!this.zip.files[path]; } listFiles() { return Object.keys(this.zip.files); } async export(outputType) { const zipOutputType = JsZipHelper.toJsZipOutputType(outputType); const output = await this.zip.generateAsync({ type: zipOutputType, compression: "DEFLATE", compressionOptions: { level: 6 // between 1 (best speed) and 9 (best compression) } }); return output; } } class Delimiters { constructor(initial) { _defineProperty(this, "tagStart", "{"); _defineProperty(this, "tagEnd", "}"); _defineProperty(this, "containerTagOpen", "#"); _defineProperty(this, "containerTagClose", "/"); _defineProperty(this, "tagOptionsStart", "["); _defineProperty(this, "tagOptionsEnd", "]"); Object.assign(this, initial); this.encodeAndValidate(); if (this.containerTagOpen === this.containerTagClose) throw new Error(`${"containerTagOpen"} can not be equal to ${"containerTagClose"}`); } encodeAndValidate() { const keys = ['tagStart', 'tagEnd', 'containerTagOpen', 'containerTagClose']; for (const key of keys) { const value = this[key]; if (!value) throw new Error(`${key} can not be empty.`); if (value !== value.trim()) throw new Error(`${key} can not contain leading or trailing whitespace.`); } } } class TemplateHandlerOptions { constructor(initial) { _defineProperty(this, "plugins", createDefaultPlugins()); /** * Determines the behavior in case of an empty input data. If set to true * the tag will be left untouched, if set to false the tag will be replaced * by an empty string. * * Default: false */ _defineProperty(this, "skipEmptyTags", false); _defineProperty(this, "defaultContentType", TEXT_CONTENT_TYPE); _defineProperty(this, "containerContentType", LOOP_CONTENT_TYPE); _defineProperty(this, "delimiters", new Delimiters()); _defineProperty(this, "maxXmlDepth", 20); _defineProperty(this, "extensions", {}); _defineProperty(this, "scopeDataResolver", void 0); Object.assign(this, initial); if (initial) { this.delimiters = new Delimiters(initial.delimiters); } if (!this.plugins.length) { throw new Error('Plugins list can not be empty'); } } } class TemplateHandler { constructor(options) { var _this$options$extensi, _this$options$extensi2, _this$options$extensi3, _this$options$extensi4; /** * Version number of the `easy-template-x` library. */ _defineProperty(this, "version", "4.1.0" ); _defineProperty(this, "xmlParser", new XmlParser()); _defineProperty(this, "docxParser", void 0); _defineProperty(this, "compiler", void 0); _defineProperty(this, "options", void 0); this.options = new TemplateHandlerOptions(options); // // this is the library's composition root // this.docxParser = new DocxParser(this.xmlParser); const delimiterSearcher = new DelimiterSearcher(this.docxParser); delimiterSearcher.startDelimiter = this.options.delimiters.tagStart; delimiterSearcher.endDelimiter = this.options.delimiters.tagEnd; delimiterSearcher.maxXmlDepth = this.options.maxXmlDepth; const tagParser = new TagParser(this.docxParser, this.options.delimiters); this.compiler = new TemplateCompiler(delimiterSearcher, tagParser, this.options.plugins, { skipEmptyTags: this.options.skipEmptyTags, defaultContentType: this.options.defaultContentType, containerContentType: this.options.containerContentType }); this.options.plugins.forEach(plugin => { plugin.setUtilities({ xmlParser: this.xmlParser, docxParser: this.docxParser, compiler: this.compiler }); }); const extensionUtilities = { xmlParser: this.xmlParser, docxParser: this.docxParser, tagParser, compiler: this.compiler }; (_this$options$extensi = this.options.extensions) === null || _this$options$extensi === void 0 ? void 0 : (_this$options$extensi2 = _this$options$extensi.beforeCompilation) === null || _this$options$extensi2 === void 0 ? void 0 : _this$options$extensi2.forEach(extension => { extension.setUtilities(extensionUtilities); }); (_this$options$extensi3 = this.options.extensions) === null || _this$options$extensi3 === void 0 ? void 0 : (_this$options$extensi4 = _this$options$extensi3.afterCompilation) === null || _this$options$extensi4 === void 0 ? void 0 : _this$options$extensi4.forEach(extension => { extension.setUtilities(extensionUtilities); }); } // // public methods // async process(templateFile, data) { // load the docx file const docx = await this.loadDocx(templateFile); // prepare context const scopeData = new ScopeData(data); scopeData.scopeDataResolver = this.options.scopeDataResolver; const context = { docx, currentPart: null }; const contentParts = await docx.getContentParts(); for (const part of contentParts) { var _this$options$extensi5, _this$options$extensi6; context.currentPart = part; // extensions - before compilation await this.callExtensions((_this$options$extensi5 = this.options.extensions) === null || _this$options$extensi5 === void 0 ? void 0 : _this$options$extensi5.beforeCompilation, scopeData, context); // compilation (do replacements) const xmlRoot = await part.xmlRoot(); await this.compiler.compile(xmlRoot, scopeData, context); // extensions - after compilation await this.callExtensions((_this$options$extensi6 = this.options.extensions) === null || _this$options$extensi6 === void 0 ? void 0 : _this$options$extensi6.afterCompilation, scopeData, context); } // export the result return docx.export(templateFile.constructor); } /** * Get the text content of a single part of the document. * If the part does not exists returns null. * * @param contentPart * The content part of which to get it's text content. * Defaults to `ContentPartType.MainDocument`. */ async parseTags(templateFile, contentPart = ContentPartType.MainDocument) { const docx = await this.loadDocx(templateFile); const part = await docx.getContentPart(contentPart); const xmlRoot = await part.xmlRoot(); return this.compiler.parseTags(xmlRoot); } /** * Get the text content of a single part of the document. * If the part does not exists returns null. * * @param contentPart * The content part of which to get it's text content. * Defaults to `ContentPartType.MainDocument`. */ async getText(docxFile, contentPart = ContentPartType.MainDocument) { const docx = await this.loadDocx(docxFile); const part = await docx.getContentPart(contentPart); const text = await part.getText(); return text; } /** * Get the xml root of a single part of the document. * If the part does not exists returns null. * * @param contentPart * The content part of which to get it's text content. * Defaults to `ContentPartType.MainDocument`. */ async getXml(docxFile, contentPart = ContentPartType.MainDocument) { const docx = await this.loadDocx(docxFile); const part = await docx.getContentPart(contentPart); const xmlRoot = await part.xmlRoot(); return xmlRoot; } // // private methods // async callExtensions(extensions, scopeData, context) { if (!extensions) return; for (const extension of extensions) { await extension.execute(scopeData, context); } } async loadDocx(file) { // load the zip file let zip; try { zip = await Zip.load(file); } catch (_unused) { throw new MalformedFileError('docx'); } // load the docx file const docx = await this.docxParser.load(zip); return docx; } } exports.ArgumentError = ArgumentError; exports.Base64 = Base64; exports.Binary = Binary; exports.COMMENT_NODE_NAME = COMMENT_NODE_NAME; exports.ContentPartType = ContentPartType; exports.DelimiterSearcher = DelimiterSearcher; exports.Delimiters = Delimiters; exports.Docx = Docx; exports.DocxParser = DocxParser; exports.ImagePlugin = ImagePlugin; exports.LOOP_CONTENT_TYPE = LOOP_CONTENT_TYPE; exports.LinkPlugin = LinkPlugin; exports.LoopPlugin = LoopPlugin; exports.MalformedFileError = MalformedFileError; exports.MaxXmlDepthError = MaxXmlDepthError; exports.MimeType = MimeType; exports.MimeTypeHelper = MimeTypeHelper; exports.MissingArgumentError = MissingArgumentError; exports.MissingCloseDelimiterError = MissingCloseDelimiterError; exports.MissingStartDelimiterError = MissingStartDelimiterError; exports.Path = Path; exports.PluginContent = PluginContent; exports.RawXmlPlugin = RawXmlPlugin; exports.Regex = Regex; exports.ScopeData = ScopeData; exports.TEXT_CONTENT_TYPE = TEXT_CONTENT_TYPE; exports.TEXT_NODE_NAME = TEXT_NODE_NAME; exports.TagDisposition = TagDisposition; exports.TagOptionsParseError = TagOptionsParseError; exports.TagParser = TagParser; exports.TemplateCompiler = TemplateCompiler; exports.TemplateExtension = TemplateExtension; exports.TemplateHandler = TemplateHandler; exports.TemplateHandlerOptions = TemplateHandlerOptions; exports.TemplatePlugin = TemplatePlugin; exports.TextPlugin = TextPlugin; exports.UnclosedTagError = UnclosedTagError; exports.UnidentifiedFileTypeError = UnidentifiedFileTypeError; exports.UnknownContentTypeError = UnknownContentTypeError; exports.UnopenedTagError = UnopenedTagError; exports.UnsupportedFileTypeError = UnsupportedFileTypeError; exports.XmlDepthTracker = XmlDepthTracker; exports.XmlNode = XmlNode; exports.XmlNodeType = XmlNodeType; exports.XmlParser = XmlParser; exports.XmlPart = XmlPart; exports.Zip = Zip; exports.ZipObject = ZipObject; exports.createDefaultPlugins = createDefaultPlugins; exports.first = first; exports.inheritsFrom = inheritsFrom; exports.isNumber = isNumber; exports.isPromiseLike = isPromiseLike; exports.last = last; exports.normalizeDoubleQuotes = normalizeDoubleQuotes; exports.pushMany = pushMany; exports.sha1 = sha1; exports.stringValue = stringValue; exports.toDictionary = toDictionary;