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,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, '&amp;')
301
+ .replace(/</g, '&lt;')
302
+ .replace(/>/g, '&gt;')
303
+ .replace(/"/g, '&quot;')
304
+ .replace(/'/g, '&apos;');
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(/&amp;/g, '&')
316
+ .replace(/&lt;/g, '<')
317
+ .replace(/&gt;/g, '>')
318
+ .replace(/&quot;/g, '"')
319
+ .replace(/&apos;/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
+ }