node-pptx-templater 1.0.9 → 1.0.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-pptx-templater",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "High-performance, low-level PowerPoint (PPTX) OpenXML template engine for Node.js. Dynamically replace text, insert images, update charts (with Excel workbook data caching), and merge table cells without PowerPoint corruption or Repair Mode prompts.",
5
5
  "main": "./src/index.js",
6
6
  "type": "commonjs",
@@ -181,4 +181,4 @@
181
181
  "LICENSE",
182
182
  "CHANGELOG.md"
183
183
  ]
184
- }
184
+ }
@@ -154,58 +154,113 @@ class ChartCacheGenerator {
154
154
  }
155
155
 
156
156
  /**
157
- * Updates the chart title text in chart XML while preserving all existing
158
- * styling (spPr, txPr, overlay, layout) from the template.
157
+ * Updates the chart title text while fully preserving all existing styling.
159
158
  *
160
- * Because <c:txPr> is ignored by PowerPoint once <c:tx><c:rich> is present,
161
- * this method extracts <a:defRPr> from <c:txPr> and injects it as <a:rPr>
162
- * into the run, and uses <a:bodyPr> from <c:txPr> inside <c:rich>, so that
163
- * the template's font, size, and color are faithfully applied to the title text.
159
+ * Three strategies in priority order:
160
+ *
161
+ * 1. If <c:tx><c:rich> already exists (title was previously set via PowerPoint or this
162
+ * library): ONLY replace <a:t> text values in-place, mapped by \n-split lines to
163
+ * existing runs in document order. This preserves alignment, bold/italic/underline,
164
+ * font sizes, paragraph structure, and any other per-run or per-paragraph properties.
165
+ *
166
+ * 2. If <c:title> exists but has no <c:tx> (title text comes from <c:txPr> default
167
+ * properties): Build a new <c:tx><c:rich> by extracting <a:bodyPr>, <a:pPr> (including
168
+ * algn), and <a:defRPr>→<a:rPr> from <c:txPr>. Supports multi-line via \n splitting
169
+ * into separate <a:p> paragraphs, each inheriting the same pPr and rPr.
170
+ *
171
+ * 3. If no <c:title> block exists at all: create a minimal one.
164
172
  */
165
173
  static updateTitle(xml, title) {
166
- const escapedTitle = this.#escapeXml(title)
174
+ // Split by \n so callers can drive multi-paragraph titles
175
+ const titleLines = title.split('\n')
176
+
177
+ if (!xml.includes('<c:title>')) {
178
+ // Strategy 3 – no title block yet, create minimal
179
+ const escapedText = this.#escapeXml(titleLines[0])
180
+ const titleBlock = `<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>${escapedText}</a:t></a:r></a:p></c:rich></c:tx><c:layout/></c:title>`
181
+ return xml.replace(/(<c:chart>)/, `$1${titleBlock}`)
182
+ }
167
183
 
168
- if (xml.includes('<c:title>')) {
169
- return xml.replace(/<c:title>([\s\S]*?)<\/c:title>/, (match, titleContent) => {
170
- // Extract <a:bodyPr .../> from existing <c:txPr> to use in <c:rich>
171
- let bodyPr = '<a:bodyPr/>'
172
- const txPrMatch = /<c:txPr>([\s\S]*?)<\/c:txPr>/.exec(titleContent)
173
- if (txPrMatch) {
174
- const bodyPrMatch = /(<a:bodyPr[^>]*\/>|<a:bodyPr[^>]*>[\s\S]*?<\/a:bodyPr>)/.exec(
175
- txPrMatch[1]
184
+ return xml.replace(/<c:title>([\s\S]*?)<\/c:title>/, (match, titleContent) => {
185
+ // ── Strategy 1 ──────────────────────────────────────────────────────────────
186
+ // Existing <c:tx><c:rich> is present: preserve every element, just swap text.
187
+ if (titleContent.includes('<c:tx>')) {
188
+ const txMatch = /<c:tx>([\s\S]*?)<\/c:tx>/.exec(titleContent)
189
+ if (txMatch && txMatch[1].includes('<c:rich>')) {
190
+ let lineIndex = 0
191
+ // Replace each <a:t>…</a:t> in document order with the next line.
192
+ // Any run that maps beyond the supplied lines gets an empty string.
193
+ const updatedTx = txMatch[1].replace(/<a:t>[^<]*<\/a:t>/g, () => {
194
+ const text = lineIndex < titleLines.length ? this.#escapeXml(titleLines[lineIndex]) : ''
195
+ lineIndex++
196
+ return `<a:t>${text}</a:t>`
197
+ })
198
+ const updatedContent = titleContent.replace(
199
+ /<c:tx>[\s\S]*?<\/c:tx>/,
200
+ `<c:tx>${updatedTx}</c:tx>`
176
201
  )
177
- if (bodyPrMatch) bodyPr = bodyPrMatch[1]
202
+ return `<c:title>${updatedContent}</c:title>`
178
203
  }
204
+ }
179
205
 
180
- // Extract <a:defRPr .../> from <c:txPr><a:p><a:pPr> to use as <a:rPr> in the run
181
- let rPr = ''
182
- if (txPrMatch) {
183
- const defRPrMatch = /(<a:defRPr[\s\S]*?<\/a:defRPr>|<a:defRPr[^>]*\/>)/.exec(txPrMatch[1])
184
- if (defRPrMatch) {
185
- // Convert <a:defRPr ...> to <a:rPr ...> (same attributes, different tag name)
186
- rPr = defRPrMatch[1]
187
- .replace(/^<a:defRPr/, '<a:rPr')
188
- .replace(/<\/a:defRPr>$/, '</a:rPr>')
189
- }
190
- }
206
+ // ── Strategy 2 ──────────────────────────────────────────────────────────────
207
+ // No <c:tx> yet – build one from <c:txPr> styles.
208
+ let bodyPr = '<a:bodyPr/>'
209
+ let pPrXml = '' // paragraph properties (alignment etc.) without defRPr
210
+ let rPr = '' // run properties from defRPr
191
211
 
192
- const newTxBlock = `<c:tx><c:rich>${bodyPr}<a:lstStyle/><a:p><a:r>${rPr}<a:t>${escapedTitle}</a:t></a:r></a:p></c:rich></c:tx>`
212
+ const txPrMatch = /<c:txPr>([\s\S]*?)<\/c:txPr>/.exec(titleContent)
213
+ if (txPrMatch) {
214
+ const txPrContent = txPrMatch[1]
193
215
 
194
- if (titleContent.includes('<c:tx>')) {
195
- // Replace existing c:tx, keep all other siblings intact
196
- const updatedContent = titleContent.replace(/<c:tx>[\s\S]*?<\/c:tx>/, newTxBlock)
197
- return `<c:title>${updatedContent}</c:title>`
216
+ // Extract <a:bodyPr>
217
+ const bodyPrMatch = /(<a:bodyPr[^>]*\/>|<a:bodyPr[^>]*>[\s\S]*?<\/a:bodyPr>)/.exec(
218
+ txPrContent
219
+ )
220
+ if (bodyPrMatch) bodyPr = bodyPrMatch[1]
221
+
222
+ // Extract <a:defRPr> → convert to <a:rPr> for the run
223
+ const defRPrMatch = /(<a:defRPr[\s\S]*?<\/a:defRPr>|<a:defRPr[^>]*\/>)/.exec(txPrContent)
224
+ if (defRPrMatch) {
225
+ rPr = defRPrMatch[1].replace(/^<a:defRPr/, '<a:rPr').replace(/<\/a:defRPr>$/, '</a:rPr>')
226
+ }
227
+
228
+ // Extract <a:pPr> (keeps algn, indent, etc.) but strip <a:defRPr> from it
229
+ // since that is now expressed as <a:rPr> in the run.
230
+ // We must handle both <a:defRPr .../> (self-closing) and
231
+ // <a:defRPr ...>...</a:defRPr> (element with children).
232
+ const pPrBlockMatch = /(<a:pPr[^>]*>)([\s\S]*?)(<\/a:pPr>)/.exec(txPrContent)
233
+ if (pPrBlockMatch) {
234
+ const innerContent = pPrBlockMatch[2]
235
+ .replace(/<a:defRPr(?:[^>]*\/>|[\s\S]*?<\/a:defRPr>)/g, '')
236
+ .trim()
237
+ // Only emit pPr tag if it has attributes or remaining child content
238
+ const attrs = pPrBlockMatch[1].slice(7, -1).trim() // strip '<a:pPr' and '>'
239
+ if (attrs || innerContent) {
240
+ pPrXml = innerContent
241
+ ? `${pPrBlockMatch[1]}${innerContent}${pPrBlockMatch[3]}`
242
+ : `<a:pPr ${attrs}/>`
243
+ }
198
244
  } else {
199
- // No c:tx yet prepend before first existing sibling
200
- return `<c:title>${newTxBlock}${titleContent}</c:title>`
245
+ // Self-closing <a:pPr .../> (no children)
246
+ const scPPrMatch = /(<a:pPr[^>]*\/>)/.exec(txPrContent)
247
+ if (scPPrMatch) pPrXml = scPPrMatch[1]
201
248
  }
202
- })
203
- } else {
204
- // No title block exists yet create a minimal one
205
- const titleBlock = `<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>${escapedTitle}</a:t></a:r></a:p></c:rich></c:tx><c:layout/></c:title>`
206
- const chartPattern = /(<c:chart>)/
207
- return xml.replace(chartPattern, `$1${titleBlock}`)
208
- }
249
+ }
250
+
251
+ // Build one <a:p> per title line, each with the same pPr + rPr
252
+ const paragraphs = titleLines
253
+ .map(line => {
254
+ const escapedLine = this.#escapeXml(line)
255
+ return `<a:p>${pPrXml}<a:r>${rPr}<a:t>${escapedLine}</a:t></a:r></a:p>`
256
+ })
257
+ .join('')
258
+
259
+ const newTxBlock = `<c:tx><c:rich>${bodyPr}<a:lstStyle/>${paragraphs}</c:rich></c:tx>`
260
+
261
+ // Prepend <c:tx> before the first existing sibling (overlay, spPr, txPr, etc.)
262
+ return `<c:title>${newTxBlock}${titleContent}</c:title>`
263
+ })
209
264
  }
210
265
 
211
266
  static updateDataLabelsInXml(xml, seriesIndex, options, categories = [], seriesData = {}) {