node-pptx-templater 1.0.18 → 1.0.20

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
@@ -243,6 +243,63 @@ ppt.useSlide(1).updateTable('summary-table', [
243
243
  ]);
244
244
  ```
245
245
 
246
+ #### `addCellShape(tableId, rowIndex, colIndex, options)`
247
+ Dynamically adds a shape inside a table cell based on cell coordinates.
248
+
249
+ * **Arguments**:
250
+ * `tableId` (`string`): Table name or shape ID.
251
+ * `rowIndex` (`number`): 0-based row index.
252
+ * `colIndex` (`number`): 0-based column index.
253
+ * `options` (`Object`): Shape configuration options.
254
+ * **Returns**: `this` - The chainable presentation templater instance.
255
+
256
+ ```javascript
257
+ await ppt.addCellShape('Table', 1, 2, { type: 'circle', fill: '#10B981' });
258
+ ```
259
+
260
+ #### `updateCellShape(tableId, rowIndex, colIndex, shapeIndex, options)`
261
+ Updates an existing shape inside a table cell.
262
+
263
+ * **Arguments**:
264
+ * `tableId` (`string`): Table name or shape ID.
265
+ * `rowIndex` (`number`): 0-based row index.
266
+ * `colIndex` (`number`): 0-based column index.
267
+ * `shapeIndex` (`number`): 0-based shape index in the cell.
268
+ * `options` (`Object`): Shape configuration properties to update.
269
+ * **Returns**: `this` - The chainable presentation templater instance.
270
+
271
+ ```javascript
272
+ await ppt.updateCellShape('Table', 1, 2, 0, { fill: '#EF4444' });
273
+ ```
274
+
275
+ #### `removeCellShape(tableId, rowIndex, colIndex, shapeIndex)`
276
+ Removes a shape from a table cell.
277
+
278
+ * **Arguments**:
279
+ * `tableId` (`string`): Table name or shape ID.
280
+ * `rowIndex` (`number`): 0-based row index.
281
+ * `colIndex` (`number`): 0-based column index.
282
+ * `shapeIndex` (`number`): 0-based shape index in the cell.
283
+ * **Returns**: `this` - The chainable presentation templater instance.
284
+
285
+ ```javascript
286
+ await ppt.removeCellShape('Table', 1, 2, 0);
287
+ ```
288
+
289
+ #### `getCellShape(tableId, rowIndex, colIndex, shapeIndex)`
290
+ Discovers and retrieves details of an existing cell shape on the targeted slide.
291
+
292
+ * **Arguments**:
293
+ * `tableId` (`string`): Table name or shape ID.
294
+ * `rowIndex` (`number`): 0-based row index.
295
+ * `colIndex` (`number`): 0-based column index.
296
+ * `shapeIndex` (`number`): 0-based shape index in the cell.
297
+ * **Returns**: `Object|null` - Shape details object, or null if not found.
298
+
299
+ ```javascript
300
+ const shape = ppt.getCellShape('Table', 1, 2, 0);
301
+ ```
302
+
246
303
  #### `addTableRow(())`
247
304
  Delegates core actions to slide element sub-managers.
248
305
 
@@ -1016,6 +1073,70 @@ Updates the position and/or dimensions of an existing textbox on targeted slides
1016
1073
  ppt.useSlide(1).updateTextBoxPosition('TextBox 2', { x: 1000000, y: 1500000 });
1017
1074
  ```
1018
1075
 
1076
+ #### `validateShape(options)`
1077
+ Validates shape options configuration.
1078
+
1079
+ * **Arguments**:
1080
+ * `options` (`Object`):
1081
+ * **Returns**: `string[]` - List of validation error messages.
1082
+
1083
+ ```javascript
1084
+ const errors = ppt.validateShape(shapeOptions);
1085
+ ```
1086
+
1087
+ #### `addShape(options)`
1088
+ Adds a new shape dynamically to the targeted slide(s).
1089
+
1090
+ * **Arguments**:
1091
+ * `options` (`Object`):
1092
+ * **Returns**: `this` - The chainable presentation templater instance.
1093
+
1094
+ ```javascript
1095
+ await ppt.useSlide(1).addShape({
1096
+ type: 'rectangle',
1097
+ id: 'sales-box',
1098
+ x: 100,
1099
+ y: 100,
1100
+ width: 200,
1101
+ height: 100,
1102
+ fill: '#2563EB'
1103
+ });
1104
+ ```
1105
+
1106
+ #### `updateShape(shapeId, options)`
1107
+ Updates an existing shape in-place.
1108
+
1109
+ * **Arguments**:
1110
+ * `shapeId` (`string`):
1111
+ * `options` (`Object`):
1112
+ * **Returns**: `this` - The chainable presentation templater instance.
1113
+
1114
+ ```javascript
1115
+ await ppt.useSlide(1).updateShape('sales-box', { fill: '#10B981' });
1116
+ ```
1117
+
1118
+ #### `removeShape(shapeId)`
1119
+ Removes a shape from the targeted slide(s).
1120
+
1121
+ * **Arguments**:
1122
+ * `shapeId` (`string`):
1123
+ * **Returns**: `this` - The chainable presentation templater instance.
1124
+
1125
+ ```javascript
1126
+ await ppt.useSlide(1).removeShape('sales-box');
1127
+ ```
1128
+
1129
+ #### `getShape(shapeId)`
1130
+ Discovers and retrieves details of an existing shape on the targeted slides.
1131
+
1132
+ * **Arguments**:
1133
+ * `shapeId` (`string`):
1134
+ * **Returns**: `Object|null` - Shape details object, or null if not found.
1135
+
1136
+ ```javascript
1137
+ const shape = ppt.getShape('sales-box');
1138
+ ```
1139
+
1019
1140
  #### `updateShapeText(())`
1020
1141
  Delegates core actions to slide element sub-managers.
1021
1142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-pptx-templater",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "High-performance, low-level PowerPoint (PPTX) OpenXML template engine for Node.js. Dynamically replace text, insert images, update charts (with Excel workbook data caching), and merge table cells without PowerPoint corruption or Repair Mode prompts.",
5
5
  "main": "./src/index.js",
6
6
  "type": "commonjs",
@@ -588,7 +588,13 @@ class PPTXTemplater {
588
588
  const targetIndices = this.#getTargetSlideIndices()
589
589
 
590
590
  for (const slideIndex of targetIndices) {
591
- this.#tableManager.updateTable(slideIndex, tableId, rows, this.#slideManager)
591
+ this.#tableManager.updateTable(
592
+ slideIndex,
593
+ tableId,
594
+ rows,
595
+ this.#slideManager,
596
+ this.#shapeManager
597
+ )
592
598
  }
593
599
 
594
600
  logger.debug(`Updated table "${tableId}" in ${targetIndices.length} slide(s)`)
@@ -1914,6 +1920,185 @@ class PPTXTemplater {
1914
1920
  return shapes
1915
1921
  }
1916
1922
 
1923
+ /**
1924
+ * Validates shape options configuration.
1925
+ *
1926
+ * @param {Object} options Shape creation/update options.
1927
+ * @returns {string[]} List of validation error messages.
1928
+ */
1929
+ validateShape(options) {
1930
+ return this.#shapeManager.validateShape(options)
1931
+ }
1932
+
1933
+ /**
1934
+ * Adds a new shape dynamically to the targeted slide(s).
1935
+ *
1936
+ * @param {Object} options Shape configuration options.
1937
+ * @returns {this} The chainable presentation templater instance.
1938
+ */
1939
+ async addShape(options) {
1940
+ this.#assertLoaded()
1941
+ const targetIndices = this.#getTargetSlideIndices()
1942
+ for (const idx of targetIndices) {
1943
+ this.#shapeManager.addShape(idx, options, this.#slideManager)
1944
+ }
1945
+ return this
1946
+ }
1947
+
1948
+ /**
1949
+ * Updates an existing shape in-place.
1950
+ *
1951
+ * @param {string} shapeId Shape ID or template name to update.
1952
+ * @param {Object} options Configuration properties to update.
1953
+ * @returns {this} The chainable presentation templater instance.
1954
+ */
1955
+ async updateShape(shapeId, options) {
1956
+ this.#assertLoaded()
1957
+ const targetIndices = this.#getTargetSlideIndices()
1958
+ for (const idx of targetIndices) {
1959
+ this.#shapeManager.updateShape(idx, shapeId, options, this.#slideManager)
1960
+ }
1961
+ return this
1962
+ }
1963
+
1964
+ /**
1965
+ * Removes a shape from the targeted slide(s).
1966
+ *
1967
+ * @param {string} shapeId Shape ID or template name to remove.
1968
+ * @returns {this} The chainable presentation templater instance.
1969
+ */
1970
+ async removeShape(shapeId) {
1971
+ this.#assertLoaded()
1972
+ const targetIndices = this.#getTargetSlideIndices()
1973
+ for (const idx of targetIndices) {
1974
+ this.#shapeManager.removeShape(idx, shapeId, this.#slideManager)
1975
+ }
1976
+ return this
1977
+ }
1978
+
1979
+ /**
1980
+ * Discovers and retrieves details of an existing shape on the targeted slides.
1981
+ *
1982
+ * @param {string} shapeId Shape ID or template name to locate.
1983
+ * @returns {Object|null} Shape details object, or null if not found.
1984
+ */
1985
+ getShape(shapeId) {
1986
+ this.#assertLoaded()
1987
+ const targetIndices = this.#getTargetSlideIndices()
1988
+ for (const idx of targetIndices) {
1989
+ const shape = this.#shapeManager.getShape(idx, shapeId, this.#slideManager)
1990
+ if (shape) return shape
1991
+ }
1992
+ return null
1993
+ }
1994
+
1995
+ /**
1996
+ * Dynamically adds a shape inside a table cell based on cell coordinates.
1997
+ *
1998
+ * @param {string} tableId - Table name or shape ID.
1999
+ * @param {number} rowIndex - 0-based row index.
2000
+ * @param {number} colIndex - 0-based column index.
2001
+ * @param {Object} options - Shape configuration options.
2002
+ * @returns {this} The chainable presentation templater instance.
2003
+ */
2004
+ async addCellShape(tableId, rowIndex, colIndex, options) {
2005
+ this.#assertLoaded()
2006
+ const targetIndices = this.#getTargetSlideIndices()
2007
+ for (const idx of targetIndices) {
2008
+ this.#tableManager.addCellShape(
2009
+ idx,
2010
+ tableId,
2011
+ rowIndex,
2012
+ colIndex,
2013
+ options,
2014
+ this.#slideManager,
2015
+ this.#shapeManager
2016
+ )
2017
+ }
2018
+ return this
2019
+ }
2020
+
2021
+ /**
2022
+ * Updates an existing shape inside a table cell.
2023
+ *
2024
+ * @param {string} tableId - Table name or shape ID.
2025
+ * @param {number} rowIndex - 0-based row index.
2026
+ * @param {number} colIndex - 0-based column index.
2027
+ * @param {number} shapeIndex - 0-based shape index in the cell.
2028
+ * @param {Object} options - Shape configuration properties to update.
2029
+ * @returns {this} The chainable presentation templater instance.
2030
+ */
2031
+ async updateCellShape(tableId, rowIndex, colIndex, shapeIndex, options) {
2032
+ this.#assertLoaded()
2033
+ const targetIndices = this.#getTargetSlideIndices()
2034
+ for (const idx of targetIndices) {
2035
+ this.#tableManager.updateCellShape(
2036
+ idx,
2037
+ tableId,
2038
+ rowIndex,
2039
+ colIndex,
2040
+ shapeIndex,
2041
+ options,
2042
+ this.#slideManager,
2043
+ this.#shapeManager
2044
+ )
2045
+ }
2046
+ return this
2047
+ }
2048
+
2049
+ /**
2050
+ * Removes a shape from a table cell.
2051
+ *
2052
+ * @param {string} tableId - Table name or shape ID.
2053
+ * @param {number} rowIndex - 0-based row index.
2054
+ * @param {number} colIndex - 0-based column index.
2055
+ * @param {number} shapeIndex - 0-based shape index in the cell.
2056
+ * @returns {this} The chainable presentation templater instance.
2057
+ */
2058
+ async removeCellShape(tableId, rowIndex, colIndex, shapeIndex) {
2059
+ this.#assertLoaded()
2060
+ const targetIndices = this.#getTargetSlideIndices()
2061
+ for (const idx of targetIndices) {
2062
+ this.#tableManager.removeCellShape(
2063
+ idx,
2064
+ tableId,
2065
+ rowIndex,
2066
+ colIndex,
2067
+ shapeIndex,
2068
+ this.#slideManager,
2069
+ this.#shapeManager
2070
+ )
2071
+ }
2072
+ return this
2073
+ }
2074
+
2075
+ /**
2076
+ * Discovers and retrieves details of an existing cell shape on the targeted slide.
2077
+ *
2078
+ * @param {string} tableId - Table name or shape ID.
2079
+ * @param {number} rowIndex - 0-based row index.
2080
+ * @param {number} colIndex - 0-based column index.
2081
+ * @param {number} shapeIndex - 0-based shape index in the cell.
2082
+ * @returns {Object|null} Shape details object, or null if not found.
2083
+ */
2084
+ getCellShape(tableId, rowIndex, colIndex, shapeIndex) {
2085
+ this.#assertLoaded()
2086
+ const targetIndices = this.#getTargetSlideIndices()
2087
+ for (const idx of targetIndices) {
2088
+ const shape = this.#tableManager.getCellShape(
2089
+ idx,
2090
+ tableId,
2091
+ rowIndex,
2092
+ colIndex,
2093
+ shapeIndex,
2094
+ this.#slideManager,
2095
+ this.#shapeManager
2096
+ )
2097
+ if (shape) return shape
2098
+ }
2099
+ return null
2100
+ }
2101
+
1917
2102
  // === Image Features ===
1918
2103
  async replaceImage(imageIdOrName, sourcePathOrBuffer) {
1919
2104
  this.#assertLoaded()
@@ -40,20 +40,9 @@ const { createHash } = require('crypto')
40
40
  const { createLogger } = require('../utils/logger.js')
41
41
  const { PPTXError } = require('../utils/errors.js')
42
42
  const fsExtra = require('fs-extra')
43
- const fs = require('fs')
44
43
 
45
44
  const logger = createLogger('MediaManager')
46
45
 
47
- function getFileHash(filePath) {
48
- return new Promise((resolve, reject) => {
49
- const hash = createHash('sha1')
50
- const stream = fs.createReadStream(filePath)
51
- stream.on('data', chunk => hash.update(chunk))
52
- stream.on('end', () => resolve(hash.digest('hex')))
53
- stream.on('error', reject)
54
- })
55
- }
56
-
57
46
  function streamToBuffer(stream) {
58
47
  return new Promise((resolve, reject) => {
59
48
  const chunks = []
@@ -182,119 +171,58 @@ class MediaManager {
182
171
  let hash
183
172
  let size
184
173
  const isStream = source && typeof source.on === 'function' && typeof source.pipe === 'function'
185
- let streamForZip = null
186
174
 
187
175
  if (isStream) {
188
- if (source.path) {
189
- // It's a file stream (fs.createReadStream)
176
+ // Buffer the stream to avoid JSZip streaming pipeline crashes and file locks
177
+ data = await streamToBuffer(source)
178
+ if (source.path && typeof source.path === 'string') {
190
179
  const filePath = source.path
191
- hash = await getFileHash(filePath)
192
180
  ext = filePath.split('.').pop().toLowerCase()
193
- mimeType = mimeType || EXT_TO_MIME[ext] || 'image/png'
194
-
195
- // Check for duplicate (content-addressable dedup)
196
- if (this.#mediaHashIndex.has(hash)) {
197
- const existingPath = this.#mediaHashIndex.get(hash)
198
- logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
199
- return existingPath
200
- }
201
-
202
- // Ensure all media from template is hashed to check for duplicates
203
- await this.#ensureAllMediaHashed()
204
-
205
- if (this.#mediaHashIndex.has(hash)) {
206
- const existingPath = this.#mediaHashIndex.get(hash)
207
- logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
208
- return existingPath
209
- }
210
-
211
- const stat = await fsExtra.stat(filePath)
212
- size = stat.size
213
- streamForZip = fs.createReadStream(filePath)
214
181
  } else {
215
- // Generic stream - we must buffer it to hash and reuse
216
- data = await streamToBuffer(source)
217
- hash = this.#hashBytes(data)
218
182
  ext = this.#detectExtension(data)
219
- mimeType = mimeType || EXT_TO_MIME[ext] || 'image/png'
220
- size = data.length
221
-
222
- // Check for duplicate (content-addressable dedup)
223
- if (this.#mediaHashIndex.has(hash)) {
224
- const existingPath = this.#mediaHashIndex.get(hash)
225
- logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
226
- return existingPath
227
- }
228
-
229
- // Ensure all media from template is hashed to check for duplicates
230
- await this.#ensureAllMediaHashed()
231
-
232
- if (this.#mediaHashIndex.has(hash)) {
233
- const existingPath = this.#mediaHashIndex.get(hash)
234
- logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
235
- return existingPath
236
- }
237
183
  }
184
+ mimeType = mimeType || EXT_TO_MIME[ext] || 'image/png'
185
+ hash = this.#hashBytes(data)
186
+ size = data.length
238
187
  } else if (typeof source === 'string') {
239
- // Load from file path
240
- hash = await getFileHash(source)
188
+ // Load from file path directly to buffer
189
+ data = await fsExtra.readFile(source)
241
190
  ext = source.split('.').pop().toLowerCase()
242
191
  mimeType = mimeType || EXT_TO_MIME[ext] || 'image/png'
243
-
244
- if (this.#mediaHashIndex.has(hash)) {
245
- const existingPath = this.#mediaHashIndex.get(hash)
246
- logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
247
- return existingPath
248
- }
249
-
250
- // Ensure all media from template is hashed to check for duplicates
251
- await this.#ensureAllMediaHashed()
252
-
253
- if (this.#mediaHashIndex.has(hash)) {
254
- const existingPath = this.#mediaHashIndex.get(hash)
255
- logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
256
- return existingPath
257
- }
258
-
259
- const stat = await fsExtra.stat(source)
260
- size = stat.size
261
- streamForZip = fs.createReadStream(source)
192
+ hash = this.#hashBytes(data)
193
+ size = data.length
262
194
  } else if (Buffer.isBuffer(source) || source instanceof Uint8Array) {
263
195
  data = source
264
196
  ext = this.#detectExtension(data)
265
197
  mimeType = mimeType || EXT_TO_MIME[ext] || 'image/png'
266
198
  hash = this.#hashBytes(data)
267
199
  size = data.length
268
-
269
- if (this.#mediaHashIndex.has(hash)) {
270
- const existingPath = this.#mediaHashIndex.get(hash)
271
- logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
272
- return existingPath
273
- }
274
-
275
- // Ensure all media from template is hashed to check for duplicates
276
- await this.#ensureAllMediaHashed()
277
-
278
- if (this.#mediaHashIndex.has(hash)) {
279
- const existingPath = this.#mediaHashIndex.get(hash)
280
- logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
281
- return existingPath
282
- }
283
200
  } else {
284
201
  throw new PPTXError(
285
202
  'embedImage: source must be a file path string, Buffer, or Readable Stream'
286
203
  )
287
204
  }
288
205
 
206
+ if (this.#mediaHashIndex.has(hash)) {
207
+ const existingPath = this.#mediaHashIndex.get(hash)
208
+ logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
209
+ return existingPath
210
+ }
211
+
212
+ // Ensure all media from template is hashed to check for duplicates
213
+ await this.#ensureAllMediaHashed()
214
+
215
+ if (this.#mediaHashIndex.has(hash)) {
216
+ const existingPath = this.#mediaHashIndex.get(hash)
217
+ logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`)
218
+ return existingPath
219
+ }
220
+
289
221
  // Create a new media file
290
222
  const mediaId = this.#nextMediaId++
291
223
  const zipPath = `ppt/media/image${mediaId}.${ext}`
292
224
 
293
- if (streamForZip) {
294
- this.#zipManager.writeBinaryFile(zipPath, streamForZip)
295
- } else {
296
- this.#zipManager.writeBinaryFile(zipPath, data)
297
- }
225
+ this.#zipManager.writeBinaryFile(zipPath, data)
298
226
 
299
227
  this.#mediaHashIndex.set(hash, zipPath)
300
228
  this.#mediaRegistry.set(zipPath, { zipPath, hash, mimeType, size })