easy-template-x 6.2.0 → 6.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ Generate docx documents from templates, in Node or in the browser.
14
14
  >
15
15
  > ✓ PDF support
16
16
  > ✓ REST API integration
17
- > ✓ Zapier integration
17
+ > ✓ Zapier and Make integration
18
18
  > ✓ Live preview functionality
19
19
  > ✓ Easy-to-use UI
20
20
  >
@@ -404,6 +404,10 @@ function stringValue(val) {
404
404
  function normalizeDoubleQuotes(text) {
405
405
  return text.replace(nonStandardDoubleQuotesRegex, standardDoubleQuotes);
406
406
  }
407
+ function countOccurrences(text, substring) {
408
+ // https://stackoverflow.com/questions/4009756/how-to-count-string-occurrence-in-string
409
+ return (text.match(new RegExp(substring, 'g')) || []).length;
410
+ }
407
411
 
408
412
  class JsZipHelper {
409
413
  static toJsZipOutputType(binaryOrType) {
@@ -491,6 +495,71 @@ const XmlNodeType = Object.freeze({
491
495
  const TEXT_NODE_NAME = '#text'; // see: https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeName
492
496
  const COMMENT_NODE_NAME = '#comment';
493
497
 
498
+ class XmlDepthTracker {
499
+ depth = 0;
500
+ constructor(maxDepth) {
501
+ this.maxDepth = maxDepth;
502
+ }
503
+ increment() {
504
+ this.depth++;
505
+ if (this.depth > this.maxDepth) {
506
+ throw new MaxXmlDepthError(this.maxDepth);
507
+ }
508
+ }
509
+ decrement() {
510
+ this.depth--;
511
+ }
512
+ }
513
+
514
+ class XmlTreeIterator {
515
+ get node() {
516
+ return this._current;
517
+ }
518
+ constructor(initial, maxDepth) {
519
+ if (!initial) {
520
+ throw new InternalError("Initial node is required");
521
+ }
522
+ if (!maxDepth) {
523
+ throw new InternalError("Max depth is required");
524
+ }
525
+ this._current = initial;
526
+ this.depthTracker = new XmlDepthTracker(maxDepth);
527
+ }
528
+ next() {
529
+ if (!this._current) {
530
+ return null;
531
+ }
532
+ this._current = this.findNextNode(this._current);
533
+ return this._current;
534
+ }
535
+ setCurrent(node) {
536
+ this._current = node;
537
+ }
538
+ findNextNode(node) {
539
+ // Children
540
+ if (node.childNodes && node.childNodes.length) {
541
+ this.depthTracker.increment();
542
+ return node.childNodes[0];
543
+ }
544
+
545
+ // Siblings
546
+ if (node.nextSibling) return node.nextSibling;
547
+
548
+ // Parent sibling
549
+ while (node.parentNode) {
550
+ if (node.parentNode.nextSibling) {
551
+ this.depthTracker.decrement();
552
+ return node.parentNode.nextSibling;
553
+ }
554
+
555
+ // Go up
556
+ this.depthTracker.decrement();
557
+ node = node.parentNode;
558
+ }
559
+ return null;
560
+ }
561
+ }
562
+
494
563
  class XmlUtils {
495
564
  parser = new Parser();
496
565
  create = new Create();
@@ -690,16 +759,19 @@ let Query$1 = class Query {
690
759
  isTextNode(node) {
691
760
  if (node.nodeType === XmlNodeType.Text || node.nodeName === TEXT_NODE_NAME) {
692
761
  if (!(node.nodeType === XmlNodeType.Text && node.nodeName === TEXT_NODE_NAME)) {
693
- throw new Error(`Invalid text node. Type: '${node.nodeType}', Name: '${node.nodeName}'.`);
762
+ throw new InternalError(`Invalid text node. Type: '${node.nodeType}', Name: '${node.nodeName}'.`);
694
763
  }
695
764
  return true;
696
765
  }
697
766
  return false;
698
767
  }
768
+ isGeneralNode(node) {
769
+ return node.nodeType === XmlNodeType.General;
770
+ }
699
771
  isCommentNode(node) {
700
772
  if (node.nodeType === XmlNodeType.Comment || node.nodeName === COMMENT_NODE_NAME) {
701
773
  if (!(node.nodeType === XmlNodeType.Comment && node.nodeName === COMMENT_NODE_NAME)) {
702
- throw new Error(`Invalid comment node. Type: '${node.nodeType}', Name: '${node.nodeName}'.`);
774
+ throw new InternalError(`Invalid comment node. Type: '${node.nodeType}', Name: '${node.nodeName}'.`);
703
775
  }
704
776
  return true;
705
777
  }
@@ -778,6 +850,17 @@ let Query$1 = class Query {
778
850
  range.push(lastNode);
779
851
  return range;
780
852
  }
853
+ descendants(node, maxDepth, predicate) {
854
+ const result = [];
855
+ const it = new XmlTreeIterator(node, maxDepth);
856
+ while (it.node) {
857
+ if (predicate(it.node)) {
858
+ result.push(it.node);
859
+ }
860
+ it.next();
861
+ }
862
+ return result;
863
+ }
781
864
  };
782
865
  let Modify$1 = class Modify {
783
866
  /**
@@ -1030,22 +1113,6 @@ function recursiveRemoveEmptyTextNodes(node) {
1030
1113
  }
1031
1114
  const xml = new XmlUtils();
1032
1115
 
1033
- class XmlDepthTracker {
1034
- depth = 0;
1035
- constructor(maxDepth) {
1036
- this.maxDepth = maxDepth;
1037
- }
1038
- increment() {
1039
- this.depth++;
1040
- if (this.depth > this.maxDepth) {
1041
- throw new MaxXmlDepthError(this.maxDepth);
1042
- }
1043
- }
1044
- decrement() {
1045
- this.depth--;
1046
- }
1047
- }
1048
-
1049
1116
  /**
1050
1117
  * The types of relationships that can be created in a docx file.
1051
1118
  * A non-comprehensive list.
@@ -1149,14 +1216,17 @@ class ContentTypesFile {
1149
1216
  this.zip = zip;
1150
1217
  }
1151
1218
  async ensureContentType(mime) {
1152
- // parse the content types file
1219
+ // Parse the content types file
1153
1220
  await this.parseContentTypesFile();
1154
1221
 
1155
- // already exists
1156
- if (this.contentTypes[mime]) return;
1157
-
1158
- // add new
1222
+ // Extension already exists
1223
+ //
1224
+ // Multiple extensions may map to the same mime type, but a single
1225
+ // extension must only map to one mime type.
1159
1226
  const extension = MimeTypeHelper.getDefaultExtension(mime);
1227
+ if (this.contentTypes[extension]) return;
1228
+
1229
+ // Add new node
1160
1230
  const typeNode = xml.create.generalNode('Default');
1161
1231
  typeNode.attributes = {
1162
1232
  "Extension": extension,
@@ -1164,13 +1234,13 @@ class ContentTypesFile {
1164
1234
  };
1165
1235
  this.root.childNodes.push(typeNode);
1166
1236
 
1167
- // update state
1237
+ // Update state
1168
1238
  this.addedNew = true;
1169
- this.contentTypes[mime] = true;
1239
+ this.contentTypes[extension] = mime;
1170
1240
  }
1171
- async count() {
1241
+ async xmlString() {
1172
1242
  await this.parseContentTypesFile();
1173
- return this.root.childNodes.filter(node => !xml.query.isTextNode(node)).length;
1243
+ return xml.parser.serializeFile(this.root);
1174
1244
  }
1175
1245
 
1176
1246
  /**
@@ -1178,7 +1248,7 @@ class ContentTypesFile {
1178
1248
  * Called automatically by the holding `Docx` before exporting.
1179
1249
  */
1180
1250
  async save() {
1181
- // not change - no need to save
1251
+ // Not change - no need to save
1182
1252
  if (!this.addedNew) return;
1183
1253
  const xmlContent = xml.parser.serializeFile(this.root);
1184
1254
  this.zip.setFile(ContentTypesFile.contentTypesFilePath, xmlContent);
@@ -1197,7 +1267,9 @@ class ContentTypesFile {
1197
1267
  const genNode = node;
1198
1268
  const contentTypeAttribute = genNode.attributes['ContentType'];
1199
1269
  if (!contentTypeAttribute) continue;
1200
- this.contentTypes[contentTypeAttribute] = true;
1270
+ const extensionAttribute = genNode.attributes['Extension'];
1271
+ if (!extensionAttribute) continue;
1272
+ this.contentTypes[extensionAttribute] = contentTypeAttribute;
1201
1273
  }
1202
1274
  }
1203
1275
  }
@@ -1217,39 +1289,38 @@ class MediaFiles {
1217
1289
  * Returns the media file path.
1218
1290
  */
1219
1291
  async add(mediaFile, mime) {
1220
- // check if already added
1292
+ // Check if already added
1221
1293
  if (this.files.has(mediaFile)) return this.files.get(mediaFile);
1222
1294
 
1223
- // hash existing media files
1295
+ // Hash existing media files
1224
1296
  await this.hashMediaFiles();
1225
1297
 
1226
- // hash the new file
1298
+ // Hash the new file
1227
1299
  // Note: Even though hashing the base64 string may seem inefficient
1228
1300
  // (requires extra step in some cases) in practice it is significantly
1229
1301
  // faster than hashing a 'binarystring'.
1230
1302
  const base64 = await Binary.toBase64(mediaFile);
1231
1303
  const hash = sha1(base64);
1232
1304
 
1233
- // check if file already exists
1234
- // note: this can be optimized by keeping both mapping by filename as well as by hash
1305
+ // Check if file already exists
1306
+ // Note: this can be optimized by keeping both mapping by filename as well as by hash
1235
1307
  let path = Object.keys(this.hashes).find(p => this.hashes[p] === hash);
1236
1308
  if (path) return path;
1237
1309
 
1238
- // generate unique media file name
1310
+ // Generate unique media file name
1311
+ const baseFilename = this.baseFilename(mime);
1239
1312
  const extension = MimeTypeHelper.getDefaultExtension(mime);
1240
1313
  do {
1241
1314
  this.nextFileId++;
1242
- path = `${MediaFiles.mediaDir}/media${this.nextFileId}.${extension}`;
1315
+ path = `${MediaFiles.mediaDir}/${baseFilename}${this.nextFileId}.${extension}`;
1243
1316
  } while (this.hashes[path]);
1244
1317
 
1245
- // add media to zip
1246
- await this.zip.setFile(path, mediaFile);
1318
+ // Add media to zip
1319
+ this.zip.setFile(path, mediaFile);
1247
1320
 
1248
- // add media to our lookups
1321
+ // Add media to our lookups
1249
1322
  this.hashes[path] = hash;
1250
1323
  this.files.set(mediaFile, path);
1251
-
1252
- // return
1253
1324
  return path;
1254
1325
  }
1255
1326
  async count() {
@@ -1265,9 +1336,15 @@ class MediaFiles {
1265
1336
  if (!filename) continue;
1266
1337
  const fileData = await this.zip.getFile(path).getContentBase64();
1267
1338
  const fileHash = sha1(fileData);
1268
- this.hashes[filename] = fileHash;
1339
+ this.hashes[path] = fileHash;
1269
1340
  }
1270
1341
  }
1342
+ baseFilename(mime) {
1343
+ // Naive heuristic.
1344
+ // May need to be modified if we're going to support more mime types.
1345
+ const parts = mime.split('/');
1346
+ return parts[0];
1347
+ }
1271
1348
  }
1272
1349
 
1273
1350
  /**
@@ -2168,30 +2245,30 @@ class DelimiterSearcher {
2168
2245
 
2169
2246
  const delimiters = [];
2170
2247
  const match = new MatchState();
2171
- const depth = new XmlDepthTracker(this.maxXmlDepth);
2248
+ const it = new XmlTreeIterator(node, this.maxXmlDepth);
2172
2249
  let lookForOpenDelimiter = true;
2173
- while (node) {
2250
+ while (it.node) {
2174
2251
  // Reset state on paragraph transition
2175
- if (officeMarkup.query.isParagraphNode(node)) {
2252
+ if (officeMarkup.query.isParagraphNode(it.node)) {
2176
2253
  match.reset();
2177
2254
  }
2178
2255
 
2179
2256
  // Skip irrelevant nodes
2180
- if (!this.shouldSearchNode(node)) {
2181
- node = this.findNextNode(node, depth);
2257
+ if (!this.shouldSearchNode(it)) {
2258
+ it.next();
2182
2259
  continue;
2183
2260
  }
2184
2261
 
2185
2262
  // Search delimiters in text nodes
2186
- match.openNodes.push(node);
2263
+ match.openNodes.push(it.node);
2187
2264
  let textIndex = 0;
2188
- while (textIndex < node.textContent.length) {
2265
+ while (textIndex < it.node.textContent.length) {
2189
2266
  const delimiterPattern = lookForOpenDelimiter ? this.startDelimiter : this.endDelimiter;
2190
- const char = node.textContent[textIndex];
2267
+ const char = it.node.textContent[textIndex];
2191
2268
 
2192
2269
  // No match
2193
2270
  if (char !== delimiterPattern[match.delimiterIndex]) {
2194
- [node, textIndex] = this.noMatch(node, textIndex, match);
2271
+ textIndex = this.noMatch(it, textIndex, match);
2195
2272
  textIndex++;
2196
2273
  continue;
2197
2274
  }
@@ -2209,14 +2286,14 @@ class DelimiterSearcher {
2209
2286
  }
2210
2287
 
2211
2288
  // Full delimiter match
2212
- [node, textIndex, lookForOpenDelimiter] = this.fullMatch(node, textIndex, lookForOpenDelimiter, match, delimiters);
2289
+ [textIndex, lookForOpenDelimiter] = this.fullMatch(it, textIndex, lookForOpenDelimiter, match, delimiters);
2213
2290
  textIndex++;
2214
2291
  }
2215
- node = this.findNextNode(node, depth);
2292
+ it.next();
2216
2293
  }
2217
2294
  return delimiters;
2218
2295
  }
2219
- noMatch(node, textIndex, match) {
2296
+ noMatch(it, textIndex, match) {
2220
2297
  //
2221
2298
  // Go back to first open node
2222
2299
  //
@@ -2226,25 +2303,26 @@ class DelimiterSearcher {
2226
2303
  // Delimiter is '{!' and template text contains the string '{{!'
2227
2304
  //
2228
2305
  if (match.firstMatchIndex !== -1) {
2229
- node = first(match.openNodes);
2306
+ const node = first(match.openNodes);
2307
+ it.setCurrent(node);
2230
2308
  textIndex = match.firstMatchIndex;
2231
2309
  }
2232
2310
 
2233
2311
  // Update state
2234
2312
  match.reset();
2235
- if (textIndex < node.textContent.length - 1) {
2236
- match.openNodes.push(node);
2313
+ if (textIndex < it.node.textContent.length - 1) {
2314
+ match.openNodes.push(it.node);
2237
2315
  }
2238
- return [node, textIndex];
2316
+ return textIndex;
2239
2317
  }
2240
- fullMatch(node, textIndex, lookForOpenDelimiter, match, delimiters) {
2318
+ fullMatch(it, textIndex, lookForOpenDelimiter, match, delimiters) {
2241
2319
  // Move all delimiters characters to the same text node
2242
2320
  if (match.openNodes.length > 1) {
2243
2321
  const firstNode = first(match.openNodes);
2244
2322
  const lastNode = last(match.openNodes);
2245
2323
  officeMarkup.modify.joinTextNodesRange(firstNode, lastNode);
2246
- textIndex += firstNode.textContent.length - node.textContent.length;
2247
- node = firstNode;
2324
+ textIndex += firstNode.textContent.length - it.node.textContent.length;
2325
+ it.setCurrent(firstNode);
2248
2326
  }
2249
2327
 
2250
2328
  // Store delimiter
@@ -2254,41 +2332,18 @@ class DelimiterSearcher {
2254
2332
  // Update state
2255
2333
  lookForOpenDelimiter = !lookForOpenDelimiter;
2256
2334
  match.reset();
2257
- if (textIndex < node.textContent.length - 1) {
2258
- match.openNodes.push(node);
2335
+ if (textIndex < it.node.textContent.length - 1) {
2336
+ match.openNodes.push(it.node);
2259
2337
  }
2260
- return [node, textIndex, lookForOpenDelimiter];
2338
+ return [textIndex, lookForOpenDelimiter];
2261
2339
  }
2262
- shouldSearchNode(node) {
2263
- if (!xml.query.isTextNode(node)) return false;
2264
- if (!node.textContent) return false;
2265
- if (!node.parentNode) return false;
2266
- if (!officeMarkup.query.isTextNode(node.parentNode)) return false;
2340
+ shouldSearchNode(it) {
2341
+ if (!xml.query.isTextNode(it.node)) return false;
2342
+ if (!it.node.textContent) return false;
2343
+ if (!it.node.parentNode) return false;
2344
+ if (!officeMarkup.query.isTextNode(it.node.parentNode)) return false;
2267
2345
  return true;
2268
2346
  }
2269
- findNextNode(node, depth) {
2270
- // Children
2271
- if (node.childNodes && node.childNodes.length) {
2272
- depth.increment();
2273
- return node.childNodes[0];
2274
- }
2275
-
2276
- // Siblings
2277
- if (node.nextSibling) return node.nextSibling;
2278
-
2279
- // Parent sibling
2280
- while (node.parentNode) {
2281
- if (node.parentNode.nextSibling) {
2282
- depth.decrement();
2283
- return node.parentNode.nextSibling;
2284
- }
2285
-
2286
- // Go up
2287
- depth.decrement();
2288
- node = node.parentNode;
2289
- }
2290
- return null;
2291
- }
2292
2347
  createDelimiterMark(match, isOpenDelimiter) {
2293
2348
  return {
2294
2349
  index: match.firstMatchIndex,
@@ -2529,17 +2584,6 @@ class TemplatePlugin {
2529
2584
  }
2530
2585
  }
2531
2586
 
2532
- /**
2533
- * Apparently it is not that important for the ID to be unique...
2534
- * Word displays two images correctly even if they both have the same ID.
2535
- * Further more, Word will assign each a unique ID upon saving (it assigns
2536
- * consecutive integers starting with 1).
2537
- *
2538
- * Note: The same principal applies to image names.
2539
- *
2540
- * Tested in Word v1908
2541
- */
2542
- let nextImageId = 1;
2543
2587
  class ImagePlugin extends TemplatePlugin {
2544
2588
  contentType = 'image';
2545
2589
  async simpleTagReplacements(tag, data, context) {
@@ -2556,12 +2600,47 @@ class ImagePlugin extends TemplatePlugin {
2556
2600
  await context.docx.contentTypes.ensureContentType(content.format);
2557
2601
 
2558
2602
  // Create the xml markup
2559
- const imageId = nextImageId++;
2603
+ const imageId = await this.getNextImageId(context);
2560
2604
  const imageXml = this.createMarkup(imageId, relId, content);
2561
2605
  const wordTextNode = officeMarkup.query.containingTextNode(tag.xmlTextNode);
2562
2606
  xml.modify.insertAfter(imageXml, wordTextNode);
2563
2607
  officeMarkup.modify.removeTag(tag.xmlTextNode);
2564
2608
  }
2609
+ async getNextImageId(context) {
2610
+ // Init plugin context.
2611
+ if (!context.pluginContext[this.contentType]) {
2612
+ context.pluginContext[this.contentType] = {};
2613
+ }
2614
+ const pluginContext = context.pluginContext[this.contentType];
2615
+ if (!pluginContext.lastDrawingObjectId) {
2616
+ pluginContext.lastDrawingObjectId = {};
2617
+ }
2618
+ const lastIdMap = pluginContext.lastDrawingObjectId;
2619
+ const lastIdKey = context.currentPart.path;
2620
+
2621
+ // Get next image ID if already initialized.
2622
+ if (lastIdMap[lastIdKey]) {
2623
+ lastIdMap[lastIdKey]++;
2624
+ return lastIdMap[lastIdKey];
2625
+ }
2626
+
2627
+ // Init next image ID.
2628
+ const partRoot = await context.currentPart.xmlRoot();
2629
+ const maxDepth = context.options.maxXmlDepth;
2630
+
2631
+ // Get all existing doc props IDs
2632
+ // (docPr stands for "Drawing Object Non-Visual Properties", which isn't
2633
+ // exactly a good acronym but that's how it's called nevertheless)
2634
+ const docProps = xml.query.descendants(partRoot, maxDepth, node => {
2635
+ return xml.query.isGeneralNode(node) && node.nodeName === 'wp:docPr';
2636
+ });
2637
+
2638
+ // Start counting from the current max
2639
+ const ids = docProps.map(prop => parseInt(prop.attributes.id)).filter(isNumber);
2640
+ const maxId = Math.max(...ids, 0);
2641
+ lastIdMap[lastIdKey] = maxId + 1;
2642
+ return lastIdMap[lastIdKey];
2643
+ }
2565
2644
  createMarkup(imageId, relId, content) {
2566
2645
  // http://officeopenxml.com/drwPicInline.php
2567
2646
 
@@ -4618,7 +4697,7 @@ class TemplateHandler {
4618
4697
  /**
4619
4698
  * Version number of the `easy-template-x` library.
4620
4699
  */
4621
- version = "6.2.0" ;
4700
+ version = "6.2.2" ;
4622
4701
  constructor(options) {
4623
4702
  this.options = new TemplateHandlerOptions(options);
4624
4703
 
@@ -4666,7 +4745,11 @@ class TemplateHandler {
4666
4745
  scopeData.scopeDataResolver = this.options.scopeDataResolver;
4667
4746
  const context = {
4668
4747
  docx,
4669
- currentPart: null
4748
+ currentPart: null,
4749
+ pluginContext: {},
4750
+ options: {
4751
+ maxXmlDepth: this.options.maxXmlDepth
4752
+ }
4670
4753
  };
4671
4754
  const contentParts = await docx.getContentParts();
4672
4755
  for (const part of contentParts) {
@@ -4759,6 +4842,7 @@ class TemplateHandler {
4759
4842
  exports.Base64 = Base64;
4760
4843
  exports.Binary = Binary;
4761
4844
  exports.COMMENT_NODE_NAME = COMMENT_NODE_NAME;
4845
+ exports.ChartPlugin = ChartPlugin;
4762
4846
  exports.DelimiterSearcher = DelimiterSearcher;
4763
4847
  exports.Delimiters = Delimiters;
4764
4848
  exports.Docx = Docx;
@@ -4806,9 +4890,11 @@ exports.UnsupportedFileTypeError = UnsupportedFileTypeError;
4806
4890
  exports.Xlsx = Xlsx;
4807
4891
  exports.XmlDepthTracker = XmlDepthTracker;
4808
4892
  exports.XmlNodeType = XmlNodeType;
4893
+ exports.XmlTreeIterator = XmlTreeIterator;
4809
4894
  exports.XmlUtils = XmlUtils;
4810
4895
  exports.Zip = Zip;
4811
4896
  exports.ZipObject = ZipObject;
4897
+ exports.countOccurrences = countOccurrences;
4812
4898
  exports.createDefaultPlugins = createDefaultPlugins;
4813
4899
  exports.first = first;
4814
4900
  exports.inheritsFrom = inheritsFrom;