node-pptx-templater 2.0.0-alpha.1 → 2.0.0-alpha.2
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 +1 -1
- package/src/core/OutputWriter.js +15 -2
- package/src/core/PPTXTemplater.js +21 -5
- package/src/core/ValidationEngine.js +40 -0
- package/src/managers/ContentTypesManager.js +13 -0
- package/src/managers/MediaManager.js +38 -0
- package/src/managers/RelationshipManager.js +21 -0
- package/src/managers/SlideManager.js +951 -60
- package/src/utils/relationshipUtils.js +3 -3
|
@@ -28,13 +28,20 @@ const { createLogger } = require('../utils/logger.js')
|
|
|
28
28
|
const { PPTXError, SlideNotFoundError } = require('../utils/errors.js')
|
|
29
29
|
const { REL_TYPES } = require('./RelationshipManager.js')
|
|
30
30
|
const { buildNewSlideXml } = require('../templates/slideTemplate.js')
|
|
31
|
-
const { remapRelationshipIds } = require('../utils/relationshipUtils.js')
|
|
31
|
+
const { remapRelationshipIds, generateRelationshipId } = require('../utils/relationshipUtils.js')
|
|
32
|
+
const { generateSlideId } = require('../utils/idUtils.js')
|
|
32
33
|
|
|
33
34
|
const logger = createLogger('SlideManager')
|
|
34
35
|
|
|
35
36
|
/** MIME type for PPTX slide parts. */
|
|
36
37
|
const SLIDE_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml'
|
|
37
38
|
|
|
39
|
+
const CHART_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.drawingml.chart+xml'
|
|
40
|
+
const CHART_STYLE_CONTENT_TYPE = 'application/vnd.ms-office.chartstyle+xml'
|
|
41
|
+
const CHART_COLORS_CONTENT_TYPE = 'application/vnd.ms-office.chartcolorstyle+xml'
|
|
42
|
+
const WORKBOOK_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
43
|
+
const ACTIVEX_CONTENT_TYPE = 'application/vnd.ms-office.activeX'
|
|
44
|
+
|
|
38
45
|
/**
|
|
39
46
|
* @typedef {Object} SlideInfo
|
|
40
47
|
* @property {number} index - 1-based slide number.
|
|
@@ -89,6 +96,12 @@ class SlideManager {
|
|
|
89
96
|
*/
|
|
90
97
|
#presentationObj = null
|
|
91
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Map of old presentation relationship IDs to new sequential IDs.
|
|
101
|
+
* @private @type {Map<string, string>}
|
|
102
|
+
*/
|
|
103
|
+
#presentationIdMap = null
|
|
104
|
+
|
|
92
105
|
/**
|
|
93
106
|
* @param {XMLParser} xmlParser
|
|
94
107
|
* @param {RelationshipManager} relationshipManager
|
|
@@ -265,6 +278,14 @@ class SlideManager {
|
|
|
265
278
|
state.dirty = false
|
|
266
279
|
}
|
|
267
280
|
|
|
281
|
+
const dirtyContent = this.#zipManager.readCachedFile(info.zipPath)
|
|
282
|
+
if (dirtyContent && dirtyContent !== state.xmlStr) {
|
|
283
|
+
state.xmlStr = dirtyContent
|
|
284
|
+
this.#slideXmlCache.set(info.zipPath, dirtyContent)
|
|
285
|
+
state.xmlObj = null
|
|
286
|
+
state.indexBuilt = false
|
|
287
|
+
}
|
|
288
|
+
|
|
268
289
|
if (state.xmlStr) {
|
|
269
290
|
return state.xmlStr
|
|
270
291
|
}
|
|
@@ -291,6 +312,14 @@ class SlideManager {
|
|
|
291
312
|
const info = this.#slides.get(slideIndex)
|
|
292
313
|
const state = this.#slideStates.get(slideIndex)
|
|
293
314
|
|
|
315
|
+
const dirtyContent = this.#zipManager.readCachedFile(info.zipPath)
|
|
316
|
+
if (dirtyContent && dirtyContent !== state.xmlStr) {
|
|
317
|
+
state.xmlStr = dirtyContent
|
|
318
|
+
this.#slideXmlCache.set(info.zipPath, dirtyContent)
|
|
319
|
+
state.xmlObj = null
|
|
320
|
+
state.indexBuilt = false
|
|
321
|
+
}
|
|
322
|
+
|
|
294
323
|
if (!state.xmlStr) {
|
|
295
324
|
const xml = await this.#zipManager.readFile(info.zipPath)
|
|
296
325
|
if (!xml) throw new SlideNotFoundError(`Slide ${slideIndex} XML not found at ${info.zipPath}`)
|
|
@@ -389,9 +418,8 @@ class SlideManager {
|
|
|
389
418
|
relationshipManager.addRelationship(slideZipPath, REL_TYPES.SLIDE_LAYOUT, relativeLayoutPath)
|
|
390
419
|
|
|
391
420
|
// Generate a unique slide ID
|
|
392
|
-
const
|
|
393
|
-
const
|
|
394
|
-
const newSlideId = String(maxId + 1)
|
|
421
|
+
const existingSlideIds = Array.from(this.#slides.values()).map(s => s.slideId)
|
|
422
|
+
const newSlideId = generateSlideId(existingSlideIds)
|
|
395
423
|
|
|
396
424
|
const slideInfo = {
|
|
397
425
|
index: newIndex,
|
|
@@ -429,8 +457,24 @@ class SlideManager {
|
|
|
429
457
|
* @param {number} sourceIndex - 1-based source slide number.
|
|
430
458
|
* @param {number} [atPosition] - Insert position (1-based). Default: append.
|
|
431
459
|
* @param {RelationshipManager} relationshipManager
|
|
460
|
+
* @param {MediaManager} mediaManager
|
|
461
|
+
* @returns {Promise<void>}
|
|
462
|
+
*/
|
|
463
|
+
async cloneSlide(sourceIndex, atPosition, relationshipManager, mediaManager) {
|
|
464
|
+
const promise = this.#cloneSlideInternal(
|
|
465
|
+
sourceIndex,
|
|
466
|
+
atPosition,
|
|
467
|
+
relationshipManager,
|
|
468
|
+
mediaManager
|
|
469
|
+
)
|
|
470
|
+
this.#zipManager.addPendingPromise(promise)
|
|
471
|
+
return promise
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* @private
|
|
432
476
|
*/
|
|
433
|
-
|
|
477
|
+
async #cloneSlideInternal(sourceIndex, atPosition, relationshipManager, mediaManager) {
|
|
434
478
|
this.#assertSlideExists(sourceIndex)
|
|
435
479
|
const sourceInfo = this.#slides.get(sourceIndex)
|
|
436
480
|
logger.debug('Source Slide Info:', sourceInfo)
|
|
@@ -443,26 +487,90 @@ class SlideManager {
|
|
|
443
487
|
const slideFileName = `slide${nextFileIndex}.xml`
|
|
444
488
|
const slideZipPath = `ppt/slides/${slideFileName}`
|
|
445
489
|
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
logger.debug('Source XML length:',
|
|
490
|
+
// Snapshot source XML — never mutate the original slide content
|
|
491
|
+
const sourceXmlSnapshot = this.getSlideXml(sourceIndex)
|
|
492
|
+
logger.debug('Source XML length:', sourceXmlSnapshot ? sourceXmlSnapshot.length : 0)
|
|
449
493
|
|
|
450
|
-
// Copy relationships
|
|
451
494
|
const sourceRels = relationshipManager.getRelationships(sourceInfo.zipPath)
|
|
452
495
|
logger.debug('Source Rels Path searched:', relationshipManager.getRelsPath(sourceInfo.zipPath))
|
|
453
496
|
logger.debug('Source Rels found:', sourceRels)
|
|
454
497
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
498
|
+
const idMap = await this.#deepCloneSlideRelationships(
|
|
499
|
+
sourceInfo.zipPath,
|
|
500
|
+
slideZipPath,
|
|
501
|
+
relationshipManager,
|
|
502
|
+
mediaManager,
|
|
503
|
+
sourceRels,
|
|
504
|
+
[REL_TYPES.NOTES_SLIDE]
|
|
505
|
+
)
|
|
506
|
+
logger.debug('Deep-cloned relationship ID map:', Array.from(idMap.entries()))
|
|
507
|
+
|
|
508
|
+
let cloneXml = remapRelationshipIds(sourceXmlSnapshot, idMap)
|
|
509
|
+
cloneXml = this.#regenerateTableRowIds(cloneXml)
|
|
510
|
+
|
|
511
|
+
this.#zipManager.writeFile(slideZipPath, cloneXml)
|
|
512
|
+
this.#slideXmlCache.set(slideZipPath, cloneXml)
|
|
513
|
+
|
|
514
|
+
// Clone notes slide if source slide has one
|
|
515
|
+
const notesRel = sourceRels.find(r => r.type === REL_TYPES.NOTES_SLIDE)
|
|
516
|
+
if (notesRel) {
|
|
517
|
+
const sourceNotesPath = relationshipManager.resolveTarget(sourceInfo.zipPath, notesRel.target)
|
|
518
|
+
let notesXml = this.#zipManager.readCachedFile(sourceNotesPath)
|
|
519
|
+
if (!notesXml) {
|
|
520
|
+
notesXml = await this.#zipManager.readFile(sourceNotesPath)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (notesXml) {
|
|
524
|
+
let nextNotesIndex = 1
|
|
525
|
+
while (this.#zipManager.hasFile(`ppt/notesSlides/notesSlide${nextNotesIndex}.xml`)) {
|
|
526
|
+
nextNotesIndex++
|
|
527
|
+
}
|
|
528
|
+
const notesFileName = `notesSlide${nextNotesIndex}.xml`
|
|
529
|
+
const notesZipPath = `ppt/notesSlides/${notesFileName}`
|
|
530
|
+
|
|
531
|
+
// Deep-clone relationships from source notes slide (so slide-specific resources like tags are also copied)
|
|
532
|
+
const sourceNotesRels = relationshipManager.getRelationships(sourceNotesPath)
|
|
533
|
+
const notesIdMap = await this.#deepCloneSlideRelationships(
|
|
534
|
+
sourceNotesPath,
|
|
535
|
+
notesZipPath,
|
|
536
|
+
relationshipManager,
|
|
537
|
+
mediaManager,
|
|
538
|
+
sourceNotesRels
|
|
539
|
+
)
|
|
460
540
|
|
|
461
|
-
|
|
462
|
-
|
|
541
|
+
// Update target in notes relationships to point back to the new slide
|
|
542
|
+
// IMPORTANT: patch the back-reference BEFORE flushing to ZIP so the
|
|
543
|
+
// .rels file on disk contains the correct slide path.
|
|
544
|
+
const notesRels = relationshipManager.getRelationships(notesZipPath)
|
|
545
|
+
const slideRel = notesRels.find(
|
|
546
|
+
r => r.type === REL_TYPES.SLIDE || r.type.endsWith('/slide')
|
|
547
|
+
)
|
|
548
|
+
if (slideRel) {
|
|
549
|
+
slideRel.target = `../slides/${slideFileName}`
|
|
550
|
+
}
|
|
551
|
+
// Flush AFTER the target is patched so the written .rels is correct
|
|
552
|
+
relationshipManager.flushRelationships(notesZipPath)
|
|
553
|
+
|
|
554
|
+
// Remap relationship IDs in notes XML
|
|
555
|
+
notesXml = remapRelationshipIds(notesXml, notesIdMap)
|
|
556
|
+
|
|
557
|
+
// Write the notes slide XML to ZIP
|
|
558
|
+
this.#zipManager.writeFile(notesZipPath, notesXml)
|
|
559
|
+
|
|
560
|
+
// Add content type override
|
|
561
|
+
this.#contentTypesManager.addOverride(
|
|
562
|
+
notesZipPath,
|
|
563
|
+
'application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml'
|
|
564
|
+
)
|
|
463
565
|
|
|
464
|
-
|
|
465
|
-
|
|
566
|
+
// Add notes slide relationship to the new slide
|
|
567
|
+
relationshipManager.addRelationship(
|
|
568
|
+
slideZipPath,
|
|
569
|
+
REL_TYPES.NOTES_SLIDE,
|
|
570
|
+
`../notesSlides/${notesFileName}`
|
|
571
|
+
)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
466
574
|
|
|
467
575
|
// Add to presentation.xml
|
|
468
576
|
const rId = relationshipManager.addRelationship(
|
|
@@ -471,9 +579,8 @@ class SlideManager {
|
|
|
471
579
|
`slides/${slideFileName}`
|
|
472
580
|
)
|
|
473
581
|
|
|
474
|
-
const
|
|
475
|
-
const
|
|
476
|
-
const newSlideId = String(maxId + 1)
|
|
582
|
+
const existingSlideIds = Array.from(this.#slides.values()).map(s => s.slideId)
|
|
583
|
+
const newSlideId = generateSlideId(existingSlideIds)
|
|
477
584
|
|
|
478
585
|
const slideInfo = {
|
|
479
586
|
index: newIndex,
|
|
@@ -486,7 +593,7 @@ class SlideManager {
|
|
|
486
593
|
|
|
487
594
|
this.#slides.set(newIndex, slideInfo)
|
|
488
595
|
this.#slideStates.set(newIndex, {
|
|
489
|
-
xmlStr:
|
|
596
|
+
xmlStr: cloneXml,
|
|
490
597
|
xmlObj: null,
|
|
491
598
|
dirty: false,
|
|
492
599
|
indexBuilt: false,
|
|
@@ -501,6 +608,401 @@ class SlideManager {
|
|
|
501
608
|
logger.debug(`Cloned slide ${sourceIndex} to new slide ${newIndex}`)
|
|
502
609
|
}
|
|
503
610
|
|
|
611
|
+
/**
|
|
612
|
+
* Deep-clones slide relationships so every embedded part (chart, image, VML drawing,
|
|
613
|
+
* SmartArt, custom XML, audio/video, etc.) gets its own independent copy.
|
|
614
|
+
* Shared presentation-level resources (layout, master, theme, tableStyles) are reused.
|
|
615
|
+
*
|
|
616
|
+
* IMPORTANT: Every internal part that is unique to a slide MUST be deep-copied here.
|
|
617
|
+
* Sharing mutable internal parts between slides causes PowerPoint corruption, particularly
|
|
618
|
+
* in enterprise environments with strict validators (e.g. Boldon James Classifier).
|
|
619
|
+
* @private
|
|
620
|
+
*/
|
|
621
|
+
async #deepCloneSlideRelationships(
|
|
622
|
+
sourcePath,
|
|
623
|
+
destPath,
|
|
624
|
+
relationshipManager,
|
|
625
|
+
mediaManager,
|
|
626
|
+
sourceRels,
|
|
627
|
+
excludeTypes = []
|
|
628
|
+
) {
|
|
629
|
+
const idMap = new Map()
|
|
630
|
+
const destRels = []
|
|
631
|
+
|
|
632
|
+
for (const rel of sourceRels) {
|
|
633
|
+
if (excludeTypes.includes(rel.type)) continue
|
|
634
|
+
|
|
635
|
+
let target = rel.target
|
|
636
|
+
const resolvedTarget =
|
|
637
|
+
rel.targetMode === 'External'
|
|
638
|
+
? null
|
|
639
|
+
: relationshipManager.resolveTarget(sourcePath, rel.target)
|
|
640
|
+
const typeEnd = rel.type.split('/').pop().toLowerCase()
|
|
641
|
+
|
|
642
|
+
if (rel.targetMode === 'External') {
|
|
643
|
+
// External hyperlinks, audio/video links — reuse as-is
|
|
644
|
+
} else if (rel.type === REL_TYPES.CHART) {
|
|
645
|
+
// Charts → deep copy with independent workbook/style/color
|
|
646
|
+
target = await this.#copyChartPart(resolvedTarget, relationshipManager)
|
|
647
|
+
} else if (rel.type === REL_TYPES.IMAGE) {
|
|
648
|
+
// Raster/vector images — copy as new media part
|
|
649
|
+
const newMediaPath = await mediaManager.copyMediaAsNewPart(resolvedTarget)
|
|
650
|
+
target = `../media/${newMediaPath.split('/').pop()}`
|
|
651
|
+
} else if (
|
|
652
|
+
rel.type === REL_TYPES.SLIDE_LAYOUT ||
|
|
653
|
+
rel.type === REL_TYPES.SLIDE_MASTER ||
|
|
654
|
+
rel.type === REL_TYPES.THEME ||
|
|
655
|
+
rel.type === REL_TYPES.TABLE_STYLES ||
|
|
656
|
+
rel.type ===
|
|
657
|
+
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster' ||
|
|
658
|
+
rel.type ===
|
|
659
|
+
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide' ||
|
|
660
|
+
rel.type === REL_TYPES.SLIDE
|
|
661
|
+
) {
|
|
662
|
+
// Shared presentation-level resources — reuse same target
|
|
663
|
+
} else if (typeEnd === 'audio' || typeEnd === 'video' || typeEnd === 'media') {
|
|
664
|
+
// Embedded audio/video binary — copy to new media file
|
|
665
|
+
if (this.#zipManager.hasFile(resolvedTarget)) {
|
|
666
|
+
const newPath = await this.#copyBinaryPart(resolvedTarget)
|
|
667
|
+
if (newPath) target = this.#makeRelativeTarget(destPath, newPath)
|
|
668
|
+
}
|
|
669
|
+
} else if (typeEnd === 'vmldrawing') {
|
|
670
|
+
// VML drawings (legacy shapes, comment boxes) — must be independent per slide
|
|
671
|
+
if (this.#zipManager.hasFile(resolvedTarget)) {
|
|
672
|
+
const newTarget = await this.#copyGenericXmlPart(
|
|
673
|
+
resolvedTarget,
|
|
674
|
+
destPath,
|
|
675
|
+
relationshipManager
|
|
676
|
+
)
|
|
677
|
+
if (newTarget) target = newTarget
|
|
678
|
+
}
|
|
679
|
+
} else if (
|
|
680
|
+
typeEnd === 'diagramdata' ||
|
|
681
|
+
typeEnd === 'diagramlayout' ||
|
|
682
|
+
typeEnd === 'diagramquickstyle' ||
|
|
683
|
+
typeEnd === 'diagramcolors' ||
|
|
684
|
+
typeEnd === 'diagram'
|
|
685
|
+
) {
|
|
686
|
+
// SmartArt/diagram parts — each slide needs its own copy
|
|
687
|
+
if (this.#zipManager.hasFile(resolvedTarget)) {
|
|
688
|
+
const newTarget = await this.#copyGenericXmlPart(
|
|
689
|
+
resolvedTarget,
|
|
690
|
+
destPath,
|
|
691
|
+
relationshipManager
|
|
692
|
+
)
|
|
693
|
+
if (newTarget) target = newTarget
|
|
694
|
+
}
|
|
695
|
+
} else if (typeEnd === 'oleobject' || typeEnd === 'activex') {
|
|
696
|
+
// Non-chart OLE / ActiveX objects — copy binary
|
|
697
|
+
if (this.#zipManager.hasFile(resolvedTarget)) {
|
|
698
|
+
const newPath = await this.#copyBinaryPart(resolvedTarget)
|
|
699
|
+
if (newPath) {
|
|
700
|
+
target = this.#makeRelativeTarget(destPath, newPath)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
} else if (rel.target && !rel.target.startsWith('http') && resolvedTarget) {
|
|
704
|
+
// Catch-all for any other internal part: copy XML if the file extension is .xml,
|
|
705
|
+
// otherwise copy as binary. This handles custom XML (e.g. Boldon James classification
|
|
706
|
+
// parts) and any future unknown relationship types.
|
|
707
|
+
if (this.#zipManager.hasFile(resolvedTarget)) {
|
|
708
|
+
if (resolvedTarget.toLowerCase().endsWith('.xml')) {
|
|
709
|
+
// Text/XML content
|
|
710
|
+
const newTarget = await this.#copyGenericXmlPart(
|
|
711
|
+
resolvedTarget,
|
|
712
|
+
destPath,
|
|
713
|
+
relationshipManager
|
|
714
|
+
)
|
|
715
|
+
if (newTarget) target = newTarget
|
|
716
|
+
} else {
|
|
717
|
+
// Binary content
|
|
718
|
+
const newPath = await this.#copyBinaryPart(resolvedTarget)
|
|
719
|
+
if (newPath) {
|
|
720
|
+
target = this.#makeRelativeTarget(destPath, newPath)
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
} else {
|
|
724
|
+
// Part not in ZIP (may be external or already purged) — keep original target
|
|
725
|
+
logger.warn(`Clone: part not found in ZIP, reusing original target: ${resolvedTarget}`)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const newId = generateRelationshipId(destRels.map(r => r.id))
|
|
730
|
+
const newRel = { id: newId, type: rel.type, target }
|
|
731
|
+
if (rel.targetMode) newRel.targetMode = rel.targetMode
|
|
732
|
+
destRels.push(newRel)
|
|
733
|
+
idMap.set(rel.id, newId)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
relationshipManager.setRelationships(destPath, destRels)
|
|
737
|
+
relationshipManager.flushRelationships(destPath)
|
|
738
|
+
return idMap
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Copies a chart part and all of its dependent chart style/color/workbook parts.
|
|
743
|
+
* @private
|
|
744
|
+
* @returns {Promise<string>} Relative target for the cloned slide relationship.
|
|
745
|
+
*/
|
|
746
|
+
async #copyChartPart(sourceChartPath, relationshipManager) {
|
|
747
|
+
let chartXml = this.#zipManager.readCachedFile(sourceChartPath)
|
|
748
|
+
if (!chartXml) {
|
|
749
|
+
chartXml = await this.#zipManager.readFile(sourceChartPath)
|
|
750
|
+
}
|
|
751
|
+
if (!chartXml) {
|
|
752
|
+
throw new PPTXError(`Cannot clone slide: chart not found at ${sourceChartPath}`)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const sourceChartRels = relationshipManager.getRelationships(sourceChartPath)
|
|
756
|
+
let nextChartNum = 1
|
|
757
|
+
while (this.#zipManager.hasFile(`ppt/charts/chart${nextChartNum}.xml`)) {
|
|
758
|
+
nextChartNum++
|
|
759
|
+
}
|
|
760
|
+
const destChartFileName = `chart${nextChartNum}.xml`
|
|
761
|
+
const destChartPath = `ppt/charts/${destChartFileName}`
|
|
762
|
+
|
|
763
|
+
const destChartRels = []
|
|
764
|
+
const chartRelIdMap = new Map()
|
|
765
|
+
|
|
766
|
+
for (const rel of sourceChartRels) {
|
|
767
|
+
const resolved = relationshipManager.resolveTarget(sourceChartPath, rel.target)
|
|
768
|
+
let newTarget = rel.target
|
|
769
|
+
|
|
770
|
+
if (rel.type === REL_TYPES.PACKAGE || rel.target.includes('../embeddings/')) {
|
|
771
|
+
const bytes = await this.#zipManager.readBinaryFile(resolved)
|
|
772
|
+
if (bytes) {
|
|
773
|
+
const fileName = resolved.split('/').pop()
|
|
774
|
+
let nextEmbed = 1
|
|
775
|
+
let destWorkbookPath = `ppt/embeddings/Microsoft_Excel_Worksheet${nextEmbed}.xlsx`
|
|
776
|
+
if (fileName.endsWith('.bin')) {
|
|
777
|
+
destWorkbookPath = `ppt/embeddings/oleObject${nextEmbed}.bin`
|
|
778
|
+
}
|
|
779
|
+
while (this.#zipManager.hasFile(destWorkbookPath)) {
|
|
780
|
+
nextEmbed++
|
|
781
|
+
destWorkbookPath = fileName.endsWith('.bin')
|
|
782
|
+
? `ppt/embeddings/oleObject${nextEmbed}.bin`
|
|
783
|
+
: `ppt/embeddings/Microsoft_Excel_Worksheet${nextEmbed}.xlsx`
|
|
784
|
+
}
|
|
785
|
+
this.#zipManager.writeBinaryFile(destWorkbookPath, bytes)
|
|
786
|
+
this.#contentTypesManager.addOverride(
|
|
787
|
+
destWorkbookPath,
|
|
788
|
+
fileName.endsWith('.bin') ? ACTIVEX_CONTENT_TYPE : WORKBOOK_CONTENT_TYPE
|
|
789
|
+
)
|
|
790
|
+
newTarget = `../embeddings/${destWorkbookPath.split('/').pop()}`
|
|
791
|
+
}
|
|
792
|
+
} else if (/colors\d+\.xml$/i.test(rel.target)) {
|
|
793
|
+
newTarget = await this.#copyChartSupportXml(resolved, 'colors', CHART_COLORS_CONTENT_TYPE)
|
|
794
|
+
} else if (/style\d+\.xml$/i.test(rel.target)) {
|
|
795
|
+
newTarget = await this.#copyChartSupportXml(resolved, 'style', CHART_STYLE_CONTENT_TYPE)
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const newId = generateRelationshipId(destChartRels.map(r => r.id))
|
|
799
|
+
const newRel = { id: newId, type: rel.type, target: newTarget }
|
|
800
|
+
if (rel.targetMode) newRel.targetMode = rel.targetMode
|
|
801
|
+
destChartRels.push(newRel)
|
|
802
|
+
chartRelIdMap.set(rel.id, newId)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
chartXml = remapRelationshipIds(chartXml, chartRelIdMap)
|
|
806
|
+
this.#zipManager.writeFile(destChartPath, chartXml)
|
|
807
|
+
relationshipManager.setRelationships(destChartPath, destChartRels)
|
|
808
|
+
relationshipManager.flushRelationships(destChartPath)
|
|
809
|
+
this.#contentTypesManager.addOverride(destChartPath, CHART_CONTENT_TYPE)
|
|
810
|
+
|
|
811
|
+
return `../charts/${destChartFileName}`
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Copies a chart colors/style XML part to a new sequentially numbered file.
|
|
816
|
+
* @private
|
|
817
|
+
*/
|
|
818
|
+
async #copyChartSupportXml(sourcePath, prefix, contentType) {
|
|
819
|
+
let content = this.#zipManager.readCachedFile(sourcePath)
|
|
820
|
+
if (!content) {
|
|
821
|
+
content = await this.#zipManager.readFile(sourcePath)
|
|
822
|
+
}
|
|
823
|
+
if (!content) return sourcePath.split('/').pop()
|
|
824
|
+
|
|
825
|
+
let nextNum = 1
|
|
826
|
+
let destFileName = `${prefix}${nextNum}.xml`
|
|
827
|
+
while (this.#zipManager.hasFile(`ppt/charts/${destFileName}`)) {
|
|
828
|
+
nextNum++
|
|
829
|
+
destFileName = `${prefix}${nextNum}.xml`
|
|
830
|
+
}
|
|
831
|
+
const destPath = `ppt/charts/${destFileName}`
|
|
832
|
+
this.#zipManager.writeFile(destPath, content)
|
|
833
|
+
this.#contentTypesManager.addOverride(destPath, contentType)
|
|
834
|
+
return destFileName
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Copies a generic XML text part to a new sequentially numbered path in the same
|
|
839
|
+
* directory as the source. Returns a relative target path from the destination slide,
|
|
840
|
+
* or null if the copy failed.
|
|
841
|
+
* @private
|
|
842
|
+
* @param {string} sourcePath - Absolute ZIP path of the source XML part.
|
|
843
|
+
* @returns {Promise<string|null>} Relative target for the cloned slide relationship.
|
|
844
|
+
*/
|
|
845
|
+
async #copyGenericXmlPart(sourcePath, destPath, relationshipManager) {
|
|
846
|
+
let content = this.#zipManager.readCachedFile(sourcePath)
|
|
847
|
+
if (!content) {
|
|
848
|
+
content = await this.#zipManager.readFile(sourcePath)
|
|
849
|
+
}
|
|
850
|
+
if (!content) return null
|
|
851
|
+
|
|
852
|
+
const parts = sourcePath.split('/')
|
|
853
|
+
const dir = parts.slice(0, -1).join('/')
|
|
854
|
+
const fileName = parts[parts.length - 1]
|
|
855
|
+
// Extract base name and extension: e.g. 'vmlDrawing1.xml' → base='vmlDrawing', ext='xml'
|
|
856
|
+
const lastDot = fileName.lastIndexOf('.')
|
|
857
|
+
const ext = lastDot >= 0 ? fileName.slice(lastDot) : ''
|
|
858
|
+
const base = lastDot >= 0 ? fileName.slice(0, lastDot).replace(/\d+$/, '') : fileName
|
|
859
|
+
|
|
860
|
+
let num = 1
|
|
861
|
+
let destPartPath = `${dir}/${base}${num}${ext}`
|
|
862
|
+
while (this.#zipManager.hasFile(destPartPath)) {
|
|
863
|
+
num++
|
|
864
|
+
destPartPath = `${dir}/${base}${num}${ext}`
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
this.#zipManager.writeFile(destPartPath, content)
|
|
868
|
+
|
|
869
|
+
// Mirror content type if one exists for the source
|
|
870
|
+
const srcContentType = this.#contentTypesManager.getOverrideContentType(sourcePath)
|
|
871
|
+
if (srcContentType) {
|
|
872
|
+
this.#contentTypesManager.addOverride(destPartPath, srcContentType)
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Clone any relationships/dependencies of this XML part recursively
|
|
876
|
+
await this.#copyPartDependencies(sourcePath, destPartPath, relationshipManager)
|
|
877
|
+
|
|
878
|
+
logger.debug(`Cloned XML part: ${sourcePath} → ${destPartPath}`)
|
|
879
|
+
return this.#makeRelativeTarget(destPath, destPartPath)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Recursively copies relationships and dependency parts for a cloned XML part.
|
|
884
|
+
* @private
|
|
885
|
+
* @param {string} sourcePartPath - Absolute ZIP path of the source XML part.
|
|
886
|
+
* @param {string} destPartPath - Absolute ZIP path of the destination XML part.
|
|
887
|
+
* @param {RelationshipManager} relationshipManager
|
|
888
|
+
* @returns {Promise<void>}
|
|
889
|
+
*/
|
|
890
|
+
async #copyPartDependencies(sourcePartPath, destPartPath, relationshipManager) {
|
|
891
|
+
const rels = relationshipManager.getRelationships(sourcePartPath)
|
|
892
|
+
if (!rels || rels.length === 0) return
|
|
893
|
+
|
|
894
|
+
// 1. Copy relationships file first (sets up destPath rels list)
|
|
895
|
+
relationshipManager.copyRelationships(sourcePartPath, destPartPath)
|
|
896
|
+
|
|
897
|
+
// 2. Iterate and clone each target part
|
|
898
|
+
const destRels = relationshipManager.getRelationships(destPartPath)
|
|
899
|
+
let dirty = false
|
|
900
|
+
|
|
901
|
+
for (const rel of destRels) {
|
|
902
|
+
if (rel.targetMode === 'External' || !rel.target || rel.target.startsWith('http')) continue
|
|
903
|
+
|
|
904
|
+
const resolved = relationshipManager.resolveTarget(sourcePartPath, rel.target)
|
|
905
|
+
if (!this.#zipManager.hasFile(resolved)) continue
|
|
906
|
+
|
|
907
|
+
let newTarget = rel.target
|
|
908
|
+
if (resolved.toLowerCase().endsWith('.xml')) {
|
|
909
|
+
// XML part - copy recursively
|
|
910
|
+
newTarget = await this.#copyGenericXmlPart(resolved, destPartPath, relationshipManager)
|
|
911
|
+
} else {
|
|
912
|
+
// Binary part
|
|
913
|
+
const newBinaryPath = await this.#copyBinaryPart(resolved)
|
|
914
|
+
if (newBinaryPath) {
|
|
915
|
+
newTarget = this.#makeRelativeTarget(destPartPath, newBinaryPath)
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (newTarget && newTarget !== rel.target) {
|
|
920
|
+
rel.target = newTarget
|
|
921
|
+
dirty = true
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (dirty) {
|
|
926
|
+
relationshipManager.flushRelationships(destPartPath)
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Copies a binary part (audio, video, OLE object, etc.) to a new path in the same
|
|
932
|
+
* directory as the source. Returns the new absolute ZIP path, or null on failure.
|
|
933
|
+
* @private
|
|
934
|
+
* @param {string} sourcePath - Absolute ZIP path of the source binary part.
|
|
935
|
+
* @returns {Promise<string|null>} New absolute ZIP path.
|
|
936
|
+
*/
|
|
937
|
+
async #copyBinaryPart(sourcePath) {
|
|
938
|
+
const data = await this.#zipManager.readBinaryFile(sourcePath)
|
|
939
|
+
if (!data) return null
|
|
940
|
+
|
|
941
|
+
const parts = sourcePath.split('/')
|
|
942
|
+
const dir = parts.slice(0, -1).join('/')
|
|
943
|
+
const fileName = parts[parts.length - 1]
|
|
944
|
+
const lastDot = fileName.lastIndexOf('.')
|
|
945
|
+
const ext = lastDot >= 0 ? fileName.slice(lastDot) : ''
|
|
946
|
+
const base = lastDot >= 0 ? fileName.slice(0, lastDot).replace(/\d+$/, '') : fileName
|
|
947
|
+
|
|
948
|
+
let num = 1
|
|
949
|
+
let destPath = `${dir}/${base}${num}${ext}`
|
|
950
|
+
while (this.#zipManager.hasFile(destPath)) {
|
|
951
|
+
num++
|
|
952
|
+
destPath = `${dir}/${base}${num}${ext}`
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
this.#zipManager.writeBinaryFile(destPath, data)
|
|
956
|
+
|
|
957
|
+
const srcContentType = this.#contentTypesManager.getOverrideContentType(sourcePath)
|
|
958
|
+
if (srcContentType) {
|
|
959
|
+
this.#contentTypesManager.addOverride(destPath, srcContentType)
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
logger.debug(`Cloned binary part: ${sourcePath} → ${destPath}`)
|
|
963
|
+
return destPath
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Builds a relative target path from a source ZIP part to a destination ZIP path.
|
|
968
|
+
* @private
|
|
969
|
+
* @param {string} fromPath - Absolute path of the part containing the relationship.
|
|
970
|
+
* @param {string} toPath - Absolute ZIP path of the target.
|
|
971
|
+
* @returns {string} Relative path from fromPath's directory to toPath.
|
|
972
|
+
*/
|
|
973
|
+
#makeRelativeTarget(fromPath, toPath) {
|
|
974
|
+
const fromDir = fromPath.split('/').slice(0, -1)
|
|
975
|
+
const toParts = toPath.split('/')
|
|
976
|
+
// Find common prefix length
|
|
977
|
+
let common = 0
|
|
978
|
+
while (
|
|
979
|
+
common < fromDir.length &&
|
|
980
|
+
common < toParts.length - 1 &&
|
|
981
|
+
fromDir[common] === toParts[common]
|
|
982
|
+
) {
|
|
983
|
+
common++
|
|
984
|
+
}
|
|
985
|
+
const ups = fromDir.length - common
|
|
986
|
+
const downs = toParts.slice(common)
|
|
987
|
+
return (ups > 0 ? '../'.repeat(ups) : './') + downs.join('/')
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Assigns fresh a16:rowId values in cloned slide XML so table rows are independent.
|
|
992
|
+
* @private
|
|
993
|
+
*/
|
|
994
|
+
#regenerateTableRowIds(xml) {
|
|
995
|
+
const used = new Set()
|
|
996
|
+
return xml.replace(/(<a16:rowId[^>]*\sval=")([^"]+)(")/g, (_match, prefix, _oldVal, suffix) => {
|
|
997
|
+
let newVal
|
|
998
|
+
do {
|
|
999
|
+
newVal = String(Math.floor(Math.random() * 0xffffffff))
|
|
1000
|
+
} while (used.has(newVal))
|
|
1001
|
+
used.add(newVal)
|
|
1002
|
+
return `${prefix}${newVal}${suffix}`
|
|
1003
|
+
})
|
|
1004
|
+
}
|
|
1005
|
+
|
|
504
1006
|
/**
|
|
505
1007
|
* Removes a slide from the presentation.
|
|
506
1008
|
*
|
|
@@ -510,32 +1012,58 @@ class SlideManager {
|
|
|
510
1012
|
this.#assertSlideExists(slideIndex)
|
|
511
1013
|
const info = this.#slides.get(slideIndex)
|
|
512
1014
|
|
|
513
|
-
//
|
|
1015
|
+
// 1. Check for notes slide relationship and remove it first
|
|
1016
|
+
const slideRels = this.#relationshipManager.getRelationships(info.zipPath)
|
|
1017
|
+
const notesRel = slideRels.find(r => r.type === REL_TYPES.NOTES_SLIDE)
|
|
1018
|
+
if (notesRel) {
|
|
1019
|
+
const notesPath = this.#relationshipManager.resolveTarget(info.zipPath, notesRel.target)
|
|
1020
|
+
|
|
1021
|
+
// Remove from ZIP
|
|
1022
|
+
this.#zipManager.removeFile(notesPath)
|
|
1023
|
+
|
|
1024
|
+
// Remove its relationships file — use getRelsPath for robustness
|
|
1025
|
+
const notesRelsPath = this.#relationshipManager.getRelsPath(notesPath)
|
|
1026
|
+
this.#zipManager.removeFile(notesRelsPath)
|
|
1027
|
+
|
|
1028
|
+
// Remove relationships from cache
|
|
1029
|
+
this.#relationshipManager.deleteRelationships(notesPath)
|
|
1030
|
+
|
|
1031
|
+
// Remove content type override
|
|
1032
|
+
this.#contentTypesManager.removeOverride(notesPath)
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// 2. Remove slide XML from ZIP
|
|
514
1036
|
this.#zipManager.removeFile(info.zipPath)
|
|
515
1037
|
|
|
516
|
-
// Remove
|
|
517
|
-
const
|
|
518
|
-
this.#zipManager.removeFile(
|
|
1038
|
+
// 3. Remove slide .rels file — use getRelsPath so non-standard paths work correctly
|
|
1039
|
+
const slideRelsPath = this.#relationshipManager.getRelsPath(info.zipPath)
|
|
1040
|
+
this.#zipManager.removeFile(slideRelsPath)
|
|
1041
|
+
|
|
1042
|
+
// 4. Remove slide relationship cache entry
|
|
1043
|
+
this.#relationshipManager.deleteRelationships(info.zipPath)
|
|
519
1044
|
|
|
520
|
-
// Remove
|
|
1045
|
+
// 5. Remove slide XML cache entries
|
|
521
1046
|
this.#slideXmlCache.delete(info.zipPath)
|
|
522
1047
|
this.#slideStates.delete(slideIndex)
|
|
523
1048
|
|
|
524
|
-
// Remove
|
|
525
|
-
this.#
|
|
1049
|
+
// 6. Remove content type from [Content_Types].xml
|
|
1050
|
+
this.#contentTypesManager.removeOverride(info.zipPath)
|
|
526
1051
|
|
|
527
|
-
// Remove
|
|
528
|
-
this.#relationshipManager.
|
|
1052
|
+
// 7. Remove relationship from presentation.xml.rels
|
|
1053
|
+
this.#relationshipManager.removeRelationship('ppt/presentation.xml', info.relationshipId)
|
|
529
1054
|
|
|
530
|
-
// Remove
|
|
531
|
-
|
|
1055
|
+
// 8. Remove from presentation.xml sldIdLst and sections BEFORE reindexing.
|
|
1056
|
+
// This ensures the sldIdLst is consistent with the relationship list.
|
|
1057
|
+
this.#removeSlideFromPresentation(info.slideId)
|
|
532
1058
|
|
|
533
|
-
// Remove from slides map and reindex
|
|
1059
|
+
// 9. Remove from slides map and reindex remaining slides
|
|
534
1060
|
this.#slides.delete(slideIndex)
|
|
535
1061
|
this.#reindexSlides()
|
|
536
1062
|
|
|
537
|
-
//
|
|
538
|
-
|
|
1063
|
+
// 10. Rebuild sldIdLst and synchronize sections to reflect the updated slide order.
|
|
1064
|
+
// This is essential: after reindexing, the relationship IDs and slide IDs are
|
|
1065
|
+
// still valid but the sldIdLst order must be rebuilt so sections match.
|
|
1066
|
+
this.rebuildPresentationSlideOrder()
|
|
539
1067
|
|
|
540
1068
|
logger.debug(`Removed slide ${slideIndex}`)
|
|
541
1069
|
}
|
|
@@ -906,7 +1434,20 @@ class SlideManager {
|
|
|
906
1434
|
* @returns {Promise<void>}
|
|
907
1435
|
*/
|
|
908
1436
|
async preloadAll() {
|
|
909
|
-
await Promise.all(
|
|
1437
|
+
await Promise.all(
|
|
1438
|
+
this.getAllSlideIndices().map(async i => {
|
|
1439
|
+
await this.getSlideXmlAsync(i)
|
|
1440
|
+
|
|
1441
|
+
// Also check and preload notes slide XML
|
|
1442
|
+
const info = this.#slides.get(i)
|
|
1443
|
+
const rels = this.#relationshipManager.getRelationships(info.zipPath)
|
|
1444
|
+
const notesRel = rels.find(r => r.type === REL_TYPES.NOTES_SLIDE)
|
|
1445
|
+
if (notesRel) {
|
|
1446
|
+
const notesPath = this.#relationshipManager.resolveTarget(info.zipPath, notesRel.target)
|
|
1447
|
+
await this.#zipManager.readFile(notesPath)
|
|
1448
|
+
}
|
|
1449
|
+
})
|
|
1450
|
+
)
|
|
910
1451
|
}
|
|
911
1452
|
|
|
912
1453
|
/**
|
|
@@ -991,9 +1532,10 @@ class SlideManager {
|
|
|
991
1532
|
const sldIdLst = this.#xmlParser.getNode(this.#presentationObj, 'p:presentation.p:sldIdLst')
|
|
992
1533
|
if (!sldIdLst?.['p:sldId']) return
|
|
993
1534
|
|
|
1535
|
+
const targetIdStr = String(slideId)
|
|
994
1536
|
sldIdLst['p:sldId'] = (
|
|
995
1537
|
Array.isArray(sldIdLst['p:sldId']) ? sldIdLst['p:sldId'] : [sldIdLst['p:sldId']]
|
|
996
|
-
).filter(s => s['@_id'] !==
|
|
1538
|
+
).filter(s => String(s['@_id']) !== targetIdStr)
|
|
997
1539
|
|
|
998
1540
|
// Also remove from any PowerPoint sections
|
|
999
1541
|
this.#removeSlideFromSections(slideId)
|
|
@@ -1061,19 +1603,28 @@ class SlideManager {
|
|
|
1061
1603
|
? sectionLst['p14:section']
|
|
1062
1604
|
: [sectionLst['p14:section']]
|
|
1063
1605
|
|
|
1064
|
-
// 1.
|
|
1065
|
-
const
|
|
1606
|
+
// 1. Identify section anchors (first slide of each section)
|
|
1607
|
+
const sectionAnchors = []
|
|
1066
1608
|
for (const section of sections) {
|
|
1067
|
-
const
|
|
1068
|
-
if (
|
|
1069
|
-
const sldIds = Array.isArray(
|
|
1070
|
-
?
|
|
1071
|
-
: [
|
|
1609
|
+
const sldIdLstObj = section['p14:sldIdLst']
|
|
1610
|
+
if (sldIdLstObj?.['p14:sldId']) {
|
|
1611
|
+
const sldIds = Array.isArray(sldIdLstObj['p14:sldId'])
|
|
1612
|
+
? sldIdLstObj['p14:sldId']
|
|
1613
|
+
: [sldIdLstObj['p14:sldId']]
|
|
1614
|
+
// Find the first valid slide ID in this section that is still present in the ordered list
|
|
1615
|
+
let anchorId = null
|
|
1072
1616
|
for (const sldId of sldIds) {
|
|
1073
1617
|
if (sldId && sldId['@_id']) {
|
|
1074
|
-
|
|
1618
|
+
const idStr = String(sldId['@_id'])
|
|
1619
|
+
if (orderedSlideIds.includes(idStr)) {
|
|
1620
|
+
anchorId = idStr
|
|
1621
|
+
break
|
|
1622
|
+
}
|
|
1075
1623
|
}
|
|
1076
1624
|
}
|
|
1625
|
+
sectionAnchors.push({ section, anchorId })
|
|
1626
|
+
} else {
|
|
1627
|
+
sectionAnchors.push({ section, anchorId: null })
|
|
1077
1628
|
}
|
|
1078
1629
|
}
|
|
1079
1630
|
|
|
@@ -1086,19 +1637,21 @@ class SlideManager {
|
|
|
1086
1637
|
}
|
|
1087
1638
|
}
|
|
1088
1639
|
|
|
1089
|
-
// 3.
|
|
1640
|
+
// 3. Populate sections by tracing ordered slide IDs and switching sections when an anchor is reached
|
|
1641
|
+
let currentSection = sections[0] || null
|
|
1642
|
+
|
|
1090
1643
|
for (const slideId of orderedSlideIds) {
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
if (!
|
|
1099
|
-
|
|
1644
|
+
// Check if this slideId is the anchor of another section
|
|
1645
|
+
const matchingAnchor = sectionAnchors.find(sa => sa.anchorId === slideId)
|
|
1646
|
+
if (matchingAnchor) {
|
|
1647
|
+
currentSection = matchingAnchor.section
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
if (currentSection) {
|
|
1651
|
+
if (!currentSection['p14:sldIdLst']) {
|
|
1652
|
+
currentSection['p14:sldIdLst'] = { 'p14:sldId': [] }
|
|
1100
1653
|
}
|
|
1101
|
-
|
|
1654
|
+
currentSection['p14:sldIdLst']['p14:sldId'].push({ '@_id': slideId })
|
|
1102
1655
|
}
|
|
1103
1656
|
}
|
|
1104
1657
|
}
|
|
@@ -1174,10 +1727,26 @@ class SlideManager {
|
|
|
1174
1727
|
* @param {number} slideIndex
|
|
1175
1728
|
* @param {number} [atPosition]
|
|
1176
1729
|
* @param {RelationshipManager} relationshipManager
|
|
1177
|
-
* @
|
|
1730
|
+
* @param {MediaManager} mediaManager
|
|
1731
|
+
* @returns {Promise<number>}
|
|
1178
1732
|
*/
|
|
1179
|
-
duplicateSlide(slideIndex, atPosition, relationshipManager) {
|
|
1180
|
-
this
|
|
1733
|
+
async duplicateSlide(slideIndex, atPosition, relationshipManager, mediaManager) {
|
|
1734
|
+
const promise = this.#duplicateSlideInternal(
|
|
1735
|
+
slideIndex,
|
|
1736
|
+
atPosition,
|
|
1737
|
+
relationshipManager,
|
|
1738
|
+
mediaManager
|
|
1739
|
+
)
|
|
1740
|
+
this.#zipManager.addPendingPromise(promise)
|
|
1741
|
+
return promise
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* Internal duplicate implementation.
|
|
1746
|
+
* @private
|
|
1747
|
+
*/
|
|
1748
|
+
async #duplicateSlideInternal(slideIndex, atPosition, relationshipManager, mediaManager) {
|
|
1749
|
+
await this.#cloneSlideInternal(slideIndex, null, relationshipManager, mediaManager)
|
|
1181
1750
|
const count = this.slideCount
|
|
1182
1751
|
if (atPosition !== undefined && atPosition !== count) {
|
|
1183
1752
|
const order = []
|
|
@@ -1368,6 +1937,328 @@ class SlideManager {
|
|
|
1368
1937
|
}
|
|
1369
1938
|
}
|
|
1370
1939
|
|
|
1940
|
+
/**
|
|
1941
|
+
* Normalizes the slide filenames, slide IDs, and relationship IDs on export.
|
|
1942
|
+
* Ensures slide filenames are strictly sequential (slide1.xml, slide2.xml, ...)
|
|
1943
|
+
* and match their visual order, and updates all relationships and content types.
|
|
1944
|
+
* Also normalizes notes slide filenames to match slide order.
|
|
1945
|
+
*
|
|
1946
|
+
* @param {RelationshipManager} relationshipManager
|
|
1947
|
+
* @param {ContentTypesManager} contentTypesManager
|
|
1948
|
+
*/
|
|
1949
|
+
async normalizeStructure(relationshipManager, contentTypesManager) {
|
|
1950
|
+
const slides = this.getAllSlideInfo()
|
|
1951
|
+
if (slides.length === 0) return
|
|
1952
|
+
|
|
1953
|
+
// 1. Map old path -> new path, old filename -> new filename
|
|
1954
|
+
const pathMap = new Map()
|
|
1955
|
+
const nameMap = new Map()
|
|
1956
|
+
|
|
1957
|
+
slides.forEach((info, idx) => {
|
|
1958
|
+
const newPath = `ppt/slides/slide${idx + 1}.xml`
|
|
1959
|
+
pathMap.set(info.zipPath, newPath)
|
|
1960
|
+
|
|
1961
|
+
const oldName = info.zipPath.split('/').pop()
|
|
1962
|
+
const newName = `slide${idx + 1}.xml`
|
|
1963
|
+
nameMap.set(oldName, newName)
|
|
1964
|
+
})
|
|
1965
|
+
|
|
1966
|
+
// Read all slide XML contents and relationships into memory first
|
|
1967
|
+
const slideData = []
|
|
1968
|
+
for (const info of slides) {
|
|
1969
|
+
const xml = this.getSlideXml(info.index)
|
|
1970
|
+
const rels = relationshipManager.getRelationships(info.zipPath)
|
|
1971
|
+
slideData.push({
|
|
1972
|
+
info,
|
|
1973
|
+
xml,
|
|
1974
|
+
rels: [...rels],
|
|
1975
|
+
})
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// Phase 1: Remove all old files from ZIP and clear their cache entries
|
|
1979
|
+
for (const data of slideData) {
|
|
1980
|
+
const oldPath = data.info.zipPath
|
|
1981
|
+
const newPath = pathMap.get(oldPath)
|
|
1982
|
+
|
|
1983
|
+
if (oldPath !== newPath) {
|
|
1984
|
+
// Remove XML file from ZIP
|
|
1985
|
+
this.#zipManager.removeFile(oldPath)
|
|
1986
|
+
|
|
1987
|
+
// Remove .rels file from ZIP and clear RelationshipManager cache
|
|
1988
|
+
const oldRelsKey = relationshipManager.getRelsPath(oldPath)
|
|
1989
|
+
this.#zipManager.removeFile(oldRelsKey)
|
|
1990
|
+
relationshipManager.deleteRelationships(oldPath)
|
|
1991
|
+
|
|
1992
|
+
// Remove content type override
|
|
1993
|
+
contentTypesManager.removeOverride(oldPath)
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// Phase 2: Write all new files and update cache entries
|
|
1998
|
+
for (let i = 0; i < slideData.length; i++) {
|
|
1999
|
+
const { info, xml, rels } = slideData[i]
|
|
2000
|
+
const oldPath = info.zipPath
|
|
2001
|
+
const newPath = pathMap.get(oldPath)
|
|
2002
|
+
|
|
2003
|
+
// Update slide XML cache key
|
|
2004
|
+
this.#slideXmlCache.delete(oldPath)
|
|
2005
|
+
this.#slideXmlCache.set(newPath, xml)
|
|
2006
|
+
|
|
2007
|
+
// Update SlideInfo path in memory
|
|
2008
|
+
info.zipPath = newPath
|
|
2009
|
+
|
|
2010
|
+
// Write slide XML to ZIP
|
|
2011
|
+
this.#zipManager.writeFile(newPath, xml)
|
|
2012
|
+
|
|
2013
|
+
// Add content type override
|
|
2014
|
+
contentTypesManager.addOverride(newPath, SLIDE_CONTENT_TYPE)
|
|
2015
|
+
|
|
2016
|
+
// Set relationships for the new path in memory and flush to ZIP
|
|
2017
|
+
relationshipManager.setRelationships(newPath, rels)
|
|
2018
|
+
relationshipManager.flushRelationships(newPath)
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// 2. Update slide-to-slide hyperlink targets and notes slide targets in all slide relationships
|
|
2022
|
+
for (const info of slides) {
|
|
2023
|
+
const rels = relationshipManager.getRelationships(info.zipPath)
|
|
2024
|
+
let dirty = false
|
|
2025
|
+
for (const rel of rels) {
|
|
2026
|
+
if (rel.target && !rel.targetMode) {
|
|
2027
|
+
const targetParts = rel.target.split('/')
|
|
2028
|
+
const targetName = targetParts[targetParts.length - 1]
|
|
2029
|
+
if (nameMap.has(targetName)) {
|
|
2030
|
+
targetParts[targetParts.length - 1] = nameMap.get(targetName)
|
|
2031
|
+
rel.target = targetParts.join('/')
|
|
2032
|
+
dirty = true
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
if (dirty) {
|
|
2037
|
+
relationshipManager.flushRelationships(info.zipPath)
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// 3. Normalize notes slides: renumber them to match the slide order
|
|
2042
|
+
await this.#normalizeNotesSlides(slides, relationshipManager, contentTypesManager, nameMap)
|
|
2043
|
+
|
|
2044
|
+
// 4. Update slide targets in presentation.xml relationships if slide filenames changed
|
|
2045
|
+
const presRels = relationshipManager.getRelationships('ppt/presentation.xml')
|
|
2046
|
+
|
|
2047
|
+
presRels.forEach(rel => {
|
|
2048
|
+
const target = rel.target
|
|
2049
|
+
if (target) {
|
|
2050
|
+
const targetParts = target.split('/')
|
|
2051
|
+
const targetName = targetParts[targetParts.length - 1]
|
|
2052
|
+
if (nameMap.has(targetName)) {
|
|
2053
|
+
targetParts[targetParts.length - 1] = nameMap.get(targetName)
|
|
2054
|
+
rel.target = targetParts.join('/')
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
})
|
|
2058
|
+
|
|
2059
|
+
// Flush relationships for ppt/presentation.xml to ZIP
|
|
2060
|
+
relationshipManager.flushRelationships('ppt/presentation.xml')
|
|
2061
|
+
|
|
2062
|
+
// Clear any stale presentationIdMap (no longer used)
|
|
2063
|
+
this.#presentationIdMap = null
|
|
2064
|
+
|
|
2065
|
+
// 7. Rebuild presentation.xml sldIdLst and synchronize sections
|
|
2066
|
+
this.rebuildPresentationSlideOrder()
|
|
2067
|
+
|
|
2068
|
+
// 8. Remove orphan slide parts left over from add/remove/duplicate operations
|
|
2069
|
+
this.#purgeOrphanSlideParts(slides, relationshipManager, contentTypesManager)
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/**
|
|
2073
|
+
* Deletes slide XML/.rels parts that are no longer referenced by the presentation.
|
|
2074
|
+
* @private
|
|
2075
|
+
*/
|
|
2076
|
+
#purgeOrphanSlideParts(slides, relationshipManager, contentTypesManager) {
|
|
2077
|
+
const referenced = new Set(slides.map(s => s.zipPath))
|
|
2078
|
+
const slideFiles = this.#zipManager
|
|
2079
|
+
.listFiles('ppt/slides/')
|
|
2080
|
+
.filter(f => /\/slide\d+\.xml$/.test(f))
|
|
2081
|
+
|
|
2082
|
+
for (const zipPath of slideFiles) {
|
|
2083
|
+
if (referenced.has(zipPath)) continue
|
|
2084
|
+
|
|
2085
|
+
this.#zipManager.removeFile(zipPath)
|
|
2086
|
+
const relsPath = relationshipManager.getRelsPath(zipPath)
|
|
2087
|
+
this.#zipManager.removeFile(relsPath)
|
|
2088
|
+
relationshipManager.deleteRelationships(zipPath)
|
|
2089
|
+
contentTypesManager.removeOverride(zipPath)
|
|
2090
|
+
this.#slideXmlCache.delete(zipPath)
|
|
2091
|
+
logger.debug(`Purged orphan slide part: ${zipPath}`)
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
/**
|
|
2096
|
+
* Normalizes notes slide filenames so they are sequentially numbered
|
|
2097
|
+
* matching their associated slides and updates all relevant relationships.
|
|
2098
|
+
* @private
|
|
2099
|
+
*/
|
|
2100
|
+
async #normalizeNotesSlides(slides, relationshipManager, contentTypesManager, slideNameMap) {
|
|
2101
|
+
const NOTES_CONTENT_TYPE =
|
|
2102
|
+
'application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml'
|
|
2103
|
+
|
|
2104
|
+
// Collect current notes slides from slide relationships
|
|
2105
|
+
const notesData = []
|
|
2106
|
+
for (let i = 0; i < slides.length; i++) {
|
|
2107
|
+
const info = slides[i]
|
|
2108
|
+
const rels = relationshipManager.getRelationships(info.zipPath)
|
|
2109
|
+
const notesRel = rels.find(r => r.type === REL_TYPES.NOTES_SLIDE)
|
|
2110
|
+
if (!notesRel) continue
|
|
2111
|
+
|
|
2112
|
+
const oldNotesPath = relationshipManager.resolveTarget(info.zipPath, notesRel.target)
|
|
2113
|
+
const newNotesFileName = `notesSlide${i + 1}.xml`
|
|
2114
|
+
const newNotesPath = `ppt/notesSlides/${newNotesFileName}`
|
|
2115
|
+
notesData.push({
|
|
2116
|
+
slideInfo: info,
|
|
2117
|
+
notesRel,
|
|
2118
|
+
oldNotesPath,
|
|
2119
|
+
newNotesPath,
|
|
2120
|
+
newNotesFileName,
|
|
2121
|
+
slideNewFileName: info.zipPath.split('/').pop(),
|
|
2122
|
+
})
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
if (notesData.length === 0) return
|
|
2126
|
+
|
|
2127
|
+
// Build a map of old notes filename → new notes filename (for cross-references)
|
|
2128
|
+
const notesNameMap = new Map()
|
|
2129
|
+
for (const nd of notesData) {
|
|
2130
|
+
const oldName = nd.oldNotesPath.split('/').pop()
|
|
2131
|
+
notesNameMap.set(oldName, nd.newNotesFileName)
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// Phase A: Read all notes XML and relationships into memory, then remove old files if path changes
|
|
2135
|
+
for (const nd of notesData) {
|
|
2136
|
+
if (nd.oldNotesPath !== nd.newNotesPath) {
|
|
2137
|
+
// Load cached XML (already preloaded by preloadAll)
|
|
2138
|
+
let notesXml = this.#zipManager.readCachedFile(nd.oldNotesPath)
|
|
2139
|
+
if (!notesXml) {
|
|
2140
|
+
// Fallback: async read from ZIP
|
|
2141
|
+
notesXml = await this.#zipManager.readFile(nd.oldNotesPath)
|
|
2142
|
+
}
|
|
2143
|
+
nd._notesXml = notesXml
|
|
2144
|
+
|
|
2145
|
+
const notesRels = relationshipManager.getRelationships(nd.oldNotesPath)
|
|
2146
|
+
nd._notesRels = [...notesRels]
|
|
2147
|
+
|
|
2148
|
+
// Remove old notes XML, .rels, and content type
|
|
2149
|
+
this.#zipManager.removeFile(nd.oldNotesPath)
|
|
2150
|
+
const oldRelsKey = relationshipManager.getRelsPath(nd.oldNotesPath)
|
|
2151
|
+
this.#zipManager.removeFile(oldRelsKey)
|
|
2152
|
+
relationshipManager.deleteRelationships(nd.oldNotesPath)
|
|
2153
|
+
contentTypesManager.removeOverride(nd.oldNotesPath)
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
// Phase B: Write new notes files and update relationships
|
|
2158
|
+
for (const nd of notesData) {
|
|
2159
|
+
if (nd.oldNotesPath === nd.newNotesPath) {
|
|
2160
|
+
// Path unchanged — still ensure the back-reference to the slide is correct
|
|
2161
|
+
const notesRels = relationshipManager.getRelationships(nd.newNotesPath)
|
|
2162
|
+
const backRef = notesRels.find(r => r.type === REL_TYPES.SLIDE || r.type.endsWith('/slide'))
|
|
2163
|
+
if (backRef) {
|
|
2164
|
+
const expectedTarget = `../slides/${nd.slideNewFileName}`
|
|
2165
|
+
if (backRef.target !== expectedTarget) {
|
|
2166
|
+
backRef.target = expectedTarget
|
|
2167
|
+
relationshipManager.flushRelationships(nd.newNotesPath)
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
continue
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
const notesXml = nd._notesXml
|
|
2174
|
+
const notesRels = nd._notesRels || []
|
|
2175
|
+
|
|
2176
|
+
// Update the notes rels: fix back-reference to the (possibly renamed) slide
|
|
2177
|
+
for (const nr of notesRels) {
|
|
2178
|
+
if (nr.type === REL_TYPES.SLIDE || nr.type.endsWith('/slide')) {
|
|
2179
|
+
nr.target = `../slides/${nd.slideNewFileName}`
|
|
2180
|
+
} else if (nr.target && !nr.targetMode) {
|
|
2181
|
+
const parts = nr.target.split('/')
|
|
2182
|
+
const fname = parts[parts.length - 1]
|
|
2183
|
+
if (slideNameMap.has(fname)) {
|
|
2184
|
+
parts[parts.length - 1] = slideNameMap.get(fname)
|
|
2185
|
+
nr.target = parts.join('/')
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// Write new notes XML to ZIP
|
|
2191
|
+
if (notesXml) {
|
|
2192
|
+
this.#zipManager.writeFile(nd.newNotesPath, notesXml)
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// Write new notes relationships
|
|
2196
|
+
relationshipManager.setRelationships(nd.newNotesPath, notesRels)
|
|
2197
|
+
relationshipManager.flushRelationships(nd.newNotesPath)
|
|
2198
|
+
|
|
2199
|
+
// Register content type
|
|
2200
|
+
contentTypesManager.addOverride(nd.newNotesPath, NOTES_CONTENT_TYPE)
|
|
2201
|
+
|
|
2202
|
+
// Update the slide's notes relationship target to point to the new notes path
|
|
2203
|
+
const slideRels = relationshipManager.getRelationships(nd.slideInfo.zipPath)
|
|
2204
|
+
const slideNotesRel = slideRels.find(r => r.type === REL_TYPES.NOTES_SLIDE)
|
|
2205
|
+
if (slideNotesRel) {
|
|
2206
|
+
slideNotesRel.target = `../notesSlides/${nd.newNotesFileName}`
|
|
2207
|
+
relationshipManager.flushRelationships(nd.slideInfo.zipPath)
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
// Also update any notes-to-notes cross-references in unchanged notes
|
|
2212
|
+
for (const nd of notesData) {
|
|
2213
|
+
const notesRels = relationshipManager.getRelationships(nd.newNotesPath)
|
|
2214
|
+
let dirty = false
|
|
2215
|
+
for (const nr of notesRels) {
|
|
2216
|
+
if (nr.target && !nr.targetMode) {
|
|
2217
|
+
const parts = nr.target.split('/')
|
|
2218
|
+
const fname = parts[parts.length - 1]
|
|
2219
|
+
if (notesNameMap.has(fname)) {
|
|
2220
|
+
parts[parts.length - 1] = notesNameMap.get(fname)
|
|
2221
|
+
nr.target = parts.join('/')
|
|
2222
|
+
dirty = true
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
if (dirty) {
|
|
2227
|
+
relationshipManager.flushRelationships(nd.newNotesPath)
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
/**
|
|
2233
|
+
* Recursively walks an in-memory parsed XML object and remaps @_r:id attribute
|
|
2234
|
+
* values according to the provided ID map. This is more reliable than regex
|
|
2235
|
+
* replacement on the XML string because it only targets actual relationship ID
|
|
2236
|
+
* attributes, not arbitrary attribute values that might match the pattern.
|
|
2237
|
+
* @private
|
|
2238
|
+
* @param {Object} obj
|
|
2239
|
+
* @param {Map<string,string>} idMap
|
|
2240
|
+
*/
|
|
2241
|
+
#remapPresentationRIds(obj, idMap) {
|
|
2242
|
+
if (!obj || typeof obj !== 'object') return
|
|
2243
|
+
for (const key of Object.keys(obj)) {
|
|
2244
|
+
if (key === '@_r:id') {
|
|
2245
|
+
const currentVal = obj[key]
|
|
2246
|
+
if (idMap.has(currentVal)) {
|
|
2247
|
+
obj[key] = idMap.get(currentVal)
|
|
2248
|
+
}
|
|
2249
|
+
} else {
|
|
2250
|
+
const child = obj[key]
|
|
2251
|
+
if (Array.isArray(child)) {
|
|
2252
|
+
for (const item of child) {
|
|
2253
|
+
this.#remapPresentationRIds(item, idMap)
|
|
2254
|
+
}
|
|
2255
|
+
} else if (child && typeof child === 'object') {
|
|
2256
|
+
this.#remapPresentationRIds(child, idMap)
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
1371
2262
|
markSlideObjDirty(slideIndex) {
|
|
1372
2263
|
this.#assertSlideExists(slideIndex)
|
|
1373
2264
|
const state = this.#slideStates.get(slideIndex)
|