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,160 @@
1
+ /**
2
+ * @fileoverview ContentTypesManager - Manages registrations in [Content_Types].xml.
3
+ *
4
+ * Implements structured, XML-safe manipulation of the OPC manifest.
5
+ */
6
+
7
+ import { createLogger } from '../utils/logger.js';
8
+ import { PPTXError } from '../utils/errors.js';
9
+
10
+ const logger = createLogger('ContentTypesManager');
11
+
12
+ const TYPES_XML_PATH = '[Content_Types].xml';
13
+
14
+ export class ContentTypesManager {
15
+ /** @private @type {XMLParser} */
16
+ #xmlParser;
17
+
18
+ /** @private @type {Object} */
19
+ #contentTypesObj = null;
20
+
21
+ /**
22
+ * @param {XMLParser} xmlParser
23
+ */
24
+ constructor(xmlParser) {
25
+ this.#xmlParser = xmlParser;
26
+ }
27
+
28
+ /**
29
+ * Initializes the manager by reading and parsing [Content_Types].xml from the ZIP.
30
+ *
31
+ * @param {ZipManager} zipManager
32
+ * @returns {Promise<void>}
33
+ */
34
+ async initialize(zipManager) {
35
+ const content = await zipManager.readFile(TYPES_XML_PATH);
36
+ if (!content) {
37
+ throw new PPTXError(`${TYPES_XML_PATH} is missing from the archive.`);
38
+ }
39
+
40
+ this.#contentTypesObj = this.#xmlParser.parse(content, TYPES_XML_PATH);
41
+
42
+ // Ensure structure is correct
43
+ if (!this.#contentTypesObj.Types) {
44
+ this.#contentTypesObj.Types = {
45
+ '@_xmlns': 'http://schemas.openxmlformats.org/package/2006/content-types',
46
+ Default: [],
47
+ Override: []
48
+ };
49
+ }
50
+
51
+ // Ensure array properties
52
+ if (!this.#contentTypesObj.Types.Default) {
53
+ this.#contentTypesObj.Types.Default = [];
54
+ } else if (!Array.isArray(this.#contentTypesObj.Types.Default)) {
55
+ this.#contentTypesObj.Types.Default = [this.#contentTypesObj.Types.Default];
56
+ }
57
+
58
+ if (!this.#contentTypesObj.Types.Override) {
59
+ this.#contentTypesObj.Types.Override = [];
60
+ } else if (!Array.isArray(this.#contentTypesObj.Types.Override)) {
61
+ this.#contentTypesObj.Types.Override = [this.#contentTypesObj.Types.Override];
62
+ }
63
+
64
+ logger.debug(`Loaded [Content_Types].xml with ${this.#contentTypesObj.Types.Default.length} Defaults and ${this.#contentTypesObj.Types.Override.length} Overrides`);
65
+ }
66
+
67
+ /**
68
+ * Registers a default content type for a file extension.
69
+ *
70
+ * @param {string} extension - The file extension (e.g., 'png').
71
+ * @param {string} contentType - The MIME type.
72
+ */
73
+ addDefault(extension, contentType) {
74
+ const extLower = extension.toLowerCase();
75
+ const defaults = this.#contentTypesObj.Types.Default;
76
+
77
+ const existing = defaults.find(d => d['@_Extension']?.toLowerCase() === extLower);
78
+ if (existing) {
79
+ existing['@_ContentType'] = contentType;
80
+ } else {
81
+ defaults.push({
82
+ '@_Extension': extLower,
83
+ '@_ContentType': contentType
84
+ });
85
+ logger.debug(`Registered default content type for extension .${extLower} -> ${contentType}`);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Registers an override content type for a specific package part.
91
+ *
92
+ * @param {string} partName - Absolute part path starting with '/' (e.g., '/ppt/slides/slide1.xml').
93
+ * @param {string} contentType - The MIME type.
94
+ */
95
+ addOverride(partName, contentType) {
96
+ const normalizedPart = partName.startsWith('/') ? partName : `/${partName}`;
97
+ const overrides = this.#contentTypesObj.Types.Override;
98
+
99
+ const existing = overrides.find(o => o['@_PartName'] === normalizedPart);
100
+ if (existing) {
101
+ existing['@_ContentType'] = contentType;
102
+ } else {
103
+ overrides.push({
104
+ '@_PartName': normalizedPart,
105
+ '@_ContentType': contentType
106
+ });
107
+ logger.debug(`Registered override content type for ${normalizedPart} -> ${contentType}`);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Removes an override content type registration.
113
+ *
114
+ * @param {string} partName - Absolute part path (e.g., '/ppt/slides/slide1.xml').
115
+ */
116
+ removeOverride(partName) {
117
+ const normalizedPart = partName.startsWith('/') ? partName : `/${partName}`;
118
+ const overrides = this.#contentTypesObj.Types.Override;
119
+
120
+ const filtered = overrides.filter(o => o['@_PartName'] !== normalizedPart);
121
+ if (filtered.length !== overrides.length) {
122
+ this.#contentTypesObj.Types.Override = filtered;
123
+ logger.debug(`Removed content type override for ${normalizedPart}`);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Checks if a default registration exists for an extension.
129
+ *
130
+ * @param {string} extension
131
+ * @returns {boolean}
132
+ */
133
+ hasDefault(extension) {
134
+ const extLower = extension.toLowerCase();
135
+ return this.#contentTypesObj.Types.Default.some(d => d['@_Extension']?.toLowerCase() === extLower);
136
+ }
137
+
138
+ /**
139
+ * Checks if an override registration exists for a part.
140
+ *
141
+ * @param {string} partName
142
+ * @returns {boolean}
143
+ */
144
+ hasOverride(partName) {
145
+ const normalizedPart = partName.startsWith('/') ? partName : `/${partName}`;
146
+ return this.#contentTypesObj.Types.Override.some(o => o['@_PartName'] === normalizedPart);
147
+ }
148
+
149
+ /**
150
+ * Serializes back to [Content_Types].xml and writes to ZIP.
151
+ *
152
+ * @param {ZipManager} zipManager
153
+ */
154
+ flush(zipManager) {
155
+ const declaration = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
156
+ const xml = this.#xmlParser.build(this.#contentTypesObj, declaration);
157
+ zipManager.writeFile(TYPES_XML_PATH, xml);
158
+ logger.debug(`Flushed ${TYPES_XML_PATH}`);
159
+ }
160
+ }
@@ -0,0 +1,451 @@
1
+ /**
2
+ * @fileoverview HyperlinkManager - Manages hyperlinks in PPTX slides.
3
+ *
4
+ * Hyperlinks in OpenXML PPTX:
5
+ * ─────────────────────────────────────────────────────────────────
6
+ * Hyperlinks are defined as relationships and referenced in slide XML.
7
+ *
8
+ * External Hyperlink (text):
9
+ * 1. Add relationship to slide .rels file:
10
+ * <Relationship Id="rId9" Type=".../hyperlink"
11
+ * Target="https://example.com" TargetMode="External"/>
12
+ *
13
+ * 2. Reference in text run:
14
+ * <a:r>
15
+ * <a:rPr>
16
+ * <a:hlinkClick r:id="rId9"/> ← click action
17
+ * </a:rPr>
18
+ * <a:t>Open Website</a:t>
19
+ * </a:r>
20
+ *
21
+ * Shape Hyperlink:
22
+ * Applied to a:sp via p:sp > p:nvSpPr > p:cNvSpPr:
23
+ * <a:hlinkClick r:id="rId9"/>
24
+ *
25
+ * Slide-to-Slide Navigation:
26
+ * Uses a special action setting:
27
+ * <a:hlinkClick r:id="rId9" action="ppaction://hlinksldjump"/>
28
+ * Where rId9 points to another slide (Target="../slides/slide3.xml")
29
+ *
30
+ * Mouse-Over Hyperlinks:
31
+ * Use <a:hlinkMouseOver r:id="..."/> instead of hlinkClick.
32
+ */
33
+
34
+ import { createLogger } from '../utils/logger.js';
35
+ import { PPTXError } from '../utils/errors.js';
36
+ import { REL_TYPES } from './RelationshipManager.js';
37
+
38
+ const logger = createLogger('HyperlinkManager');
39
+
40
+ /**
41
+ * @class HyperlinkManager
42
+ * @description Manages hyperlink creation and modification in PPTX slides.
43
+ */
44
+ export class HyperlinkManager {
45
+ /** @private @type {XMLParser} */
46
+ #xmlParser;
47
+ /** @private @type {RelationshipManager} */
48
+ #relationshipManager;
49
+
50
+ /**
51
+ * @param {XMLParser} xmlParser
52
+ * @param {RelationshipManager} relationshipManager
53
+ */
54
+ constructor(xmlParser, relationshipManager) {
55
+ this.#xmlParser = xmlParser;
56
+ this.#relationshipManager = relationshipManager;
57
+ }
58
+
59
+ /**
60
+ * Adds an external hyperlink (URL) to text matching the given text in a slide.
61
+ *
62
+ * @param {number} slideIndex - 1-based slide index.
63
+ * @param {HyperlinkOptions} options
64
+ * @param {string} options.text - Text to make clickable.
65
+ * @param {string} options.url - Target URL.
66
+ * @param {string} [options.tooltip] - Optional screen tip tooltip.
67
+ * @param {SlideManager} slideManager
68
+ * @param {RelationshipManager} relationshipManager
69
+ */
70
+ addExternalHyperlink(slideIndex, options, slideManager, relationshipManager) {
71
+ const { text, url, tooltip } = options;
72
+ const slideInfo = slideManager.getSlideInfo(slideIndex);
73
+ const slideXml = slideManager.getSlideXml(slideIndex);
74
+
75
+ // Add the hyperlink relationship to the slide's .rels file
76
+ const rId = relationshipManager.addRelationship(
77
+ slideInfo.zipPath,
78
+ REL_TYPES.HYPERLINK,
79
+ url,
80
+ 'External'
81
+ );
82
+
83
+ // Update the slide XML to reference the new rId
84
+ const updatedXml = this.#injectHyperlinkOnText(slideXml, text, rId, tooltip);
85
+ slideManager.setSlideXml(slideIndex, updatedXml);
86
+
87
+ logger.debug(`Added hyperlink to "${text}" → ${url} (${rId}) in slide ${slideIndex}`);
88
+ }
89
+
90
+ /**
91
+ * Adds an inter-slide hyperlink (navigates to another slide).
92
+ *
93
+ * @param {number} sourceSlideIndex - Source slide (1-based).
94
+ * @param {number} targetSlideIndex - Destination slide (1-based).
95
+ * @param {SlideManager} slideManager
96
+ * @param {RelationshipManager} relationshipManager
97
+ */
98
+ addSlideHyperlink(sourceSlideIndex, targetSlideIndex, slideManager, relationshipManager) {
99
+ const sourceInfo = slideManager.getSlideInfo(sourceSlideIndex);
100
+ const targetInfo = slideManager.getSlideInfo(targetSlideIndex);
101
+
102
+ // Build relative target path from source to target slide
103
+ const relativePath = `../slides/${targetInfo.zipPath.split('/').pop()}`;
104
+
105
+ // Add relationship pointing to the target slide
106
+ const rId = relationshipManager.addRelationship(
107
+ sourceInfo.zipPath,
108
+ REL_TYPES.SLIDE,
109
+ relativePath
110
+ );
111
+
112
+ // Add hlinkClick with slide jump action to the slide number placeholder
113
+ const slideXml = slideManager.getSlideXml(sourceSlideIndex);
114
+ const updatedXml = this.#injectSlideJumpHyperlink(slideXml, rId);
115
+ slideManager.setSlideXml(sourceSlideIndex, updatedXml);
116
+
117
+ logger.debug(`Linked slide ${sourceSlideIndex} → slide ${targetSlideIndex} (${rId})`);
118
+ }
119
+
120
+ /**
121
+ * Adds an inter-slide hyperlink to specific text.
122
+ *
123
+ * @param {number} sourceSlideIndex - Source slide (1-based).
124
+ * @param {string} text - Text to make clickable.
125
+ * @param {number} targetSlideIndex - Destination slide (1-based).
126
+ * @param {SlideManager} slideManager
127
+ * @param {RelationshipManager} relationshipManager
128
+ */
129
+ addTextSlideLink(sourceSlideIndex, text, targetSlideIndex, slideManager, relationshipManager) {
130
+ const sourceInfo = slideManager.getSlideInfo(sourceSlideIndex);
131
+ const targetInfo = slideManager.getSlideInfo(targetSlideIndex);
132
+
133
+ const relativePath = `../slides/${targetInfo.zipPath.split('/').pop()}`;
134
+ const rId = relationshipManager.addRelationship(
135
+ sourceInfo.zipPath,
136
+ REL_TYPES.SLIDE,
137
+ relativePath
138
+ );
139
+
140
+ const slideXml = slideManager.getSlideXml(sourceSlideIndex);
141
+ // Use injectHyperlinkOnText but append action attribute
142
+ const actionAttr = 'action="ppaction://hlinksldjump"';
143
+ const updatedXml = this.#injectHyperlinkOnText(slideXml, text, rId, null, actionAttr);
144
+ slideManager.setSlideXml(sourceSlideIndex, updatedXml);
145
+
146
+ logger.debug(`Linked text "${text}" on slide ${sourceSlideIndex} → slide ${targetSlideIndex} (${rId})`);
147
+ }
148
+
149
+ /**
150
+ * Adds an inter-slide hyperlink to a shape/image.
151
+ *
152
+ * @param {number} sourceSlideIndex - Source slide (1-based).
153
+ * @param {string} shapeName - Name/id of the shape.
154
+ * @param {number} targetSlideIndex - Destination slide (1-based).
155
+ * @param {SlideManager} slideManager
156
+ * @param {RelationshipManager} relationshipManager
157
+ */
158
+ addShapeSlideLink(sourceSlideIndex, shapeName, targetSlideIndex, slideManager, relationshipManager) {
159
+ const sourceInfo = slideManager.getSlideInfo(sourceSlideIndex);
160
+ const targetInfo = slideManager.getSlideInfo(targetSlideIndex);
161
+
162
+ const relativePath = `../slides/${targetInfo.zipPath.split('/').pop()}`;
163
+ const rId = relationshipManager.addRelationship(
164
+ sourceInfo.zipPath,
165
+ REL_TYPES.SLIDE,
166
+ relativePath
167
+ );
168
+
169
+ const slideXml = slideManager.getSlideXml(sourceSlideIndex);
170
+ const actionAttr = 'action="ppaction://hlinksldjump"';
171
+ const updatedXml = this.#injectHyperlinkOnShape(slideXml, shapeName, rId, actionAttr);
172
+ slideManager.setSlideXml(sourceSlideIndex, updatedXml);
173
+
174
+ logger.debug(`Linked shape "${shapeName}" on slide ${sourceSlideIndex} → slide ${targetSlideIndex} (${rId})`);
175
+ }
176
+
177
+ /**
178
+ * Adds a hyperlink to a shape by name.
179
+ *
180
+ * @param {number} slideIndex
181
+ * @param {string} shapeName - cNvPr name attribute of the shape.
182
+ * @param {string} url - Target URL.
183
+ * @param {SlideManager} slideManager
184
+ * @param {RelationshipManager} relationshipManager
185
+ */
186
+ addShapeHyperlink(slideIndex, shapeName, url, slideManager, relationshipManager) {
187
+ const slideInfo = slideManager.getSlideInfo(slideIndex);
188
+ const slideXml = slideManager.getSlideXml(slideIndex);
189
+
190
+ const rId = relationshipManager.addRelationship(
191
+ slideInfo.zipPath,
192
+ REL_TYPES.HYPERLINK,
193
+ url,
194
+ 'External'
195
+ );
196
+
197
+ const updatedXml = this.#injectHyperlinkOnShape(slideXml, shapeName, rId);
198
+ slideManager.setSlideXml(slideIndex, updatedXml);
199
+ logger.debug(`Added shape hyperlink on "${shapeName}" → ${url}`);
200
+ }
201
+
202
+ /**
203
+ * Removes a hyperlink from text in a slide.
204
+ *
205
+ * @param {number} slideIndex
206
+ * @param {string} text - Text with hyperlink to remove.
207
+ * @param {SlideManager} slideManager
208
+ * @param {RelationshipManager} relationshipManager
209
+ */
210
+ removeHyperlink(slideIndex, text, slideManager, relationshipManager) {
211
+ const slideXml = slideManager.getSlideXml(slideIndex);
212
+ const slideInfo = slideManager.getSlideInfo(slideIndex);
213
+
214
+ // Find the rId for this hyperlink
215
+ const hlinkPattern = new RegExp(
216
+ `<a:hlinkClick[^>]*r:id="(rId\\d+)"[^/]*/>[\\s\\S]*?<a:t>${this.#escapeRegex(text)}</a:t>`
217
+ );
218
+ const match = hlinkPattern.exec(slideXml);
219
+
220
+ if (match) {
221
+ const rId = match[1];
222
+ // Remove the hlinkClick attribute from the rPr
223
+ const updatedXml = slideXml.replace(
224
+ new RegExp(`<a:hlinkClick[^>]*r:id="${rId}"[^/]*/>`, 'g'),
225
+ ''
226
+ );
227
+ slideManager.setSlideXml(slideIndex, updatedXml);
228
+ relationshipManager.removeRelationship(slideInfo.zipPath, rId);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Injects an hlinkClick reference on a text run matching the given text.
234
+ *
235
+ * @private
236
+ * @param {string} slideXml - Slide XML.
237
+ * @param {string} text - Target text to find.
238
+ * @param {string} rId - Relationship ID.
239
+ * @param {string} [tooltip] - Optional tooltip.
240
+ * @param {string} [actionAttr] - Optional action attribute (e.g. for slide jump).
241
+ * @returns {string} Updated slide XML.
242
+ */
243
+ #injectHyperlinkOnText(slideXml, text, rId, tooltip, actionAttr = '') {
244
+ const escapedText = this.#escapeXml(text);
245
+ const textPattern = new RegExp(`(<a:t>)(${this.#escapeRegex(escapedText)})(<\/a:t>)`, 'g');
246
+
247
+ if (!textPattern.test(slideXml)) {
248
+ logger.warn(`Text "${text}" not found in slide XML`);
249
+ return slideXml;
250
+ }
251
+
252
+ const tipAttr = tooltip ? ` tooltip="${this.#escapeXml(tooltip)}"` : '';
253
+ const actAttr = actionAttr ? ` ${actionAttr}` : '';
254
+ const rIdAttr = rId ? ` r:id="${rId}"` : '';
255
+ const hlinkXml = `<a:hlinkClick xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"${rIdAttr}${tipAttr}${actAttr}/>`;
256
+
257
+ // We need to add the hlinkClick INSIDE the a:rPr of the text run containing our text
258
+ let updated = slideXml;
259
+
260
+ // Find the text node
261
+ const tStart = slideXml.indexOf(`<a:t>${escapedText}</a:t>`);
262
+ if (tStart === -1) {
263
+ const tStartPlain = slideXml.indexOf(`<a:t>${text}</a:t>`);
264
+ if (tStartPlain === -1) {
265
+ logger.warn(`Could not locate text "${text}" in slide XML`);
266
+ return slideXml;
267
+ }
268
+ }
269
+
270
+ // Find the containing <a:r> tag
271
+ const rStart = updated.lastIndexOf('<a:r>', tStart);
272
+ const rEnd = updated.indexOf('</a:r>', tStart);
273
+
274
+ if (rStart === -1 || rEnd === -1) {
275
+ return slideXml;
276
+ }
277
+
278
+ const runXml = updated.substring(rStart, rEnd + '</a:r>'.length);
279
+
280
+ // Check if rPr exists
281
+ if (runXml.includes('<a:rPr')) {
282
+ const rPrEnd = runXml.indexOf('>', runXml.indexOf('<a:rPr'));
283
+ const rPrIsSelfClosing = runXml[rPrEnd - 1] === '/';
284
+
285
+ let newRunXml;
286
+ if (rPrIsSelfClosing) {
287
+ const rPrStart = runXml.indexOf('<a:rPr');
288
+ const rPrFull = runXml.substring(rPrStart, rPrEnd + 1);
289
+ const rPrAttribs = rPrFull.replace('/>', '');
290
+ newRunXml = runXml.replace(
291
+ rPrFull,
292
+ `${rPrAttribs}>${hlinkXml}</a:rPr>`
293
+ );
294
+ } else {
295
+ const rPrClose = runXml.indexOf('</a:rPr>');
296
+ newRunXml =
297
+ runXml.substring(0, rPrClose) + hlinkXml + runXml.substring(rPrClose);
298
+ }
299
+
300
+ updated =
301
+ updated.substring(0, rStart) + newRunXml + updated.substring(rEnd + '</a:r>'.length);
302
+ } else {
303
+ const tTagStart = runXml.indexOf('<a:t>');
304
+ const newRunXml =
305
+ runXml.substring(0, tTagStart) +
306
+ `<a:rPr lang="en-US" dirty="0">${hlinkXml}</a:rPr>` +
307
+ runXml.substring(tTagStart);
308
+ updated =
309
+ updated.substring(0, rStart) + newRunXml + updated.substring(rEnd + '</a:r>'.length);
310
+ }
311
+
312
+ return updated;
313
+ }
314
+
315
+ /**
316
+ * Injects a slide-jump hyperlink action on the slide number placeholder.
317
+ * @private
318
+ */
319
+ #injectSlideJumpHyperlink(slideXml, rId) {
320
+ const hlinkXml = `<a:hlinkClick xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="${rId}" action="ppaction://hlinksldjump"/>`;
321
+
322
+ // Look for slide number field (<a:fld type="slidenum">) or first text run
323
+ const fldPattern = /<a:fld[^>]*type="slidenum"[^>]*>/;
324
+ if (fldPattern.test(slideXml)) {
325
+ // Add action to the fld element
326
+ return slideXml.replace(
327
+ fldPattern,
328
+ match => match.replace('>', `>${hlinkXml}`)
329
+ );
330
+ }
331
+
332
+ // Fallback: add to first text in slide
333
+ return this.#injectHyperlinkOnFirstText(slideXml, rId);
334
+ }
335
+
336
+ /**
337
+ * Injects hyperlink on the first text run in a slide.
338
+ * @private
339
+ */
340
+ #injectHyperlinkOnFirstText(slideXml, rId) {
341
+ const firstT = slideXml.indexOf('<a:t>');
342
+ if (firstT === -1) return slideXml;
343
+
344
+ const text = slideXml.substring(firstT + 5, slideXml.indexOf('</a:t>', firstT));
345
+ return this.#injectHyperlinkOnText(slideXml, text, rId);
346
+ }
347
+
348
+ /**
349
+ * Injects hyperlink on a shape by adding hlinkClick to the cNvSpPr.
350
+ * @private
351
+ */
352
+ #injectHyperlinkOnShape(slideXml, shapeName, rId, actionAttr = '') {
353
+ const actAttr = actionAttr ? ` ${actionAttr}` : '';
354
+ const rIdAttr = rId ? ` r:id="${rId}"` : '';
355
+ const hlinkXml = `<a:hlinkClick xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"${rIdAttr}${actAttr}/>`;
356
+
357
+ // Find the shape by name
358
+ const namePattern = new RegExp(`name="${this.#escapeRegex(shapeName)}"`);
359
+ const nameMatch = namePattern.exec(slideXml);
360
+ if (!nameMatch) {
361
+ logger.warn(`Shape "${shapeName}" not found`);
362
+ return slideXml;
363
+ }
364
+
365
+ // Find the p:sp containing this shape and inject into nvSpPr
366
+ const spStart = slideXml.lastIndexOf('<p:sp>', nameMatch.index);
367
+ const spEnd = slideXml.indexOf('</p:sp>', nameMatch.index);
368
+
369
+ if (spStart === -1 || spEnd === -1) return slideXml;
370
+
371
+ const spXml = slideXml.substring(spStart, spEnd + '</p:sp>'.length);
372
+ const cNvSpPrEnd = spXml.indexOf('</p:cNvSpPr>');
373
+
374
+ if (cNvSpPrEnd === -1) return slideXml;
375
+
376
+ const newSpXml =
377
+ spXml.substring(0, cNvSpPrEnd) + hlinkXml + spXml.substring(cNvSpPrEnd);
378
+
379
+ return (
380
+ slideXml.substring(0, spStart) + newSpXml + slideXml.substring(spEnd + '</p:sp>'.length)
381
+ );
382
+ }
383
+
384
+ /**
385
+ * @private
386
+ */
387
+ #escapeXml(str) {
388
+ return str
389
+ .replace(/&/g, '&amp;')
390
+ .replace(/</g, '&lt;')
391
+ .replace(/>/g, '&gt;')
392
+ .replace(/"/g, '&quot;')
393
+ .replace(/'/g, '&apos;');
394
+ }
395
+
396
+ /**
397
+ * Adds a special navigation action (next slide, prev slide, etc.) to a text element.
398
+ *
399
+ * @param {number} slideIndex
400
+ * @param {string} text
401
+ * @param {'next'|'previous'|'first'|'last'} navType
402
+ * @param {SlideManager} slideManager
403
+ */
404
+ addTextNavigationLink(slideIndex, text, navType, slideManager) {
405
+ const action = this.#getNavigationAction(navType);
406
+ const slideXml = slideManager.getSlideXml(slideIndex);
407
+ const updatedXml = this.#injectHyperlinkOnText(slideXml, text, '', null, `action="${action}"`);
408
+ slideManager.setSlideXml(slideIndex, updatedXml);
409
+ logger.debug(`Added navigation link (${navType}) to "${text}" in slide ${slideIndex}`);
410
+ }
411
+
412
+ /**
413
+ * Adds a special navigation action (next slide, prev slide, etc.) to a shape.
414
+ *
415
+ * @param {number} slideIndex
416
+ * @param {string} shapeName
417
+ * @param {'next'|'previous'|'first'|'last'} navType
418
+ * @param {SlideManager} slideManager
419
+ */
420
+ addShapeNavigationLink(slideIndex, shapeName, navType, slideManager) {
421
+ const action = this.#getNavigationAction(navType);
422
+ const slideXml = slideManager.getSlideXml(slideIndex);
423
+ const updatedXml = this.#injectHyperlinkOnShape(slideXml, shapeName, '', `action="${action}"`);
424
+ slideManager.setSlideXml(slideIndex, updatedXml);
425
+ logger.debug(`Added navigation link (${navType}) to shape "${shapeName}" in slide ${slideIndex}`);
426
+ }
427
+
428
+ /**
429
+ * Resolves a navigation type string to its ppaction target.
430
+ * @private
431
+ */
432
+ #getNavigationAction(navType) {
433
+ const type = String(navType).toLowerCase();
434
+ switch (type) {
435
+ case 'next': return 'ppaction://hlinkshowjump?s=nextslide';
436
+ case 'previous':
437
+ case 'prev': return 'ppaction://hlinkshowjump?s=prevslide';
438
+ case 'first': return 'ppaction://hlinkshowjump?s=firstslide';
439
+ case 'last': return 'ppaction://hlinkshowjump?s=lastslide';
440
+ default:
441
+ throw new PPTXError(`Invalid navigation type: ${navType}`);
442
+ }
443
+ }
444
+
445
+ /**
446
+ * @private
447
+ */
448
+ #escapeRegex(str) {
449
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
450
+ }
451
+ }