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 +1 -1
- package/package.json +4 -4
- package/src/managers/ChartManager.js +3 -3
- package/src/managers/RelationshipManager.js +63 -25
- package/src/managers/ShapeManager.js +10 -34
- package/src/managers/SlideManager.js +64 -2
- package/src/managers/TableManager.js +2 -2
- package/src/managers/ZipManager.js +30 -11
- package/src/managers/charts/ChartWorkbookUpdater.js +0 -1
- package/src/utils/relationshipUtils.js +8 -17
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%
|
|
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": "
|
|
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": "
|
|
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": ">=
|
|
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
|
-
|
|
306
|
+
logger.debug(`Found embedded workbook: ${xlsxPath}`)
|
|
307
307
|
const updatedXlsx = await ChartWorkbookUpdater.updateWorkbook(buffer, cleanNumericData)
|
|
308
308
|
if (updatedXlsx) {
|
|
309
|
-
|
|
309
|
+
logger.debug(`Writing updated workbook to: ${xlsxPath}, size: ${updatedXlsx.length}`)
|
|
310
310
|
this.#zipManager.writeBinaryFile(xlsxPath, updatedXlsx)
|
|
311
311
|
}
|
|
312
312
|
} else {
|
|
313
|
-
|
|
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
|
-
|
|
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
|
|
137
|
-
const
|
|
138
|
-
const
|
|
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
|
|
150
|
-
return this.#relationships.get(
|
|
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
|
|
214
|
+
const key = this.#getNormalizedKey(partPath)
|
|
188
215
|
|
|
189
|
-
if (!this.#relationships.has(
|
|
190
|
-
this.#relationships.set(
|
|
216
|
+
if (!this.#relationships.has(key)) {
|
|
217
|
+
this.#relationships.set(key, [])
|
|
191
218
|
}
|
|
192
219
|
|
|
193
|
-
const existing = this.#relationships.get(
|
|
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(
|
|
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
|
|
214
|
-
const existing = this.#relationships.get(
|
|
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(
|
|
217
|
-
this.#flushRels(
|
|
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
|
|
229
|
-
const existing = this.#relationships.get(
|
|
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(
|
|
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
|
|
285
|
+
const destKey = this.#getNormalizedKey(destPath)
|
|
249
286
|
const idMap = new Map()
|
|
250
287
|
|
|
251
|
-
if (!this.#relationships.has(
|
|
252
|
-
this.#relationships.set(
|
|
288
|
+
if (!this.#relationships.has(destKey)) {
|
|
289
|
+
this.#relationships.set(destKey, [])
|
|
253
290
|
}
|
|
254
291
|
|
|
255
|
-
const destRels = this.#relationships.get(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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,
|
|
1170
|
-
const rowHeights = this.#calculateRowHeights(slideIndex, tableId, slideManager, tblObj,
|
|
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
|
|
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
|
|
243
|
-
|
|
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
|
|
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
|
|
295
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
70
|
+
if (!idMap || idMap.size === 0) return xml
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 = {
|