node-pptx-templater 1.1.10 → 2.0.0-alpha.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-pptx-templater",
3
- "version": "1.1.10",
3
+ "version": "2.0.0-alpha.1",
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,7 +167,7 @@
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"
@@ -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
  }
@@ -244,6 +244,16 @@ class RelationshipManager {
244
244
  this.#flushRels(key, partPath)
245
245
  }
246
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)
255
+ }
256
+
247
257
  /**
248
258
  * Updates the target of an existing relationship.
249
259
  *
@@ -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,7 +433,7 @@ class SlideManager {
433
433
  cloneSlide(sourceIndex, atPosition, relationshipManager) {
434
434
  this.#assertSlideExists(sourceIndex)
435
435
  const sourceInfo = this.#slides.get(sourceIndex)
436
- console.log('[DEBUG] Source Slide Info:', sourceInfo)
436
+ logger.debug('Source Slide Info:', sourceInfo)
437
437
 
438
438
  const newIndex = this.#slides.size + 1
439
439
  let nextFileIndex = 1
@@ -445,21 +445,18 @@ class SlideManager {
445
445
 
446
446
  // Copy the source XML
447
447
  let sourceXml = this.getSlideXml(sourceIndex)
448
- console.log('[DEBUG] Source XML length:', sourceXml ? sourceXml.length : 0)
448
+ logger.debug('Source XML length:', sourceXml ? sourceXml.length : 0)
449
449
 
450
450
  // Copy relationships
451
451
  const sourceRels = relationshipManager.getRelationships(sourceInfo.zipPath)
452
- console.log(
453
- '[DEBUG] Source Rels Path searched:',
454
- relationshipManager.getRelsPath(sourceInfo.zipPath)
455
- )
456
- console.log('[DEBUG] Source Rels found:', sourceRels)
452
+ logger.debug('Source Rels Path searched:', relationshipManager.getRelsPath(sourceInfo.zipPath))
453
+ logger.debug('Source Rels found:', sourceRels)
457
454
 
458
455
  // Copy relationships from source slide (excluding notes, which are slide-specific)
459
456
  const idMap = relationshipManager.copyRelationships(sourceInfo.zipPath, slideZipPath, [
460
457
  REL_TYPES.NOTES_SLIDE,
461
458
  ])
462
- console.log('[DEBUG] Copied relationship ID map:', Array.from(idMap.entries()))
459
+ logger.debug('Copied relationship ID map:', Array.from(idMap.entries()))
463
460
 
464
461
  // Remap relationship IDs in the cloned XML to match the new targets
465
462
  sourceXml = remapRelationshipIds(sourceXml, idMap)
@@ -498,7 +495,7 @@ class SlideManager {
498
495
  tableMap: new Map(),
499
496
  chartMap: new Map(),
500
497
  })
501
- this.#addSlideToPresentation(rId, newSlideId)
498
+ this.#addSlideToPresentation(rId, newSlideId, sourceInfo.slideId)
502
499
  this.#registerSlideContentType(slideFileName)
503
500
 
504
501
  logger.debug(`Cloned slide ${sourceIndex} to new slide ${newIndex}`)
@@ -527,6 +524,9 @@ class SlideManager {
527
524
  // Remove relationship from presentation.xml
528
525
  this.#relationshipManager.removeRelationship('ppt/presentation.xml', info.relationshipId)
529
526
 
527
+ // Remove relationships cache
528
+ this.#relationshipManager.deleteRelationships(info.zipPath)
529
+
530
530
  // Remove content type from [Content_Types].xml
531
531
  this.#contentTypesManager.removeOverride(info.zipPath)
532
532
 
@@ -909,11 +909,61 @@ class SlideManager {
909
909
  await Promise.all(this.getAllSlideIndices().map(i => this.getSlideXmlAsync(i)))
910
910
  }
911
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
+
912
962
  /**
913
963
  * Updates the presentation.xml sldIdLst with a new slide entry.
914
964
  * @private
915
965
  */
916
- #addSlideToPresentation(rId, slideId) {
966
+ #addSlideToPresentation(rId, slideId, sourceSlideId = null) {
917
967
  if (!this.#presentationObj) return
918
968
 
919
969
  let sldIdLst = this.#xmlParser.getNode(this.#presentationObj, 'p:presentation.p:sldIdLst')
@@ -928,6 +978,7 @@ class SlideManager {
928
978
  }
929
979
 
930
980
  sldIdLst['p:sldId'].push({ '@_id': slideId, '@_r:id': rId })
981
+ this.#addSlideToSections(slideId, sourceSlideId)
931
982
  this.#flushPresentation()
932
983
  }
933
984
 
@@ -997,6 +1048,63 @@ class SlideManager {
997
1048
  })
998
1049
 
999
1050
  sldIdLst['p:sldId'] = ordered
1051
+
1052
+ // Synchronize sections order with the new slide order
1053
+ const orderedSlideIds = ordered.map(o => String(o['@_id']))
1054
+ const extLst = this.#xmlParser.getNode(this.#presentationObj, 'p:presentation.p:extLst')
1055
+ if (extLst?.['p:ext']) {
1056
+ const exts = Array.isArray(extLst['p:ext']) ? extLst['p:ext'] : [extLst['p:ext']]
1057
+ for (const ext of exts) {
1058
+ const sectionLst = ext['p14:sectionLst']
1059
+ if (sectionLst?.['p14:section']) {
1060
+ const sections = Array.isArray(sectionLst['p14:section'])
1061
+ ? sectionLst['p14:section']
1062
+ : [sectionLst['p14:section']]
1063
+
1064
+ // 1. Map each slideId to its section name/id (so we can identify its section)
1065
+ const slideToSectionMap = new Map()
1066
+ for (const section of sections) {
1067
+ const sldIdLst = section['p14:sldIdLst']
1068
+ if (sldIdLst?.['p14:sldId']) {
1069
+ const sldIds = Array.isArray(sldIdLst['p14:sldId'])
1070
+ ? sldIdLst['p14:sldId']
1071
+ : [sldIdLst['p14:sldId']]
1072
+ for (const sldId of sldIds) {
1073
+ if (sldId && sldId['@_id']) {
1074
+ slideToSectionMap.set(String(sldId['@_id']), section)
1075
+ }
1076
+ }
1077
+ }
1078
+ }
1079
+
1080
+ // 2. Clear the slide lists of all sections
1081
+ for (const section of sections) {
1082
+ if (!section['p14:sldIdLst']) {
1083
+ section['p14:sldIdLst'] = { 'p14:sldId': [] }
1084
+ } else {
1085
+ section['p14:sldIdLst']['p14:sldId'] = []
1086
+ }
1087
+ }
1088
+
1089
+ // 3. Re-populate the sections in the new slide order
1090
+ for (const slideId of orderedSlideIds) {
1091
+ const section = slideToSectionMap.get(slideId)
1092
+ if (section) {
1093
+ section['p14:sldIdLst']['p14:sldId'].push({ '@_id': slideId })
1094
+ } else if (sections.length > 0) {
1095
+ // Fallback: if a slide has no section (e.g. newly added without section info),
1096
+ // place it in the last section to maintain contiguity.
1097
+ const lastSection = sections[sections.length - 1]
1098
+ if (!lastSection['p14:sldIdLst']) {
1099
+ lastSection['p14:sldIdLst'] = { 'p14:sldId': [] }
1100
+ }
1101
+ lastSection['p14:sldIdLst']['p14:sldId'].push({ '@_id': slideId })
1102
+ }
1103
+ }
1104
+ }
1105
+ }
1106
+ }
1107
+
1000
1108
  this.#flushPresentation()
1001
1109
  }
1002
1110
 
@@ -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
@@ -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 = {