node-pptx-templater 1.1.8 → 2.0.0-alpha.0

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
@@ -13,7 +13,7 @@
13
13
  <a href="https://github.com/jsuyog2/node-pptx-templater/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/jsuyog2/node-pptx-templater/ci.yml?branch=main&style=flat-square&color=34d399" alt="CI"></a>
14
14
  <a href="https://www.npmjs.com/package/node-pptx-templater"><img src="https://img.shields.io/npm/dm/node-pptx-templater.svg?style=flat-square&color=a855f7" alt="Downloads"></a>
15
15
  <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square" alt="MIT License"></a>
16
- <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen?style=flat-square" alt="Node.js 18+"></a>
16
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%3E%3D20.12.0-brightgreen?style=flat-square" alt="Node.js 20.12.0+"></a>
17
17
  </p>
18
18
 
19
19
  <p align="center">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-pptx-templater",
3
- "version": "1.1.8",
3
+ "version": "2.0.0-alpha.0",
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
  "module": "./src/index.mjs",
@@ -15,7 +15,7 @@
15
15
  }
16
16
  },
17
17
  "bin": {
18
- "node-pptx-templater": "./src/cli/index.js"
18
+ "node-pptx-templater": "src/cli/index.js"
19
19
  },
20
20
  "scripts": {
21
21
  "test": "vitest run",
@@ -167,14 +167,14 @@
167
167
  "license": "MIT",
168
168
  "repository": {
169
169
  "type": "git",
170
- "url": "https://github.com/jsuyog2/node-pptx-templater.git"
170
+ "url": "git+https://github.com/jsuyog2/node-pptx-templater.git"
171
171
  },
172
172
  "bugs": {
173
173
  "url": "https://github.com/jsuyog2/node-pptx-templater/issues"
174
174
  },
175
175
  "homepage": "https://jsuyog2.github.io/node-pptx-templater",
176
176
  "engines": {
177
- "node": ">=18.0.0"
177
+ "node": ">=20.12.0"
178
178
  },
179
179
  "dependencies": {
180
180
  "chalk": "^4.1.2",
@@ -303,14 +303,14 @@ class ChartManager {
303
303
  const xlsxPath = relationshipManager.resolveTarget(chartZipPath, rel.target)
304
304
  const buffer = await this.#zipManager.readBinaryFile(xlsxPath)
305
305
  if (buffer) {
306
- console.log(`Found embedded workbook: ${xlsxPath}`)
306
+ logger.debug(`Found embedded workbook: ${xlsxPath}`)
307
307
  const updatedXlsx = await ChartWorkbookUpdater.updateWorkbook(buffer, cleanNumericData)
308
308
  if (updatedXlsx) {
309
- console.log(`Writing updated workbook to: ${xlsxPath}, size: ${updatedXlsx.length}`)
309
+ logger.debug(`Writing updated workbook to: ${xlsxPath}, size: ${updatedXlsx.length}`)
310
310
  this.#zipManager.writeBinaryFile(xlsxPath, updatedXlsx)
311
311
  }
312
312
  } else {
313
- console.log(`Could not find workbook at: ${xlsxPath}`)
313
+ logger.debug(`Could not find workbook at: ${xlsxPath}`)
314
314
  }
315
315
  }
316
316
  }
@@ -114,7 +114,8 @@ class RelationshipManager {
114
114
  relFiles.map(async relsPath => {
115
115
  const content = await zipManager.readFile(relsPath)
116
116
  if (content) {
117
- this.#relationships.set(relsPath, this.#parseRels(content, relsPath))
117
+ const normalizedPath = this.#normalizeRelsPath(relsPath)
118
+ this.#relationships.set(normalizedPath, this.#parseRels(content, relsPath))
118
119
  }
119
120
  })
120
121
  )
@@ -122,6 +123,31 @@ class RelationshipManager {
122
123
  logger.debug(`Loaded ${this.#relationships.size} relationship files`)
123
124
  }
124
125
 
126
+ /**
127
+ * Normalizes path separators and strips leading slashes for robust lookup.
128
+ * @private
129
+ * @param {string} path
130
+ * @returns {string}
131
+ */
132
+ #normalizeRelsPath(path) {
133
+ if (!path) return ''
134
+ let normalized = path.replace(/\\/g, '/')
135
+ if (normalized.startsWith('/')) {
136
+ normalized = normalized.substring(1)
137
+ }
138
+ return normalized
139
+ }
140
+
141
+ /**
142
+ * Gets the normalized key for a part's relationships map.
143
+ * @private
144
+ * @param {string} partPath
145
+ * @returns {string}
146
+ */
147
+ #getNormalizedKey(partPath) {
148
+ return this.#normalizeRelsPath(this.getRelsPath(partPath))
149
+ }
150
+
125
151
  /**
126
152
  * Returns the relationship file path for a given part path.
127
153
  *
@@ -133,9 +159,10 @@ class RelationshipManager {
133
159
  * @returns {string} Path to the corresponding .rels file.
134
160
  */
135
161
  getRelsPath(partPath) {
136
- const lastSlash = partPath.lastIndexOf('/')
137
- const dir = lastSlash >= 0 ? partPath.substring(0, lastSlash) : ''
138
- const file = lastSlash >= 0 ? partPath.substring(lastSlash + 1) : partPath
162
+ const normalizedPartPath = partPath.replace(/\\/g, '/')
163
+ const lastSlash = normalizedPartPath.lastIndexOf('/')
164
+ const dir = lastSlash >= 0 ? normalizedPartPath.substring(0, lastSlash) : ''
165
+ const file = lastSlash >= 0 ? normalizedPartPath.substring(lastSlash + 1) : normalizedPartPath
139
166
  return dir ? `${dir}/_rels/${file}.rels` : `_rels/${file}.rels`
140
167
  }
141
168
 
@@ -146,8 +173,8 @@ class RelationshipManager {
146
173
  * @returns {Relationship[]} Array of relationships.
147
174
  */
148
175
  getRelationships(partPath) {
149
- const relsPath = this.getRelsPath(partPath)
150
- return this.#relationships.get(relsPath) || []
176
+ const key = this.#getNormalizedKey(partPath)
177
+ return this.#relationships.get(key) || []
151
178
  }
152
179
 
153
180
  /**
@@ -184,20 +211,20 @@ class RelationshipManager {
184
211
  * @returns {string} The assigned relationship ID (e.g., 'rId3').
185
212
  */
186
213
  addRelationship(partPath, type, target, targetMode) {
187
- const relsPath = this.getRelsPath(partPath)
214
+ const key = this.#getNormalizedKey(partPath)
188
215
 
189
- if (!this.#relationships.has(relsPath)) {
190
- this.#relationships.set(relsPath, [])
216
+ if (!this.#relationships.has(key)) {
217
+ this.#relationships.set(key, [])
191
218
  }
192
219
 
193
- const existing = this.#relationships.get(relsPath)
220
+ const existing = this.#relationships.get(key)
194
221
  const newId = generateRelationshipId(existing.map(r => r.id))
195
222
 
196
223
  const rel = { id: newId, type, target }
197
224
  if (targetMode) rel.targetMode = targetMode
198
225
 
199
226
  existing.push(rel)
200
- this.#flushRels(relsPath, partPath)
227
+ this.#flushRels(key, partPath)
201
228
 
202
229
  logger.debug(`Added relationship ${newId} (${type.split('/').pop()}) to ${partPath}`)
203
230
  return newId
@@ -210,11 +237,21 @@ class RelationshipManager {
210
237
  * @param {string} rId - Relationship ID to remove.
211
238
  */
212
239
  removeRelationship(partPath, rId) {
213
- const relsPath = this.getRelsPath(partPath)
214
- const existing = this.#relationships.get(relsPath) || []
240
+ const key = this.#getNormalizedKey(partPath)
241
+ const existing = this.#relationships.get(key) || []
215
242
  const filtered = existing.filter(r => r.id !== rId)
216
- this.#relationships.set(relsPath, filtered)
217
- this.#flushRels(relsPath, partPath)
243
+ this.#relationships.set(key, filtered)
244
+ this.#flushRels(key, partPath)
245
+ }
246
+
247
+ /**
248
+ * Completely removes all relationships for a part from the cache.
249
+ *
250
+ * @param {string} partPath - ZIP path of the part.
251
+ */
252
+ deleteRelationships(partPath) {
253
+ const key = this.#getNormalizedKey(partPath)
254
+ this.#relationships.delete(key)
218
255
  }
219
256
 
220
257
  /**
@@ -225,12 +262,12 @@ class RelationshipManager {
225
262
  * @param {string} newTarget - New target value.
226
263
  */
227
264
  updateRelationshipTarget(partPath, rId, newTarget) {
228
- const relsPath = this.getRelsPath(partPath)
229
- const existing = this.#relationships.get(relsPath) || []
265
+ const key = this.#getNormalizedKey(partPath)
266
+ const existing = this.#relationships.get(key) || []
230
267
  const rel = existing.find(r => r.id === rId)
231
268
  if (rel) {
232
269
  rel.target = newTarget
233
- this.#flushRels(relsPath, partPath)
270
+ this.#flushRels(key, partPath)
234
271
  }
235
272
  }
236
273
 
@@ -245,14 +282,14 @@ class RelationshipManager {
245
282
  */
246
283
  copyRelationships(sourcePath, destPath, excludeTypes = []) {
247
284
  const sourceRels = this.getRelationships(sourcePath)
248
- const destRelsPath = this.getRelsPath(destPath)
285
+ const destKey = this.#getNormalizedKey(destPath)
249
286
  const idMap = new Map()
250
287
 
251
- if (!this.#relationships.has(destRelsPath)) {
252
- this.#relationships.set(destRelsPath, [])
288
+ if (!this.#relationships.has(destKey)) {
289
+ this.#relationships.set(destKey, [])
253
290
  }
254
291
 
255
- const destRels = this.#relationships.get(destRelsPath)
292
+ const destRels = this.#relationships.get(destKey)
256
293
 
257
294
  for (const rel of sourceRels) {
258
295
  if (excludeTypes.includes(rel.type)) continue
@@ -262,7 +299,7 @@ class RelationshipManager {
262
299
  idMap.set(rel.id, newId)
263
300
  }
264
301
 
265
- this.#flushRels(destRelsPath, destPath)
302
+ this.#flushRels(destKey, destPath)
266
303
  return idMap
267
304
  }
268
305
 
@@ -329,10 +366,11 @@ class RelationshipManager {
329
366
  * @param {string} partPath - For logging.
330
367
  */
331
368
  #flushRels(relsPath, _partPath) {
332
- const rels = this.#relationships.get(relsPath) || []
369
+ const key = this.#normalizeRelsPath(relsPath)
370
+ const rels = this.#relationships.get(key) || []
333
371
  const xml = this.#buildRelsXml(rels)
334
372
  if (this.#zipManager) {
335
- this.#zipManager.writeFile(relsPath, xml)
373
+ this.#zipManager.writeFile(key, xml)
336
374
  }
337
375
  }
338
376
 
@@ -863,6 +863,10 @@ class ShapeManager {
863
863
  normalized.rotation !== undefined ? ` rot="${Math.round(normalized.rotation * 60000)}"` : ''
864
864
 
865
865
  // Text box body properties
866
+ // NOTE: p:txBody is REQUIRED on every p:sp element per the OOXML spec.
867
+ // PowerPoint will trigger a repair dialog if it is missing, even for purely
868
+ // graphical shapes (circles, rectangles used as visual indicators, etc.).
869
+ // When no text is provided we emit an empty body with an end-paragraph run.
866
870
  let txBodyXml = ''
867
871
  if (normalized.text !== undefined && normalized.text !== null) {
868
872
  const textStyle = normalized.textStyle || {}
@@ -886,46 +890,18 @@ class ShapeManager {
886
890
  const lines = String(normalized.text).split(/\r?\n/)
887
891
  const paragraphsXml = lines
888
892
  .map(line => {
889
- return `<a:p>
890
- ${alignAttr}
891
- <a:r>
892
- <a:rPr lang="en-US" sz="${fontSizeVal}"${boldAttr}${italicAttr}>
893
- ${colorFill}
894
- </a:rPr>
895
- <a:t>${escapeXml(line)}</a:t>
896
- </a:r>
897
- </a:p>`
893
+ return `<a:p>${alignAttr}<a:r><a:rPr lang="en-US" sz="${fontSizeVal}"${boldAttr}${italicAttr}>${colorFill}</a:rPr><a:t>${escapeXml(line)}</a:t></a:r></a:p>`
898
894
  })
899
895
  .join('')
900
896
 
901
- txBodyXml = `<p:txBody>
902
- <a:bodyPr wrap="square" rtlCol="0">
903
- <a:normAutofit/>
904
- </a:bodyPr>
905
- <a:lstStyle/>
906
- ${paragraphsXml}
907
- </p:txBody>`
897
+ txBodyXml = `<p:txBody><a:bodyPr wrap="square" rtlCol="0"><a:normAutofit/></a:bodyPr><a:lstStyle/>${paragraphsXml}</p:txBody>`
898
+ } else {
899
+ // Graphical-only shape: emit a minimal empty txBody to satisfy the OOXML schema
900
+ txBodyXml = `<p:txBody><a:bodyPr rtlCol="0"/><a:lstStyle/><a:p><a:endParaRPr lang="en-US" dirty="0"/></a:p></p:txBody>`
908
901
  }
909
902
 
910
903
  // Build shape XML block
911
- const shapeXml = `<p:sp>
912
- <p:nvSpPr>
913
- <p:cNvPr id="${newId}" name="${escapeXml(name)}"/>
914
- <p:cNvSpPr/>
915
- <p:nvPr/>
916
- </p:nvSpPr>
917
- <p:spPr>
918
- <a:xfrm${rotAttr}>
919
- <a:off x="${xEmu}" y="${yEmu}"/>
920
- <a:ext cx="${wEmu}" cy="${hEmu}"/>
921
- </a:xfrm>
922
- <a:prstGeom prst="${preset}">${avLstXml}</a:prstGeom>
923
- ${fillXml}
924
- ${borderXml}
925
- ${shadowXml}
926
- </p:spPr>
927
- ${txBodyXml}
928
- </p:sp>`
904
+ const shapeXml = `<p:sp><p:nvSpPr><p:cNvPr id="${newId}" name="${escapeXml(name)}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm${rotAttr}><a:off x="${xEmu}" y="${yEmu}"/><a:ext cx="${wEmu}" cy="${hEmu}"/></a:xfrm><a:prstGeom prst="${preset}">${avLstXml}</a:prstGeom>${fillXml}${borderXml}${shadowXml}</p:spPr>${txBodyXml}</p:sp>`
929
905
 
930
906
  const parsed = this.#xmlParser.parse(shapeXml, 'shape.xml')['p:sp']
931
907
  const shapeObj = Array.isArray(parsed) ? parsed[0] : parsed
@@ -433,6 +433,7 @@ class SlideManager {
433
433
  cloneSlide(sourceIndex, atPosition, relationshipManager) {
434
434
  this.#assertSlideExists(sourceIndex)
435
435
  const sourceInfo = this.#slides.get(sourceIndex)
436
+ logger.debug('Source Slide Info:', sourceInfo)
436
437
 
437
438
  const newIndex = this.#slides.size + 1
438
439
  let nextFileIndex = 1
@@ -444,11 +445,18 @@ class SlideManager {
444
445
 
445
446
  // Copy the source XML
446
447
  let sourceXml = this.getSlideXml(sourceIndex)
448
+ logger.debug('Source XML length:', sourceXml ? sourceXml.length : 0)
449
+
450
+ // Copy relationships
451
+ const sourceRels = relationshipManager.getRelationships(sourceInfo.zipPath)
452
+ logger.debug('Source Rels Path searched:', relationshipManager.getRelsPath(sourceInfo.zipPath))
453
+ logger.debug('Source Rels found:', sourceRels)
447
454
 
448
455
  // Copy relationships from source slide (excluding notes, which are slide-specific)
449
456
  const idMap = relationshipManager.copyRelationships(sourceInfo.zipPath, slideZipPath, [
450
457
  REL_TYPES.NOTES_SLIDE,
451
458
  ])
459
+ logger.debug('Copied relationship ID map:', Array.from(idMap.entries()))
452
460
 
453
461
  // Remap relationship IDs in the cloned XML to match the new targets
454
462
  sourceXml = remapRelationshipIds(sourceXml, idMap)
@@ -487,7 +495,7 @@ class SlideManager {
487
495
  tableMap: new Map(),
488
496
  chartMap: new Map(),
489
497
  })
490
- this.#addSlideToPresentation(rId, newSlideId)
498
+ this.#addSlideToPresentation(rId, newSlideId, sourceInfo.slideId)
491
499
  this.#registerSlideContentType(slideFileName)
492
500
 
493
501
  logger.debug(`Cloned slide ${sourceIndex} to new slide ${newIndex}`)
@@ -516,6 +524,9 @@ class SlideManager {
516
524
  // Remove relationship from presentation.xml
517
525
  this.#relationshipManager.removeRelationship('ppt/presentation.xml', info.relationshipId)
518
526
 
527
+ // Remove relationships cache
528
+ this.#relationshipManager.deleteRelationships(info.zipPath)
529
+
519
530
  // Remove content type from [Content_Types].xml
520
531
  this.#contentTypesManager.removeOverride(info.zipPath)
521
532
 
@@ -898,11 +909,61 @@ class SlideManager {
898
909
  await Promise.all(this.getAllSlideIndices().map(i => this.getSlideXmlAsync(i)))
899
910
  }
900
911
 
912
+ /**
913
+ * Adds a slide ID to sections in presentation.xml.
914
+ * @private
915
+ */
916
+ #addSlideToSections(slideId, sourceSlideId = null) {
917
+ if (!this.#presentationObj) return
918
+ const extLst = this.#xmlParser.getNode(this.#presentationObj, 'p:presentation.p:extLst')
919
+ if (!extLst?.['p:ext']) return
920
+
921
+ const exts = Array.isArray(extLst['p:ext']) ? extLst['p:ext'] : [extLst['p:ext']]
922
+ for (const ext of exts) {
923
+ const sectionLst = ext['p14:sectionLst']
924
+ if (!sectionLst?.['p14:section']) continue
925
+
926
+ const sections = sectionLst['p14:section']
927
+ const targetIdStr = String(slideId)
928
+ const sourceIdStr = sourceSlideId ? String(sourceSlideId) : null
929
+
930
+ if (sourceIdStr) {
931
+ for (const section of sections) {
932
+ const sldIdLst = section['p14:sldIdLst']
933
+ if (!sldIdLst?.['p14:sldId']) continue
934
+
935
+ const sldIds = sldIdLst['p14:sldId']
936
+ const idx = sldIds.findIndex(s => String(s['@_id']) === sourceIdStr)
937
+ if (idx !== -1) {
938
+ logger.debug(
939
+ `Inserting slide ${targetIdStr} after slide ${sourceIdStr} in section "${section['@_name']}"`
940
+ )
941
+ sldIds.splice(idx + 1, 0, { '@_id': targetIdStr })
942
+ return
943
+ }
944
+ }
945
+ }
946
+
947
+ if (sections.length > 0) {
948
+ const lastSection = sections[sections.length - 1]
949
+ if (!lastSection['p14:sldIdLst']) {
950
+ lastSection['p14:sldIdLst'] = { 'p14:sldId': [] }
951
+ }
952
+ if (!lastSection['p14:sldIdLst']['p14:sldId']) {
953
+ lastSection['p14:sldIdLst']['p14:sldId'] = []
954
+ }
955
+ const sldIds = lastSection['p14:sldIdLst']['p14:sldId']
956
+ logger.debug(`Appending slide ${targetIdStr} to last section "${lastSection['@_name']}"`)
957
+ sldIds.push({ '@_id': targetIdStr })
958
+ }
959
+ }
960
+ }
961
+
901
962
  /**
902
963
  * Updates the presentation.xml sldIdLst with a new slide entry.
903
964
  * @private
904
965
  */
905
- #addSlideToPresentation(rId, slideId) {
966
+ #addSlideToPresentation(rId, slideId, sourceSlideId = null) {
906
967
  if (!this.#presentationObj) return
907
968
 
908
969
  let sldIdLst = this.#xmlParser.getNode(this.#presentationObj, 'p:presentation.p:sldIdLst')
@@ -917,6 +978,7 @@ class SlideManager {
917
978
  }
918
979
 
919
980
  sldIdLst['p:sldId'].push({ '@_id': slideId, '@_r:id': rId })
981
+ this.#addSlideToSections(slideId, sourceSlideId)
920
982
  this.#flushPresentation()
921
983
  }
922
984
 
@@ -1166,8 +1166,8 @@ class TableManager {
1166
1166
  const tableX = xfrm?.['a:off']?.['@_x'] ? parseInt(xfrm['a:off']['@_x'], 10) : 0
1167
1167
  const tableY = xfrm?.['a:off']?.['@_y'] ? parseInt(xfrm['a:off']['@_y'], 10) : 0
1168
1168
 
1169
- const colWidths = this.#calculateColumnWidths(slideIndex, tableId, slideManager, tblObj, true)
1170
- const rowHeights = this.#calculateRowHeights(slideIndex, tableId, slideManager, tblObj, true)
1169
+ const colWidths = this.#calculateColumnWidths(slideIndex, tableId, slideManager, tblObj, false)
1170
+ const rowHeights = this.#calculateRowHeights(slideIndex, tableId, slideManager, tblObj, false)
1171
1171
 
1172
1172
  const R = this.getMergeRegion(slideIndex, tableId, rowIndex, colIndex, slideManager)
1173
1173
  let pr = rowIndex
@@ -213,6 +213,19 @@ class ZipManager {
213
213
  logger.debug('Created blank PPTX structure')
214
214
  }
215
215
 
216
+ /**
217
+ * Normalizes path separators and strips leading slashes.
218
+ * @private
219
+ */
220
+ #normalizePath(zipPath) {
221
+ if (!zipPath) return ''
222
+ let normal = zipPath.replace(/\\/g, '/')
223
+ if (normal.startsWith('/')) {
224
+ normal = normal.substring(1)
225
+ }
226
+ return normal
227
+ }
228
+
216
229
  /**
217
230
  * Reads and caches a text file from the ZIP archive.
218
231
  *
@@ -221,7 +234,7 @@ class ZipManager {
221
234
  */
222
235
  async readFile(zipPath) {
223
236
  // Normalize path separators
224
- const normalPath = zipPath.replace(/\\/g, '/')
237
+ const normalPath = this.#normalizePath(zipPath)
225
238
 
226
239
  // Return cached version if available and not dirty
227
240
  if (this.#xmlCache.has(normalPath) && !this.#dirtyFiles.has(normalPath)) {
@@ -239,8 +252,11 @@ class ZipManager {
239
252
  if (entry.type === 'text') {
240
253
  content = entry.content
241
254
  } else {
242
- const { TextDecoder } = require('util')
243
- content = new TextDecoder('utf-8').decode(entry.content)
255
+ const decoder =
256
+ typeof globalThis.TextDecoder !== 'undefined'
257
+ ? new globalThis.TextDecoder('utf-8')
258
+ : new (require('util').TextDecoder)('utf-8')
259
+ content = decoder.decode(entry.content)
244
260
  }
245
261
  this.#xmlCache.set(normalPath, content)
246
262
  return content
@@ -278,7 +294,7 @@ class ZipManager {
278
294
  * @returns {string|null} Cached content or null.
279
295
  */
280
296
  readCachedFile(zipPath) {
281
- const normalPath = zipPath.replace(/\\/g, '/')
297
+ const normalPath = this.#normalizePath(zipPath)
282
298
  if (this.#dirtyFiles.has(normalPath)) {
283
299
  return this.#dirtyFiles.get(normalPath)
284
300
  }
@@ -291,8 +307,11 @@ class ZipManager {
291
307
  if (entry.type === 'text') {
292
308
  content = entry.content
293
309
  } else {
294
- const { TextDecoder } = require('util')
295
- content = new TextDecoder('utf-8').decode(entry.content)
310
+ const decoder =
311
+ typeof globalThis.TextDecoder !== 'undefined'
312
+ ? new globalThis.TextDecoder('utf-8')
313
+ : new (require('util').TextDecoder)('utf-8')
314
+ content = decoder.decode(entry.content)
296
315
  }
297
316
  this.#xmlCache.set(normalPath, content)
298
317
  return content
@@ -307,7 +326,7 @@ class ZipManager {
307
326
  * @returns {Promise<Uint8Array|null>} Binary content or null if not found.
308
327
  */
309
328
  async readBinaryFile(zipPath) {
310
- const normalPath = zipPath.replace(/\\/g, '/')
329
+ const normalPath = this.#normalizePath(zipPath)
311
330
  if (this.#dirtyBinaryFiles.has(normalPath)) {
312
331
  return this.#dirtyBinaryFiles.get(normalPath)
313
332
  }
@@ -329,7 +348,7 @@ class ZipManager {
329
348
  }
330
349
 
331
350
  writeFile(zipPath, content) {
332
- const normalPath = zipPath.replace(/\\/g, '/')
351
+ const normalPath = this.#normalizePath(zipPath)
333
352
  this.#dirtyFiles.set(normalPath, content)
334
353
  this.#xmlCache.set(normalPath, content)
335
354
  this.#removedFiles.delete(normalPath)
@@ -340,7 +359,7 @@ class ZipManager {
340
359
  }
341
360
 
342
361
  writeBinaryFile(zipPath, data) {
343
- const normalPath = zipPath.replace(/\\/g, '/')
362
+ const normalPath = this.#normalizePath(zipPath)
344
363
  this.#dirtyBinaryFiles.set(normalPath, data)
345
364
  this.#removedFiles.delete(normalPath)
346
365
  if (this.#zip) {
@@ -380,7 +399,7 @@ class ZipManager {
380
399
  * @param {string} zipPath - Path to remove.
381
400
  */
382
401
  removeFile(zipPath) {
383
- const normalPath = zipPath.replace(/\\/g, '/')
402
+ const normalPath = this.#normalizePath(zipPath)
384
403
  this.#removedFiles.add(normalPath)
385
404
  this.#xmlCache.delete(normalPath)
386
405
  this.#dirtyFiles.delete(normalPath)
@@ -397,7 +416,7 @@ class ZipManager {
397
416
  * @returns {boolean}
398
417
  */
399
418
  hasFile(zipPath) {
400
- const normalPath = zipPath.replace(/\\/g, '/')
419
+ const normalPath = this.#normalizePath(zipPath)
401
420
  if (this.#removedFiles.has(normalPath)) return false
402
421
  if (this.#dirtyFiles.has(normalPath) || this.#dirtyBinaryFiles.has(normalPath)) return true
403
422
  if (this.#cachedFiles && this.#cachedFiles.has(normalPath)) return true
@@ -58,7 +58,6 @@ class ChartWorkbookUpdater {
58
58
  compressionOptions: { level: 6 },
59
59
  })
60
60
  } catch (err) {
61
- console.error('Failed to update embedded workbook', err)
62
61
  logger.error('Failed to update embedded workbook', err)
63
62
  return workbookData // Return original if failed
64
63
  }
@@ -67,24 +67,15 @@ function isValidRelationshipId(str) {
67
67
  * remapRelationshipIds(xml, new Map([['rId1', 'rId5'], ['rId2', 'rId6']]));
68
68
  */
69
69
  function remapRelationshipIds(xml, idMap) {
70
- let updated = xml
70
+ if (!idMap || idMap.size === 0) return xml
71
71
 
72
- // Sort by length descending to avoid partial replacements (e.g., rId1 replacing part of rId10)
73
- const sortedEntries = Array.from(idMap.entries()).sort(([a], [b]) => b.length - a.length)
74
-
75
- for (const [oldId, newId] of sortedEntries) {
76
- // Replace rId references in attribute values: r:id="rId1", r:embed="rId1"
77
- const pattern = new RegExp(`(r:[a-zA-Z]+=")${oldId}(")|rId="${oldId}(")`, 'g')
78
- updated = updated.replace(pattern, (match, pre, post) => {
79
- if (pre) return `${pre}${newId}${post}`
80
- return match.replace(oldId, newId)
81
- })
82
-
83
- // Simple global replace as fallback
84
- updated = updated.split(`"${oldId}"`).join(`"${newId}"`)
85
- }
86
-
87
- return updated
72
+ // Perform single-pass replacement of relationship IDs inside quotes to prevent clobbering
73
+ return xml.replace(/([\'"])(rId\d+)([\'"])/g, (match, openQuote, id, closeQuote) => {
74
+ if (idMap.has(id)) {
75
+ return `${openQuote}${idMap.get(id)}${closeQuote}`
76
+ }
77
+ return match
78
+ })
88
79
  }
89
80
 
90
81
  module.exports = {