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.
- package/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/package.json +83 -0
- package/src/cli/commands/build.js +79 -0
- package/src/cli/commands/debug.js +46 -0
- package/src/cli/commands/extract.js +42 -0
- package/src/cli/commands/inspect.js +39 -0
- package/src/cli/commands/validate.js +36 -0
- package/src/cli/index.js +132 -0
- package/src/core/OutputWriter.js +181 -0
- package/src/core/PPTXTemplater.js +961 -0
- package/src/core/TemplateEngine.js +321 -0
- package/src/index.js +43 -0
- package/src/managers/ChartManager.js +317 -0
- package/src/managers/ContentTypesManager.js +160 -0
- package/src/managers/HyperlinkManager.js +451 -0
- package/src/managers/MediaManager.js +307 -0
- package/src/managers/RelationshipManager.js +401 -0
- package/src/managers/SlideManager.js +950 -0
- package/src/managers/TableManager.js +416 -0
- package/src/managers/ZipManager.js +298 -0
- package/src/managers/charts/ChartCacheGenerator.js +156 -0
- package/src/managers/charts/ChartParser.js +43 -0
- package/src/managers/charts/ChartRelationshipManager.js +33 -0
- package/src/managers/charts/ChartWorkbookUpdater.js +130 -0
- package/src/parsers/XMLParser.js +291 -0
- package/src/templates/blankPptx.js +1 -0
- package/src/templates/slideTemplate.js +314 -0
- package/src/utils/contentTypesHelper.js +149 -0
- package/src/utils/errors.js +129 -0
- package/src/utils/idUtils.js +54 -0
- package/src/utils/logger.js +113 -0
- package/src/utils/relationshipUtils.js +89 -0
- 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, '&')
|
|
390
|
+
.replace(/</g, '<')
|
|
391
|
+
.replace(/>/g, '>')
|
|
392
|
+
.replace(/"/g, '"')
|
|
393
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|