'use strict'; var JSZip = require('jszip'); var xmldom = require('@xmldom/xmldom'); var getProp = require('lodash.get'); var JSON5 = require('json5'); class InternalError extends Error { constructor(message) { super(`Internal error: ${message}`); } } class InternalArgumentMissingError extends InternalError { constructor(argName) { super(`Argument '${argName}' is missing.`); this.argName = argName; } } class MalformedFileError extends Error { constructor(expectedFileType) { super(`Malformed file detected. Make sure the file is a valid ${expectedFileType} file.`); this.expectedFileType = expectedFileType; } } class MaxXmlDepthError extends Error { constructor(maxDepth) { super(`XML maximum depth reached (max depth: ${maxDepth}).`); this.maxDepth = maxDepth; } } class TemplateSyntaxError extends Error { constructor(message) { super(message); } } class MissingCloseDelimiterError extends TemplateSyntaxError { constructor(openDelimiterText) { super(`Close delimiter is missing from '${openDelimiterText}'.`); this.openDelimiterText = openDelimiterText; } } class MissingStartDelimiterError extends TemplateSyntaxError { constructor(closeDelimiterText) { super(`Open delimiter is missing from '${closeDelimiterText}'.`); this.closeDelimiterText = closeDelimiterText; } } class TagOptionsParseError extends TemplateSyntaxError { constructor(tagRawText, parseError) { super(`Failed to parse tag options of '${tagRawText}': ${parseError.message}.`); this.tagRawText = tagRawText; this.parseError = parseError; } } class TemplateDataError extends Error { constructor(message) { super(message); } } class UnclosedTagError extends TemplateSyntaxError { constructor(tagName) { super(`Tag '${tagName}' is never closed.`); 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 TemplateDataError { constructor(contentType, tagRawText, path) { super(`Content type '${contentType}' does not have a registered plugin to handle it.`); this.contentType = contentType; this.tagRawText = tagRawText; this.path = path; } } class UnopenedTagError extends TemplateSyntaxError { constructor(tagName) { super(`Tag '${tagName}' is closed but was never opened.`); this.tagName = tagName; } } class UnsupportedFileTypeError extends Error { constructor(fileType) { super(`Filetype "${fileType}" is not supported.`); 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) { const normalizedParts = parts.flatMap(part => part?.split('/')?.map(p => p.trim()).filter(Boolean)); // Handle . and .. parts const resolvedParts = []; for (const part of normalizedParts) { if (part === '.') { continue; // Ignore . parts } if (part === '..') { resolvedParts.pop(); // Go up one directory continue; } resolvedParts.push(part); } return resolvedParts.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 JsZipHelper { static toJsZipOutputType(binaryOrType) { if (!binaryOrType) throw new InternalArgumentMissingError("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, binaryFormat) { this.zipObject = zipObject; this.binaryFormat = binaryFormat; } getContentText() { return this.zipObject.async('text'); } getContentBase64() { return this.zipObject.async('binarystring'); } getContentBinary(outputType) { const zipOutputType = JsZipHelper.toJsZipOutputType(outputType ?? this.binaryFormat); return this.zipObject.async(zipOutputType); } } class Zip { static async load(file) { const zip = await JSZip.loadAsync(file); return new Zip(zip, file.constructor); } constructor(zip, binaryFormat) { this.zip = zip; this.binaryFormat = binaryFormat; } getFile(path) { const internalZipObject = this.zip.files[path]; if (!internalZipObject) return null; return new ZipObject(internalZipObject, this.binaryFormat); } 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 ?? this.binaryFormat); const output = await this.zip.generateAsync({ type: zipOutputType, compression: "DEFLATE", compressionOptions: { level: 6 // between 1 (best speed) and 9 (best compression) } }); return output; } } const XmlNodeType = Object.freeze({ Text: "Text", General: "General", Comment: "Comment" }); const TEXT_NODE_NAME = '#text'; // see: https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeName const COMMENT_NODE_NAME = '#comment'; class XmlDepthTracker { depth = 0; constructor(maxDepth) { this.maxDepth = maxDepth; } increment() { this.depth++; if (this.depth > this.maxDepth) { throw new MaxXmlDepthError(this.maxDepth); } } decrement() { this.depth--; } } class XmlTreeIterator { get node() { return this._current; } constructor(initial, maxDepth) { if (!initial) { throw new InternalError("Initial node is required"); } if (!maxDepth) { throw new InternalError("Max depth is required"); } this._current = initial; this.depthTracker = new XmlDepthTracker(maxDepth); } next() { if (!this._current) { return null; } this._current = this.findNextNode(this._current); return this._current; } setCurrent(node) { this._current = node; } findNextNode(node) { // Children if (node.childNodes && node.childNodes.length) { this.depthTracker.increment(); return node.childNodes[0]; } // Siblings if (node.nextSibling) return node.nextSibling; // Parent sibling while (node.parentNode) { if (node.parentNode.nextSibling) { this.depthTracker.decrement(); return node.parentNode.nextSibling; } // Go up this.depthTracker.decrement(); node = node.parentNode; } return null; } } class XmlUtils { parser = new Parser(); create = new Create(); query = new Query$1(); modify = new Modify$1(); } class Parser { static xmlFileHeader = ''; /** * We always use the DOMParser from 'xmldom', even in the browser since it * handles xml namespaces more forgivingly (required mainly by the * RawXmlPlugin). */ static parser = new xmldom.DOMParser({ errorHandler: { // Ignore xmldom warnings. They are often incorrect since we are // parsing OOXML, not HTML. warning: () => {} } }); parse(str) { const doc = this.domParse(str); return xml.create.fromDomNode(doc.documentElement); } domParse(str) { if (str === null || str === undefined) throw new InternalArgumentMissingError("str"); return Parser.parser.parseFromString(str, "text/xml"); } /** * 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 InternalArgumentMissingError("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 ''; }); } serializeNode(node) { if (!node) return ''; if (xml.query.isTextNode(node)) return xml.parser.encodeValue(node.textContent || ''); if (xml.query.isCommentNode(node)) { return ``; } // attributes let attributes = ''; if (node.attributes) { const attributeNames = Object.keys(node.attributes); if (attributeNames.length) { attributes = ' ' + attributeNames.map(name => `${name}="${xml.parser.encodeValue(node.attributes[name] || '')}"`).join(' '); } } // open tag const hasChildren = (node.childNodes || []).length > 0; const suffix = hasChildren ? '' : '/'; const openTag = `<${node.nodeName}${attributes}${suffix}>`; let xmlString; if (hasChildren) { // child nodes const childrenXml = node.childNodes.map(child => xml.parser.serializeNode(child)).join(''); // close tag const closeTag = ``; xmlString = openTag + childrenXml + closeTag; } else { xmlString = openTag; } return xmlString; } serializeFile(xmlNode) { return Parser.xmlFileHeader + xml.parser.serializeNode(xmlNode); } } class Create { textNode(text) { return { nodeType: XmlNodeType.Text, nodeName: TEXT_NODE_NAME, textContent: text }; } generalNode(name, init) { const node = { nodeType: XmlNodeType.General, nodeName: name }; if (init?.attributes) { node.attributes = init.attributes; } if (init?.childNodes) { for (const child of init.childNodes) { xml.modify.appendChild(node, child); } } return node; } commentNode(text) { return { nodeType: XmlNodeType.Comment, nodeName: COMMENT_NODE_NAME, commentContent: text }; } cloneNode(node, deep) { if (!node) throw new InternalArgumentMissingError("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; } } /** * The conversion is always deep. */ fromDomNode(domNode) { let xmlNode; // basic properties switch (domNode.nodeType) { case domNode.TEXT_NODE: { xmlNode = xml.create.textNode(domNode.textContent); break; } case domNode.COMMENT_NODE: { xmlNode = xml.create.commentNode(domNode.textContent?.trim()); break; } case domNode.ELEMENT_NODE: { const generalNode = xmlNode = xml.create.generalNode(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 = xml.create.generalNode(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 = xml.create.fromDomNode(domChild); // set references xmlNode.childNodes.push(curChild); curChild.parentNode = xmlNode; if (prevChild) { prevChild.nextSibling = curChild; } prevChild = curChild; } } return xmlNode; } } let Query$1 = class Query { isTextNode(node) { if (node.nodeType === XmlNodeType.Text || node.nodeName === TEXT_NODE_NAME) { if (!(node.nodeType === XmlNodeType.Text && node.nodeName === TEXT_NODE_NAME)) { throw new InternalError(`Invalid text node. Type: '${node.nodeType}', Name: '${node.nodeName}'.`); } return true; } return false; } isGeneralNode(node) { return node.nodeType === XmlNodeType.General; } isCommentNode(node) { if (node.nodeType === XmlNodeType.Comment || node.nodeName === COMMENT_NODE_NAME) { if (!(node.nodeType === XmlNodeType.Comment && node.nodeName === COMMENT_NODE_NAME)) { throw new InternalError(`Invalid comment node. Type: '${node.nodeType}', Name: '${node.nodeName}'.`); } return true; } return false; } /** * 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, createIfMissing = true) { if (!node) { return null; } if (xml.query.isTextNode(node)) { return node; } // Existing text nodes if (node.childNodes) { const allTextNodes = node.childNodes.filter(child => xml.query.isTextNode(child)); if (allTextNodes.length) { const lastTextNode = last(allTextNodes); if (!lastTextNode.textContent) lastTextNode.textContent = ''; return lastTextNode; } } if (!createIfMissing) { return null; } // Create new text node const newTextNode = { nodeType: XmlNodeType.Text, nodeName: TEXT_NODE_NAME, textContent: '' }; xml.modify.appendChild(node, newTextNode); return newTextNode; } findParent(node, predicate) { while (node) { if (predicate(node)) return node; node = node.parentNode; } return null; } findParentByName(node, nodeName) { return xml.query.findParent(node, n => n.nodeName === nodeName); } findChild(node, predicate) { if (!node) return null; return (node.childNodes || []).find(child => predicate(child)); } findChildByName(node, childName) { return xml.query.findChild(node, n => n.nodeName === childName); } /** * Returns all siblings between 'firstNode' and 'lastNode' inclusive. */ siblingsInRange(firstNode, lastNode) { if (!firstNode) throw new InternalArgumentMissingError("firstNode"); if (!lastNode) throw new InternalArgumentMissingError("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; } descendants(node, maxDepth, predicate) { const result = []; const it = new XmlTreeIterator(node, maxDepth); while (it.node) { if (predicate(it.node)) { result.push(it.node); } it.next(); } return result; } }; let Modify$1 = class Modify { /** * 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 InternalArgumentMissingError("newNode"); if (!referenceNode) throw new InternalArgumentMissingError("referenceNode"); if (!referenceNode.parentNode) throw new Error(`'${"referenceNode"}' has no parent`); const childNodes = referenceNode.parentNode.childNodes; const beforeNodeIndex = childNodes.indexOf(referenceNode); xml.modify.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 InternalArgumentMissingError("newNode"); if (!referenceNode) throw new InternalArgumentMissingError("referenceNode"); if (!referenceNode.parentNode) throw new Error(`'${"referenceNode"}' has no parent`); const childNodes = referenceNode.parentNode.childNodes; const referenceNodeIndex = childNodes.indexOf(referenceNode); xml.modify.insertChild(referenceNode.parentNode, newNode, referenceNodeIndex + 1); } insertChild(parent, child, childIndex) { if (!parent) throw new InternalArgumentMissingError("parent"); if (xml.query.isTextNode(parent)) throw new Error('Appending children to text nodes is forbidden'); if (!child) throw new InternalArgumentMissingError("child"); if (!parent.childNodes) parent.childNodes = []; // revert to append if (childIndex === parent.childNodes.length) { xml.modify.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 InternalArgumentMissingError("parent"); if (xml.query.isTextNode(parent)) throw new Error('Appending children to text nodes is forbidden'); if (!child) throw new InternalArgumentMissingError("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 InternalArgumentMissingError("node"); if (!node.parentNode) throw new Error('Node has no parent'); xml.modify.removeChild(node.parentNode, node); } /** * 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. */ removeChild(parent, childOrIndex) { if (!parent) throw new InternalArgumentMissingError("parent"); if (childOrIndex === null || childOrIndex === undefined) throw new InternalArgumentMissingError("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]; } removeChildren(parent, predicate) { while (parent.childNodes?.length) { const index = parent.childNodes.findIndex(predicate); if (index === -1) { break; } xml.modify.removeChild(parent, index); } } /** * 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; xml.modify.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 = xml.create.cloneNode(parent, false); if (parent.parentNode) { xml.modify.insertBefore(left, parent); } const right = parent; // move nodes from 'right' to 'left' let curChild = right.childNodes[0]; while (curChild != child) { xml.modify.remove(curChild); xml.modify.appendChild(left, curChild); curChild = right.childNodes[0]; } // remove child if (removeChild) { xml.modify.removeChild(right, 0); } return [left, right]; } /** * Recursively removes text nodes leaving only "general nodes". */ removeEmptyTextNodes(node) { recursiveRemoveEmptyTextNodes(node); } }; // // private functions // function cloneNodeDeep(original) { const clone = {}; // basic properties clone.nodeType = original.nodeType; clone.nodeName = original.nodeName; if (xml.query.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 (xml.query.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; } const xml = new XmlUtils(); /** * The types of relationships that can be created in a docx file. * A non-comprehensive list. */ const RelType = Object.freeze({ Package: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/package', MainDocument: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument', Header: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header', Footer: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer', Styles: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles', SharedStrings: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings', Link: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', Image: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', Chart: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart', ChartColors: 'http://schemas.microsoft.com/office/2011/relationships/chartColorStyle', Worksheet: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet', Table: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table' }); class Relationship { static fromXml(xml) { return new Relationship({ id: xml.attributes?.['Id'], type: xml.attributes?.['Type'], target: Relationship.normalizeRelTarget(xml.attributes?.['Target']), targetMode: xml.attributes?.['TargetMode'] }); } static normalizeRelTarget(target) { if (!target) { return target; } if (target.startsWith('/')) { return target.substring(1); } return target; } constructor(initial) { Object.assign(this, initial); } toXml() { const node = xml.create.generalNode('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; } } const MimeType = Object.freeze({ Png: 'image/png', Jpeg: 'image/jpeg', Gif: 'image/gif', Bmp: 'image/bmp', Svg: 'image/svg+xml' }); 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 RelType.Image; default: throw new UnsupportedFileTypeError(mime); } } } /** * http://officeopenxml.com/anatomyofOOXML.php */ class ContentTypesFile { static contentTypesFilePath = '[Content_Types].xml'; addedNew = false; constructor(zip) { this.zip = zip; } async ensureContentType(mime) { // Parse the content types file await this.parseContentTypesFile(); // Mime type already exists if (this.contentTypes[mime]) return; // Extension already exists // Unfortunately, this can happen in real life so we need to handle it. const extension = MimeTypeHelper.getDefaultExtension(mime); if (Object.values(this.contentTypes).includes(extension)) return; // Add new node const typeNode = xml.create.generalNode('Default'); typeNode.attributes = { "Extension": extension, "ContentType": mime }; this.root.childNodes.push(typeNode); // Update state this.addedNew = true; this.contentTypes[mime] = extension; } async count() { await this.parseContentTypesFile(); return this.root.childNodes.filter(node => !xml.query.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 = xml.parser.serializeFile(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 = xml.parser.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; const extensionAttribute = genNode.attributes['Extension']; if (!extensionAttribute) continue; this.contentTypes[contentTypeAttribute] = extensionAttribute; } } } /** * Handles media files of the main document. */ class MediaFiles { static mediaDir = 'word/media'; files = new Map(); nextFileId = 0; constructor(zip) { this.zip = zip; } /** * 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; } } } /** * A rels file is an xml file that contains the relationship information of a single docx "part". * * See: http://officeopenxml.com/anatomyofOOXML.php */ class RelsFile { nextRelId = 0; constructor(partPath, zip) { this.zip = zip; this.partDir = partPath && Path.getDirectory(partPath); const partFilename = partPath && Path.getFilename(partPath); this.relsFilePath = Path.combine(this.partDir, '_rels', `${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); } absoluteTargetPath(relTarget) { if (this.partDir && relTarget.startsWith(this.partDir)) { return relTarget; } return Path.combine(this.partDir, relTarget); } /** * 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 = xml.parser.serializeFile(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 xmlString = await relsFile.getContentText(); root = xml.parser.parse(xmlString); } 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 = Relationship.normalizeRelTarget(attributes['Target']); if (typeAttr && targetAttr) { const relTargetKey = this.getRelTargetKey(typeAttr, targetAttr); this.relTargets[relTargetKey] = idAttr; } } } getRelTargetKey(type, target) { return `${type} - ${target}`; } createRootNode() { const root = xml.create.generalNode('Relationships'); root.attributes = { 'xmlns': 'http://schemas.openxmlformats.org/package/2006/relationships' }; root.childNodes = []; return root; } } /** * Represents an OpenXml package part. * * Most common parts are xml files, but it can also be any other arbitrary file. * * See: https://en.wikipedia.org/wiki/Open_Packaging_Conventions */ class OpenXmlPart { openedParts = {}; constructor(path, zip) { this.path = path; this.zip = zip; this.rels = new RelsFile(this.path, zip); } // // 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 file = this.zip.getFile(this.path); const xmlString = await file.getContentText(); this.root = xml.parser.parse(xmlString); } return this.root; } /** * Get the text content of the part. */ async getText() { const xmlDocument = await this.xmlRoot(); // ugly but good enough... const xmlString = xml.parser.serializeFile(xmlDocument); const domDocument = xml.parser.domParse(xmlString); return domDocument.documentElement.textContent; } /** * Get the binary content of the part. */ async getContentBinary(outputType) { const file = this.zip.getFile(this.path); return await file.getContentBinary(outputType); } /** * Get a related OpenXmlPart by the relationship ID. */ async getPartById(relId) { const rels = await this.rels.list(); const rel = rels.find(r => r.id === relId); if (!rel) { return null; } return this.openPart(rel); } /** * Get all related OpenXmlParts by the relationship type. */ async getFirstPartByType(type) { const rels = await this.rels.list(); const rel = rels.find(r => r.type === type); if (!rel) { return null; } return this.openPart(rel); } /** * Get all related OpenXmlParts by the relationship type. */ async getPartsByType(type) { const rels = await this.rels.list(); const relsByType = rels.filter(r => r.type === type); if (!relsByType?.length) { return []; } const parts = []; for (const rel of relsByType) { const part = this.openPart(rel); parts.push(part); } return parts; } /** * Save the part and all related parts. * * **Notice:** * - Saving binary changes requires binary content to be explicitly provided. * - Binary changes of related parts are not automatically saved. */ async save(binaryContent) { // Save self - binary if (binaryContent) { this.zip.setFile(this.path, binaryContent); } // Save self - xml else if (this.root) { const xmlRoot = await this.xmlRoot(); const xmlContent = xml.parser.serializeFile(xmlRoot); this.zip.setFile(this.path, xmlContent); } // Save opened parts for (const part of Object.values(this.openedParts)) { await part.save(); } // Save rels await this.rels.save(); } openPart(rel) { const relTargetPath = this.rels.absoluteTargetPath(rel.target); const part = new OpenXmlPart(relTargetPath, this.zip); this.openedParts[relTargetPath] = part; return part; } } /** * Represents a single docx file. */ class Docx { /** * Load a docx file from a binary zip file. */ static async load(file) { // Load the zip file let zip; try { zip = await Zip.load(file); } catch { throw new MalformedFileError('docx'); } // Load the docx file const docx = await Docx.open(zip); return docx; } /** * Open a docx file from an instantiated zip file. */ static async open(zip) { const mainDocumentPath = await Docx.getMainDocumentPath(zip); if (!mainDocumentPath) throw new MalformedFileError('docx'); return new Docx(mainDocumentPath, zip); } static async getMainDocumentPath(zip) { const rootPart = ''; const rootRels = new RelsFile(rootPart, zip); const relations = await rootRels.list(); return relations.find(rel => rel.type == RelType.MainDocument)?.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) { this.zip = zip; this.mainDocument = new OpenXmlPart(mainDocumentPath, zip); this.mediaFiles = new MediaFiles(zip); this.contentTypes = new ContentTypesFile(zip); } // // public methods // async getContentParts() { const parts = [this.mainDocument]; const relTypes = [RelType.Header, RelType.Footer, RelType.Chart]; for (const relType of relTypes) { const typeParts = await this.mainDocument.getPartsByType(relType); if (typeParts?.length) { parts.push(...typeParts); } } return parts; } async export(outputType) { await this.mainDocument.save(); await this.contentTypes.save(); return await this.zip.export(outputType); } } /** * Wordprocessing Markup Language node names. */ class W { Paragraph = 'w:p'; ParagraphProperties = 'w:pPr'; Run = 'w:r'; RunProperties = 'w:rPr'; Text = 'w:t'; Table = 'w:tbl'; TableRow = 'w:tr'; TableCell = 'w:tc'; NumberProperties = 'w:numPr'; } /** * Drawing Markup Language node names. * * These elements are part of the main drawingML namespace: * xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main". */ class A { Paragraph = 'a:p'; ParagraphProperties = 'a:pPr'; Run = 'a:r'; RunProperties = 'a:rPr'; Text = 'a:t'; } /** * Office Markup Language (OML) node names. * * Office Markup Language is my generic term for the markup languages that are * used in Office Open XML documents. Including but not limited to * Wordprocessing Markup Language, Drawing Markup Language and Spreadsheet * Markup Language. */ class OmlNode { /** * Wordprocessing Markup Language node names. */ static W = new W(); /** * Drawing Markup Language node names. */ static A = new A(); } class OmlAttribute { static SpacePreserve = 'xml:space'; } // // Wordprocessing Markup Language (WML) 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 // /** * Office Markup Language (OML) utilities. * * Office Markup Language is my generic term for the markup languages that are * used in Office Open XML documents. Including but not limited to * Wordprocessing Markup Language, Drawing Markup Language and Spreadsheet * Markup Language. */ class OfficeMarkup { /** * Office Markup query utilities. */ query = new Query(); /** * Office Markup modify utilities. */ modify = new Modify(); } /** * Wordprocessing Markup Language (WML) query utilities. */ class Query { isTextNode(node) { return node.nodeName === OmlNode.W.Text || node.nodeName === OmlNode.A.Text; } isRunNode(node) { return node.nodeName === OmlNode.W.Run || node.nodeName === OmlNode.A.Run; } isRunPropertiesNode(node) { return node.nodeName === OmlNode.W.RunProperties || node.nodeName === OmlNode.A.RunProperties; } isTableCellNode(node) { return node.nodeName === OmlNode.W.TableCell; } isParagraphNode(node) { return node.nodeName === OmlNode.W.Paragraph || node.nodeName === OmlNode.A.Paragraph; } isParagraphPropertiesNode(node) { return node.nodeName === OmlNode.W.ParagraphProperties || node.nodeName === OmlNode.A.ParagraphProperties; } isListParagraph(paragraphNode) { const paragraphProperties = officeMarkup.query.findParagraphPropertiesNode(paragraphNode); const listNumberProperties = xml.query.findChildByName(paragraphProperties, OmlNode.W.NumberProperties); return !!listNumberProperties; } findParagraphPropertiesNode(paragraphNode) { if (!officeMarkup.query.isParagraphNode(paragraphNode)) throw new Error(`Expected paragraph node but received a '${paragraphNode.nodeName}' node.`); return xml.query.findChild(paragraphNode, officeMarkup.query.isParagraphPropertiesNode); } /** * Search for the first direct child **Word** text node (i.e. a node). */ firstTextNodeChild(node) { if (!node) return null; if (!officeMarkup.query.isRunNode(node)) return null; if (!node.childNodes) return null; for (const child of node.childNodes) { if (officeMarkup.query.isTextNode(child)) return child; } return null; } /** * Search **upwards** for the first **Office** text node (i.e. a or node). */ containingTextNode(node) { if (!node) return null; if (!xml.query.isTextNode(node)) throw new Error(`'Invalid argument ${"node"}. Expected a XmlTextNode.`); return xml.query.findParent(node, officeMarkup.query.isTextNode); } /** * Search **upwards** for the first run node. */ containingRunNode(node) { return xml.query.findParent(node, officeMarkup.query.isRunNode); } /** * Search **upwards** for the first paragraph node. */ containingParagraphNode(node) { return xml.query.findParent(node, officeMarkup.query.isParagraphNode); } /** * Search **upwards** for the first "table row" node. */ containingTableRowNode(node) { return xml.query.findParentByName(node, OmlNode.W.TableRow); } /** * Search **upwards** for the first "table cell" node. */ containingTableCellNode(node) { return xml.query.findParent(node, officeMarkup.query.isTableCellNode); } /** * Search **upwards** for the first "table" node. */ containingTableNode(node) { return xml.query.findParentByName(node, OmlNode.W.Table); } // // Advanced queries // isEmptyTextNode(node) { if (!officeMarkup.query.isTextNode(node)) throw new Error(`Text node expected but '${node.nodeName}' received.`); if (!node.childNodes?.length) return true; const xmlTextNode = node.childNodes[0]; if (!xml.query.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 (!officeMarkup.query.isRunNode(node)) throw new Error(`Run node expected but '${node.nodeName}' received.`); for (const child of node.childNodes ?? []) { if (officeMarkup.query.isRunPropertiesNode(child)) continue; if (officeMarkup.query.isTextNode(child) && officeMarkup.query.isEmptyTextNode(child)) continue; return false; } return true; } } /** * Office Markup Language (OML) modify utilities. */ class Modify { /** * 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 = officeMarkup.query.containingTextNode(textNode); const newWordTextNode = xml.create.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) officeMarkup.modify.setSpacePreserveAttribute(wordTextNode); officeMarkup.modify.setSpacePreserveAttribute(newWordTextNode); if (addBefore) { // insert new node before existing one xml.modify.insertBefore(newWordTextNode, wordTextNode); firstXmlTextNode = xml.query.lastTextChild(newWordTextNode); secondXmlTextNode = textNode; } else { // insert new node after existing one const curIndex = wordTextNode.parentNode.childNodes.indexOf(wordTextNode); xml.modify.insertChild(wordTextNode.parentNode, newWordTextNode, curIndex + 1); firstXmlTextNode = textNode; secondXmlTextNode = xml.query.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 = officeMarkup.query.containingParagraphNode(textNode); if (containingParagraph != paragraph) throw new Error(`Node '${"textNode"}' is not a descendant of '${"paragraph"}'.`); const runNode = officeMarkup.query.containingRunNode(textNode); const wordTextNode = officeMarkup.query.containingTextNode(textNode); // create run clone const leftRun = xml.create.cloneNode(runNode, false); const rightRun = runNode; xml.modify.insertBefore(leftRun, rightRun); // copy props from original run node (preserve style) const runProps = rightRun.childNodes.find(node => officeMarkup.query.isRunPropertiesNode(node)); if (runProps) { const leftRunProps = xml.create.cloneNode(runProps, true); xml.modify.appendChild(leftRun, leftRunProps); } // move nodes from 'right' to 'left' const firstRunChildIndex = runProps ? 1 : 0; let curChild = rightRun.childNodes[firstRunChildIndex]; while (curChild != wordTextNode) { xml.modify.remove(curChild); xml.modify.appendChild(leftRun, curChild); curChild = rightRun.childNodes[firstRunChildIndex]; } // remove text node if (removeTextNode) { xml.modify.removeChild(rightRun, firstRunChildIndex); } // create paragraph clone const leftPara = xml.create.cloneNode(containingParagraph, false); const rightPara = containingParagraph; xml.modify.insertBefore(leftPara, rightPara); // copy props from original paragraph (preserve style) const paragraphProps = rightPara.childNodes.find(node => officeMarkup.query.isParagraphPropertiesNode(node)); if (paragraphProps) { const leftParagraphProps = xml.create.cloneNode(paragraphProps, true); xml.modify.appendChild(leftPara, leftParagraphProps); } // move nodes from 'right' to 'left' const firstParaChildIndex = paragraphProps ? 1 : 0; curChild = rightPara.childNodes[firstParaChildIndex]; while (curChild != rightRun) { xml.modify.remove(curChild); xml.modify.appendChild(leftPara, curChild); curChild = rightPara.childNodes[firstParaChildIndex]; } // clean paragraphs - remove empty runs if (officeMarkup.query.isEmptyRun(leftRun)) xml.modify.remove(leftRun); if (officeMarkup.query.isEmptyRun(rightRun)) xml.modify.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 = officeMarkup.query.containingRunNode(from); const secondRunNode = officeMarkup.query.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 = officeMarkup.query.containingTextNode(from); const secondWordTextNode = officeMarkup.query.containingTextNode(to); const totalText = []; // iterate runs let curRunNode = firstRunNode; while (curRunNode) { // iterate text nodes let curWordTextNode; if (curRunNode === firstRunNode) { curWordTextNode = firstWordTextNode; } else { curWordTextNode = officeMarkup.query.firstTextNodeChild(curRunNode); } while (curWordTextNode) { if (!officeMarkup.query.isTextNode(curWordTextNode)) { curWordTextNode = curWordTextNode.nextSibling; continue; } // move text to first node const curXmlTextNode = xml.query.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) { xml.modify.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) { xml.modify.remove(runToRemove); } } // set the text content const firstXmlTextNode = xml.query.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 (officeMarkup.query.isRunNode(curChild)) { xml.modify.removeChild(second, childIndex); xml.modify.appendChild(first, curChild); } else { childIndex++; } } } setSpacePreserveAttribute(node) { if (!node.attributes) { node.attributes = {}; } if (!node.attributes[OmlAttribute.SpacePreserve]) { node.attributes[OmlAttribute.SpacePreserve] = 'preserve'; } } removeTag(textNode) { const wordTextNode = officeMarkup.query.containingTextNode(textNode); const runNode = officeMarkup.query.containingRunNode(textNode); // Remove the word text node xml.modify.remove(wordTextNode); // Remove the run node if it's empty if (officeMarkup.query.isEmptyRun(runNode)) { xml.modify.remove(runNode); } } } /** * Office Markup Language utilities. */ const officeMarkup = new OfficeMarkup(); /** * Represents a single xlsx file. */ class Xlsx { /** * Load an xlsx file from a binary zip file. */ static async load(file) { // Load the zip file let zip; try { zip = await Zip.load(file); } catch { throw new MalformedFileError('xlsx'); } // Load the xlsx file const xlsx = await Xlsx.open(zip); return xlsx; } /** * Open an xlsx file from an instantiated zip file. */ static async open(zip) { const mainDocumentPath = await Xlsx.getMainDocumentPath(zip); if (!mainDocumentPath) throw new MalformedFileError('xlsx'); return new Xlsx(mainDocumentPath, zip); } static async getMainDocumentPath(zip) { const rootPart = ''; const rootRels = new RelsFile(rootPart, zip); const relations = await rootRels.list(); return relations.find(rel => rel.type == RelType.MainDocument)?.target; } // // fields // _parts = {}; /** * **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) { this.zip = zip; this.mainDocument = new OpenXmlPart(mainDocumentPath, zip); } // // public methods // async export(outputType) { await this.saveXmlChanges(); return await this.zip.export(outputType); } // // private methods // async saveXmlChanges() { const parts = [this.mainDocument, ...Object.values(this._parts)]; for (const part of parts) { await part.save(); } } } class MatchState { /** * The index of the current delimiter character being matched. * * Example: If the delimiter is `{!` and delimiterIndex is 0, it means we * are now looking for the character `{`. If it is 1, then we are looking * for `!`. */ delimiterIndex = 0; /** * The list of text nodes containing the delimiter characters. */ openNodes = []; /** * The index of the first character of the delimiter, in the text node it * was found at. * * Example: If the delimiter is `{!`, and the text node content is `abc{!xyz`, * then the firstMatchIndex is 3. */ firstMatchIndex = -1; reset() { this.delimiterIndex = 0; this.openNodes = []; this.firstMatchIndex = -1; } } class DelimiterSearcher { maxXmlDepth = 20; startDelimiter = "{"; endDelimiter = "}"; 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 a single 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 it = new XmlTreeIterator(node, this.maxXmlDepth); let lookForOpenDelimiter = true; while (it.node) { // Reset state on paragraph transition if (officeMarkup.query.isParagraphNode(it.node)) { match.reset(); } // Skip irrelevant nodes if (!this.shouldSearchNode(it)) { it.next(); continue; } // Search delimiters in text nodes match.openNodes.push(it.node); let textIndex = 0; while (textIndex < it.node.textContent.length) { const delimiterPattern = lookForOpenDelimiter ? this.startDelimiter : this.endDelimiter; const char = it.node.textContent[textIndex]; // No match if (char !== delimiterPattern[match.delimiterIndex]) { textIndex = this.noMatch(it, 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 [textIndex, lookForOpenDelimiter] = this.fullMatch(it, textIndex, lookForOpenDelimiter, match, delimiters); textIndex++; } it.next(); } return delimiters; } noMatch(it, 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) { const node = first(match.openNodes); it.setCurrent(node); textIndex = match.firstMatchIndex; } // Update state match.reset(); if (textIndex < it.node.textContent.length - 1) { match.openNodes.push(it.node); } return textIndex; } fullMatch(it, 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); officeMarkup.modify.joinTextNodesRange(firstNode, lastNode); textIndex += firstNode.textContent.length - it.node.textContent.length; it.setCurrent(firstNode); } // Store delimiter const delimiterMark = this.createDelimiterMark(match, lookForOpenDelimiter); delimiters.push(delimiterMark); // Update state lookForOpenDelimiter = !lookForOpenDelimiter; match.reset(); if (textIndex < it.node.textContent.length - 1) { match.openNodes.push(it.node); } return [textIndex, lookForOpenDelimiter]; } shouldSearchNode(it) { if (!xml.query.isTextNode(it.node)) return false; if (!it.node.textContent) return false; if (!it.node.parentNode) return false; if (!officeMarkup.query.isTextNode(it.node.parentNode)) return false; return true; } 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; } path = []; strPath = []; constructor(data) { 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); } } const TagDisposition = Object.freeze({ Open: "Open", Close: "Close", SelfClosed: "SelfClosed" }); class TagParser { constructor(delimiters) { if (!delimiters) throw new InternalArgumentMissingError("delimiters"); this.delimiters = 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; if (!sameNode) { const startParagraph = officeMarkup.query.containingParagraphNode(startTextNode); const endParagraph = officeMarkup.query.containingParagraphNode(endTextNode); if (startParagraph !== endParagraph) { throw new MissingCloseDelimiterError(startTextNode.textContent); } } // trim start if (openDelimiter.index > 0) { officeMarkup.modify.splitTextNode(startTextNode, openDelimiter.index, true); if (sameNode) { closeDelimiter.index -= openDelimiter.index; } } // trim end if (closeDelimiter.index < endTextNode.textContent.length - 1) { endTextNode = officeMarkup.modify.splitTextNode(endTextNode, closeDelimiter.index + this.delimiters.tagEnd.length, true); if (sameNode) { startTextNode = endTextNode; } } // join nodes if (!sameNode) { officeMarkup.modify.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) { tag.rawText = tag.xmlTextNode.textContent; const tagParts = this.tagRegex.exec(tag.rawText); const tagName = (tagParts.groups?.["tagName"] || '').trim(); // Ignoring empty tags. if (!tagName?.length) { tag.disposition = TagDisposition.SelfClosed; return; } // Tag options. const tagOptionsText = (tagParts.groups?.["tagOptions"] || '').trim(); if (tagOptionsText) { try { tag.options = JSON5.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; } } class TemplatePlugin { /** * The content type this plugin handles. */ /** * 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 } } class ImagePlugin extends TemplatePlugin { contentType = 'image'; async simpleTagReplacements(tag, data, context) { const content = data.getScopeData(); if (!content || !content.source) { officeMarkup.modify.removeTag(tag.xmlTextNode); 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 = await this.getNextImageId(context); const imageXml = this.createMarkup(imageId, relId, content); const wordTextNode = officeMarkup.query.containingTextNode(tag.xmlTextNode); xml.modify.insertAfter(imageXml, wordTextNode); officeMarkup.modify.removeTag(tag.xmlTextNode); } async getNextImageId(context) { // Init plugin context. if (!context.pluginContext[this.contentType]) { context.pluginContext[this.contentType] = {}; } if (!context.pluginContext[this.contentType]) { context.pluginContext[this.contentType] = {}; } const pluginContext = context.pluginContext[this.contentType]; if (!pluginContext.lastDrawingObjectId) { pluginContext.lastDrawingObjectId = {}; } const lastIdMap = pluginContext.lastDrawingObjectId; // Get next image ID if already initialized. if (lastIdMap[context.currentPart.path]) { lastIdMap[context.currentPart.path]++; return lastIdMap[context.currentPart.path]; } // Init next image ID. const partRoot = await context.currentPart.xmlRoot(); const maxDepth = context.options.maxXmlDepth; // Get all existing doc props IDs // (docPr stands for "Drawing Object Non-Visual Properties", which isn't // exactly a good acronym but that's how it's called nevertheless) const docProps = xml.query.descendants(partRoot, maxDepth, node => { return xml.query.isGeneralNode(node) && node.nodeName === 'wp:docPr'; }); // Start counting from the current max const ids = docProps.map(prop => parseInt(prop.attributes.id)).filter(isNumber); const maxId = Math.max(...ids, 0); lastIdMap[context.currentPart.path] = maxId + 1; return lastIdMap[context.currentPart.path]; } 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 = xml.parser.parse(markupText); xml.modify.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 TemplateDataError(`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); } } class LinkPlugin extends TemplatePlugin { contentType = 'link'; async simpleTagReplacements(tag, data, context) { const content = data.getScopeData(); if (!content || !content.target) { officeMarkup.modify.removeTag(tag.xmlTextNode); return; } // Add rel const relId = await context.currentPart.rels.add(content.target, RelType.Link, 'External'); // Generate markup const wordTextNode = officeMarkup.query.containingTextNode(tag.xmlTextNode); const wordRunNode = officeMarkup.query.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 = xml.parser.parse(markupText); xml.modify.removeEmptyTextNodes(markupXml); // remove whitespace // Copy props from original run node (preserve style) const runProps = xml.query.findChild(wordRunNode, officeMarkup.query.isRunPropertiesNode); if (runProps) { const linkRunProps = xml.create.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 => officeMarkup.query.isTextNode(node)); if (textNodesInRun.length > 1) { const [runBeforeTag] = xml.modify.splitByChild(tagRunNode, tagTextNode, true); textNodesInRun = runBeforeTag.childNodes.filter(node => officeMarkup.query.isTextNode(node)); xml.modify.insertAfter(linkMarkup, runBeforeTag); if (textNodesInRun.length === 0) { xml.modify.remove(runBeforeTag); } } // Already isolated else { xml.modify.insertAfter(linkMarkup, tagRunNode); xml.modify.remove(tagRunNode); } } } class LoopListStrategy { isApplicable(openTag, closeTag, isCondition) { if (isCondition) { return false; } const containingParagraph = officeMarkup.query.containingParagraphNode(openTag.xmlTextNode); return officeMarkup.query.isListParagraph(containingParagraph); } splitBefore(openTag, closeTag) { const firstParagraph = officeMarkup.query.containingParagraphNode(openTag.xmlTextNode); const lastParagraph = officeMarkup.query.containingParagraphNode(closeTag.xmlTextNode); const paragraphsToRepeat = xml.query.siblingsInRange(firstParagraph, lastParagraph); // remove the loop tags xml.modify.remove(openTag.xmlTextNode); xml.modify.remove(closeTag.xmlTextNode); return { firstNode: firstParagraph, nodesToRepeat: paragraphsToRepeat, lastNode: lastParagraph }; } mergeBack(paragraphGroups, firstParagraph, lastParagraphs) { for (const curParagraphsGroup of paragraphGroups) { for (const paragraph of curParagraphsGroup) { xml.modify.insertBefore(paragraph, lastParagraphs); } } // remove the old paragraphs xml.modify.remove(firstParagraph); if (firstParagraph !== lastParagraphs) { xml.modify.remove(lastParagraphs); } } } class LoopParagraphStrategy { isApplicable(openTag, closeTag, isCondition) { return true; } splitBefore(openTag, closeTag) { // gather some info let firstParagraph = officeMarkup.query.containingParagraphNode(openTag.xmlTextNode); let lastParagraph = officeMarkup.query.containingParagraphNode(closeTag.xmlTextNode); const areSame = firstParagraph === lastParagraph; // split first paragraph let splitResult = officeMarkup.modify.splitParagraphByTextNode(firstParagraph, openTag.xmlTextNode, true); firstParagraph = splitResult[0]; let afterFirstParagraph = splitResult[1]; if (areSame) lastParagraph = afterFirstParagraph; // split last paragraph splitResult = officeMarkup.modify.splitParagraphByTextNode(lastParagraph, closeTag.xmlTextNode, true); const beforeLastParagraph = splitResult[0]; lastParagraph = splitResult[1]; if (areSame) afterFirstParagraph = beforeLastParagraph; // disconnect splitted paragraph from their parents xml.modify.remove(afterFirstParagraph); if (!areSame) xml.modify.remove(beforeLastParagraph); // extract all paragraphs in between let middleParagraphs; if (areSame) { middleParagraphs = [afterFirstParagraph]; } else { const inBetween = xml.modify.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 officeMarkup.modify.joinParagraphs(mergeTo, curParagraphsGroup[0]); // add middle and last paragraphs to the original document for (let i = 1; i < curParagraphsGroup.length; i++) { xml.modify.insertBefore(curParagraphsGroup[i], lastParagraph); mergeTo = curParagraphsGroup[i]; } } // merge last paragraph officeMarkup.modify.joinParagraphs(mergeTo, lastParagraph); // remove the old last paragraph (was merged into the new one) xml.modify.remove(lastParagraph); } } const LoopOver = Object.freeze({ /** * Loop over the entire row. */ Row: 'row', /** * Loop over the entire column. */ Column: 'column', /** * Loop over the content enclosed between the opening and closing tag. */ Content: 'content' }); class LoopTableColumnsStrategy { isApplicable(openTag, closeTag, isCondition) { const openCell = officeMarkup.query.containingTableCellNode(openTag.xmlTextNode); if (!openCell) return false; const closeCell = officeMarkup.query.containingTableCellNode(closeTag.xmlTextNode); if (!closeCell) return false; const options = openTag.options; const forceColumnLoop = options?.loopOver === LoopOver.Column; // If both tags are in the same cell, assume it's a paragraph loop (iterate content, not columns). if (!forceColumnLoop && openCell === closeCell) return false; const openTable = officeMarkup.query.containingTableNode(openCell); if (!openTable) return false; const closeTable = officeMarkup.query.containingTableNode(closeCell); if (!closeTable) return false; // If the tags are in different tables, don't apply this strategy. if (openTable !== closeTable) return false; const openRow = officeMarkup.query.containingTableRowNode(openCell); if (!openRow) return false; const closeRow = officeMarkup.query.containingTableRowNode(closeCell); if (!closeRow) return false; const openColumnIndex = this.getColumnIndex(openRow, openCell); if (openColumnIndex === -1) return false; const closeColumnIndex = this.getColumnIndex(closeRow, closeCell); if (closeColumnIndex === -1) return false; // If the tags are in different columns, assume it's a table rows loop (iterate rows, not columns). if (!forceColumnLoop && openColumnIndex !== closeColumnIndex) return false; return true; } splitBefore(openTag, closeTag) { const firstCell = officeMarkup.query.containingTableCellNode(openTag.xmlTextNode); const lastCell = officeMarkup.query.containingTableCellNode(closeTag.xmlTextNode); const firstRow = officeMarkup.query.containingTableRowNode(firstCell); const lastRow = officeMarkup.query.containingTableRowNode(lastCell); const firstColumnIndex = this.getColumnIndex(firstRow, firstCell); const lastColumnIndex = this.getColumnIndex(lastRow, lastCell); const table = officeMarkup.query.containingTableNode(firstCell); // Remove the loop tags xml.modify.remove(openTag.xmlTextNode); xml.modify.remove(closeTag.xmlTextNode); // Extract the columns to repeat. // This is a single synthetic table with the columns to repeat. const columnsWrapper = this.extractColumns(table, firstColumnIndex, lastColumnIndex); return { firstNode: firstCell, nodesToRepeat: [columnsWrapper], lastNode: lastCell }; } mergeBack(columnsWrapperGroups, firstCell, lastCell) { const table = officeMarkup.query.containingTableNode(firstCell); const firstRow = officeMarkup.query.containingTableRowNode(firstCell); const firstColumnIndex = this.getColumnIndex(firstRow, firstCell); const lastRow = officeMarkup.query.containingTableRowNode(lastCell); const lastColumnIndex = this.getColumnIndex(lastRow, lastCell); let index = firstColumnIndex; for (const colWrapperGroup of columnsWrapperGroups) { if (colWrapperGroup.length !== 1) { throw new Error('Expected a single synthetic table as the columns wrapper.'); } const colWrapper = colWrapperGroup[0]; this.insertColumnAfterIndex(table, colWrapper, index); index++; } // Remove the old columns this.removeColumn(table, firstColumnIndex); if (firstColumnIndex !== lastColumnIndex) { this.removeColumn(table, lastColumnIndex + index); } } extractColumns(table, firstColumnIndex, lastColumnIndex) { // Create a synthetic table to hold the columns const syntheticTable = xml.create.generalNode('w:tbl'); // For each row in the original table const rows = table.childNodes?.filter(node => node.nodeName === 'w:tr') || []; for (const row of rows) { const syntheticRow = xml.create.cloneNode(row, false); const cells = row.childNodes?.filter(node => node.nodeName === 'w:tc') || []; // Copy only the cells within our column range for (let i = firstColumnIndex; i <= lastColumnIndex; i++) { if (cells[i]) { xml.modify.appendChild(syntheticRow, xml.create.cloneNode(cells[i], true)); } } xml.modify.appendChild(syntheticTable, syntheticRow); } return syntheticTable; } insertColumnAfterIndex(table, column, index) { // Get all rows from both tables const sourceRows = column.childNodes?.filter(node => node.nodeName === 'w:tr') || []; const targetRows = table.childNodes?.filter(node => node.nodeName === 'w:tr') || []; // Insert columns in the target table for (let i = 0; i < targetRows.length; i++) { const targetRow = targetRows[i]; const sourceRow = sourceRows[i]; if (!sourceRow || !targetRow) { continue; } // We expect exactly one cell per row in the synthetic source table const sourceCell = sourceRow.childNodes?.[0]; if (!sourceCell) { throw new Error(`Cell not found in synthetic source table row ${i}.`); } const targetCell = this.getColumnByIndex(targetRow, index); const newCell = xml.create.cloneNode(sourceCell, true); if (targetCell) { xml.modify.insertAfter(newCell, targetCell); } else { xml.modify.appendChild(targetRow, newCell); } } } removeColumn(table, index) { const rows = table.childNodes?.filter(node => node.nodeName === 'w:tr') || []; for (const row of rows) { const cell = this.getColumnByIndex(row, index); if (!cell) { continue; } xml.modify.remove(cell); } } getColumnIndex(row, cell) { return row.childNodes?.filter(child => child.nodeName === 'w:tc')?.findIndex(child => child === cell); } getColumnByIndex(row, index) { return row.childNodes?.filter(child => child.nodeName === 'w:tc')?.[index]; } } class LoopTableRowsStrategy { isApplicable(openTag, closeTag, isCondition) { const openCell = officeMarkup.query.containingTableCellNode(openTag.xmlTextNode); if (!openCell) return false; const closeCell = officeMarkup.query.containingTableCellNode(closeTag.xmlTextNode); if (!closeCell) return false; const options = openTag.options; const forceRowLoop = options?.loopOver === LoopOver.Row; // If both tags are in the same cell, assume it's a paragraph loop (iterate content, not rows). if (!forceRowLoop && openCell === closeCell) return false; return true; } splitBefore(openTag, closeTag) { const firstRow = officeMarkup.query.containingTableRowNode(openTag.xmlTextNode); const lastRow = officeMarkup.query.containingTableRowNode(closeTag.xmlTextNode); const firstTable = officeMarkup.query.containingTableNode(firstRow); const lastTable = officeMarkup.query.containingTableNode(lastRow); if (firstTable !== lastTable) { throw new TemplateSyntaxError(`Open and close tags are not in the same table: ${openTag.rawText} and ${closeTag.rawText}. Are you trying to repeat rows across adjacent or nested tables?`); } const rowsToRepeat = xml.query.siblingsInRange(firstRow, lastRow); // remove the loop tags xml.modify.remove(openTag.xmlTextNode); xml.modify.remove(closeTag.xmlTextNode); return { firstNode: firstRow, nodesToRepeat: rowsToRepeat, lastNode: lastRow }; } mergeBack(rowGroups, firstRow, lastRow) { let insertAfter = lastRow; for (const curRowsGroup of rowGroups) { for (const row of curRowsGroup) { xml.modify.insertAfter(row, insertAfter); insertAfter = row; } } // Remove old rows - between first and last row xml.modify.removeSiblings(firstRow, lastRow); // Remove old rows - first and last rows xml.modify.remove(firstRow); if (firstRow !== lastRow) { xml.modify.remove(lastRow); } } } const LOOP_CONTENT_TYPE = 'loop'; class LoopPlugin extends TemplatePlugin { contentType = LOOP_CONTENT_TYPE; loopStrategies = [new LoopTableColumnsStrategy(), new LoopTableRowsStrategy(), new LoopListStrategy(), new LoopParagraphStrategy() // the default strategy ]; setUtilities(utilities) { this.utilities = 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, isCondition)); 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 => xml.create.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 = xml.create.generalNode('dummyRootNode'); curNodes.forEach(node => xml.modify.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 = xml.modify.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 { contentType = 'rawXml'; simpleTagReplacements(tag, data) { const value = data.getScopeData(); const replaceNode = value?.replaceParagraph ? officeMarkup.query.containingParagraphNode(tag.xmlTextNode) : officeMarkup.query.containingTextNode(tag.xmlTextNode); if (typeof value?.xml === 'string') { const newNode = xml.parser.parse(value.xml); xml.modify.insertBefore(newNode, replaceNode); } if (value?.replaceParagraph) { xml.modify.remove(replaceNode); } else { officeMarkup.modify.removeTag(tag.xmlTextNode); } } } const TEXT_CONTENT_TYPE = 'text'; class TextPlugin extends TemplatePlugin { 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; // Clean up if the text node is now empty if (!text) { officeMarkup.modify.removeTag(textNode); return; } // Make sure leading and trailing whitespace are preserved const wordTextNode = officeMarkup.query.containingTextNode(textNode); officeMarkup.modify.setSpacePreserveAttribute(wordTextNode); } replaceMultiLine(textNode, lines) { const runNode = officeMarkup.query.containingRunNode(textNode); const namespace = runNode.nodeName.split(':')[0]; // First line if (lines[0]) { textNode.textContent = lines[0]; } // Other lines for (let i = 1; i < lines.length; i++) { // Add line break const lineBreak = this.getLineBreak(namespace); xml.modify.appendChild(runNode, lineBreak); // Add text if (lines[i]) { const lineNode = this.createOfficeTextNode(namespace, lines[i]); xml.modify.appendChild(runNode, lineNode); } } } getLineBreak(namespace) { return xml.create.generalNode(namespace + ':br'); } createOfficeTextNode(namespace, text) { const wordTextNode = xml.create.generalNode(namespace + ':t'); wordTextNode.attributes = {}; officeMarkup.modify.setSpacePreserveAttribute(wordTextNode); wordTextNode.childNodes = [xml.create.textNode(text)]; return wordTextNode; } } const chartTypes = Object.freeze({ area3DChart: "c:area3DChart", areaChart: "c:areaChart", bar3DChart: "c:bar3DChart", barChart: "c:barChart", line3DChart: "c:line3DChart", lineChart: "c:lineChart", doughnutChart: "c:doughnutChart", ofPieChart: "c:ofPieChart", pie3DChart: "c:pie3DChart", pieChart: "c:pieChart", scatterChart: "c:scatterChart", bubbleChart: "c:bubbleChart" }); // Section 18.8.30 of the ECMA-376 standard // https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.numberingformat // https://support.microsoft.com/en-us/office/number-format-codes-in-excel-for-mac-5026bbd6-04bc-48cd-bf33-80f18b4eae68 const formatIds = Object.freeze({ "General": 0, "0": 1, "0.00": 2, "#,##0": 3, "#,##0.00": 4, "0%": 9, "0.00%": 10, "0.00E+00": 11, "# ?/?": 12, "# ??/?": 13, "mm-dd-yy": 14, "d-mmm-yy": 15, "d-mmm": 16, "mmm-yy": 17, "h:mm AM/PM": 18, "h:mm:ss AM/PM": 19, "h:mm": 20, "h:mm:ss": 21, "m/d/yy h:mm": 22, "#,##0 ;(#,##0)": 37, "#,##0 ;[Red](#,##0)": 38, "#,##0.00;(#,##0.00)": 39, "#,##0.00;[Red](#,##0.00)": 40, "mm:ss": 45, "[h]:mm:ss": 46, "mmss.0": 47, "##0.0E+0": 48, "@": 49 }); // // Functions // function chartFriendlyName(chartType) { const name = chartType.replace("c:", "").replace("Chart", ""); return name.charAt(0).toUpperCase() + name.slice(1); } function isStandardChartType(chartType) { return chartType === chartTypes.area3DChart || chartType === chartTypes.areaChart || chartType === chartTypes.bar3DChart || chartType === chartTypes.barChart || chartType === chartTypes.line3DChart || chartType === chartTypes.lineChart || chartType === chartTypes.doughnutChart || chartType === chartTypes.ofPieChart || chartType === chartTypes.pie3DChart || chartType === chartTypes.pieChart; } function isScatterChartType(chartType) { return chartType === chartTypes.scatterChart; } function isBubbleChartType(chartType) { return chartType === chartTypes.bubbleChart; } function isStandardChartData(chartData) { return "categories" in chartData; } function isScatterChartData(chartData) { // Simple, but until we have additional ChartData types, it's enough return !isStandardChartData(chartData) && "series" in chartData; } function isBubbleChartData(chartData) { if (!isScatterChartData(chartData)) { return false; } return chartData.series.some(ser => ser.values.some(val => "size" in val)); } function isStringCategories(categories) { const first = categories.names[0]; return typeof first === "string"; } function isDateCategories(categories) { const first = categories.names[0]; return first instanceof Date; } function formatCode(categories) { if (isStringCategories(categories)) { return "General"; } if (isDateCategories(categories)) { return categories.formatCode ?? "mm-dd-yy"; } return categories.formatCode ?? "General"; } function scatterXValues(series) { const uniqueXValues = new Set(); for (const ser of series) { for (const val of ser.values) { uniqueXValues.add(val.x); } } return Array.from(uniqueXValues).sort((a, b) => a - b); } function scatterYValues(xValues, series) { const yValuesMap = {}; for (const val of series.values) { yValuesMap[val.x] = val.y; } return xValues.map(x => yValuesMap[x]); } function bubbleSizeValues(xValues, series) { const sizeValuesMap = {}; for (const val of series.values) { sizeValuesMap[val.x] = val.size; } return xValues.map(x => sizeValuesMap[x]); } class ChartColors { static async load(chartPart) { const colors = new ChartColors(chartPart); await colors.init(); return colors; } initialized = false; constructor(chartPart) { this.chartPart = chartPart; } setSeriesColor(chartType, seriesNode, isNewSeries, color) { if (!this.initialized) { throw new Error("Chart colors not initialized"); } if (!seriesNode) { return; } let colorRoot; if (chartType === chartTypes.scatterChart) { // Controls the color of the marker - the dot in the scatter chart colorRoot = seriesNode.childNodes?.find(child => child.nodeName === "c:marker"); } else { // Controls the color of the shape - line, bar, bubble, etc. colorRoot = seriesNode.childNodes?.find(child => child.nodeName === "c:spPr"); } if (!colorRoot) { return; } this.recurseSetColor(colorRoot, isNewSeries, color); } recurseSetColor(node, isNewSeries, color) { if (!node) { return; } // Was accent color (auto-selected color) if (node.nodeName == "a:schemeClr" && /accent\d+/.test(node.attributes?.["val"] ?? "")) { // New color is a number (auto-select color by accent index) // Only auto-select the color if it's a new series, otherwise keep the existing color if (typeof color === "number" && isNewSeries) { this.setAccentColor(node, color); return; } // New color is a string (apply the user-selected hex color) if (typeof color === "string") { node.nodeName = "a:srgbClr"; node.attributes["val"] = color; node.childNodes = []; return; } return; } // Was srgb color (user-defined color) if (node.nodeName == "a:srgbClr") { if (typeof color === "string") { node.attributes["val"] = color; } return; } for (const child of node.childNodes ?? []) { this.recurseSetColor(child, isNewSeries, color); } } setAccentColor(currentNode, seriesIndex) { const colorConfig = this.getAccentColorConfig(seriesIndex); currentNode.attributes["val"] = colorConfig.name; if (colorConfig.lumMod) { let lumModeNode = currentNode.childNodes?.find(child => child.nodeName === "a:lumMod"); if (!lumModeNode) { lumModeNode = xml.create.generalNode("a:lumMod", { attributes: {} }); xml.modify.appendChild(currentNode, lumModeNode); } lumModeNode.attributes["val"] = colorConfig.lumMod; } else { const lumModeNode = currentNode.childNodes?.find(child => child.nodeName === "a:lumMod"); if (lumModeNode) { xml.modify.removeChild(currentNode, lumModeNode); } } if (colorConfig.lumOff) { let lumOffNode = currentNode.childNodes?.find(child => child.nodeName === "a:lumOff"); if (!lumOffNode) { lumOffNode = xml.create.generalNode("a:lumOff", { attributes: {} }); xml.modify.appendChild(currentNode, lumOffNode); } lumOffNode.attributes["val"] = colorConfig.lumOff; } else { const lumOffNode = currentNode.childNodes?.find(child => child.nodeName === "a:lumOff"); if (lumOffNode) { xml.modify.removeChild(currentNode, lumOffNode); } } } getAccentColorConfig(seriesIndex) { const accent = this.accents[seriesIndex % this.accents.length]; let variation; if (seriesIndex < this.accents.length) { variation = null; } else { const variationIndex = Math.floor(seriesIndex / this.accents.length) % this.variations.length; variation = this.variations[variationIndex]; } return { name: accent, lumMod: variation?.lumMod, lumOff: variation?.lumOff }; } async init() { if (this.initialized) { return; } const colorsPart = await this.chartPart.getFirstPartByType(RelType.ChartColors); if (!colorsPart) { this.initialized = true; return; } const root = await colorsPart.xmlRoot(); const accents = root.childNodes?.filter(child => child.nodeName === "a:schemeClr")?.map(node => node.attributes["val"]); const variations = root.childNodes?.filter(child => child.nodeName === "cs:variation")?.map(node => { if (!node.childNodes?.length) { return null; } const lumModNode = node.childNodes.find(n => n.nodeName === "a:lumMod"); const lumOffNode = node.childNodes.find(n => n.nodeName === "a:lumOff"); return { lumMod: lumModNode?.attributes["val"], lumOff: lumOffNode?.attributes["val"] }; }).filter(Boolean); this.accents = accents; this.variations = variations; this.initialized = true; } } function validateChartData(chartType, chartData) { if (isStandardChartType(chartType)) { validateStandardChartData(chartData, chartType); return; } if (isScatterChartType(chartType)) { validateScatterChartData(chartData, chartType); return; } if (isBubbleChartType(chartType)) { validateScatterChartData(chartData, chartType); validateBubbleChartData(chartData, chartType); return; } throw new TemplateDataError("Invalid chart data: " + JSON.stringify(chartData)); } function validateStandardChartData(chartData, chartType) { if (!chartData.categories) { throw new TemplateDataError(`${chartFriendlyName(chartType)} chart must have categories.`); } if (!chartData.categories.names) { throw new TemplateDataError(`${chartFriendlyName(chartType)} chart categories must have a "names" field.`); } for (const ser of chartData.series) { if (!ser.values) { throw new TemplateDataError(`${chartFriendlyName(chartType)} chart series must have a "values" field.`); } // Check if the series values and category names have the same length (same number of x and y values) if (ser.values.length != chartData.categories.names.length) { throw new TemplateDataError(`${chartFriendlyName(chartType)} chart series values and category names must have the same length.`); } // Verify series values are numbers for (const val of ser.values) { if (val === null || val === undefined) { continue; } if (typeof val === "number") { continue; } throw new TemplateDataError(`${chartFriendlyName(chartType)} chart series values must be numbers.`); } } } function validateScatterChartData(chartData, chartType) { if (!chartData.series) { throw new TemplateDataError(`${chartFriendlyName(chartType)} chart must have series.`); } for (const ser of chartData.series) { if (!ser.values) { throw new TemplateDataError(`${chartFriendlyName(chartType)} chart series must have a "values" field.`); } for (const val of ser.values) { // Verify series values are valid point objects if (typeof val === "object" && "x" in val && "y" in val) { continue; } throw new TemplateDataError(`${chartFriendlyName(chartType)} chart series values must have x and y properties.`); } } } function validateBubbleChartData(chartData, chartType) { for (const ser of chartData.series) { for (const val of ser.values) { // Verify series points have a "size" property (x and y are checked in validateScatterChartData) if (typeof val === "object" && "size" in val) { continue; } throw new TemplateDataError(`${chartFriendlyName(chartType)} chart series values must have a "size" property.`); } } } // Based on: https://github.com/OpenXmlDev/Open-Xml-PowerTools/blob/vNext/OpenXmlPowerTools/ChartUpdater.cs const space = " "; const xValuesTitle = "X-Values"; async function updateChart(chartPart, chartData) { // Normalize the chart data: // Shallow clone and make sure series names are set. chartData = Object.assign({}, chartData); for (let i = 0; i < chartData.series.length; i++) { const ser = chartData.series[i]; chartData.series[i] = Object.assign({}, ser); chartData.series[i].name = seriesName(ser.name, i); } // Get the chart node const root = await chartPart.xmlRoot(); if (root.nodeName !== "c:chartSpace") { throw new Error(`Unexpected chart root node "${root.nodeName}"`); } const chartWrapperNode = root.childNodes?.find(child => child.nodeName === "c:chart"); if (!chartWrapperNode) { throw new Error("Chart node not found"); } const plotAreaNode = chartWrapperNode.childNodes?.find(child => child.nodeName === "c:plotArea"); if (!plotAreaNode) { throw new Error("Plot area node not found"); } const chartNode = plotAreaNode.childNodes?.find(child => Object.values(chartTypes).includes(child.nodeName)); if (!chartNode) { const plotAreaChildren = plotAreaNode.childNodes?.map(child => `<${child.nodeName}>`); const supportedChartTypes = Object.values(chartTypes).join(", "); throw new TemplateSyntaxError(`Unsupported chart type. Plot area children: ${plotAreaChildren?.join(", ")}. Supported chart types: ${supportedChartTypes}`); } const chartType = chartNode.nodeName; // Input validation validateChartData(chartType, chartData); // Assemble the existing chart information const existingSeries = readExistingSeries(chartNode, chartData); const sheetName = existingSeries.map(ser => ser.sheetName).filter(Boolean)?.[0]; const colors = await ChartColors.load(chartPart); const existingChart = { chartPart, chartNode, chartType, sheetName, colors, series: existingSeries }; // Update embedded worksheet await updateEmbeddedExcelFile(existingChart, chartData); // Update inline series updateInlineSeries(existingChart, chartData); } // // Read the first series // function readExistingSeries(chartNode, chartData) { const series = chartNode.childNodes?.filter(child => child.nodeName === "c:ser"); return series.map(ser => readSingleSeries(ser, chartData)); } function readSingleSeries(seriesNode, chartData) { const sheetName = getSheetName(seriesNode); const shapeProperties = seriesNode?.childNodes?.find(child => child.nodeName === "c:spPr"); const chartExtensibility = seriesNode?.childNodes?.find(child => child.nodeName === "c:extLst"); const formatCode = seriesNode?.childNodes?.find(child => child.nodeName === "c:cat")?.childNodes?.find(child => child.nodeName === "c:numRef")?.childNodes?.find(child => child.nodeName === "c:numCache")?.childNodes?.find(child => child.nodeName === "c:formatCode")?.childNodes?.find(child => xml.query.isTextNode(child))?.textContent; return { sheetName, shapePropertiesMarkup: xml.parser.serializeNode(shapeProperties), chartSpecificMarkup: chartSpecificMarkup(seriesNode), categoriesMarkup: categoriesMarkup(chartData, sheetName, formatCode), chartExtensibilityMarkup: xml.parser.serializeNode(chartExtensibility) }; } function getSheetName(firstSeries) { if (!firstSeries) { return null; } const formulaNode = firstSeries?.childNodes?.find(child => child.nodeName === "c:tx")?.childNodes?.find(child => child.nodeName === "c:strRef")?.childNodes?.find(child => child.nodeName === "c:f"); const formula = xml.query.lastTextChild(formulaNode, false); if (!formula) { return null; } return formula.textContent?.split('!')[0]; } function categoriesMarkup(chartData, sheetName, firstSeriesFormatCode) { if (isScatterChartData(chartData)) { return scatterXValuesMarkup(chartData, sheetName); } return standardCategoriesMarkup(chartData, sheetName, firstSeriesFormatCode); } function standardCategoriesMarkup(chartData, sheetName, firstSeriesFormatCode) { function getCategoryName(name) { if (name instanceof Date) { return excelDateValue(name); } return name; } const ptNodes = ` ${chartData.categories.names.map((name, index) => ` ${getCategoryName(name)} `).join("\n")} `; if (!sheetName) { // String literal if (isStringCategories(chartData.categories)) { return ` ${ptNodes} `; } // Number literal return ` ${ptNodes} `; } const formula = `${sheetName}!$A$2:$A$${chartData.categories.names.length + 1}`; // String reference if (isStringCategories(chartData.categories)) { return ` ${formula} ${ptNodes} `; } // Number reference const formatCodeValue = chartData.categories.formatCode ? formatCode(chartData.categories) : firstSeriesFormatCode ?? formatCode(chartData.categories); return ` ${formula} ${formatCodeValue} ${ptNodes} `; } function scatterXValuesMarkup(chartData, sheetName) { const xValues = scatterXValues(chartData.series); const ptNodes = ` ${xValues.map((x, index) => ` ${x} `).join("\n")} `; // Number literal if (!sheetName) { return ` ${ptNodes} `; } const formula = `${sheetName}!$A$2:$A$${xValues.length + 1}`; // Number reference return ` ${formula} General ${ptNodes} `; } function chartSpecificMarkup(firstSeries) { if (!firstSeries) { return ""; } const pictureOptions = firstSeries.childNodes?.find(child => child.nodeName === "c:pictureOptions"); const dLbls = firstSeries.childNodes?.find(child => child.nodeName === "c:dLbls"); const trendline = firstSeries.childNodes?.find(child => child.nodeName === "c:trendline"); const errBars = firstSeries.childNodes?.find(child => child.nodeName === "c:errBars"); const invertIfNegative = firstSeries.childNodes?.find(child => child.nodeName === "c:invertIfNegative"); const marker = firstSeries.childNodes?.find(child => child.nodeName === "c:marker"); const smooth = firstSeries.childNodes?.find(child => child.nodeName === "c:smooth"); const explosion = firstSeries.childNodes?.find(child => child.nodeName === "c:explosion"); const dPt = firstSeries.childNodes?.filter(child => child.nodeName === "c:dPt"); const firstSliceAngle = firstSeries.childNodes?.find(child => child.nodeName === "c:firstSliceAngle"); const holeSize = firstSeries.childNodes?.find(child => child.nodeName === "c:holeSize"); const serTx = firstSeries.childNodes?.find(child => child.nodeName === "c:serTx"); return ` ${xml.parser.serializeNode(pictureOptions)} ${xml.parser.serializeNode(dLbls)} ${xml.parser.serializeNode(trendline)} ${xml.parser.serializeNode(errBars)} ${xml.parser.serializeNode(invertIfNegative)} ${xml.parser.serializeNode(marker)} ${xml.parser.serializeNode(smooth)} ${xml.parser.serializeNode(explosion)} ${dPt.map(dPt => xml.parser.serializeNode(dPt)).join("\n")} ${xml.parser.serializeNode(firstSliceAngle)} ${xml.parser.serializeNode(holeSize)} ${xml.parser.serializeNode(serTx)} `; } // // Update inline series // function updateInlineSeries(existingChart, chartData) { // Remove all old series xml.modify.removeChildren(existingChart.chartNode, child => child.nodeName === "c:ser"); // Create new series const newSeries = chartData.series.map((s, index) => createSeries(existingChart, s.name, index, chartData)); for (const series of newSeries) { xml.modify.appendChild(existingChart.chartNode, series); } } function createSeries(existingChart, seriesName, seriesIndex, chartData) { const firstSeries = existingChart.series[0]; const isNewSeries = !existingChart.series[seriesIndex]; const existingSeries = existingChart.series[seriesIndex] ?? firstSeries; const title = titleMarkup(seriesName, seriesIndex, existingSeries?.sheetName); const values = valuesMarkup(seriesIndex, chartData, existingSeries?.sheetName); const series = parseXmlNode(` ${title} ${existingSeries?.shapePropertiesMarkup ?? ""} ${existingSeries?.chartSpecificMarkup ?? ""} ${existingSeries?.categoriesMarkup ?? ""} ${values} ${existingSeries?.chartExtensibilityMarkup ?? ""} `); const color = selectSeriesColor(seriesIndex, chartData); existingChart.colors.setSeriesColor(existingChart.chartType, series, isNewSeries, color); return series; } function titleMarkup(seriesName, seriesIndex, sheetName) { if (!sheetName) { return ` ${seriesName} `; } const formula = `${sheetName}!$${excelColumnId(seriesIndex + 1)}$1`; return ` ${formula} ${seriesName} `; } function valuesMarkup(seriesIndex, chartData, sheetName) { if (isScatterChartData(chartData)) { return scatterValuesMarkup(seriesIndex, chartData, sheetName); } return standardValuesMarkup(seriesIndex, chartData, sheetName); } function standardValuesMarkup(seriesIndex, chartData, sheetName) { if (!sheetName) { // Number literal return ` ${chartData.categories.names.map((name, catIndex) => ` ${chartData.series[seriesIndex].values[catIndex]} `).join("\n")} `; } // Number reference const columnId = excelColumnId(seriesIndex + 1); const formula = `${sheetName}!$${columnId}$2:$${columnId}$${chartData.categories.names.length + 1}`; return ` ${formula} General ${chartData.categories.names.map((name, catIndex) => ` ${chartData.series[seriesIndex].values[catIndex]} `).join("\n")} `; } function scatterValuesMarkup(seriesIndex, chartData, sheetName) { const xValues = scatterXValues(chartData.series); const yValues = scatterYValues(xValues, chartData.series[seriesIndex]); const ptCountNode = ` `; // Y values const yValueNodes = yValues.map((y, index) => { if (y === null || y === undefined) { return ""; } return ` ${y} `; }); // Bubble size values const bubbleSizeNodes = isBubbleChartData(chartData) ? chartData.series[seriesIndex].values.map((v, index) => { if (v.size === null || v.size === undefined) { return ""; } return ` ${v.size} `; }) : []; // Number literal if (!sheetName) { const yVal = ` ${ptCountNode} ${yValueNodes.join("\n")} `; if (!isBubbleChartData(chartData)) { return yVal; } const bubbleSize = ` ${ptCountNode} ${bubbleSizeNodes.join("\n")} `; return ` ${yVal} ${bubbleSize} `; } // Number reference const yValColumnId = excelColumnId(seriesIndex + 1); const yValFormula = `${sheetName}!$${yValColumnId}$2:$${yValColumnId}$${yValues.length + 1}`; const yVal = ` ${yValFormula} General ${ptCountNode} ${yValueNodes.join("\n")} `; if (!isBubbleChartData(chartData)) { return yVal; } const bubbleSizeColumnId = excelColumnId(seriesIndex + 2); const bubbleSizeFormula = `${sheetName}!$${bubbleSizeColumnId}$2:$${bubbleSizeColumnId}$${yValues.length + 1}`; const bubbleSize = ` ${bubbleSizeFormula} General ${ptCountNode} ${bubbleSizeNodes.join("\n")} `; return ` ${yVal} ${bubbleSize} `; } function selectSeriesColor(seriesIndex, chartData) { // Use manual hex color const color = chartData.series[seriesIndex].color?.trim(); if (color) { const hex = color.startsWith("#") ? color.slice(1) : color; return hex.toUpperCase(); } // Auto-select accent color return seriesIndex; } // // Update the embedded Excel workbook file // async function updateEmbeddedExcelFile(existingChart, chartData) { // Get the relation ID of the embedded Excel file const rootNode = await existingChart.chartPart.xmlRoot(); const externalDataNode = rootNode.childNodes?.find(child => child.nodeName === "c:externalData"); const workbookRelId = externalDataNode?.attributes["r:id"]; if (!workbookRelId) { return; } // Open the embedded Excel file const xlsxPart = await existingChart.chartPart.getPartById(workbookRelId); if (!xlsxPart) { return; } const xlsxBinary = await xlsxPart.getContentBinary(); const xlsx = await Xlsx.load(xlsxBinary); // Update the workbook const workbookPart = xlsx.mainDocument; const sharedStrings = await updateSharedStringsPart(workbookPart, chartData); const sheetPart = await updateSheetPart(workbookPart, existingChart.sheetName, sharedStrings, chartData); if (sheetPart) { await updateTablePart(sheetPart, chartData); } await workbookPart.save(); // Save the Excel file const newXlsxBinary = await xlsx.export(); await xlsxPart.save(newXlsxBinary); } async function updateSharedStringsPart(workbookPart, chartData) { // Get the shared strings part const sharedStringsPart = await workbookPart.getFirstPartByType(RelType.SharedStrings); if (!sharedStringsPart) { return {}; } // Get the shared strings part root const root = await sharedStringsPart.xmlRoot(); // Remove all existing strings root.childNodes = []; let count = 0; const sharedStrings = {}; function addString(str) { xml.modify.appendChild(root, xml.create.generalNode("si", { childNodes: [xml.create.generalNode("t", { attributes: { [OmlAttribute.SpacePreserve]: "preserve" }, childNodes: [xml.create.textNode(str)] })] })); sharedStrings[str] = count; count++; } // Default strings if (isStandardChartData(chartData)) { addString(space); } if (isScatterChartData(chartData)) { addString(xValuesTitle); } // Category strings if (isStandardChartData(chartData) && isStringCategories(chartData.categories)) { for (const name of chartData.categories.names) { addString(name); } } // Series strings for (const name of chartData.series.map(s => s.name)) { addString(name); if (isBubbleChartData(chartData)) { addString(name + " Size"); } } // Update attributes root.attributes["count"] = count.toString(); root.attributes["uniqueCount"] = count.toString(); return sharedStrings; } async function updateSheetPart(workbookPart, sheetName, sharedStrings, chartData) { // Get the sheet rel ID const root = await workbookPart.xmlRoot(); const sheetNode = root.childNodes?.find(child => child.nodeName === "sheets")?.childNodes?.find(child => child.nodeName === "sheet" && child.attributes["name"] == sheetName); const sheetRelId = sheetNode?.attributes["r:id"]; if (!sheetRelId) { return null; } // Get the sheet part const sheetPart = await workbookPart.getPartById(sheetRelId); if (!sheetPart) { return null; } const sheetRoot = await sheetPart.xmlRoot(); let newRows = []; if (isStandardChartData(chartData)) { newRows = await updateSheetRootStandard(workbookPart, sheetRoot, chartData, sharedStrings); } else if (isScatterChartData(chartData)) { newRows = await updateSheetRootScatter(workbookPart, sheetRoot, chartData, sharedStrings); } // Replace sheet data const sheetDataNode = sheetRoot.childNodes?.find(child => child.nodeName === "sheetData"); sheetDataNode.childNodes = []; for (const row of newRows) { xml.modify.appendChild(sheetDataNode, row); } return sheetPart; } async function updateSheetRootStandard(workbookPart, sheetRoot, chartData, sharedStrings) { // Create first row const firstRow = ` ${sharedStrings[space]} ${chartData.series.map((s, index) => ` ${sharedStrings[s.name]} `).join("\n")} `; // Create other rows const categoryDataTypeAttribute = isStringCategories(chartData.categories) ? ` t="s"` : ""; const categoryStyleIdAttribute = await updateStylesPart(workbookPart, sheetRoot, chartData.categories); function getCategoryName(name) { if (name instanceof Date) { return excelDateValue(name); } if (typeof name === "string") { return sharedStrings[name]; } return name; } const otherRows = chartData.categories.names.map((name, rowIndex) => ` ${getCategoryName(name)} ${chartData.series.map((s, columnIndex) => ` ${s.values[rowIndex]} `).join("\n")} `); return [parseXmlNode(firstRow), ...otherRows.map(row => parseXmlNode(row))]; } async function updateSheetRootScatter(workbookPart, sheetRoot, chartData, sharedStrings) { const isBubbleChart = isBubbleChartData(chartData); // Create first row const firstRowColumns = chartData.series.map((s, index) => { const baseIndex = isBubbleChart ? index * 2 : index; const seriesNameColumn = ` ${sharedStrings[s.name]} `; if (!isBubbleChart) { return seriesNameColumn; } const bubbleSizeColumn = ` ${sharedStrings[s.name + " Size"]} `; return ` ${seriesNameColumn} ${bubbleSizeColumn} `; }); const firstRow = ` ${sharedStrings[xValuesTitle]} ${firstRowColumns.join("\n")} `; const xValues = scatterXValues(chartData.series); // Create other rows const yValues = chartData.series.map(s => scatterYValues(xValues, s)); const bubbleSizes = isBubbleChart ? chartData.series.map(s => bubbleSizeValues(xValues, s)) : []; function otherRowColumns(rowIndex) { return chartData.series.map((s, seriesIndex) => { const baseIndex = isBubbleChart ? seriesIndex * 2 : seriesIndex; const yValueColumn = ` ${yValues[seriesIndex][rowIndex]} `; if (!isBubbleChart) { return yValueColumn; } const bubbleSizeColumn = ` ${bubbleSizes[seriesIndex][rowIndex]} `; return ` ${yValueColumn} ${bubbleSizeColumn} `; }); } const otherRows = xValues.map((x, rowIndex) => ` ${x} ${otherRowColumns(rowIndex).join("\n")} `); return [parseXmlNode(firstRow), ...otherRows.map(row => parseXmlNode(row))]; } async function updateTablePart(sheetPart, chartData) { const tablePart = await sheetPart.getFirstPartByType(RelType.Table); if (!tablePart) { return; } // Update ref attribute const tablePartRoot = await tablePart.xmlRoot(); tablePartRoot.attributes["ref"] = `A1:${excelRowAndColumnId(tableRowsCount(chartData), chartData.series.length)}`; // Find old table columns const tableColumnsNode = tablePartRoot.childNodes?.find(child => child.nodeName === "tableColumns"); // Add new table columns const firstColumnName = isScatterChartData(chartData) ? xValuesTitle : space; const otherColumns = chartData.series.map((s, index) => { const baseIndex = isBubbleChartData(chartData) ? index * 2 : index; return ` ${isBubbleChartData(chartData) ? ` ` : ""} `; }); const tableColumns = ` ${otherColumns.join("\n")} `; xml.modify.insertAfter(parseXmlNode(tableColumns), tableColumnsNode); // Remove old table columns xml.modify.removeChild(tablePartRoot, tableColumnsNode); } function tableRowsCount(chartData) { if (isScatterChartData(chartData)) { return scatterXValues(chartData.series).length; } return chartData.categories.names.length; } async function updateStylesPart(workbookPart, sheetRoot, categories) { // https://github.com/OpenXmlDev/Open-Xml-PowerTools/blob/vNext/OpenXmlPowerTools/ChartUpdater.cs#L507 if (isStringCategories(categories)) { return ""; } const stylesPart = await workbookPart.getFirstPartByType(RelType.Styles); const stylesRoot = await stylesPart.xmlRoot(); // Find or create cellXfs let cellXfs = stylesRoot.childNodes?.find(child => child.nodeName === "cellXfs"); if (!cellXfs) { const cellStyleXfs = stylesRoot.childNodes?.find(child => child.nodeName === "cellStyleXfs"); const borders = stylesRoot.childNodes?.find(child => child.nodeName === "borders"); if (!cellStyleXfs && !borders) { throw new Error("Internal error. CellXfs, CellStyleXfs and Borders not found."); } const stylesCellXfs = xml.create.generalNode("cellXfs", { attributes: { count: "0" } }); xml.modify.insertAfter(stylesCellXfs, cellStyleXfs ?? borders); // Use the cellXfs node from the sheet part cellXfs = sheetRoot.childNodes?.find(child => child.nodeName === "cellXfs"); } // Add xf to cellXfs const count = parseInt(cellXfs.attributes["count"]); cellXfs.attributes["count"] = (count + 1).toString(); xml.modify.appendChild(cellXfs, parseXmlNode(` `)); return `s="${count}"`; } // // Helper functions // function seriesName(name, index) { return name ?? `Series ${index + 1}`; } function excelColumnId(i) { // From: https://github.com/OpenXmlDev/Open-Xml-PowerTools/blob/vNext/OpenXmlPowerTools/PtOpenXmlUtil.cs#L1559 const A = 65; if (i >= 0 && i <= 25) { return String.fromCharCode(A + i); } if (i >= 26 && i <= 701) { const v = i - 26; const h = Math.floor(v / 26); const l = v % 26; return String.fromCharCode(A + h) + String.fromCharCode(A + l); } // 17576 if (i >= 702 && i <= 18277) { const v = i - 702; const h = Math.floor(v / 676); const r = v % 676; const m = Math.floor(r / 26); const l = r % 26; return String.fromCharCode(A + h) + String.fromCharCode(A + m) + String.fromCharCode(A + l); } throw new Error(`Column reference out of range: ${i}`); } function excelRowAndColumnId(row, col) { return excelColumnId(col) + (row + 1).toString(); } function excelDateValue(date) { const millisPerDay = 86400000; const excelEpoch = new Date("1899-12-30"); return (date.getTime() - excelEpoch.getTime()) / millisPerDay; } function parseXmlNode(xmlString) { const xmlNode = xml.parser.parse(xmlString); xml.modify.removeEmptyTextNodes(xmlNode); return xmlNode; } class ChartPlugin extends TemplatePlugin { contentType = 'chart'; async simpleTagReplacements(tag, data, context) { const chartNode = xml.query.findParentByName(tag.xmlTextNode, "c:chart"); if (!chartNode) { throw new TemplateSyntaxError("Chart tag not placed in chart title"); } const content = data.getScopeData(); if (!content) { officeMarkup.modify.removeTag(tag.xmlTextNode); return; } // Replace or remove the tag if (content.title) { updateTitle(tag, content.title); } else { officeMarkup.modify.removeTag(tag.xmlTextNode); } if (!chartHasData(content)) { return; } // Update the chart await updateChart(context.currentPart, content); } } function updateTitle(tag, newTitle) { const wordTextNode = officeMarkup.query.containingTextNode(tag.xmlTextNode); // Create the new title node const newXmlTextNode = xml.create.textNode(newTitle); const newWordTextNode = xml.create.generalNode(OmlNode.A.Text, { childNodes: [newXmlTextNode] }); xml.modify.insertAfter(newWordTextNode, wordTextNode); // Remove the tag node xml.modify.remove(wordTextNode); // Split the run if needed. // Chart title run node can only have one text node const curRun = officeMarkup.query.containingRunNode(newWordTextNode); const runTextNodes = curRun.childNodes.filter(node => officeMarkup.query.isTextNode(node)); if (runTextNodes.length > 1) { // Remove the last text node const lastTextNode = runTextNodes[runTextNodes.length - 1]; xml.modify.remove(lastTextNode); // Create a new run const newRun = xml.create.cloneNode(curRun, true); for (const node of newRun.childNodes) { if (officeMarkup.query.isTextNode(node)) { xml.modify.remove(node); } } xml.modify.insertAfter(newRun, curRun); // Add the text node to the new run xml.modify.appendChild(newRun, lastTextNode); } } function chartHasData(content) { return !!content?.series?.length; } function createDefaultPlugins() { return [new LoopPlugin(), new RawXmlPlugin(), new ChartPlugin(), 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.pluginsLookup = toDictionary(plugins, p => p.contentType); this.options = options; } /** * 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 { /** * Called by the TemplateHandler at runtime. */ setUtilities(utilities) { this.utilities = utilities; } } class Delimiters { tagStart = "{"; tagEnd = "}"; containerTagOpen = "#"; containerTagClose = "/"; tagOptionsStart = "["; tagOptionsEnd = "]"; constructor(initial) { 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 { 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 */ skipEmptyTags = false; defaultContentType = TEXT_CONTENT_TYPE; containerContentType = LOOP_CONTENT_TYPE; delimiters = new Delimiters(); maxXmlDepth = 20; extensions = {}; constructor(initial) { 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 { /** * Version number of the `easy-template-x` library. */ version = "6.2.1" ; constructor(options) { this.options = new TemplateHandlerOptions(options); // // this is the library's composition root // const delimiterSearcher = new DelimiterSearcher(); delimiterSearcher.startDelimiter = this.options.delimiters.tagStart; delimiterSearcher.endDelimiter = this.options.delimiters.tagEnd; delimiterSearcher.maxXmlDepth = this.options.maxXmlDepth; const tagParser = new TagParser(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({ compiler: this.compiler }); }); const extensionUtilities = { tagParser, compiler: this.compiler }; this.options.extensions?.beforeCompilation?.forEach(extension => { extension.setUtilities(extensionUtilities); }); this.options.extensions?.afterCompilation?.forEach(extension => { extension.setUtilities(extensionUtilities); }); } // // public methods // async process(templateFile, data) { // load the docx file const docx = await Docx.load(templateFile); // prepare context const scopeData = new ScopeData(data); scopeData.scopeDataResolver = this.options.scopeDataResolver; const context = { docx, currentPart: null, pluginContext: {}, options: { maxXmlDepth: this.options.maxXmlDepth } }; const contentParts = await docx.getContentParts(); for (const part of contentParts) { context.currentPart = part; // extensions - before compilation await this.callExtensions(this.options.extensions?.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.extensions?.afterCompilation, scopeData, context); } // export the result return docx.export(); } async parseTags(templateFile) { const docx = await Docx.load(templateFile); const tags = []; const parts = await docx.getContentParts(); for (const part of parts) { const xmlRoot = await part.xmlRoot(); const partTags = this.compiler.parseTags(xmlRoot); if (partTags?.length) { tags.push(...partTags); } } return tags; } /** * Get the text content of one or more parts of the document. * If more than one part exists, the concatenated text content of all parts is returned. * If no matching parts are found, returns an empty string. * * @param relType * The relationship type of the parts whose text content you want to retrieve. * Defaults to `RelType.MainDocument`. */ async getText(docxFile, relType = RelType.MainDocument) { const parts = await this.getParts(docxFile, relType); const partsText = await Promise.all(parts.map(p => p.getText())); return partsText.join('\n\n'); } /** * Get the xml root of a single part of the document. * If no matching part is found, returns null. * * @param relType * The relationship type of the parts whose xml root you want to retrieve. * If more than one part exists, the first one is returned. * Defaults to `RelType.MainDocument`. */ async getXml(docxFile, relType = RelType.MainDocument) { const docx = await Docx.load(docxFile); if (relType === RelType.MainDocument) { return await docx.mainDocument.xmlRoot(); } const part = await docx.mainDocument.getFirstPartByType(relType); if (!part) { return null; } return await part.xmlRoot(); } async getParts(docxFile, relType) { const docx = await Docx.load(docxFile); if (relType === RelType.MainDocument) { return [docx.mainDocument]; } const parts = await docx.mainDocument.getPartsByType(relType); return parts; } // // private methods // async callExtensions(extensions, scopeData, context) { if (!extensions) return; for (const extension of extensions) { await extension.execute(scopeData, context); } } } exports.Base64 = Base64; exports.Binary = Binary; exports.COMMENT_NODE_NAME = COMMENT_NODE_NAME; exports.DelimiterSearcher = DelimiterSearcher; exports.Delimiters = Delimiters; exports.Docx = Docx; exports.ImagePlugin = ImagePlugin; exports.InternalArgumentMissingError = InternalArgumentMissingError; exports.InternalError = InternalError; 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.MissingCloseDelimiterError = MissingCloseDelimiterError; exports.MissingStartDelimiterError = MissingStartDelimiterError; exports.OfficeMarkup = OfficeMarkup; exports.OmlAttribute = OmlAttribute; exports.OmlNode = OmlNode; exports.OpenXmlPart = OpenXmlPart; exports.Path = Path; exports.PluginContent = PluginContent; exports.RawXmlPlugin = RawXmlPlugin; exports.Regex = Regex; exports.RelType = RelType; exports.Relationship = Relationship; 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.TemplateDataError = TemplateDataError; exports.TemplateExtension = TemplateExtension; exports.TemplateHandler = TemplateHandler; exports.TemplateHandlerOptions = TemplateHandlerOptions; exports.TemplatePlugin = TemplatePlugin; exports.TemplateSyntaxError = TemplateSyntaxError; exports.TextPlugin = TextPlugin; exports.UnclosedTagError = UnclosedTagError; exports.UnidentifiedFileTypeError = UnidentifiedFileTypeError; exports.UnknownContentTypeError = UnknownContentTypeError; exports.UnopenedTagError = UnopenedTagError; exports.UnsupportedFileTypeError = UnsupportedFileTypeError; exports.Xlsx = Xlsx; exports.XmlDepthTracker = XmlDepthTracker; exports.XmlNodeType = XmlNodeType; exports.XmlTreeIterator = XmlTreeIterator; exports.XmlUtils = XmlUtils; 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.officeMarkup = officeMarkup; exports.pushMany = pushMany; exports.sha1 = sha1; exports.stringValue = stringValue; exports.toDictionary = toDictionary; exports.xml = xml;