node-pptx-templater 2.0.0-alpha.0 → 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.
@@ -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 existingIds = Array.from(this.#slides.values()).map(s => parseInt(s.slideId, 10))
393
- const maxId = existingIds.length > 0 ? Math.max(...existingIds) : 255
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
- cloneSlide(sourceIndex, atPosition, relationshipManager) {
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
- // Copy the source XML
447
- let sourceXml = this.getSlideXml(sourceIndex)
448
- logger.debug('Source XML length:', sourceXml ? sourceXml.length : 0)
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
- // Copy relationships from source slide (excluding notes, which are slide-specific)
456
- const idMap = relationshipManager.copyRelationships(sourceInfo.zipPath, slideZipPath, [
457
- REL_TYPES.NOTES_SLIDE,
458
- ])
459
- logger.debug('Copied relationship ID map:', Array.from(idMap.entries()))
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)
460
510
 
461
- // Remap relationship IDs in the cloned XML to match the new targets
462
- sourceXml = remapRelationshipIds(sourceXml, idMap)
511
+ this.#zipManager.writeFile(slideZipPath, cloneXml)
512
+ this.#slideXmlCache.set(slideZipPath, cloneXml)
463
513
 
464
- this.#zipManager.writeFile(slideZipPath, sourceXml)
465
- this.#slideXmlCache.set(slideZipPath, sourceXml)
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
+ )
540
+
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
+ )
565
+
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 existingIds = Array.from(this.#slides.values()).map(s => parseInt(s.slideId, 10))
475
- const maxId = Math.max(...existingIds)
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: sourceXml,
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
- // Remove from ZIP
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 its relationships file
517
- const relsFileName = info.zipPath.split('/').pop() + '.rels'
518
- this.#zipManager.removeFile(`ppt/slides/_rels/${relsFileName}`)
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)
519
1041
 
520
- // Remove from cache
1042
+ // 4. Remove slide relationship cache entry
1043
+ this.#relationshipManager.deleteRelationships(info.zipPath)
1044
+
1045
+ // 5. Remove slide XML cache entries
521
1046
  this.#slideXmlCache.delete(info.zipPath)
522
1047
  this.#slideStates.delete(slideIndex)
523
1048
 
524
- // Remove relationship from presentation.xml
525
- this.#relationshipManager.removeRelationship('ppt/presentation.xml', info.relationshipId)
1049
+ // 6. Remove content type from [Content_Types].xml
1050
+ this.#contentTypesManager.removeOverride(info.zipPath)
526
1051
 
527
- // Remove relationships cache
528
- this.#relationshipManager.deleteRelationships(info.zipPath)
1052
+ // 7. Remove relationship from presentation.xml.rels
1053
+ this.#relationshipManager.removeRelationship('ppt/presentation.xml', info.relationshipId)
529
1054
 
530
- // Remove content type from [Content_Types].xml
531
- this.#contentTypesManager.removeOverride(info.zipPath)
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
- // Update presentation.xml
538
- this.#removeSlideFromPresentation(info.slideId)
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(this.getAllSlideIndices().map(i => this.getSlideXmlAsync(i)))
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'] !== slideId)
1538
+ ).filter(s => String(s['@_id']) !== targetIdStr)
997
1539
 
998
1540
  // Also remove from any PowerPoint sections
999
1541
  this.#removeSlideFromSections(slideId)
@@ -1048,6 +1590,74 @@ class SlideManager {
1048
1590
  })
1049
1591
 
1050
1592
  sldIdLst['p:sldId'] = ordered
1593
+
1594
+ // Synchronize sections order with the new slide order
1595
+ const orderedSlideIds = ordered.map(o => String(o['@_id']))
1596
+ const extLst = this.#xmlParser.getNode(this.#presentationObj, 'p:presentation.p:extLst')
1597
+ if (extLst?.['p:ext']) {
1598
+ const exts = Array.isArray(extLst['p:ext']) ? extLst['p:ext'] : [extLst['p:ext']]
1599
+ for (const ext of exts) {
1600
+ const sectionLst = ext['p14:sectionLst']
1601
+ if (sectionLst?.['p14:section']) {
1602
+ const sections = Array.isArray(sectionLst['p14:section'])
1603
+ ? sectionLst['p14:section']
1604
+ : [sectionLst['p14:section']]
1605
+
1606
+ // 1. Identify section anchors (first slide of each section)
1607
+ const sectionAnchors = []
1608
+ for (const section of sections) {
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
1616
+ for (const sldId of sldIds) {
1617
+ if (sldId && sldId['@_id']) {
1618
+ const idStr = String(sldId['@_id'])
1619
+ if (orderedSlideIds.includes(idStr)) {
1620
+ anchorId = idStr
1621
+ break
1622
+ }
1623
+ }
1624
+ }
1625
+ sectionAnchors.push({ section, anchorId })
1626
+ } else {
1627
+ sectionAnchors.push({ section, anchorId: null })
1628
+ }
1629
+ }
1630
+
1631
+ // 2. Clear the slide lists of all sections
1632
+ for (const section of sections) {
1633
+ if (!section['p14:sldIdLst']) {
1634
+ section['p14:sldIdLst'] = { 'p14:sldId': [] }
1635
+ } else {
1636
+ section['p14:sldIdLst']['p14:sldId'] = []
1637
+ }
1638
+ }
1639
+
1640
+ // 3. Populate sections by tracing ordered slide IDs and switching sections when an anchor is reached
1641
+ let currentSection = sections[0] || null
1642
+
1643
+ for (const slideId of orderedSlideIds) {
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': [] }
1653
+ }
1654
+ currentSection['p14:sldIdLst']['p14:sldId'].push({ '@_id': slideId })
1655
+ }
1656
+ }
1657
+ }
1658
+ }
1659
+ }
1660
+
1051
1661
  this.#flushPresentation()
1052
1662
  }
1053
1663
 
@@ -1117,10 +1727,26 @@ class SlideManager {
1117
1727
  * @param {number} slideIndex
1118
1728
  * @param {number} [atPosition]
1119
1729
  * @param {RelationshipManager} relationshipManager
1120
- * @returns {number}
1730
+ * @param {MediaManager} mediaManager
1731
+ * @returns {Promise<number>}
1121
1732
  */
1122
- duplicateSlide(slideIndex, atPosition, relationshipManager) {
1123
- this.cloneSlide(slideIndex, null, relationshipManager)
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)
1124
1750
  const count = this.slideCount
1125
1751
  if (atPosition !== undefined && atPosition !== count) {
1126
1752
  const order = []
@@ -1311,6 +1937,328 @@ class SlideManager {
1311
1937
  }
1312
1938
  }
1313
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
+
1314
2262
  markSlideObjDirty(slideIndex) {
1315
2263
  this.#assertSlideExists(slideIndex)
1316
2264
  const state = this.#slideStates.get(slideIndex)