node-pptx-templater 1.0.2 → 1.0.3

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.
Files changed (37) hide show
  1. package/README.md +336 -281
  2. package/package.json +1 -1
  3. package/src/cli/commands/build.js +30 -31
  4. package/src/cli/commands/debug.js +23 -23
  5. package/src/cli/commands/extract.js +21 -21
  6. package/src/cli/commands/inspect.js +23 -23
  7. package/src/cli/commands/validate.js +17 -17
  8. package/src/cli/index.js +39 -36
  9. package/src/core/OutputWriter.js +79 -78
  10. package/src/core/PPTXTemplater.js +856 -273
  11. package/src/core/TemplateEngine.js +67 -71
  12. package/src/core/ValidationEngine.js +246 -0
  13. package/src/index.js +30 -17
  14. package/src/managers/ChartManager.js +195 -70
  15. package/src/managers/ContentTypesManager.js +49 -45
  16. package/src/managers/HyperlinkManager.js +146 -142
  17. package/src/managers/ImageManager.js +336 -0
  18. package/src/managers/MediaManager.js +62 -81
  19. package/src/managers/RelationshipManager.js +99 -95
  20. package/src/managers/ShapeManager.js +340 -0
  21. package/src/managers/SlideManager.js +408 -311
  22. package/src/managers/TableManager.js +979 -262
  23. package/src/managers/TextManager.js +197 -0
  24. package/src/managers/ZipManager.js +69 -69
  25. package/src/managers/charts/ChartCacheGenerator.js +75 -58
  26. package/src/managers/charts/ChartParser.js +9 -13
  27. package/src/managers/charts/ChartRelationshipManager.js +12 -10
  28. package/src/managers/charts/ChartWorkbookUpdater.js +59 -56
  29. package/src/parsers/XMLParser.js +47 -50
  30. package/src/templates/blankPptx.js +3 -2
  31. package/src/templates/slideTemplate.js +28 -34
  32. package/src/utils/contentTypesHelper.js +40 -54
  33. package/src/utils/errors.js +18 -18
  34. package/src/utils/idUtils.js +16 -14
  35. package/src/utils/logger.js +18 -16
  36. package/src/utils/relationshipUtils.js +19 -20
  37. package/src/utils/xmlUtils.js +26 -26
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @fileoverview TextManager - Handles slide text search, retrieval, and replacement.
3
+ */
4
+
5
+ const { createLogger } = require('../utils/logger.js')
6
+
7
+ const logger = createLogger('TextManager')
8
+
9
+ /**
10
+ * @class TextManager
11
+ * @description Manages text elements, replacements, and search inside slide XML.
12
+ */
13
+ class TextManager {
14
+ /** @private @type {XMLParser} */
15
+ #xmlParser
16
+
17
+ /**
18
+ * @param {XMLParser} xmlParser
19
+ */
20
+ constructor(xmlParser) {
21
+ this.#xmlParser = xmlParser
22
+ }
23
+
24
+ /**
25
+ * Replaces a specific tag/placeholder with a value.
26
+ *
27
+ * @param {number} slideIndex
28
+ * @param {string} tag - E.g. '{{name}}' or 'name' (auto-wraps if simple).
29
+ * @param {string} value
30
+ * @param {Object} options
31
+ * @param {SlideManager} slideManager
32
+ * @param {TemplateEngine} templateEngine
33
+ */
34
+ replaceTextByTag(slideIndex, tag, value, _options = {}, slideManager, templateEngine) {
35
+ const slideXml = slideManager.getSlideXml(slideIndex)
36
+
37
+ // Auto-wrap tag in {{}} if not already present
38
+ const normalizedTag = tag.startsWith('{{') && tag.endsWith('}}') ? tag : `{{${tag}}}`
39
+
40
+ const replacements = { [normalizedTag]: value }
41
+ const updatedXml = templateEngine.replaceTextInXml(slideXml, replacements)
42
+
43
+ slideManager.setSlideXml(slideIndex, updatedXml)
44
+ logger.debug(`Replaced text tag "${normalizedTag}" with value on slide ${slideIndex}`)
45
+ }
46
+
47
+ /**
48
+ * Performs multiple text replacements at once.
49
+ *
50
+ * @param {number} slideIndex
51
+ * @param {Object.<string, string>} replacements - Map of key -> value.
52
+ * @param {Object} options
53
+ * @param {SlideManager} slideManager
54
+ * @param {TemplateEngine} templateEngine
55
+ */
56
+ replaceMultiple(slideIndex, replacements, _options = {}, slideManager, templateEngine) {
57
+ const slideXml = slideManager.getSlideXml(slideIndex)
58
+
59
+ // Normalize keys in the replacements map to ensure they are wrapped in placeholders
60
+ const normalized = {}
61
+ for (const [key, val] of Object.entries(replacements)) {
62
+ const normalizedKey = key.startsWith('{{') && key.endsWith('}}') ? key : `{{${key}}}`
63
+ normalized[normalizedKey] = val
64
+ }
65
+
66
+ const updatedXml = templateEngine.replaceTextInXml(slideXml, normalized)
67
+ slideManager.setSlideXml(slideIndex, updatedXml)
68
+ logger.debug(`Replaced multiple tags on slide ${slideIndex}`)
69
+ }
70
+
71
+ /**
72
+ * Searches for a text string inside all text runs on a slide.
73
+ *
74
+ * @param {number} slideIndex
75
+ * @param {string} searchText
76
+ * @param {SlideManager} slideManager
77
+ * @returns {Array<Object>} List of match details.
78
+ */
79
+ findText(slideIndex, searchText, slideManager) {
80
+ const slideXml = slideManager.getSlideXml(slideIndex)
81
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
82
+ const results = []
83
+
84
+ // Find in shapes (p:sp)
85
+ const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
86
+ if (!spTree) return results
87
+
88
+ this.#searchShapesForText(spTree, searchText, results)
89
+ return results
90
+ }
91
+
92
+ /**
93
+ * Extracts and returns all text elements on a slide.
94
+ *
95
+ * @param {number} slideIndex
96
+ * @param {SlideManager} slideManager
97
+ * @returns {Array<Object>} Elements with text contents.
98
+ */
99
+ getTextElements(slideIndex, slideManager) {
100
+ const slideXml = slideManager.getSlideXml(slideIndex)
101
+ const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
102
+ const results = []
103
+
104
+ const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
105
+ if (!spTree) return results
106
+
107
+ this.#collectTextElements(spTree, results)
108
+ return results
109
+ }
110
+
111
+ #searchShapesForText(container, searchText, results) {
112
+ if (!container) return
113
+
114
+ let shapes = container['p:sp'] || []
115
+ if (!Array.isArray(shapes)) shapes = [shapes]
116
+
117
+ for (const shape of shapes) {
118
+ const cNvPr = shape?.['p:nvSpPr']?.['p:cNvPr']
119
+ const shapeName = cNvPr ? cNvPr['@_name'] : 'unnamed'
120
+ const shapeId = cNvPr ? String(cNvPr['@_id']) : 'unknown'
121
+
122
+ const txBody = shape['p:txBody']
123
+ if (txBody && txBody['a:p']) {
124
+ const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
125
+ paras.forEach((p, pIdx) => {
126
+ let pText = ''
127
+ if (p['a:r']) {
128
+ const runs = Array.isArray(p['a:r']) ? p['a:r'] : [p['a:r']]
129
+ runs.forEach(r => {
130
+ if (r['a:t']) pText += String(r['a:t'])
131
+ })
132
+ }
133
+
134
+ if (pText.toLowerCase().includes(searchText.toLowerCase())) {
135
+ results.push({
136
+ shapeId,
137
+ shapeName,
138
+ paragraphIndex: pIdx,
139
+ text: pText,
140
+ match: searchText,
141
+ })
142
+ }
143
+ })
144
+ }
145
+ }
146
+
147
+ let groups = container['p:grpSp'] || []
148
+ if (!Array.isArray(groups)) groups = [groups]
149
+ for (const g of groups) {
150
+ this.#searchShapesForText(g, searchText, results)
151
+ }
152
+ }
153
+
154
+ #collectTextElements(container, results) {
155
+ if (!container) return
156
+
157
+ let shapes = container['p:sp'] || []
158
+ if (!Array.isArray(shapes)) shapes = [shapes]
159
+
160
+ for (const shape of shapes) {
161
+ const cNvPr = shape?.['p:nvSpPr']?.['p:cNvPr']
162
+ const shapeName = cNvPr ? cNvPr['@_name'] : 'unnamed'
163
+ const shapeId = cNvPr ? String(cNvPr['@_id']) : 'unknown'
164
+
165
+ const txBody = shape['p:txBody']
166
+ if (txBody && txBody['a:p']) {
167
+ const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
168
+ paras.forEach((p, pIdx) => {
169
+ let pText = ''
170
+ if (p['a:r']) {
171
+ const runs = Array.isArray(p['a:r']) ? p['a:r'] : [p['a:r']]
172
+ runs.forEach(r => {
173
+ if (r['a:t']) pText += String(r['a:t'])
174
+ })
175
+ }
176
+
177
+ if (pText.trim()) {
178
+ results.push({
179
+ shapeId,
180
+ shapeName,
181
+ paragraphIndex: pIdx,
182
+ text: pText,
183
+ })
184
+ }
185
+ })
186
+ }
187
+ }
188
+
189
+ let groups = container['p:grpSp'] || []
190
+ if (!Array.isArray(groups)) groups = [groups]
191
+ for (const g of groups) {
192
+ this.#collectTextElements(g, results)
193
+ }
194
+ }
195
+ }
196
+
197
+ module.exports = { TextManager }
@@ -13,13 +13,13 @@
13
13
  * raw file content within the ZIP.
14
14
  */
15
15
 
16
- const JSZip = require('jszip');
17
- const fsExtra = require('fs-extra');
18
- const { createLogger } = require('../utils/logger.js');
19
- const { PPTXError } = require('../utils/errors.js');
20
- const { BLANK_PPTX_BASE64 } = require('../templates/blankPptx.js');
16
+ const JSZip = require('jszip')
17
+ const fsExtra = require('fs-extra')
18
+ const { createLogger } = require('../utils/logger.js')
19
+ const { PPTXError } = require('../utils/errors.js')
20
+ const { BLANK_PPTX_BASE64 } = require('../templates/blankPptx.js')
21
21
 
22
- const logger = createLogger('ZipManager');
22
+ const logger = createLogger('ZipManager')
23
23
 
24
24
  /**
25
25
  * @class ZipManager
@@ -32,25 +32,25 @@ class ZipManager {
32
32
  * @private
33
33
  * @type {JSZip}
34
34
  */
35
- #zip = null;
35
+ #zip = null
36
36
 
37
37
  /**
38
38
  * @private
39
39
  * @type {Map<string, string>} Cache of decoded XML strings for fast repeated access.
40
40
  */
41
- #xmlCache = new Map();
41
+ #xmlCache = new Map()
42
42
 
43
43
  /**
44
44
  * @private
45
45
  * @type {Map<string, string>} Dirty (modified) files that need to be re-written.
46
46
  */
47
- #dirtyFiles = new Map();
47
+ #dirtyFiles = new Map()
48
48
 
49
49
  /**
50
50
  * @private
51
51
  * @type {Map<string, string>} Core properties (dc:title, dc:creator, etc.)
52
52
  */
53
- #coreProperties = new Map();
53
+ #coreProperties = new Map()
54
54
 
55
55
  /**
56
56
  * Loads a PPTX file from a path or Buffer.
@@ -61,22 +61,24 @@ class ZipManager {
61
61
  */
62
62
  async load(source) {
63
63
  try {
64
- let data;
64
+ let data
65
65
  if (typeof source === 'string') {
66
- logger.debug(`Reading file: ${source}`);
67
- data = await fsExtra.readFile(source);
66
+ logger.debug(`Reading file: ${source}`)
67
+ data = await fsExtra.readFile(source)
68
68
  } else if (Buffer.isBuffer(source) || source instanceof Uint8Array) {
69
- data = source;
69
+ data = source
70
70
  } else {
71
- throw new PPTXError(`Invalid source type: ${typeof source}. Expected string path or Buffer.`);
71
+ throw new PPTXError(
72
+ `Invalid source type: ${typeof source}. Expected string path or Buffer.`
73
+ )
72
74
  }
73
75
 
74
- this.#zip = await JSZip.loadAsync(data);
75
- await this.#loadCoreProperties();
76
- logger.debug(`ZIP loaded successfully. Files: ${Object.keys(this.#zip.files).length}`);
76
+ this.#zip = await JSZip.loadAsync(data)
77
+ await this.#loadCoreProperties()
78
+ logger.debug(`ZIP loaded successfully. Files: ${Object.keys(this.#zip.files).length}`)
77
79
  } catch (err) {
78
- if (err instanceof PPTXError) throw err;
79
- throw new PPTXError(`Failed to load PPTX: ${err.message}`, err);
80
+ if (err instanceof PPTXError) throw err
81
+ throw new PPTXError(`Failed to load PPTX: ${err.message}`, err)
80
82
  }
81
83
  }
82
84
 
@@ -85,10 +87,10 @@ class ZipManager {
85
87
  * @returns {Promise<void>}
86
88
  */
87
89
  async createBlank() {
88
- const buffer = Buffer.from(BLANK_PPTX_BASE64, 'base64');
89
- this.#zip = await JSZip.loadAsync(buffer);
90
- await this.#loadCoreProperties();
91
- logger.debug('Created blank PPTX structure');
90
+ const buffer = Buffer.from(BLANK_PPTX_BASE64, 'base64')
91
+ this.#zip = await JSZip.loadAsync(buffer)
92
+ await this.#loadCoreProperties()
93
+ logger.debug('Created blank PPTX structure')
92
94
  }
93
95
 
94
96
  /**
@@ -99,27 +101,27 @@ class ZipManager {
99
101
  */
100
102
  async readFile(zipPath) {
101
103
  // Normalize path separators
102
- const normalPath = zipPath.replace(/\\/g, '/');
104
+ const normalPath = zipPath.replace(/\\/g, '/')
103
105
 
104
106
  // Return cached version if available and not dirty
105
107
  if (this.#xmlCache.has(normalPath) && !this.#dirtyFiles.has(normalPath)) {
106
- return this.#xmlCache.get(normalPath);
108
+ return this.#xmlCache.get(normalPath)
107
109
  }
108
110
 
109
111
  // Check dirty files (pending writes)
110
112
  if (this.#dirtyFiles.has(normalPath)) {
111
- return this.#dirtyFiles.get(normalPath);
113
+ return this.#dirtyFiles.get(normalPath)
112
114
  }
113
115
 
114
- const file = this.#zip.file(normalPath);
116
+ const file = this.#zip.file(normalPath)
115
117
  if (!file) {
116
- logger.debug(`File not found in ZIP: ${normalPath}`);
117
- return null;
118
+ logger.debug(`File not found in ZIP: ${normalPath}`)
119
+ return null
118
120
  }
119
121
 
120
- const content = await file.async('text');
121
- this.#xmlCache.set(normalPath, content);
122
- return content;
122
+ const content = await file.async('text')
123
+ this.#xmlCache.set(normalPath, content)
124
+ return content
123
125
  }
124
126
 
125
127
  /**
@@ -129,10 +131,10 @@ class ZipManager {
129
131
  * @returns {Promise<Uint8Array|null>} Binary content or null if not found.
130
132
  */
131
133
  async readBinaryFile(zipPath) {
132
- const normalPath = zipPath.replace(/\\/g, '/');
133
- const file = this.#zip.file(normalPath);
134
- if (!file) return null;
135
- return file.async('uint8array');
134
+ const normalPath = zipPath.replace(/\\/g, '/')
135
+ const file = this.#zip.file(normalPath)
136
+ if (!file) return null
137
+ return file.async('uint8array')
136
138
  }
137
139
 
138
140
  /**
@@ -143,12 +145,12 @@ class ZipManager {
143
145
  * @param {string} content - UTF-8 string content.
144
146
  */
145
147
  writeFile(zipPath, content) {
146
- const normalPath = zipPath.replace(/\\/g, '/');
147
- this.#dirtyFiles.set(normalPath, content);
148
- this.#xmlCache.set(normalPath, content);
148
+ const normalPath = zipPath.replace(/\\/g, '/')
149
+ this.#dirtyFiles.set(normalPath, content)
150
+ this.#xmlCache.set(normalPath, content)
149
151
  // Also write to the underlying JSZip object
150
- this.#zip.file(normalPath, content);
151
- logger.debug(`Queued write: ${normalPath}`);
152
+ this.#zip.file(normalPath, content)
153
+ logger.debug(`Queued write: ${normalPath}`)
152
154
  }
153
155
 
154
156
  /**
@@ -158,23 +160,23 @@ class ZipManager {
158
160
  * @param {Buffer|Uint8Array} data - Binary data.
159
161
  */
160
162
  writeBinaryFile(zipPath, data) {
161
- const normalPath = zipPath.replace(/\\/g, '/');
162
- this.#zip.file(normalPath, data);
163
- logger.debug(`Queued binary write: ${normalPath}`);
163
+ const normalPath = zipPath.replace(/\\/g, '/')
164
+ this.#zip.file(normalPath, data)
165
+ logger.debug(`Queued binary write: ${normalPath}`)
164
166
  }
165
167
 
166
168
  /**
167
169
  * @private
168
170
  * @type {Promise[]}
169
171
  */
170
- #pendingPromises = [];
172
+ #pendingPromises = []
171
173
 
172
174
  /**
173
175
  * Adds a promise to the pending queue to be awaited before saving.
174
176
  * @param {Promise} promise
175
177
  */
176
178
  addPendingPromise(promise) {
177
- this.#pendingPromises.push(promise);
179
+ this.#pendingPromises.push(promise)
178
180
  }
179
181
 
180
182
  /**
@@ -183,8 +185,8 @@ class ZipManager {
183
185
  */
184
186
  async waitForPendingWrites() {
185
187
  if (this.#pendingPromises.length > 0) {
186
- await Promise.all(this.#pendingPromises);
187
- this.#pendingPromises = [];
188
+ await Promise.all(this.#pendingPromises)
189
+ this.#pendingPromises = []
188
190
  }
189
191
  }
190
192
 
@@ -194,10 +196,10 @@ class ZipManager {
194
196
  * @param {string} zipPath - Path to remove.
195
197
  */
196
198
  removeFile(zipPath) {
197
- const normalPath = zipPath.replace(/\\/g, '/');
198
- this.#zip.remove(normalPath);
199
- this.#xmlCache.delete(normalPath);
200
- this.#dirtyFiles.delete(normalPath);
199
+ const normalPath = zipPath.replace(/\\/g, '/')
200
+ this.#zip.remove(normalPath)
201
+ this.#xmlCache.delete(normalPath)
202
+ this.#dirtyFiles.delete(normalPath)
201
203
  }
202
204
 
203
205
  /**
@@ -207,8 +209,8 @@ class ZipManager {
207
209
  * @returns {boolean}
208
210
  */
209
211
  hasFile(zipPath) {
210
- const normalPath = zipPath.replace(/\\/g, '/');
211
- return this.#zip.file(normalPath) !== null;
212
+ const normalPath = zipPath.replace(/\\/g, '/')
213
+ return this.#zip.file(normalPath) !== null
212
214
  }
213
215
 
214
216
  /**
@@ -218,9 +220,7 @@ class ZipManager {
218
220
  * @returns {string[]} Array of matching file paths.
219
221
  */
220
222
  listFiles(prefix = '') {
221
- return Object.keys(this.#zip.files).filter(
222
- f => !this.#zip.files[f].dir && f.startsWith(prefix)
223
- );
223
+ return Object.keys(this.#zip.files).filter(f => !this.#zip.files[f].dir && f.startsWith(prefix))
224
224
  }
225
225
 
226
226
  /**
@@ -234,7 +234,7 @@ class ZipManager {
234
234
  type: 'nodebuffer',
235
235
  compression: 'DEFLATE',
236
236
  compressionOptions: { level: 6 },
237
- });
237
+ })
238
238
  }
239
239
 
240
240
  /**
@@ -248,7 +248,7 @@ class ZipManager {
248
248
  compression: 'DEFLATE',
249
249
  compressionOptions: { level: 6 },
250
250
  streamFiles: true,
251
- });
251
+ })
252
252
  }
253
253
 
254
254
  /**
@@ -258,7 +258,7 @@ class ZipManager {
258
258
  * @returns {string|undefined}
259
259
  */
260
260
  getCoreProperty(key) {
261
- return this.#coreProperties.get(key);
261
+ return this.#coreProperties.get(key)
262
262
  }
263
263
 
264
264
  /**
@@ -269,7 +269,7 @@ class ZipManager {
269
269
  * @param {string} value - Property value.
270
270
  */
271
271
  setCoreProperty(key, value) {
272
- this.#coreProperties.set(key, value);
272
+ this.#coreProperties.set(key, value)
273
273
  }
274
274
 
275
275
  /**
@@ -277,14 +277,14 @@ class ZipManager {
277
277
  * @private
278
278
  */
279
279
  async #loadCoreProperties() {
280
- const coreXml = await this.readFile('docProps/core.xml');
281
- if (!coreXml) return;
280
+ const coreXml = await this.readFile('docProps/core.xml')
281
+ if (!coreXml) return
282
282
 
283
283
  // Simple regex extraction for core properties (lightweight vs full parse)
284
- const propPattern = /<(dc:[a-zA-Z]+|dcterms:[a-zA-Z]+)[^>]*>([^<]*)<\/\1>/g;
285
- let match;
284
+ const propPattern = /<(dc:[a-zA-Z]+|dcterms:[a-zA-Z]+)[^>]*>([^<]*)<\/\1>/g
285
+ let match
286
286
  while ((match = propPattern.exec(coreXml)) !== null) {
287
- this.#coreProperties.set(match[1], match[2]);
287
+ this.#coreProperties.set(match[1], match[2])
288
288
  }
289
289
  }
290
290
 
@@ -293,8 +293,8 @@ class ZipManager {
293
293
  * @returns {JSZip}
294
294
  */
295
295
  get rawZip() {
296
- return this.#zip;
296
+ return this.#zip
297
297
  }
298
298
  }
299
299
 
300
- module.exports = { ZipManager };
300
+ module.exports = { ZipManager }