node-pptx-templater 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +415 -0
  4. package/package.json +83 -0
  5. package/src/cli/commands/build.js +79 -0
  6. package/src/cli/commands/debug.js +46 -0
  7. package/src/cli/commands/extract.js +42 -0
  8. package/src/cli/commands/inspect.js +39 -0
  9. package/src/cli/commands/validate.js +36 -0
  10. package/src/cli/index.js +132 -0
  11. package/src/core/OutputWriter.js +181 -0
  12. package/src/core/PPTXTemplater.js +961 -0
  13. package/src/core/TemplateEngine.js +321 -0
  14. package/src/index.js +43 -0
  15. package/src/managers/ChartManager.js +317 -0
  16. package/src/managers/ContentTypesManager.js +160 -0
  17. package/src/managers/HyperlinkManager.js +451 -0
  18. package/src/managers/MediaManager.js +307 -0
  19. package/src/managers/RelationshipManager.js +401 -0
  20. package/src/managers/SlideManager.js +950 -0
  21. package/src/managers/TableManager.js +416 -0
  22. package/src/managers/ZipManager.js +298 -0
  23. package/src/managers/charts/ChartCacheGenerator.js +156 -0
  24. package/src/managers/charts/ChartParser.js +43 -0
  25. package/src/managers/charts/ChartRelationshipManager.js +33 -0
  26. package/src/managers/charts/ChartWorkbookUpdater.js +130 -0
  27. package/src/parsers/XMLParser.js +291 -0
  28. package/src/templates/blankPptx.js +1 -0
  29. package/src/templates/slideTemplate.js +314 -0
  30. package/src/utils/contentTypesHelper.js +149 -0
  31. package/src/utils/errors.js +129 -0
  32. package/src/utils/idUtils.js +54 -0
  33. package/src/utils/logger.js +113 -0
  34. package/src/utils/relationshipUtils.js +89 -0
  35. package/src/utils/xmlUtils.js +115 -0
@@ -0,0 +1,307 @@
1
+ /**
2
+ * @fileoverview MediaManager - Manages media files (images, videos, audio) in PPTX.
3
+ *
4
+ * Media in OpenXML PPTX:
5
+ * ─────────────────────────────────────────────────────────────────
6
+ * All media files are stored in ppt/media/ within the PPTX ZIP.
7
+ * They are referenced via relationships from slides, layouts, etc.
8
+ *
9
+ * Media Reference Chain:
10
+ * slide.xml → slide.xml.rels → ppt/media/imageN.{ext}
11
+ *
12
+ * Image in slide XML:
13
+ * <p:pic>
14
+ * <p:nvPicPr>
15
+ * <p:cNvPr id="3" name="logo"/>
16
+ * </p:nvPicPr>
17
+ * <p:blipFill>
18
+ * <a:blip r:embed="rId4"/> ← references image via rId
19
+ * </p:blipFill>
20
+ * <p:spPr>
21
+ * <a:xfrm>
22
+ * <a:off x="457200" y="274638"/> ← position (EMU)
23
+ * <a:ext cx="2743200" cy="1828800"/> ← size (EMU)
24
+ * </a:xfrm>
25
+ * </p:spPr>
26
+ * </p:pic>
27
+ *
28
+ * Media Deduplication:
29
+ * Multiple slides may embed the same logo/background.
30
+ * We hash file content (SHA-1) and reuse existing media files
31
+ * instead of adding duplicates — reducing output file size.
32
+ *
33
+ * Supported formats:
34
+ * Images: PNG, JPEG, GIF, SVG, TIFF, BMP, WMF, EMF
35
+ * Video: MP4, AVI, MOV, WMV
36
+ * Audio: MP3, WAV, M4A
37
+ */
38
+
39
+ import { createHash } from 'crypto';
40
+ import { createLogger } from '../utils/logger.js';
41
+ import { PPTXError } from '../utils/errors.js';
42
+ import { REL_TYPES } from './RelationshipManager.js';
43
+ import fsExtra from 'fs-extra';
44
+
45
+ const logger = createLogger('MediaManager');
46
+
47
+ /**
48
+ * MIME type to extension mapping for media files.
49
+ */
50
+ const MEDIA_TYPES = {
51
+ 'image/png': 'png',
52
+ 'image/jpeg': 'jpeg',
53
+ 'image/jpg': 'jpg',
54
+ 'image/gif': 'gif',
55
+ 'image/svg+xml': 'svg',
56
+ 'image/tiff': 'tiff',
57
+ 'image/bmp': 'bmp',
58
+ 'image/x-wmf': 'wmf',
59
+ 'image/x-emf': 'emf',
60
+ 'image/webp': 'webp',
61
+ 'video/mp4': 'mp4',
62
+ 'audio/mpeg': 'mp3',
63
+ };
64
+
65
+ /**
66
+ * Extension to MIME type mapping.
67
+ */
68
+ const EXT_TO_MIME = {
69
+ png: 'image/png',
70
+ jpg: 'image/jpeg',
71
+ jpeg: 'image/jpeg',
72
+ gif: 'image/gif',
73
+ svg: 'image/svg+xml',
74
+ tiff: 'image/tiff',
75
+ bmp: 'image/bmp',
76
+ wmf: 'image/x-wmf',
77
+ emf: 'image/x-emf',
78
+ webp: 'image/webp',
79
+ mp4: 'video/mp4',
80
+ mp3: 'audio/mpeg',
81
+ };
82
+
83
+ /**
84
+ * @class MediaManager
85
+ * @description Manages media embedding, deduplication, and retrieval in PPTX files.
86
+ */
87
+ export class MediaManager {
88
+ /** @private @type {ContentTypesManager} */
89
+ #contentTypesManager;
90
+ /** @private @type {ZipManager} */
91
+ #zipManager;
92
+
93
+ /**
94
+ * @param {ContentTypesManager} contentTypesManager
95
+ */
96
+ constructor(contentTypesManager) {
97
+ this.#contentTypesManager = contentTypesManager;
98
+ }
99
+
100
+ /**
101
+ * Content hash → existing media ZIP path for deduplication.
102
+ * @private @type {Map<string, string>}
103
+ */
104
+ #mediaHashIndex = new Map();
105
+
106
+ /**
107
+ * All known media files.
108
+ * @private @type {Map<string, MediaInfo>}
109
+ */
110
+ #mediaRegistry = new Map();
111
+
112
+ /**
113
+ * Counter for generating unique media file names.
114
+ * @private @type {number}
115
+ */
116
+ #nextMediaId = 1;
117
+
118
+ /**
119
+ * Initializes by scanning existing media files in the PPTX.
120
+ *
121
+ * @param {ZipManager} zipManager
122
+ * @returns {Promise<void>}
123
+ */
124
+ async initialize(zipManager) {
125
+ this.#zipManager = zipManager;
126
+ const mediaFiles = zipManager.listFiles('ppt/media/');
127
+
128
+ // Index all existing media files by content hash for deduplication
129
+ await Promise.all(
130
+ mediaFiles.map(async mediaPath => {
131
+ const data = await zipManager.readBinaryFile(mediaPath);
132
+ if (data) {
133
+ const hash = this.#hashBytes(data);
134
+ const ext = mediaPath.split('.').pop().toLowerCase();
135
+ const mimeType = EXT_TO_MIME[ext] || 'application/octet-stream';
136
+
137
+ const mediaInfo = { zipPath: mediaPath, hash, mimeType, size: data.length };
138
+ this.#mediaHashIndex.set(hash, mediaPath);
139
+ this.#mediaRegistry.set(mediaPath, mediaInfo);
140
+
141
+ // Track the highest media ID to avoid collisions
142
+ const numMatch = /\d+/.exec(mediaPath.split('/').pop());
143
+ if (numMatch) {
144
+ const num = parseInt(numMatch[0], 10);
145
+ if (num >= this.#nextMediaId) this.#nextMediaId = num + 1;
146
+ }
147
+ }
148
+ })
149
+ );
150
+
151
+ logger.debug(`Indexed ${this.#mediaRegistry.size} media file(s)`);
152
+ }
153
+
154
+ /**
155
+ * Returns the total number of media files.
156
+ * @returns {number}
157
+ */
158
+ get mediaCount() {
159
+ return this.#mediaRegistry.size;
160
+ }
161
+
162
+ /**
163
+ * Embeds a new image from a file path or Buffer.
164
+ * Automatically deduplicates — if the same image already exists,
165
+ * returns the existing ZIP path instead of creating a duplicate.
166
+ *
167
+ * @param {string|Buffer} source - File path or image Buffer.
168
+ * @param {string} [mimeType] - MIME type (auto-detected from extension if omitted).
169
+ * @returns {Promise<string>} ZIP path of the embedded image (e.g., 'ppt/media/image5.png').
170
+ */
171
+ async embedImage(source, mimeType) {
172
+ let data;
173
+ let ext;
174
+
175
+ if (typeof source === 'string') {
176
+ // Load from file path
177
+ data = await fsExtra.readFile(source);
178
+ ext = source.split('.').pop().toLowerCase();
179
+ mimeType = mimeType || EXT_TO_MIME[ext] || 'image/png';
180
+ } else if (Buffer.isBuffer(source) || source instanceof Uint8Array) {
181
+ data = source;
182
+ // Detect format from magic bytes
183
+ ext = this.#detectExtension(data);
184
+ mimeType = mimeType || EXT_TO_MIME[ext] || 'image/png';
185
+ } else {
186
+ throw new PPTXError('embedImage: source must be a file path string or Buffer');
187
+ }
188
+
189
+ // Check for duplicate (content-addressable dedup)
190
+ const hash = this.#hashBytes(data);
191
+ if (this.#mediaHashIndex.has(hash)) {
192
+ const existingPath = this.#mediaHashIndex.get(hash);
193
+ logger.debug(`Reusing existing media: ${existingPath} (hash: ${hash.substring(0, 8)}...)`);
194
+ return existingPath;
195
+ }
196
+
197
+ // Create a new media file
198
+ const mediaId = this.#nextMediaId++;
199
+ const zipPath = `ppt/media/image${mediaId}.${ext}`;
200
+
201
+ this.#zipManager.writeBinaryFile(zipPath, data);
202
+ this.#mediaHashIndex.set(hash, zipPath);
203
+ this.#mediaRegistry.set(zipPath, { zipPath, hash, mimeType, size: data.length });
204
+
205
+ // Register content type
206
+ this.#registerContentType(ext, mimeType);
207
+
208
+ logger.debug(`Embedded new media: ${zipPath} (${data.length} bytes)`);
209
+ return zipPath;
210
+ }
211
+
212
+ /**
213
+ * Generates the slide XML snippet for an image element.
214
+ *
215
+ * @param {string} rId - Relationship ID pointing to the media file.
216
+ * @param {ImageElement} opts - Image element options.
217
+ * @param {number} opts.x - X position in EMU.
218
+ * @param {number} opts.y - Y position in EMU.
219
+ * @param {number} opts.width - Width in EMU.
220
+ * @param {number} opts.height - Height in EMU.
221
+ * @param {string} [opts.name] - Shape name.
222
+ * @param {number} [opts.shapeId] - Shape ID.
223
+ * @returns {string} XML snippet for the image.
224
+ */
225
+ buildImageXml(rId, opts) {
226
+ const { x = 0, y = 0, width = 2743200, height = 1828800, name = 'image', shapeId = 1 } = opts;
227
+
228
+ return `<p:pic>
229
+ <p:nvPicPr>
230
+ <p:cNvPr id="${shapeId}" name="${name}"/>
231
+ <p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
232
+ <p:nvPr/>
233
+ </p:nvPicPr>
234
+ <p:blipFill>
235
+ <a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="${rId}" cstate="print">
236
+ <a:extLst><a:ext uri="{28A0092B-C50C-407E-A947-70E740481C1C}"><a14:useLocalDpi xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" val="0"/></a:ext></a:extLst>
237
+ </a:blip>
238
+ <a:stretch><a:fillRect/></a:stretch>
239
+ </p:blipFill>
240
+ <p:spPr>
241
+ <a:xfrm>
242
+ <a:off x="${x}" y="${y}"/>
243
+ <a:ext cx="${width}" cy="${height}"/>
244
+ </a:xfrm>
245
+ <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
246
+ </p:spPr>
247
+ </p:pic>`;
248
+ }
249
+
250
+ /**
251
+ * Gets info about a media file by its ZIP path.
252
+ *
253
+ * @param {string} zipPath
254
+ * @returns {MediaInfo|undefined}
255
+ */
256
+ getMediaInfo(zipPath) {
257
+ return this.#mediaRegistry.get(zipPath);
258
+ }
259
+
260
+ /**
261
+ * Returns all registered media files.
262
+ * @returns {MediaInfo[]}
263
+ */
264
+ getAllMedia() {
265
+ return Array.from(this.#mediaRegistry.values());
266
+ }
267
+
268
+ /**
269
+ * Computes a SHA-1 hash of binary data.
270
+ * Used for content-addressable deduplication.
271
+ *
272
+ * @private
273
+ * @param {Buffer|Uint8Array} data
274
+ * @returns {string} Hex digest.
275
+ */
276
+ #hashBytes(data) {
277
+ return createHash('sha1').update(data).digest('hex');
278
+ }
279
+
280
+ /**
281
+ * Detects image format from magic bytes.
282
+ *
283
+ * @private
284
+ * @param {Buffer|Uint8Array} data
285
+ * @returns {string} File extension.
286
+ */
287
+ #detectExtension(data) {
288
+ const sig = data.slice(0, 8);
289
+
290
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
291
+ if (sig[0] === 0x89 && sig[1] === 0x50) return 'png';
292
+ // JPEG: FF D8 FF
293
+ if (sig[0] === 0xFF && sig[1] === 0xD8) return 'jpg';
294
+ // GIF: 47 49 46
295
+ if (sig[0] === 0x47 && sig[1] === 0x49) return 'gif';
296
+ // WEBP: 52 49 46 46 ... 57 45 42 50
297
+ if (sig[0] === 0x52 && sig[1] === 0x49 && sig[8] === 0x57) return 'webp';
298
+ // BMP: 42 4D
299
+ if (sig[0] === 0x42 && sig[1] === 0x4D) return 'bmp';
300
+
301
+ return 'png'; // Default fallback
302
+ }
303
+
304
+ #registerContentType(ext, mimeType) {
305
+ this.#contentTypesManager.addDefault(ext, mimeType);
306
+ }
307
+ }
@@ -0,0 +1,401 @@
1
+ /**
2
+ * @fileoverview RelationshipManager - Manages OpenXML relationship files (.rels).
3
+ *
4
+ * In OpenXML, every part (slide, layout, master, chart, image) that references
5
+ * another part does so through a "relationship" file stored in a _rels/ folder.
6
+ *
7
+ * Relationship File Structure:
8
+ * ┌─────────────────────────────────────────────────────────────────┐
9
+ * │ <?xml version="1.0" encoding="UTF-8" standalone="yes"?> │
10
+ * │ <Relationships xmlns="..."> │
11
+ * │ <Relationship │
12
+ * │ Id="rId1" │
13
+ * │ Type="...slideLayout" │
14
+ * │ Target="../slideLayouts/slideLayout1.xml"/> │
15
+ * │ <Relationship │
16
+ * │ Id="rId2" │
17
+ * │ Type="...hyperlink" │
18
+ * │ Target="https://example.com" │
19
+ * │ TargetMode="External"/> │
20
+ * │ </Relationships> │
21
+ * └─────────────────────────────────────────────────────────────────┘
22
+ *
23
+ * Relationship ID rules:
24
+ * - Must be unique within each .rels file
25
+ * - Format: rId1, rId2, rId3, ... (sequential)
26
+ * - Referenced by r:id="rId1" attributes in the parent part
27
+ *
28
+ * Common relationship types (shortened):
29
+ * - .../slide → presentation → slide
30
+ * - .../slideLayout → slide → layout
31
+ * - .../slideMaster → layout → master
32
+ * - .../chart → slide → chart
33
+ * - .../image → slide → image
34
+ * - .../hyperlink → text run → external URL
35
+ * - .../slideToSlide → slide → another slide (inter-slide link)
36
+ */
37
+
38
+ import { createLogger } from '../utils/logger.js';
39
+ import { PPTXError } from '../utils/errors.js';
40
+ import { generateRelationshipId } from '../utils/relationshipUtils.js';
41
+
42
+ const logger = createLogger('RelationshipManager');
43
+
44
+ /**
45
+ * OpenXML relationship type constants.
46
+ * Using shortened forms; full URIs are in the OpenXML spec.
47
+ */
48
+ export const REL_TYPES = {
49
+ SLIDE: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide',
50
+ SLIDE_LAYOUT: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout',
51
+ SLIDE_MASTER: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster',
52
+ CHART: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart',
53
+ IMAGE: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
54
+ HYPERLINK: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink',
55
+ NOTES_SLIDE: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide',
56
+ THEME: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme',
57
+ TABLE_STYLES: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles',
58
+ PRESENTATION: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument',
59
+ CORE_PROPERTIES: 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties',
60
+ EXTENDED_PROPERTIES: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties',
61
+ PACKAGE: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/package',
62
+ };
63
+
64
+ /**
65
+ * @class RelationshipManager
66
+ * @description Parses, manages, and writes OpenXML relationship (.rels) files.
67
+ *
68
+ * Each PPTX part that has relationships gets a corresponding .rels file in
69
+ * a _rels/ subdirectory. For example:
70
+ * - ppt/presentation.xml → ppt/_rels/presentation.xml.rels
71
+ * - ppt/slides/slide1.xml → ppt/slides/_rels/slide1.xml.rels
72
+ */
73
+ export class RelationshipManager {
74
+ /**
75
+ * @private
76
+ * @type {XMLParser}
77
+ */
78
+ #xmlParser;
79
+
80
+ /**
81
+ * @private
82
+ * @type {ZipManager}
83
+ */
84
+ #zipManager;
85
+
86
+ /**
87
+ * @private
88
+ * @type {Map<string, Relationship[]>}
89
+ * Maps zip path → parsed relationships array.
90
+ * Key: relationship file path (e.g., 'ppt/_rels/presentation.xml.rels')
91
+ */
92
+ #relationships = new Map();
93
+
94
+ /**
95
+ * @param {XMLParser} xmlParser
96
+ */
97
+ constructor(xmlParser) {
98
+ this.#xmlParser = xmlParser;
99
+ }
100
+
101
+ /**
102
+ * Initializes by discovering all .rels files in the ZIP.
103
+ *
104
+ * @param {ZipManager} zipManager
105
+ * @returns {Promise<void>}
106
+ */
107
+ async initialize(zipManager) {
108
+ this.#zipManager = zipManager;
109
+ const relFiles = zipManager.listFiles('').filter(f => f.endsWith('.rels'));
110
+
111
+ await Promise.all(
112
+ relFiles.map(async relsPath => {
113
+ const content = await zipManager.readFile(relsPath);
114
+ if (content) {
115
+ this.#relationships.set(relsPath, this.#parseRels(content, relsPath));
116
+ }
117
+ })
118
+ );
119
+
120
+ logger.debug(`Loaded ${this.#relationships.size} relationship files`);
121
+ }
122
+
123
+ /**
124
+ * Returns the relationship file path for a given part path.
125
+ *
126
+ * @example
127
+ * getRelsPath('ppt/slides/slide1.xml')
128
+ * // → 'ppt/slides/_rels/slide1.xml.rels'
129
+ *
130
+ * @param {string} partPath - ZIP path of the part.
131
+ * @returns {string} Path to the corresponding .rels file.
132
+ */
133
+ getRelsPath(partPath) {
134
+ const lastSlash = partPath.lastIndexOf('/');
135
+ const dir = lastSlash >= 0 ? partPath.substring(0, lastSlash) : '';
136
+ const file = lastSlash >= 0 ? partPath.substring(lastSlash + 1) : partPath;
137
+ return dir ? `${dir}/_rels/${file}.rels` : `_rels/${file}.rels`;
138
+ }
139
+
140
+ /**
141
+ * Gets all relationships for a given part.
142
+ *
143
+ * @param {string} partPath - ZIP path of the part (not the .rels file).
144
+ * @returns {Relationship[]} Array of relationships.
145
+ */
146
+ getRelationships(partPath) {
147
+ const relsPath = this.getRelsPath(partPath);
148
+ return this.#relationships.get(relsPath) || [];
149
+ }
150
+
151
+ /**
152
+ * Gets a specific relationship by ID for a given part.
153
+ *
154
+ * @param {string} partPath - ZIP path of the part.
155
+ * @param {string} rId - Relationship ID (e.g., 'rId1').
156
+ * @returns {Relationship|null}
157
+ */
158
+ getRelationshipById(partPath, rId) {
159
+ const rels = this.getRelationships(partPath);
160
+ return rels.find(r => r.id === rId) || null;
161
+ }
162
+
163
+ /**
164
+ * Gets relationships filtered by type.
165
+ *
166
+ * @param {string} partPath - ZIP path of the part.
167
+ * @param {string} type - Relationship type (use REL_TYPES constants).
168
+ * @returns {Relationship[]}
169
+ */
170
+ getRelationshipsByType(partPath, type) {
171
+ return this.getRelationships(partPath).filter(r => r.type === type);
172
+ }
173
+
174
+ /**
175
+ * Adds a new relationship to a part.
176
+ * Automatically assigns the next available rId.
177
+ *
178
+ * @param {string} partPath - ZIP path of the owning part.
179
+ * @param {string} type - Relationship type (REL_TYPES constant).
180
+ * @param {string} target - Target path or URL.
181
+ * @param {string} [targetMode] - 'External' for URLs, omit for internal parts.
182
+ * @returns {string} The assigned relationship ID (e.g., 'rId3').
183
+ */
184
+ addRelationship(partPath, type, target, targetMode) {
185
+ const relsPath = this.getRelsPath(partPath);
186
+
187
+ if (!this.#relationships.has(relsPath)) {
188
+ this.#relationships.set(relsPath, []);
189
+ }
190
+
191
+ const existing = this.#relationships.get(relsPath);
192
+ const newId = generateRelationshipId(existing.map(r => r.id));
193
+
194
+ const rel = { id: newId, type, target };
195
+ if (targetMode) rel.targetMode = targetMode;
196
+
197
+ existing.push(rel);
198
+ this.#flushRels(relsPath, partPath);
199
+
200
+ logger.debug(`Added relationship ${newId} (${type.split('/').pop()}) to ${partPath}`);
201
+ return newId;
202
+ }
203
+
204
+ /**
205
+ * Removes a relationship from a part.
206
+ *
207
+ * @param {string} partPath - ZIP path of the owning part.
208
+ * @param {string} rId - Relationship ID to remove.
209
+ */
210
+ removeRelationship(partPath, rId) {
211
+ const relsPath = this.getRelsPath(partPath);
212
+ const existing = this.#relationships.get(relsPath) || [];
213
+ const filtered = existing.filter(r => r.id !== rId);
214
+ this.#relationships.set(relsPath, filtered);
215
+ this.#flushRels(relsPath, partPath);
216
+ }
217
+
218
+ /**
219
+ * Updates the target of an existing relationship.
220
+ *
221
+ * @param {string} partPath - ZIP path of the owning part.
222
+ * @param {string} rId - Relationship ID to update.
223
+ * @param {string} newTarget - New target value.
224
+ */
225
+ updateRelationshipTarget(partPath, rId, newTarget) {
226
+ const relsPath = this.getRelsPath(partPath);
227
+ const existing = this.#relationships.get(relsPath) || [];
228
+ const rel = existing.find(r => r.id === rId);
229
+ if (rel) {
230
+ rel.target = newTarget;
231
+ this.#flushRels(relsPath, partPath);
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Copies all relationships from one part to another.
237
+ * Used when cloning slides — the clone gets the same layout/master references.
238
+ *
239
+ * @param {string} sourcePath - Source part path.
240
+ * @param {string} destPath - Destination part path.
241
+ * @param {string[]} [excludeTypes] - Relationship types to exclude.
242
+ * @returns {Map<string, string>} Map of old rId → new rId for the cloned part.
243
+ */
244
+ copyRelationships(sourcePath, destPath, excludeTypes = []) {
245
+ const sourceRels = this.getRelationships(sourcePath);
246
+ const destRelsPath = this.getRelsPath(destPath);
247
+ const idMap = new Map();
248
+
249
+ if (!this.#relationships.has(destRelsPath)) {
250
+ this.#relationships.set(destRelsPath, []);
251
+ }
252
+
253
+ const destRels = this.#relationships.get(destRelsPath);
254
+
255
+ for (const rel of sourceRels) {
256
+ if (excludeTypes.includes(rel.type)) continue;
257
+ const newId = generateRelationshipId(destRels.map(r => r.id));
258
+ const newRel = { ...rel, id: newId };
259
+ destRels.push(newRel);
260
+ idMap.set(rel.id, newId);
261
+ }
262
+
263
+ this.#flushRels(destRelsPath, destPath);
264
+ return idMap;
265
+ }
266
+
267
+ /**
268
+ * Resolves a relative target path to an absolute ZIP path.
269
+ *
270
+ * @example
271
+ * resolveTarget('ppt/slides/slide1.xml', '../slideLayouts/slideLayout1.xml')
272
+ * // → 'ppt/slideLayouts/slideLayout1.xml'
273
+ *
274
+ * @param {string} partPath - The part that owns the relationship.
275
+ * @param {string} target - Relative target from the relationship.
276
+ * @returns {string} Absolute ZIP path.
277
+ */
278
+ resolveTarget(partPath, target) {
279
+ if (target.startsWith('http://') || target.startsWith('https://')) {
280
+ return target; // External URL — return as-is
281
+ }
282
+
283
+ const baseParts = partPath.split('/');
284
+ baseParts.pop(); // Remove file name, keep directory
285
+ const targetParts = target.split('/');
286
+
287
+ for (const part of targetParts) {
288
+ if (part === '..') {
289
+ baseParts.pop();
290
+ } else if (part !== '.') {
291
+ baseParts.push(part);
292
+ }
293
+ }
294
+
295
+ return baseParts.join('/');
296
+ }
297
+
298
+ /**
299
+ * Parses a .rels XML file into an array of relationship objects.
300
+ * @private
301
+ * @param {string} xmlContent - Raw XML content.
302
+ * @param {string} relsPath - For error reporting.
303
+ * @returns {Relationship[]}
304
+ */
305
+ #parseRels(xmlContent, relsPath) {
306
+ try {
307
+ const obj = this.#xmlParser.parse(xmlContent, relsPath);
308
+ const relationships = obj?.Relationships?.Relationship || [];
309
+ const relsArray = Array.isArray(relationships) ? relationships : [relationships];
310
+
311
+ return relsArray.map(rel => ({
312
+ id: rel['@_Id'],
313
+ type: rel['@_Type'],
314
+ target: rel['@_Target'],
315
+ targetMode: rel['@_TargetMode'] || null,
316
+ }));
317
+ } catch (err) {
318
+ logger.warn(`Failed to parse ${relsPath}: ${err.message}`);
319
+ return [];
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Serializes the in-memory relationships back to XML and updates the ZIP.
325
+ * @private
326
+ * @param {string} relsPath - Path of the .rels file.
327
+ * @param {string} partPath - For logging.
328
+ */
329
+ #flushRels(relsPath, partPath) {
330
+ const rels = this.#relationships.get(relsPath) || [];
331
+ const xml = this.#buildRelsXml(rels);
332
+ if (this.#zipManager) {
333
+ this.#zipManager.writeFile(relsPath, xml);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Builds the XML string for a relationships file.
339
+ * @private
340
+ * @param {Relationship[]} rels
341
+ * @returns {string}
342
+ */
343
+ #buildRelsXml(rels) {
344
+ const lines = rels.map(rel => {
345
+ const targetMode = rel.targetMode ? ` TargetMode="${rel.targetMode}"` : '';
346
+ return ` <Relationship Id="${rel.id}" Type="${rel.type}" Target="${rel.target}"${targetMode}/>`;
347
+ });
348
+
349
+ return [
350
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
351
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
352
+ ...lines,
353
+ '</Relationships>',
354
+ ].join('\n');
355
+ }
356
+
357
+ /**
358
+ * Flushes all dirty relationship files to the ZIP manager.
359
+ * Called before generating final output.
360
+ *
361
+ * @param {ZipManager} zipManager
362
+ */
363
+ flushAll(zipManager) {
364
+ for (const [relsPath, rels] of this.#relationships) {
365
+ const xml = this.#buildRelsXml(rels);
366
+ zipManager.writeFile(relsPath, xml);
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Scans all relationships and removes those pointing to missing internal targets.
372
+ * This is part of the repair functionality.
373
+ *
374
+ * @param {ZipManager} zipManager
375
+ */
376
+ removeOrphanRelationships(zipManager) {
377
+ let removedCount = 0;
378
+ for (const [relsPath, rels] of this.#relationships.entries()) {
379
+ // Determine the base part path from the rels path
380
+ // e.g. ppt/slides/_rels/slide1.xml.rels -> ppt/slides/slide1.xml
381
+ const partPath = relsPath.replace('_rels/', '').replace('.rels', '');
382
+
383
+ const filtered = rels.filter(rel => {
384
+ if (rel.targetMode === 'External') return true;
385
+ const targetPath = this.resolveTarget(partPath, rel.target);
386
+ if (!zipManager.hasFile(targetPath)) {
387
+ logger.warn(`Removing orphan relationship ${rel.id} pointing to missing target: ${targetPath}`);
388
+ removedCount++;
389
+ return false;
390
+ }
391
+ return true;
392
+ });
393
+
394
+ if (filtered.length !== rels.length) {
395
+ this.#relationships.set(relsPath, filtered);
396
+ this.#flushRels(relsPath, partPath);
397
+ }
398
+ }
399
+ logger.debug(`Removed ${removedCount} orphan relationship(s).`);
400
+ }
401
+ }