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,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview TemplateEngine - Text placeholder replacement engine.
|
|
3
|
+
*
|
|
4
|
+
* Handles {{placeholder}} replacement in slide XML.
|
|
5
|
+
*
|
|
6
|
+
* Challenge with OpenXML text replacement:
|
|
7
|
+
* ─────────────────────────────────────────────────────────────────
|
|
8
|
+
* A simple string "{{title}}" in PowerPoint might be split across
|
|
9
|
+
* MULTIPLE text run elements in the XML:
|
|
10
|
+
*
|
|
11
|
+
* <a:r><a:t>{{ti</a:t></a:r>
|
|
12
|
+
* <a:r><a:t>tle}}</a:t></a:r>
|
|
13
|
+
*
|
|
14
|
+
* This happens because PowerPoint splits text runs when:
|
|
15
|
+
* - Spell-check marks parts of the text
|
|
16
|
+
* - Font formatting changes mid-word
|
|
17
|
+
* - The text was typed in multiple sessions
|
|
18
|
+
*
|
|
19
|
+
* Solution Strategy:
|
|
20
|
+
* 1. Extract all <a:r><a:t>...</a:t></a:r> run sequences within each <a:p>
|
|
21
|
+
* 2. Concatenate run text to find the full combined text
|
|
22
|
+
* 3. Check if the combined text contains any placeholder
|
|
23
|
+
* 4. If yes: merge all runs into a single run with replaced text,
|
|
24
|
+
* preserving the formatting of the FIRST run as the template
|
|
25
|
+
*
|
|
26
|
+
* This "text normalization" approach correctly handles fragmented placeholders.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { createLogger } from '../utils/logger.js';
|
|
30
|
+
|
|
31
|
+
const logger = createLogger('TemplateEngine');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default placeholder pattern: {{key}}
|
|
35
|
+
* Can be overridden per-call.
|
|
36
|
+
*/
|
|
37
|
+
const DEFAULT_PLACEHOLDER_PATTERN = /\{\{([^{}]+)\}\}/g;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @class TemplateEngine
|
|
41
|
+
* @description Handles text placeholder replacement in OpenXML slide XML.
|
|
42
|
+
*
|
|
43
|
+
* Implements the text-normalization strategy to handle fragmented placeholders.
|
|
44
|
+
*/
|
|
45
|
+
export class TemplateEngine {
|
|
46
|
+
/** @private @type {XMLParser} */
|
|
47
|
+
#xmlParser;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {XMLParser} xmlParser
|
|
51
|
+
*/
|
|
52
|
+
constructor(xmlParser) {
|
|
53
|
+
this.#xmlParser = xmlParser;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Replaces all placeholders in a slide XML string.
|
|
58
|
+
* Uses the text-normalization strategy to handle fragmented text runs.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} slideXml - Raw slide XML.
|
|
61
|
+
* @param {Object.<string, string>} replacements - Placeholder → value map.
|
|
62
|
+
* @param {RegExp} [pattern] - Custom placeholder pattern. Defaults to {{key}}.
|
|
63
|
+
* @returns {string} Modified slide XML with placeholders replaced.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* const updated = engine.replaceTextInXml(slideXml, {
|
|
67
|
+
* '{{name}}': 'John Doe',
|
|
68
|
+
* '{{date}}': '2026-01-01'
|
|
69
|
+
* });
|
|
70
|
+
*/
|
|
71
|
+
replaceTextInXml(slideXml, replacements, pattern = DEFAULT_PLACEHOLDER_PATTERN) {
|
|
72
|
+
if (!replacements || Object.keys(replacements).length === 0) {
|
|
73
|
+
return slideXml;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
logger.debug(`Replacing ${Object.keys(replacements).length} placeholder(s)`);
|
|
77
|
+
|
|
78
|
+
// Step 1: Process paragraph by paragraph to handle fragmented runs
|
|
79
|
+
let updated = this.#processParagraphs(slideXml, replacements);
|
|
80
|
+
|
|
81
|
+
// Step 2: Simple direct replacement for any remaining unfragmented placeholders
|
|
82
|
+
for (const [placeholder, value] of Object.entries(replacements)) {
|
|
83
|
+
const escaped = this.#escapeXml(String(value));
|
|
84
|
+
const placeholderEscaped = this.#escapeXml(placeholder);
|
|
85
|
+
|
|
86
|
+
// Replace the XML-escaped form (e.g., {{name}} as {{name}})
|
|
87
|
+
updated = updated.split(placeholderEscaped).join(escaped);
|
|
88
|
+
// Replace the plain form (in case it's not escaped in the XML)
|
|
89
|
+
updated = updated.split(placeholder).join(escaped);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return updated;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Processes all paragraphs (<a:p>) in the slide XML, normalizing text runs
|
|
97
|
+
* within each paragraph to fix fragmented placeholders.
|
|
98
|
+
*
|
|
99
|
+
* @private
|
|
100
|
+
* @param {string} slideXml
|
|
101
|
+
* @param {Object.<string, string>} replacements
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
#processParagraphs(slideXml, replacements) {
|
|
105
|
+
// Find all <a:p>...</a:p> paragraphs
|
|
106
|
+
let updated = slideXml;
|
|
107
|
+
let offset = 0;
|
|
108
|
+
|
|
109
|
+
const paragraphPattern = /<a:p>([\s\S]*?)<\/a:p>/g;
|
|
110
|
+
let match;
|
|
111
|
+
|
|
112
|
+
while ((match = paragraphPattern.exec(slideXml)) !== null) {
|
|
113
|
+
const paragraphXml = match[0];
|
|
114
|
+
const processedParagraph = this.#processParagraph(paragraphXml, replacements);
|
|
115
|
+
|
|
116
|
+
if (processedParagraph !== paragraphXml) {
|
|
117
|
+
// Replace in the updated string (adjust for offset changes)
|
|
118
|
+
const start = updated.indexOf(paragraphXml, match.index + offset - (match.index));
|
|
119
|
+
|
|
120
|
+
// More reliable: replace from the beginning of the current search area
|
|
121
|
+
updated = updated.substring(0, match.index + offset) +
|
|
122
|
+
processedParagraph +
|
|
123
|
+
updated.substring(match.index + offset + paragraphXml.length);
|
|
124
|
+
|
|
125
|
+
offset += processedParagraph.length - paragraphXml.length;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return updated;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Processes a single paragraph, normalizing runs and replacing placeholders.
|
|
134
|
+
*
|
|
135
|
+
* @private
|
|
136
|
+
* @param {string} paragraphXml - XML of a single <a:p> element.
|
|
137
|
+
* @param {Object.<string, string>} replacements
|
|
138
|
+
* @returns {string} Updated paragraph XML.
|
|
139
|
+
*/
|
|
140
|
+
#processParagraph(paragraphXml, replacements) {
|
|
141
|
+
// Extract all text runs from this paragraph
|
|
142
|
+
const runs = this.#extractRuns(paragraphXml);
|
|
143
|
+
|
|
144
|
+
if (runs.length === 0) return paragraphXml;
|
|
145
|
+
|
|
146
|
+
// Combine text from all runs
|
|
147
|
+
const combinedText = runs.map(r => r.text).join('');
|
|
148
|
+
|
|
149
|
+
// Check if any placeholder appears in the combined text
|
|
150
|
+
let hasPlaceholder = false;
|
|
151
|
+
for (const placeholder of Object.keys(replacements)) {
|
|
152
|
+
if (combinedText.includes(placeholder)) {
|
|
153
|
+
hasPlaceholder = true;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!hasPlaceholder) return paragraphXml;
|
|
159
|
+
|
|
160
|
+
// Perform replacement on combined text
|
|
161
|
+
let replacedText = combinedText;
|
|
162
|
+
for (const [placeholder, value] of Object.entries(replacements)) {
|
|
163
|
+
replacedText = replacedText.split(placeholder).join(String(value));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Rebuild the paragraph: merge all runs into a single run using first run's format
|
|
167
|
+
return this.#mergeRunsWithText(paragraphXml, runs, replacedText);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Extracts all text runs from a paragraph's XML.
|
|
172
|
+
* Returns run XML and the extracted text content.
|
|
173
|
+
*
|
|
174
|
+
* @private
|
|
175
|
+
* @param {string} paragraphXml
|
|
176
|
+
* @returns {Array<{xml: string, text: string, start: number, end: number}>}
|
|
177
|
+
*/
|
|
178
|
+
#extractRuns(paragraphXml) {
|
|
179
|
+
const runs = [];
|
|
180
|
+
const runPattern = /(<a:r(?:\s[^>]*)?>)([\s\S]*?)(<\/a:r>)/g;
|
|
181
|
+
let match;
|
|
182
|
+
|
|
183
|
+
while ((match = runPattern.exec(paragraphXml)) !== null) {
|
|
184
|
+
const runXml = match[0];
|
|
185
|
+
// Extract text from <a:t>...</a:t> within this run
|
|
186
|
+
const tMatch = /<a:t>([\s\S]*?)<\/a:t>/.exec(runXml);
|
|
187
|
+
const text = tMatch ? this.#unescapeXml(tMatch[1]) : '';
|
|
188
|
+
|
|
189
|
+
runs.push({
|
|
190
|
+
xml: runXml,
|
|
191
|
+
text,
|
|
192
|
+
start: match.index,
|
|
193
|
+
end: match.index + runXml.length,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return runs;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Merges all runs in a paragraph into a single run using the first run's
|
|
202
|
+
* formatting as the template, with the replaced text as content.
|
|
203
|
+
*
|
|
204
|
+
* @private
|
|
205
|
+
* @param {string} paragraphXml
|
|
206
|
+
* @param {Array} runs - Extracted run info.
|
|
207
|
+
* @param {string} newText - New text to inject.
|
|
208
|
+
* @returns {string} Updated paragraph XML.
|
|
209
|
+
*/
|
|
210
|
+
#mergeRunsWithText(paragraphXml, runs, newText) {
|
|
211
|
+
if (runs.length === 0) return paragraphXml;
|
|
212
|
+
|
|
213
|
+
// Use the first run as the format template
|
|
214
|
+
const firstRun = runs[0];
|
|
215
|
+
|
|
216
|
+
// Build the replacement run: first run's format + new text
|
|
217
|
+
const mergedRunXml = this.#setRunText(firstRun.xml, this.#escapeXml(newText));
|
|
218
|
+
|
|
219
|
+
// Build new paragraph:
|
|
220
|
+
// Keep everything before first run, insert merged run, remove the rest,
|
|
221
|
+
// keep everything after last run
|
|
222
|
+
const firstRunStart = firstRun.start;
|
|
223
|
+
const lastRunEnd = runs[runs.length - 1].end;
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
paragraphXml.substring(0, firstRunStart) +
|
|
227
|
+
mergedRunXml +
|
|
228
|
+
paragraphXml.substring(lastRunEnd)
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Replaces the text content of a text run XML.
|
|
234
|
+
*
|
|
235
|
+
* @private
|
|
236
|
+
* @param {string} runXml - Run XML string.
|
|
237
|
+
* @param {string} text - New text content (already XML-escaped).
|
|
238
|
+
* @returns {string} Updated run XML.
|
|
239
|
+
*/
|
|
240
|
+
#setRunText(runXml, text) {
|
|
241
|
+
const tPattern = /(<a:t>)([\s\S]*?)(<\/a:t>)/;
|
|
242
|
+
if (tPattern.test(runXml)) {
|
|
243
|
+
return runXml.replace(tPattern, `$1${text}$3`);
|
|
244
|
+
}
|
|
245
|
+
// If no <a:t>, add one before </a:r>
|
|
246
|
+
return runXml.replace('</a:r>', `<a:t>${text}</a:t></a:r>`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Checks if a string contains any placeholder from the map.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} text
|
|
253
|
+
* @param {Object.<string, string>} replacements
|
|
254
|
+
* @returns {boolean}
|
|
255
|
+
*/
|
|
256
|
+
containsPlaceholders(text, replacements) {
|
|
257
|
+
return Object.keys(replacements).some(p => text.includes(p));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Extracts all unique placeholder keys from an XML string.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} xml - Slide XML.
|
|
264
|
+
* @param {RegExp} [pattern] - Placeholder pattern.
|
|
265
|
+
* @returns {string[]} Array of placeholder keys found.
|
|
266
|
+
*
|
|
267
|
+
* @example
|
|
268
|
+
* engine.extractPlaceholders(slideXml);
|
|
269
|
+
* // → ['{{title}}', '{{date}}', '{{company}}']
|
|
270
|
+
*/
|
|
271
|
+
extractPlaceholders(xml, pattern = DEFAULT_PLACEHOLDER_PATTERN) {
|
|
272
|
+
const placeholders = new Set();
|
|
273
|
+
const textPattern = /<a:t>([\s\S]*?)<\/a:t>/g;
|
|
274
|
+
let match;
|
|
275
|
+
|
|
276
|
+
// Extract text content first, then find placeholders
|
|
277
|
+
const allText = [];
|
|
278
|
+
while ((match = textPattern.exec(xml)) !== null) {
|
|
279
|
+
allText.push(match[1]);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const combined = allText.join('');
|
|
283
|
+
const plPattern = new RegExp(pattern.source, 'g');
|
|
284
|
+
let plMatch;
|
|
285
|
+
while ((plMatch = plPattern.exec(combined)) !== null) {
|
|
286
|
+
placeholders.add(plMatch[0]);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return Array.from(placeholders);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Escapes XML special characters.
|
|
294
|
+
* @private
|
|
295
|
+
* @param {string} str
|
|
296
|
+
* @returns {string}
|
|
297
|
+
*/
|
|
298
|
+
#escapeXml(str) {
|
|
299
|
+
return str
|
|
300
|
+
.replace(/&/g, '&')
|
|
301
|
+
.replace(/</g, '<')
|
|
302
|
+
.replace(/>/g, '>')
|
|
303
|
+
.replace(/"/g, '"')
|
|
304
|
+
.replace(/'/g, ''');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Unescapes XML entities.
|
|
309
|
+
* @private
|
|
310
|
+
* @param {string} str
|
|
311
|
+
* @returns {string}
|
|
312
|
+
*/
|
|
313
|
+
#unescapeXml(str) {
|
|
314
|
+
return str
|
|
315
|
+
.replace(/&/g, '&')
|
|
316
|
+
.replace(/</g, '<')
|
|
317
|
+
.replace(/>/g, '>')
|
|
318
|
+
.replace(/"/g, '"')
|
|
319
|
+
.replace(/'/g, "'");
|
|
320
|
+
}
|
|
321
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* node-pptx-templater
|
|
3
|
+
*
|
|
4
|
+
* A low-level PowerPoint OpenXML templating engine for Node.js.
|
|
5
|
+
* Generates and edits PPTX files directly through XML manipulation
|
|
6
|
+
* without relying on PowerPoint generation libraries.
|
|
7
|
+
*
|
|
8
|
+
* @module node-pptx-templater
|
|
9
|
+
* @author node-pptx-templater contributors
|
|
10
|
+
* @license MIT
|
|
11
|
+
*
|
|
12
|
+
* Architecture Overview:
|
|
13
|
+
* ┌─────────────────────────────────────────────────────────────┐
|
|
14
|
+
* │ PPTXTemplater │
|
|
15
|
+
* │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
|
16
|
+
* │ │ZipManager│ │XMLParser │ │SlideManager│ │ChartMgr │ │
|
|
17
|
+
* │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
|
18
|
+
* │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
|
19
|
+
* │ │TableMgr │ │HyperlinkMgr│ │MediaMgr │ │RelMgr │ │
|
|
20
|
+
* │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
|
21
|
+
* │ ┌──────────────┐ │
|
|
22
|
+
* │ │OutputWriter │ │
|
|
23
|
+
* │ └──────────────┘ │
|
|
24
|
+
* └─────────────────────────────────────────────────────────────┘
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export { PPTXTemplater } from './core/PPTXTemplater.js';
|
|
28
|
+
export { ZipManager } from './managers/ZipManager.js';
|
|
29
|
+
export { XMLParser } from './parsers/XMLParser.js';
|
|
30
|
+
export { SlideManager } from './managers/SlideManager.js';
|
|
31
|
+
export { ChartManager } from './managers/ChartManager.js';
|
|
32
|
+
export { TableManager } from './managers/TableManager.js';
|
|
33
|
+
export { HyperlinkManager } from './managers/HyperlinkManager.js';
|
|
34
|
+
export { MediaManager } from './managers/MediaManager.js';
|
|
35
|
+
export { RelationshipManager } from './managers/RelationshipManager.js';
|
|
36
|
+
export { OutputWriter } from './core/OutputWriter.js';
|
|
37
|
+
export { TemplateEngine } from './core/TemplateEngine.js';
|
|
38
|
+
|
|
39
|
+
// Utility exports
|
|
40
|
+
export { generateRelationshipId, parseRelationshipId } from './utils/relationshipUtils.js';
|
|
41
|
+
export { validateXML, repairXML } from './utils/xmlUtils.js';
|
|
42
|
+
export { createLogger } from './utils/logger.js';
|
|
43
|
+
export { PPTXError, SlideNotFoundError, ChartNotFoundError, TableNotFoundError } from './utils/errors.js';
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview ChartManager - Updates chart data in PPTX slides.
|
|
3
|
+
*
|
|
4
|
+
* Charts in OpenXML PPTX:
|
|
5
|
+
* ─────────────────────────────────────────────────────────────────
|
|
6
|
+
* Charts are embedded as separate XML files referenced via relationships.
|
|
7
|
+
* A chart on a slide appears as:
|
|
8
|
+
*
|
|
9
|
+
* <p:graphicFrame>
|
|
10
|
+
* <p:nvGraphicFramePr>
|
|
11
|
+
* <p:cNvPr id="5" name="sales-chart"/> ← chart name/ID
|
|
12
|
+
* </p:nvGraphicFramePr>
|
|
13
|
+
* <a:graphic>
|
|
14
|
+
* <a:graphicData uri="...chart">
|
|
15
|
+
* <c:chart r:id="rId5"/> ← references chart XML
|
|
16
|
+
* </a:graphicData>
|
|
17
|
+
* </a:graphic>
|
|
18
|
+
* </p:graphicFrame>
|
|
19
|
+
*
|
|
20
|
+
* The chart XML at ppt/charts/chartN.xml contains:
|
|
21
|
+
* - c:chartSpace → root element
|
|
22
|
+
* - c:chart → chart metadata
|
|
23
|
+
* - c:plotArea → chart data
|
|
24
|
+
* - c:barChart / c:lineChart / c:pieChart / etc.
|
|
25
|
+
* - c:ser → each data series
|
|
26
|
+
* - c:idx / c:order → series index/order
|
|
27
|
+
* - c:tx → series name
|
|
28
|
+
* - c:cat → categories (X-axis)
|
|
29
|
+
* - c:val → values (Y-axis)
|
|
30
|
+
*
|
|
31
|
+
* Category Reference Types:
|
|
32
|
+
* - c:strRef → string categories (labels)
|
|
33
|
+
* - c:numRef → numeric categories
|
|
34
|
+
*
|
|
35
|
+
* Value Reference Types:
|
|
36
|
+
* - c:numRef → numeric values (typical)
|
|
37
|
+
*
|
|
38
|
+
* Data is stored in both a formula (c:f) and a cache (c:strCache/c:numCache).
|
|
39
|
+
* We update both to ensure compatibility with both cached and live data.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { createLogger } from '../utils/logger.js';
|
|
43
|
+
import { ChartNotFoundError } from '../utils/errors.js';
|
|
44
|
+
import { REL_TYPES } from './RelationshipManager.js';
|
|
45
|
+
import { ChartWorkbookUpdater } from './charts/ChartWorkbookUpdater.js';
|
|
46
|
+
import { ChartCacheGenerator } from './charts/ChartCacheGenerator.js';
|
|
47
|
+
|
|
48
|
+
const logger = createLogger('ChartManager');
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Supported chart types and their XML element names.
|
|
52
|
+
*/
|
|
53
|
+
const CHART_TYPE_MAP = {
|
|
54
|
+
bar: 'c:barChart',
|
|
55
|
+
line: 'c:lineChart',
|
|
56
|
+
pie: 'c:pieChart',
|
|
57
|
+
area: 'c:areaChart',
|
|
58
|
+
scatter: 'c:scatterChart',
|
|
59
|
+
doughnut: 'c:doughnutChart',
|
|
60
|
+
radar: 'c:radarChart',
|
|
61
|
+
bubble: 'c:bubbleChart',
|
|
62
|
+
stock: 'c:stockChart',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @class ChartManager
|
|
67
|
+
* @description Handles chart data updates by directly manipulating chart XML files.
|
|
68
|
+
*
|
|
69
|
+
* Unlike high-level charting libraries, this manager edits the raw OpenXML
|
|
70
|
+
* chart structure, allowing full control while preserving styles and themes.
|
|
71
|
+
*/
|
|
72
|
+
export class ChartManager {
|
|
73
|
+
/** @private @type {XMLParser} */
|
|
74
|
+
#xmlParser;
|
|
75
|
+
/** @private @type {ZipManager} */
|
|
76
|
+
#zipManager;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Cache of chart ZIP paths: maps chartName → { zipPath, slideIndex }
|
|
80
|
+
* @private @type {Map<string, { zipPath: string, slideIndex: number }>}
|
|
81
|
+
*/
|
|
82
|
+
#chartRegistry = new Map();
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {XMLParser} xmlParser
|
|
86
|
+
*/
|
|
87
|
+
constructor(xmlParser) {
|
|
88
|
+
this.#xmlParser = xmlParser;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Initializes by scanning the ZIP for chart files.
|
|
93
|
+
*
|
|
94
|
+
* @param {ZipManager} zipManager
|
|
95
|
+
* @returns {Promise<void>}
|
|
96
|
+
*/
|
|
97
|
+
async initialize(zipManager) {
|
|
98
|
+
this.#zipManager = zipManager;
|
|
99
|
+
const chartFiles = zipManager.listFiles('ppt/charts/').filter(f => f.endsWith('.xml') && !f.includes('_rels'));
|
|
100
|
+
|
|
101
|
+
for (const chartPath of chartFiles) {
|
|
102
|
+
// Chart name is inferred from file name
|
|
103
|
+
const chartName = chartPath.split('/').pop().replace('.xml', '');
|
|
104
|
+
this.#chartRegistry.set(chartName, { zipPath: chartPath, slideIndex: null });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
logger.debug(`Found ${chartFiles.length} chart file(s)`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Updates chart data for a named chart within a slide.
|
|
112
|
+
*
|
|
113
|
+
* @param {number} slideIndex - 1-based slide index.
|
|
114
|
+
* @param {string} chartId - Chart name (from shape's cNvPr name attribute) or chart file name.
|
|
115
|
+
* @param {ChartData} data - New chart data.
|
|
116
|
+
* @param {SlideManager} slideManager
|
|
117
|
+
* @param {RelationshipManager} relationshipManager
|
|
118
|
+
* @throws {ChartNotFoundError} If the chart cannot be found.
|
|
119
|
+
*/
|
|
120
|
+
updateChart(slideIndex, chartId, data, slideManager, relationshipManager) {
|
|
121
|
+
const chartInfo = this.#findChartInSlide(slideIndex, chartId, slideManager, relationshipManager);
|
|
122
|
+
|
|
123
|
+
if (!chartInfo) {
|
|
124
|
+
throw new ChartNotFoundError(`Chart "${chartId}" not found in slide ${slideIndex}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
logger.debug(`Updating chart "${chartId}" at ${chartInfo.zipPath}`);
|
|
128
|
+
this.#updateChartXml(chartInfo.zipPath, data, relationshipManager);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Returns all chart info objects for a given slide.
|
|
133
|
+
*
|
|
134
|
+
* @param {number} slideIndex
|
|
135
|
+
* @param {SlideManager} slideManager
|
|
136
|
+
* @param {RelationshipManager} relationshipManager
|
|
137
|
+
* @returns {Array<{name: string, zipPath: string}>}
|
|
138
|
+
*/
|
|
139
|
+
getChartsInSlide(slideIndex, slideManager, relationshipManager) {
|
|
140
|
+
const slideInfo = slideManager.getSlideInfo(slideIndex);
|
|
141
|
+
const rels = relationshipManager.getRelationshipsByType(slideInfo.zipPath, REL_TYPES.CHART);
|
|
142
|
+
|
|
143
|
+
return rels.map(rel => {
|
|
144
|
+
const chartPath = relationshipManager.resolveTarget(slideInfo.zipPath, rel.target);
|
|
145
|
+
return { rId: rel.id, zipPath: chartPath };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Finds a chart in a slide by name/ID.
|
|
151
|
+
* @private
|
|
152
|
+
*
|
|
153
|
+
* @param {number} slideIndex
|
|
154
|
+
* @param {string} chartId - Shape name or rId.
|
|
155
|
+
* @param {SlideManager} slideManager
|
|
156
|
+
* @param {RelationshipManager} relationshipManager
|
|
157
|
+
* @returns {{ zipPath: string }|null}
|
|
158
|
+
*/
|
|
159
|
+
#findChartInSlide(slideIndex, chartId, slideManager, relationshipManager) {
|
|
160
|
+
const slideInfo = slideManager.getSlideInfo(slideIndex);
|
|
161
|
+
const slideXml = slideManager.getSlideXml(slideIndex);
|
|
162
|
+
|
|
163
|
+
// Strategy 1: Look for shape with matching name (cNvPr name attribute)
|
|
164
|
+
const shapeNamePattern = new RegExp(
|
|
165
|
+
`<p:cNvPr[^>]*name="${chartId}"[^>]*>(?:.*?)<c:chart[^>]*r:id="(rId\\d+)"`,
|
|
166
|
+
's'
|
|
167
|
+
);
|
|
168
|
+
const rIdMatch = shapeNamePattern.exec(slideXml);
|
|
169
|
+
|
|
170
|
+
// Strategy 2: Find graphicFrame shapes and match chart rIds
|
|
171
|
+
if (!rIdMatch) {
|
|
172
|
+
const chartRIdPattern = /<c:chart[^>]*r:id="(rId\d+)"/g;
|
|
173
|
+
let chartMatch;
|
|
174
|
+
while ((chartMatch = chartRIdPattern.exec(slideXml)) !== null) {
|
|
175
|
+
const rId = chartMatch[1];
|
|
176
|
+
const rel = relationshipManager.getRelationshipById(slideInfo.zipPath, rId);
|
|
177
|
+
if (rel) {
|
|
178
|
+
const chartPath = relationshipManager.resolveTarget(slideInfo.zipPath, rel.target);
|
|
179
|
+
// Check if chart file name matches chartId
|
|
180
|
+
if (chartPath.includes(chartId) || rel.id === chartId) {
|
|
181
|
+
return { zipPath: chartPath };
|
|
182
|
+
}
|
|
183
|
+
// For the first chart found, if chartId looks like a chart file name
|
|
184
|
+
if (chartId.startsWith('chart')) {
|
|
185
|
+
return { zipPath: chartPath };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (rIdMatch) {
|
|
192
|
+
const rId = rIdMatch[1];
|
|
193
|
+
const rel = relationshipManager.getRelationshipById(slideInfo.zipPath, rId);
|
|
194
|
+
if (rel) {
|
|
195
|
+
const chartPath = relationshipManager.resolveTarget(slideInfo.zipPath, rel.target);
|
|
196
|
+
return { zipPath: chartPath };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Strategy 3: Direct chart registry lookup
|
|
201
|
+
if (this.#chartRegistry.has(chartId)) {
|
|
202
|
+
return this.#chartRegistry.get(chartId);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Strategy 4: Try chartN naming convention
|
|
206
|
+
const chartPath = `ppt/charts/${chartId}.xml`;
|
|
207
|
+
if (this.#zipManager.hasFile(chartPath)) {
|
|
208
|
+
return { zipPath: chartPath };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Updates the chart XML file with new data.
|
|
216
|
+
* Preserves all styling, themes, and chart configuration.
|
|
217
|
+
*
|
|
218
|
+
* @private
|
|
219
|
+
* @param {string} chartZipPath - ZIP path to the chart XML file.
|
|
220
|
+
* @param {ChartData} data - New chart data.
|
|
221
|
+
* @param {RelationshipManager} relationshipManager
|
|
222
|
+
*/
|
|
223
|
+
#updateChartXml(chartZipPath, data, relationshipManager) {
|
|
224
|
+
if (!this.#zipManager.hasFile(chartZipPath)) {
|
|
225
|
+
throw new ChartNotFoundError(`Chart file not found: ${chartZipPath}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Register async update to ensure it completes before saving
|
|
229
|
+
this.#zipManager.addPendingPromise(
|
|
230
|
+
this.updateChartAsync(chartZipPath, data, relationshipManager)
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Async version of chart update — updates XML and embedded workbook.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} chartZipPath
|
|
238
|
+
* @param {ChartData} data
|
|
239
|
+
* @param {RelationshipManager} relationshipManager
|
|
240
|
+
* @returns {Promise<void>}
|
|
241
|
+
*/
|
|
242
|
+
async updateChartAsync(chartZipPath, data, relationshipManager) {
|
|
243
|
+
// 1. Read Chart XML
|
|
244
|
+
const xml = await this.#zipManager.readFile(chartZipPath);
|
|
245
|
+
if (!xml) throw new ChartNotFoundError(`Chart file not found: ${chartZipPath}`);
|
|
246
|
+
|
|
247
|
+
// 2. Apply Chart XML Updates
|
|
248
|
+
const updatedXml = this.#applyChartData(xml, data, chartZipPath);
|
|
249
|
+
this.#zipManager.writeFile(chartZipPath, updatedXml);
|
|
250
|
+
|
|
251
|
+
// 3. Find and Update Embedded Workbook
|
|
252
|
+
if (relationshipManager) {
|
|
253
|
+
const rels = relationshipManager.getRelationshipsByType(chartZipPath, REL_TYPES.PACKAGE);
|
|
254
|
+
for (const rel of rels) {
|
|
255
|
+
const xlsxPath = relationshipManager.resolveTarget(chartZipPath, rel.target);
|
|
256
|
+
const xlsxData = this.#zipManager.rawZip.file(xlsxPath);
|
|
257
|
+
if (xlsxData) {
|
|
258
|
+
console.log(`Found embedded workbook: ${xlsxPath}`);
|
|
259
|
+
const buffer = await xlsxData.async('nodebuffer');
|
|
260
|
+
const updatedXlsx = await ChartWorkbookUpdater.updateWorkbook(buffer, data);
|
|
261
|
+
if (updatedXlsx) {
|
|
262
|
+
console.log(`Writing updated workbook to: ${xlsxPath}, size: ${updatedXlsx.length}`);
|
|
263
|
+
this.#zipManager.writeBinaryFile(xlsxPath, updatedXlsx);
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
console.log(`Could not find workbook at: ${xlsxPath}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Applies new chart data to the chart XML string.
|
|
274
|
+
*
|
|
275
|
+
* @private
|
|
276
|
+
* @param {string} xml - Original chart XML.
|
|
277
|
+
* @param {ChartData} data - New data to apply.
|
|
278
|
+
* @param {string} context - For error messages.
|
|
279
|
+
* @returns {string} Updated XML.
|
|
280
|
+
*/
|
|
281
|
+
#applyChartData(xml, data, context) {
|
|
282
|
+
const { categories, series } = data;
|
|
283
|
+
|
|
284
|
+
// Detect chart type
|
|
285
|
+
const chartType = this.#detectChartType(xml);
|
|
286
|
+
logger.debug(`Updating ${chartType} chart at ${context}`);
|
|
287
|
+
|
|
288
|
+
let updatedXml = xml;
|
|
289
|
+
|
|
290
|
+
if (series && series.length > 0) {
|
|
291
|
+
updatedXml = ChartCacheGenerator.appendDynamicSeries(updatedXml, series.length);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (categories && categories.length > 0) {
|
|
295
|
+
updatedXml = ChartCacheGenerator.updateCategories(updatedXml, categories);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (series && series.length > 0) {
|
|
299
|
+
updatedXml = ChartCacheGenerator.updateSeries(updatedXml, series, categories ? categories.length : null);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return updatedXml;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Detects the chart type from its XML.
|
|
307
|
+
* @private
|
|
308
|
+
* @param {string} xml
|
|
309
|
+
* @returns {string} Chart type name.
|
|
310
|
+
*/
|
|
311
|
+
#detectChartType(xml) {
|
|
312
|
+
for (const [name, element] of Object.entries(CHART_TYPE_MAP)) {
|
|
313
|
+
if (xml.includes(element)) return name;
|
|
314
|
+
}
|
|
315
|
+
return 'unknown';
|
|
316
|
+
}
|
|
317
|
+
}
|