node-pptx-templater 1.0.7 → 1.0.8
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/README.md +125 -0
- package/package.json +1 -1
- package/src/core/PPTXTemplater.js +40 -0
- package/src/core/TemplateEngine.js +126 -0
- package/src/core/ValidationEngine.js +86 -0
- package/src/managers/ChartManager.js +43 -12
- package/src/managers/TextManager.js +271 -0
- package/src/managers/charts/ChartCacheGenerator.js +58 -3
- package/src/managers/charts/ChartWorkbookUpdater.js +20 -2
package/README.md
CHANGED
|
@@ -718,6 +718,44 @@ Adds a special navigation link (next, previous, first, last slide) to a shape or
|
|
|
718
718
|
ppt.useSlide(1).addShapeNavigationLink(options);
|
|
719
719
|
```
|
|
720
720
|
|
|
721
|
+
#### `updateText(tag, data)`
|
|
722
|
+
Updates shape text or list content by placeholder tag or shape name/ID. Supports bullet lists, numbered lists, nested lists, and custom styling.
|
|
723
|
+
|
|
724
|
+
* **Arguments**:
|
|
725
|
+
* `tag` (`string`): Placeholder tag (e.g. '{{name}}' or 'name') or shape name/ID.
|
|
726
|
+
* `data` (`string|Object`): String value or list configuration object.
|
|
727
|
+
* **Returns**: `PPTXTemplater` - this (chainable)
|
|
728
|
+
|
|
729
|
+
```javascript
|
|
730
|
+
ppt.useSlide(1).updateText('Features', {
|
|
731
|
+
list: ['Point A', 'Point B', 'Point C']
|
|
732
|
+
});
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
#### `getList(tag)`
|
|
736
|
+
Retrieves list items from a shape or text box by name or placeholder tag.
|
|
737
|
+
|
|
738
|
+
* **Arguments**:
|
|
739
|
+
* `tag` (`string`): Shape name/ID or placeholder tag.
|
|
740
|
+
* **Returns**: `Array` - Nested list structure of items.
|
|
741
|
+
|
|
742
|
+
```javascript
|
|
743
|
+
const items = ppt.useSlide(1).getList('Features');
|
|
744
|
+
console.log(items); // ['A', { text: 'B', children: [...] }]
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
#### `validateList(data)`
|
|
748
|
+
Validates a list structure and values.
|
|
749
|
+
|
|
750
|
+
* **Arguments**:
|
|
751
|
+
* `data` (`Object|Array`): List config object or array of items.
|
|
752
|
+
* **Returns**: `Object` - Report containing validation result.
|
|
753
|
+
|
|
754
|
+
```javascript
|
|
755
|
+
const result = ppt.validateList(['Valid string', 'Another item']);
|
|
756
|
+
console.log(result.valid);
|
|
757
|
+
```
|
|
758
|
+
|
|
721
759
|
#### `replaceTextByTag(())`
|
|
722
760
|
Delegates core actions to slide element sub-managers.
|
|
723
761
|
|
|
@@ -1380,6 +1418,93 @@ To preserve PowerPoint integrity, the engine ensures that if one value contains
|
|
|
1380
1418
|
|
|
1381
1419
|
---
|
|
1382
1420
|
|
|
1421
|
+
## 📋 Native Lists (Bullet & Numbered Lists)
|
|
1422
|
+
|
|
1423
|
+
PPTXForge supports native PowerPoint bullet lists and numbered lists across text placeholders, shapes, text boxes, table cells, and grouped shapes. When generating lists, the engine preserves run styles, custom bullet characters, indentation, and color overlays, generating valid OpenXML/DrawingML without repair alerts.
|
|
1424
|
+
|
|
1425
|
+
### 1. Basic Bullet List
|
|
1426
|
+
Update a shape or text placeholder to be a bullet list:
|
|
1427
|
+
|
|
1428
|
+
```javascript
|
|
1429
|
+
ppt.useSlide(1).updateText('Features', {
|
|
1430
|
+
list: [
|
|
1431
|
+
'Fast PPTX generation',
|
|
1432
|
+
'OpenXML based',
|
|
1433
|
+
'Chart updates',
|
|
1434
|
+
'Table updates'
|
|
1435
|
+
]
|
|
1436
|
+
});
|
|
1437
|
+
```
|
|
1438
|
+
|
|
1439
|
+
### 2. Numbered / Ordered List
|
|
1440
|
+
Use the `ordered` flag to convert the list to a numbered format:
|
|
1441
|
+
|
|
1442
|
+
```javascript
|
|
1443
|
+
ppt.useSlide(1).updateText('Steps', {
|
|
1444
|
+
ordered: true,
|
|
1445
|
+
list: [
|
|
1446
|
+
'Import template',
|
|
1447
|
+
'Update data',
|
|
1448
|
+
'Generate PPTX'
|
|
1449
|
+
]
|
|
1450
|
+
});
|
|
1451
|
+
```
|
|
1452
|
+
* Custom numbering systems can be specified with `style.numberType` (e.g., `arabicPeriod`, `alphaLcParen`, `romanUcPeriod`).
|
|
1453
|
+
|
|
1454
|
+
### 3. Nested / Multi-Level Lists
|
|
1455
|
+
Construct hierarchy by passing objects containing a `text` string and a `children` array:
|
|
1456
|
+
|
|
1457
|
+
```javascript
|
|
1458
|
+
ppt.useSlide(1).updateText('Requirements', {
|
|
1459
|
+
list: [
|
|
1460
|
+
'Frontend',
|
|
1461
|
+
{
|
|
1462
|
+
text: 'Backend Development',
|
|
1463
|
+
children: [
|
|
1464
|
+
'Node.js',
|
|
1465
|
+
{
|
|
1466
|
+
text: 'Databases',
|
|
1467
|
+
children: ['MS SQL', 'PostgreSQL']
|
|
1468
|
+
}
|
|
1469
|
+
]
|
|
1470
|
+
}
|
|
1471
|
+
]
|
|
1472
|
+
});
|
|
1473
|
+
```
|
|
1474
|
+
|
|
1475
|
+
### 4. Custom List Styling
|
|
1476
|
+
Customize bullet characters, colors, sizes, and font properties:
|
|
1477
|
+
|
|
1478
|
+
```javascript
|
|
1479
|
+
ppt.useSlide(1).updateText('KPIs', {
|
|
1480
|
+
list: ['Revenue Up', 'Margins Normal'],
|
|
1481
|
+
style: {
|
|
1482
|
+
fontFamily: 'Arial',
|
|
1483
|
+
fontSize: 18,
|
|
1484
|
+
color: '#0055AA',
|
|
1485
|
+
bulletColor: '#FF5500',
|
|
1486
|
+
bulletChar: '✦',
|
|
1487
|
+
bulletSize: 120 // Percentage relative to text (e.g. 120%)
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
```
|
|
1491
|
+
|
|
1492
|
+
### 5. Table Cell Lists
|
|
1493
|
+
You can also generate list hierarchies directly inside a cell of a DrawingML table:
|
|
1494
|
+
|
|
1495
|
+
```javascript
|
|
1496
|
+
ppt.useSlide(3).updateTable('sales-table', [
|
|
1497
|
+
['Category', 'Performance details'],
|
|
1498
|
+
['North Region', '{{CellPlaceholder}}']
|
|
1499
|
+
]);
|
|
1500
|
+
|
|
1501
|
+
ppt.updateText('CellPlaceholder', {
|
|
1502
|
+
list: ['Table Bullet 1', 'Table Bullet 2']
|
|
1503
|
+
});
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1506
|
+
---
|
|
1507
|
+
|
|
1383
1508
|
## 🔒 Advanced XML Security
|
|
1384
1509
|
|
|
1385
1510
|
PPTXForge uses a secure XML parser that defends your application servers against common XML vulnerabilities.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-pptx-templater",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
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",
|
|
@@ -1383,6 +1383,46 @@ class PPTXTemplater {
|
|
|
1383
1383
|
return charts
|
|
1384
1384
|
}
|
|
1385
1385
|
|
|
1386
|
+
/**
|
|
1387
|
+
* Updates shape text or list content by placeholder tag or shape name/ID.
|
|
1388
|
+
* Supports bullet lists, numbered lists, nested lists, and custom styling.
|
|
1389
|
+
*
|
|
1390
|
+
* @param {string} tag - Placeholder tag (e.g. '{{name}}' or 'name') or shape name/ID.
|
|
1391
|
+
* @param {string|Object} data - String value or list configuration object.
|
|
1392
|
+
* @returns {PPTXTemplater} this (chainable)
|
|
1393
|
+
*/
|
|
1394
|
+
updateText(tag, data) {
|
|
1395
|
+
this.#assertLoaded()
|
|
1396
|
+
const targetIndices = this.#getTargetSlideIndices()
|
|
1397
|
+
for (const idx of targetIndices) {
|
|
1398
|
+
this.#textManager.updateText(idx, tag, data, this.#slideManager, this.#templateEngine)
|
|
1399
|
+
}
|
|
1400
|
+
return this
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* Retrieves list items from a shape or text box by name or placeholder tag.
|
|
1405
|
+
*
|
|
1406
|
+
* @param {string} tag - Shape name/ID or placeholder tag.
|
|
1407
|
+
* @returns {Array} Nested list structure of items.
|
|
1408
|
+
*/
|
|
1409
|
+
getList(tag) {
|
|
1410
|
+
this.#assertLoaded()
|
|
1411
|
+
const targetIndices = this.#getTargetSlideIndices()
|
|
1412
|
+
const idx = targetIndices.length > 0 ? targetIndices[0] : 1
|
|
1413
|
+
return this.#textManager.getList(idx, tag, this.#slideManager)
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Validates a list structure and values.
|
|
1418
|
+
*
|
|
1419
|
+
* @param {Object|Array} data - List config object or array of items.
|
|
1420
|
+
* @returns {Object} Report containing validation result.
|
|
1421
|
+
*/
|
|
1422
|
+
validateList(data) {
|
|
1423
|
+
return ValidationEngine.validateList(data)
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1386
1426
|
// === Text Features ===
|
|
1387
1427
|
replaceTextByTag(tag, value, options = {}) {
|
|
1388
1428
|
this.#assertLoaded()
|
|
@@ -80,6 +80,13 @@ class TemplateEngine {
|
|
|
80
80
|
|
|
81
81
|
// Step 2: Simple direct replacement for any remaining unfragmented placeholders
|
|
82
82
|
for (const [placeholder, value] of Object.entries(replacements)) {
|
|
83
|
+
if (
|
|
84
|
+
value &&
|
|
85
|
+
typeof value === 'object' &&
|
|
86
|
+
(Array.isArray(value) || value.list !== undefined)
|
|
87
|
+
) {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
83
90
|
const escaped = this.#escapeXml(String(value))
|
|
84
91
|
const placeholderEscaped = this.#escapeXml(placeholder)
|
|
85
92
|
|
|
@@ -146,15 +153,35 @@ class TemplateEngine {
|
|
|
146
153
|
|
|
147
154
|
// Check if any placeholder appears in the combined text
|
|
148
155
|
let hasPlaceholder = false
|
|
156
|
+
let matchedPlaceholder = null
|
|
149
157
|
for (const placeholder of Object.keys(replacements)) {
|
|
150
158
|
if (combinedText.includes(placeholder)) {
|
|
151
159
|
hasPlaceholder = true
|
|
160
|
+
matchedPlaceholder = placeholder
|
|
152
161
|
break
|
|
153
162
|
}
|
|
154
163
|
}
|
|
155
164
|
|
|
156
165
|
if (!hasPlaceholder) return paragraphXml
|
|
157
166
|
|
|
167
|
+
const replacementValue = replacements[matchedPlaceholder]
|
|
168
|
+
const isList =
|
|
169
|
+
replacementValue &&
|
|
170
|
+
(Array.isArray(replacementValue) ||
|
|
171
|
+
(typeof replacementValue === 'object' && replacementValue.list !== undefined))
|
|
172
|
+
|
|
173
|
+
if (isList) {
|
|
174
|
+
const listConfig = Array.isArray(replacementValue)
|
|
175
|
+
? { list: replacementValue }
|
|
176
|
+
: replacementValue
|
|
177
|
+
const { ValidationEngine } = require('./ValidationEngine.js')
|
|
178
|
+
const validation = ValidationEngine.validateList(listConfig)
|
|
179
|
+
if (!validation.valid) {
|
|
180
|
+
throw new Error(`List validation failed: ${validation.errors.join(', ')}`)
|
|
181
|
+
}
|
|
182
|
+
return this.generateListParagraphs(paragraphXml, runs[0], listConfig)
|
|
183
|
+
}
|
|
184
|
+
|
|
158
185
|
// Perform replacement on combined text
|
|
159
186
|
let replacedText = combinedText
|
|
160
187
|
for (const [placeholder, value] of Object.entries(replacements)) {
|
|
@@ -285,6 +312,105 @@ class TemplateEngine {
|
|
|
285
312
|
return Array.from(placeholders)
|
|
286
313
|
}
|
|
287
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Generates a block of list paragraph XML elements from a template paragraph,
|
|
317
|
+
* a baseline run, and list options.
|
|
318
|
+
*
|
|
319
|
+
* @param {string} paragraphXml
|
|
320
|
+
* @param {Object} firstRun - Run XML info.
|
|
321
|
+
* @param {Object} listConfig - List styling and items.
|
|
322
|
+
* @returns {string} XML string of multiple paragraphs.
|
|
323
|
+
*/
|
|
324
|
+
generateListParagraphs(paragraphXml, firstRun, listConfig) {
|
|
325
|
+
const list = listConfig.list || []
|
|
326
|
+
const ordered = !!listConfig.ordered
|
|
327
|
+
const style = listConfig.style || {}
|
|
328
|
+
|
|
329
|
+
const flattenList = (items, currentLvl = 0) => {
|
|
330
|
+
let flat = []
|
|
331
|
+
for (const item of items) {
|
|
332
|
+
if (typeof item === 'string') {
|
|
333
|
+
flat.push({ text: item, lvl: currentLvl })
|
|
334
|
+
} else if (typeof item === 'object' && item !== null) {
|
|
335
|
+
const text = item.text || ''
|
|
336
|
+
flat.push({ text, lvl: currentLvl })
|
|
337
|
+
if (Array.isArray(item.children)) {
|
|
338
|
+
flat = flat.concat(flattenList(item.children, currentLvl + 1))
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return flat
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const flatItems = flattenList(list)
|
|
346
|
+
|
|
347
|
+
const rPrMatch = /(<a:rPr>[\s\S]*?<\/a:rPr>)/.exec(firstRun.xml)
|
|
348
|
+
let baseRPr = rPrMatch ? rPrMatch[1] : '<a:rPr/>'
|
|
349
|
+
|
|
350
|
+
if (style.fontSize) {
|
|
351
|
+
const szVal = Math.round(style.fontSize * 100)
|
|
352
|
+
if (/sz="\d+"/.test(baseRPr)) {
|
|
353
|
+
baseRPr = baseRPr.replace(/sz="\d+"/, `sz="${szVal}"`)
|
|
354
|
+
} else {
|
|
355
|
+
baseRPr = baseRPr.replace('<a:rPr', `<a:rPr sz="${szVal}"`)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (style.color) {
|
|
359
|
+
const cleanColor = style.color.replace('#', '')
|
|
360
|
+
const newFill = `<a:solidFill><a:srgbClr val="${cleanColor}"/></a:solidFill>`
|
|
361
|
+
if (/<a:solidFill>[\s\S]*?<\/a:solidFill>/.test(baseRPr)) {
|
|
362
|
+
baseRPr = baseRPr.replace(/<a:solidFill>[\s\S]*?<\/a:solidFill>/, newFill)
|
|
363
|
+
} else {
|
|
364
|
+
if (baseRPr.endsWith('/>')) {
|
|
365
|
+
baseRPr = baseRPr.replace('/>', `>${newFill}</a:rPr>`)
|
|
366
|
+
} else {
|
|
367
|
+
baseRPr = baseRPr.replace('</a:rPr>', `${newFill}</a:rPr>`)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (style.fontFamily) {
|
|
372
|
+
const latinXml = `<a:latin typeface="${style.fontFamily}"/><a:cs typeface="${style.fontFamily}"/>`
|
|
373
|
+
baseRPr = baseRPr.replace(/<a:latin\s+[^>]*\/>/g, '').replace(/<a:cs\s+[^>]*\/>/g, '')
|
|
374
|
+
if (baseRPr.endsWith('/>')) {
|
|
375
|
+
baseRPr = baseRPr.replace('/>', `>${latinXml}</a:rPr>`)
|
|
376
|
+
} else {
|
|
377
|
+
baseRPr = baseRPr.replace('</a:rPr>', `${latinXml}</a:rPr>`)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let paragraphsXml = ''
|
|
382
|
+
for (const item of flatItems) {
|
|
383
|
+
const lvl = item.lvl
|
|
384
|
+
const marL = style.marL !== undefined ? style.marL : 381000 + lvl * 457200
|
|
385
|
+
const indent = style.indent !== undefined ? style.indent : -228600
|
|
386
|
+
|
|
387
|
+
let pPr = `<a:pPr lvl="${lvl}" marL="${marL}" indent="${indent}">`
|
|
388
|
+
if (ordered) {
|
|
389
|
+
const numType = style.numberType || 'arabicPeriod'
|
|
390
|
+
pPr += `<a:buAutoNum type="${numType}"/>`
|
|
391
|
+
} else {
|
|
392
|
+
const bulletChar = style.bulletChar || '•'
|
|
393
|
+
pPr += `<a:buChar char="${bulletChar}"/>`
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (style.bulletColor) {
|
|
397
|
+
const cleanBClr = style.bulletColor.replace('#', '')
|
|
398
|
+
pPr += `<a:buClr><a:srgbClr val="${cleanBClr}"/></a:buClr>`
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (style.bulletSize) {
|
|
402
|
+
pPr += `<a:buSzPct val="${Math.round(style.bulletSize * 1000)}"/>`
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
pPr += `</a:pPr>`
|
|
406
|
+
|
|
407
|
+
const runXml = `<a:r>${baseRPr}<a:t>${this.#escapeXml(item.text)}</a:t></a:r>`
|
|
408
|
+
paragraphsXml += `<a:p>${pPr}${runXml}</a:p>`
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return paragraphsXml
|
|
412
|
+
}
|
|
413
|
+
|
|
288
414
|
/**
|
|
289
415
|
* Escapes XML special characters.
|
|
290
416
|
* @private
|
|
@@ -411,6 +411,92 @@ class ValidationEngine {
|
|
|
411
411
|
warnings,
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Validates a list structure and values.
|
|
417
|
+
*
|
|
418
|
+
* @param {Object|Array} data - List config object or array of items.
|
|
419
|
+
* @returns {Object} Report containing errors and warnings.
|
|
420
|
+
*/
|
|
421
|
+
static validateList(data) {
|
|
422
|
+
const errors = []
|
|
423
|
+
const warnings = []
|
|
424
|
+
|
|
425
|
+
if (!data) {
|
|
426
|
+
errors.push('List data must be provided')
|
|
427
|
+
return { valid: false, errors, warnings }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const listArray = Array.isArray(data) ? data : data.list
|
|
431
|
+
if (!listArray) {
|
|
432
|
+
errors.push('List data must contain an array under the "list" property or be an array')
|
|
433
|
+
return { valid: false, errors, warnings }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const checkItem = (item, level) => {
|
|
437
|
+
if (level < 0 || level > 8) {
|
|
438
|
+
errors.push(`Level ${level} is out of supported range (0 to 8)`)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (typeof item === 'string') {
|
|
442
|
+
if (item.trim() === '') {
|
|
443
|
+
errors.push('Empty list item text is not allowed')
|
|
444
|
+
}
|
|
445
|
+
} else if (typeof item === 'object' && item !== null) {
|
|
446
|
+
if (item.text === undefined || item.text === null || String(item.text).trim() === '') {
|
|
447
|
+
errors.push('Empty list item text is not allowed')
|
|
448
|
+
}
|
|
449
|
+
if (item.children) {
|
|
450
|
+
if (!Array.isArray(item.children)) {
|
|
451
|
+
errors.push('Children property must be an array of items')
|
|
452
|
+
} else {
|
|
453
|
+
item.children.forEach(child => {
|
|
454
|
+
checkItem(child, level + 1)
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
} else {
|
|
459
|
+
errors.push(`Invalid list item type: "${typeof item}"`)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
listArray.forEach(item => {
|
|
464
|
+
checkItem(item, 0)
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
if (data.style) {
|
|
468
|
+
const style = data.style
|
|
469
|
+
if (style.fontSize !== undefined) {
|
|
470
|
+
if (typeof style.fontSize !== 'number' || style.fontSize <= 0) {
|
|
471
|
+
errors.push('fontSize must be a positive number')
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (style.color !== undefined) {
|
|
475
|
+
if (typeof style.color !== 'string' || !/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(style.color)) {
|
|
476
|
+
errors.push(`Invalid color format: "${style.color}" (expected hex e.g. #FF0000)`)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (style.bulletColor !== undefined) {
|
|
480
|
+
if (
|
|
481
|
+
typeof style.bulletColor !== 'string' ||
|
|
482
|
+
!/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(style.bulletColor)
|
|
483
|
+
) {
|
|
484
|
+
errors.push(`Invalid bulletColor format: "${style.bulletColor}"`)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (style.bulletSize !== undefined) {
|
|
488
|
+
if (typeof style.bulletSize !== 'number' || style.bulletSize <= 0) {
|
|
489
|
+
errors.push('bulletSize must be a positive number')
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
valid: errors.length === 0,
|
|
496
|
+
errors,
|
|
497
|
+
warnings,
|
|
498
|
+
}
|
|
499
|
+
}
|
|
414
500
|
}
|
|
415
501
|
|
|
416
502
|
module.exports = { ValidationEngine }
|
|
@@ -262,7 +262,13 @@ class ChartManager {
|
|
|
262
262
|
const seriesLabels = normalized.labels
|
|
263
263
|
|
|
264
264
|
// 3. Apply Chart XML Updates
|
|
265
|
-
|
|
265
|
+
let updatedXml = this.#applyChartData(xml, cleanNumericData, chartZipPath)
|
|
266
|
+
if (data.title !== undefined) {
|
|
267
|
+
updatedXml = require('./charts/ChartCacheGenerator.js').ChartCacheGenerator.updateTitle(
|
|
268
|
+
updatedXml,
|
|
269
|
+
data.title
|
|
270
|
+
)
|
|
271
|
+
}
|
|
266
272
|
this.#zipManager.writeFile(chartZipPath, updatedXml)
|
|
267
273
|
|
|
268
274
|
// 4. Find and Update Embedded Workbook
|
|
@@ -680,6 +686,9 @@ class ChartManager {
|
|
|
680
686
|
|
|
681
687
|
#validateChartData(data) {
|
|
682
688
|
const { categories, series } = data
|
|
689
|
+
if (data.title !== undefined && typeof data.title !== 'string') {
|
|
690
|
+
throw new Error('Chart title must be a string')
|
|
691
|
+
}
|
|
683
692
|
if (!series || series.length === 0) return
|
|
684
693
|
|
|
685
694
|
// Series lengths remain consistent (if categories exist, check against length of categories)
|
|
@@ -688,23 +697,45 @@ class ChartManager {
|
|
|
688
697
|
: series[0].values
|
|
689
698
|
? series[0].values.length
|
|
690
699
|
: 0
|
|
700
|
+
|
|
691
701
|
for (const ser of series) {
|
|
692
702
|
const name = ser.name || 'Unknown'
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
703
|
+
if (!ser.values) {
|
|
704
|
+
ser.values = []
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Check if there is any label in the series to determine padding style
|
|
708
|
+
let seriesHasLabels = false
|
|
709
|
+
for (const val of ser.values) {
|
|
710
|
+
if (typeof val === 'object' && val !== null && val.label !== undefined) {
|
|
711
|
+
seriesHasLabels = true
|
|
712
|
+
break
|
|
713
|
+
}
|
|
698
714
|
}
|
|
699
715
|
|
|
716
|
+
// Pad or truncate values to match expectedLen
|
|
717
|
+
if (ser.values.length < expectedLen) {
|
|
718
|
+
while (ser.values.length < expectedLen) {
|
|
719
|
+
if (seriesHasLabels) {
|
|
720
|
+
ser.values.push({ value: null, label: '' })
|
|
721
|
+
} else {
|
|
722
|
+
ser.values.push(null)
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
} else if (ser.values.length > expectedLen) {
|
|
726
|
+
ser.values = ser.values.slice(0, expectedLen)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const len = ser.values.length
|
|
730
|
+
|
|
700
731
|
// Check values inside the series
|
|
701
732
|
let hasLabels = false
|
|
702
733
|
let labelCount = 0
|
|
703
734
|
for (const val of ser.values) {
|
|
704
735
|
if (typeof val === 'object' && val !== null) {
|
|
705
736
|
const numVal = val.value !== undefined ? val.value : val.data
|
|
706
|
-
// Data values remain numeric
|
|
707
|
-
if (typeof numVal !== 'number' || isNaN(numVal)) {
|
|
737
|
+
// Data values remain numeric or null
|
|
738
|
+
if (numVal !== null && (typeof numVal !== 'number' || isNaN(numVal))) {
|
|
708
739
|
throw new Error(`Data value must be numeric in series ${name}`)
|
|
709
740
|
}
|
|
710
741
|
if (val.label !== undefined) {
|
|
@@ -716,8 +747,8 @@ class ChartManager {
|
|
|
716
747
|
}
|
|
717
748
|
}
|
|
718
749
|
} else {
|
|
719
|
-
// Data values remain numeric (primitive value)
|
|
720
|
-
if (typeof val !== 'number' || isNaN(val)) {
|
|
750
|
+
// Data values remain numeric (primitive value) or null
|
|
751
|
+
if (val !== null && (typeof val !== 'number' || isNaN(val))) {
|
|
721
752
|
throw new Error(`Data value must be numeric in series ${name}`)
|
|
722
753
|
}
|
|
723
754
|
}
|
|
@@ -743,12 +774,12 @@ class ChartManager {
|
|
|
743
774
|
if (ser.values) {
|
|
744
775
|
ser.values.forEach(v => {
|
|
745
776
|
if (typeof v === 'object' && v !== null) {
|
|
746
|
-
const val = v.value !== undefined ? v.value : v.data !== undefined ? v.data :
|
|
777
|
+
const val = v.value !== undefined ? v.value : v.data !== undefined ? v.data : null
|
|
747
778
|
cleanValues.push(val)
|
|
748
779
|
labels.push(v.label)
|
|
749
780
|
if (v.label !== undefined) hasLabel = true
|
|
750
781
|
} else {
|
|
751
|
-
cleanValues.push(Number(v) || 0)
|
|
782
|
+
cleanValues.push(v === null || v === undefined ? null : Number(v) || 0)
|
|
752
783
|
labels.push(undefined)
|
|
753
784
|
}
|
|
754
785
|
})
|
|
@@ -192,6 +192,277 @@ class TextManager {
|
|
|
192
192
|
this.#collectTextElements(g, results)
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Updates shape text or list content by placeholder tag or shape name/ID.
|
|
198
|
+
*
|
|
199
|
+
* @param {number} slideIndex
|
|
200
|
+
* @param {string} tag - Placeholder tag (e.g. '{{name}}') or shape name/ID.
|
|
201
|
+
* @param {string|Object} data - String value or list configuration object.
|
|
202
|
+
* @param {SlideManager} slideManager
|
|
203
|
+
* @param {TemplateEngine} templateEngine
|
|
204
|
+
*/
|
|
205
|
+
updateText(slideIndex, tag, data, slideManager, templateEngine) {
|
|
206
|
+
const slideXml = slideManager.getSlideXml(slideIndex)
|
|
207
|
+
const normalizedTag = tag.startsWith('{{') && tag.endsWith('}}') ? tag : `{{${tag}}}`
|
|
208
|
+
|
|
209
|
+
// Option A: Tag exists as a placeholder in the slide XML
|
|
210
|
+
if (slideXml.includes(normalizedTag)) {
|
|
211
|
+
const replacements = { [normalizedTag]: data }
|
|
212
|
+
const updatedXml = templateEngine.replaceTextInXml(slideXml, replacements)
|
|
213
|
+
slideManager.setSlideXml(slideIndex, updatedXml)
|
|
214
|
+
logger.debug(`Updated text tag "${normalizedTag}" on slide ${slideIndex}`)
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Option B: Search for a shape whose name or ID matches the tag
|
|
219
|
+
const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
|
|
220
|
+
const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
|
|
221
|
+
const res = this.findShapeRecursive(spTree, tag)
|
|
222
|
+
|
|
223
|
+
if (!res) {
|
|
224
|
+
const { PPTXError } = require('../utils/errors.js')
|
|
225
|
+
throw new PPTXError(`Text placeholder or shape "${tag}" not found in slide ${slideIndex}`)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Replace the text body of the shape
|
|
229
|
+
const shape = res.shape
|
|
230
|
+
const listConfig =
|
|
231
|
+
typeof data === 'object' && data !== null
|
|
232
|
+
? data.list !== undefined
|
|
233
|
+
? data
|
|
234
|
+
: { list: [data] }
|
|
235
|
+
: { list: [String(data)] }
|
|
236
|
+
|
|
237
|
+
const { ValidationEngine } = require('../core/ValidationEngine.js')
|
|
238
|
+
const validation = ValidationEngine.validateList(listConfig)
|
|
239
|
+
if (!validation.valid) {
|
|
240
|
+
throw new Error(`List validation failed: ${validation.errors.join(', ')}`)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!shape['p:txBody']) {
|
|
244
|
+
shape['p:txBody'] = {
|
|
245
|
+
'a:bodyPr': {},
|
|
246
|
+
'a:lstStyle': {},
|
|
247
|
+
'a:p': [],
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const txBody = shape['p:txBody']
|
|
252
|
+
const originalParas = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
|
|
253
|
+
const templatePara = originalParas.length > 0 ? originalParas[0] : {}
|
|
254
|
+
const templateRuns = templatePara['a:r']
|
|
255
|
+
? Array.isArray(templatePara['a:r'])
|
|
256
|
+
? templatePara['a:r']
|
|
257
|
+
: [templatePara['a:r']]
|
|
258
|
+
: []
|
|
259
|
+
const firstRun = templateRuns.length > 0 ? templateRuns[0] : { 'a:rPr': {} }
|
|
260
|
+
|
|
261
|
+
const firstRunXml = this.#xmlParser.build({ 'a:r': firstRun })
|
|
262
|
+
const dummyParaXml = `<a:p>${firstRunXml}</a:p>`
|
|
263
|
+
const generatedXml = templateEngine.generateListParagraphs(
|
|
264
|
+
dummyParaXml,
|
|
265
|
+
{ xml: firstRunXml },
|
|
266
|
+
listConfig
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
// Parse the generated XML paragraphs back to objects
|
|
270
|
+
const wrappedXml = `<root xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">${generatedXml}</root>`
|
|
271
|
+
const parsedObj = this.#xmlParser.parse(wrappedXml)
|
|
272
|
+
let newParas = parsedObj?.root?.['a:p'] || []
|
|
273
|
+
if (!Array.isArray(newParas)) {
|
|
274
|
+
newParas = [newParas]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
txBody['a:p'] = newParas
|
|
278
|
+
|
|
279
|
+
const decl = this.#xmlParser.extractDeclaration(slideXml)
|
|
280
|
+
slideManager.setSlideXml(slideIndex, this.#xmlParser.build(slideObj, decl))
|
|
281
|
+
logger.debug(`Updated text content for shape "${tag}" on slide ${slideIndex}`)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Retrieves list items from a shape or text box by name or placeholder tag.
|
|
286
|
+
*
|
|
287
|
+
* @param {number} slideIndex
|
|
288
|
+
* @param {string} tag - Shape name/ID or placeholder tag.
|
|
289
|
+
* @param {SlideManager} slideManager
|
|
290
|
+
* @returns {Array} Nested list structure of items.
|
|
291
|
+
*/
|
|
292
|
+
getList(slideIndex, tag, slideManager) {
|
|
293
|
+
const slideXml = slideManager.getSlideXml(slideIndex)
|
|
294
|
+
const slideObj = this.#xmlParser.parse(slideXml, `slide${slideIndex}.xml`)
|
|
295
|
+
const spTree = slideObj?.['p:sld']?.['p:cSld']?.['p:spTree']
|
|
296
|
+
|
|
297
|
+
// Step 1: Find shape by name or ID matching tag
|
|
298
|
+
let res = this.findShapeRecursive(spTree, tag)
|
|
299
|
+
|
|
300
|
+
// Step 2: If not found, look for any shape containing the placeholder string
|
|
301
|
+
if (!res) {
|
|
302
|
+
const collectMatchingShape = container => {
|
|
303
|
+
if (!container) return null
|
|
304
|
+
|
|
305
|
+
let shapes = container['p:sp'] || []
|
|
306
|
+
if (!Array.isArray(shapes)) shapes = [shapes]
|
|
307
|
+
|
|
308
|
+
for (const shape of shapes) {
|
|
309
|
+
const txBody = shape['p:txBody']
|
|
310
|
+
if (txBody && txBody['a:p']) {
|
|
311
|
+
const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
|
|
312
|
+
for (const p of paras) {
|
|
313
|
+
let pText = ''
|
|
314
|
+
if (p['a:r']) {
|
|
315
|
+
const runs = Array.isArray(p['a:r']) ? p['a:r'] : [p['a:r']]
|
|
316
|
+
for (const r of runs) {
|
|
317
|
+
if (r['a:t']) pText += String(r['a:t'])
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (pText.includes(tag)) {
|
|
321
|
+
return { shape, parent: container, type: 'sp' }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Search in tables inside graphicFrames
|
|
328
|
+
let frames = container['p:graphicFrame'] || []
|
|
329
|
+
if (!Array.isArray(frames)) frames = [frames]
|
|
330
|
+
|
|
331
|
+
for (const frame of frames) {
|
|
332
|
+
const tbl = frame?.['a:graphic']?.['a:graphicData']?.['a:tbl']
|
|
333
|
+
if (tbl && tbl['a:tr']) {
|
|
334
|
+
const rows = Array.isArray(tbl['a:tr']) ? tbl['a:tr'] : [tbl['a:tr']]
|
|
335
|
+
for (const row of rows) {
|
|
336
|
+
const cells = Array.isArray(row['a:tc']) ? row['a:tc'] : [row['a:tc']]
|
|
337
|
+
for (const cell of cells) {
|
|
338
|
+
const txBody = cell['a:txBody']
|
|
339
|
+
if (txBody && txBody['a:p']) {
|
|
340
|
+
const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
|
|
341
|
+
for (const p of paras) {
|
|
342
|
+
let pText = ''
|
|
343
|
+
if (p['a:r']) {
|
|
344
|
+
const runs = Array.isArray(p['a:r']) ? p['a:r'] : [p['a:r']]
|
|
345
|
+
for (const r of runs) {
|
|
346
|
+
if (r['a:t']) pText += String(r['a:t'])
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (pText.includes(tag)) {
|
|
350
|
+
return { shape: cell, parent: row, type: 'cell' }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let groups = container['p:grpSp'] || []
|
|
360
|
+
if (!Array.isArray(groups)) groups = [groups]
|
|
361
|
+
|
|
362
|
+
for (const group of groups) {
|
|
363
|
+
const matched = collectMatchingShape(group)
|
|
364
|
+
if (matched) return matched
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return null
|
|
368
|
+
}
|
|
369
|
+
res = collectMatchingShape(spTree)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!res || !res.shape || !(res.shape['p:txBody'] || res.shape['a:txBody'])) {
|
|
373
|
+
return []
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const txBody = res.shape['p:txBody'] || res.shape['a:txBody']
|
|
377
|
+
const paras = Array.isArray(txBody['a:p']) ? txBody['a:p'] : [txBody['a:p']]
|
|
378
|
+
|
|
379
|
+
const flatItems = []
|
|
380
|
+
for (const p of paras) {
|
|
381
|
+
let lvl = 0
|
|
382
|
+
if (p['a:pPr'] && p['a:pPr']['@_lvl'] !== undefined) {
|
|
383
|
+
lvl = parseInt(p['a:pPr']['@_lvl'], 10) || 0
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let text = ''
|
|
387
|
+
if (p['a:r']) {
|
|
388
|
+
const runs = Array.isArray(p['a:r']) ? p['a:r'] : [p['a:r']]
|
|
389
|
+
const textParts = []
|
|
390
|
+
for (const r of runs) {
|
|
391
|
+
if (r['a:t']) textParts.push(String(r['a:t']))
|
|
392
|
+
}
|
|
393
|
+
text = textParts.join('')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (text.trim() !== '') {
|
|
397
|
+
flatItems.push({ text: text.trim(), lvl })
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (flatItems.length === 0) {
|
|
402
|
+
return []
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const result = []
|
|
406
|
+
const stack = []
|
|
407
|
+
|
|
408
|
+
for (const item of flatItems) {
|
|
409
|
+
const node = { text: item.text, children: [] }
|
|
410
|
+
|
|
411
|
+
while (stack.length > 0 && stack[stack.length - 1].lvl >= item.lvl) {
|
|
412
|
+
stack.pop()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (stack.length === 0) {
|
|
416
|
+
result.push(node)
|
|
417
|
+
} else {
|
|
418
|
+
const parent = stack[stack.length - 1].node
|
|
419
|
+
parent.children.push(node)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
stack.push({ lvl: item.lvl, node })
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const cleanNode = n => {
|
|
426
|
+
if (n.children.length === 0) {
|
|
427
|
+
return n.text
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
text: n.text,
|
|
431
|
+
children: n.children.map(cleanNode),
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return result.map(cleanNode)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Helper to recursively scan a container for shapes.
|
|
440
|
+
*/
|
|
441
|
+
findShapeRecursive(container, shapeId) {
|
|
442
|
+
if (!container) return null
|
|
443
|
+
|
|
444
|
+
let shapes = container['p:sp'] || []
|
|
445
|
+
if (!Array.isArray(shapes)) shapes = [shapes]
|
|
446
|
+
|
|
447
|
+
for (const shape of shapes) {
|
|
448
|
+
const cNvPr = shape?.['p:nvSpPr']?.['p:cNvPr']
|
|
449
|
+
if (cNvPr) {
|
|
450
|
+
if (cNvPr['@_name'] === shapeId || String(cNvPr['@_id']) === shapeId) {
|
|
451
|
+
return { shape, parent: container, type: 'sp' }
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let groups = container['p:grpSp'] || []
|
|
457
|
+
if (!Array.isArray(groups)) groups = [groups]
|
|
458
|
+
|
|
459
|
+
for (const group of groups) {
|
|
460
|
+
const res = this.findShapeRecursive(group, shapeId)
|
|
461
|
+
if (res) return res
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return null
|
|
465
|
+
}
|
|
195
466
|
}
|
|
196
467
|
|
|
197
468
|
module.exports = { TextManager }
|
|
@@ -16,7 +16,12 @@ class ChartCacheGenerator {
|
|
|
16
16
|
*/
|
|
17
17
|
static generateNumCache(values) {
|
|
18
18
|
const ptEntries = values
|
|
19
|
-
.map((val, i) =>
|
|
19
|
+
.map((val, i) => {
|
|
20
|
+
if (val === null || val === undefined) {
|
|
21
|
+
return `<c:pt idx="${i}"/>`
|
|
22
|
+
}
|
|
23
|
+
return `<c:pt idx="${i}"><c:v>${val}</c:v></c:pt>`
|
|
24
|
+
})
|
|
20
25
|
.join('')
|
|
21
26
|
return `<c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="${values.length}"/>${ptEntries}</c:numCache>`
|
|
22
27
|
}
|
|
@@ -189,6 +194,8 @@ class ChartCacheGenerator {
|
|
|
189
194
|
let existingTxPr = ''
|
|
190
195
|
let existingDLblPos = ''
|
|
191
196
|
let existingNumFmt = ''
|
|
197
|
+
let existingSpPr = ''
|
|
198
|
+
const existingDLblSpPrs = {}
|
|
192
199
|
const existingShowTags = {}
|
|
193
200
|
|
|
194
201
|
const dLblsMatch = /<c:dLbls>([\s\S]*?)<\/c:dLbls>/.exec(content)
|
|
@@ -206,6 +213,26 @@ class ChartCacheGenerator {
|
|
|
206
213
|
if (numFmtMatch) {
|
|
207
214
|
existingNumFmt = numFmtMatch[1]
|
|
208
215
|
}
|
|
216
|
+
const spPrMatch = /(<c:spPr>[\s\S]*?<\/c:spPr>)/.exec(dLblsContent)
|
|
217
|
+
if (spPrMatch) {
|
|
218
|
+
existingSpPr = spPrMatch[1]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Parse individual <c:dLbl> shape properties to map their background fills
|
|
222
|
+
const dLblPattern = /<c:dLbl>([\s\S]*?)<\/c:dLbl>/g
|
|
223
|
+
let dLblMatch
|
|
224
|
+
while ((dLblMatch = dLblPattern.exec(dLblsContent)) !== null) {
|
|
225
|
+
const dLblContent = dLblMatch[1]
|
|
226
|
+
const idxMatch = /<c:idx val="(\d+)"\/>/.exec(dLblContent)
|
|
227
|
+
if (idxMatch) {
|
|
228
|
+
const idx = parseInt(idxMatch[1], 10)
|
|
229
|
+
const dLblSpPrMatch = /(<c:spPr>[\s\S]*?<\/c:spPr>)/.exec(dLblContent)
|
|
230
|
+
if (dLblSpPrMatch) {
|
|
231
|
+
existingDLblSpPrs[idx] = dLblSpPrMatch[1]
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
209
236
|
const showTagsList = [
|
|
210
237
|
'showLegendKey',
|
|
211
238
|
'showVal',
|
|
@@ -231,7 +258,9 @@ class ChartCacheGenerator {
|
|
|
231
258
|
existingTxPr,
|
|
232
259
|
existingDLblPos,
|
|
233
260
|
existingNumFmt,
|
|
234
|
-
existingShowTags
|
|
261
|
+
existingShowTags,
|
|
262
|
+
existingSpPr,
|
|
263
|
+
existingDLblSpPrs
|
|
235
264
|
)
|
|
236
265
|
|
|
237
266
|
let updatedContent = content
|
|
@@ -259,7 +288,9 @@ class ChartCacheGenerator {
|
|
|
259
288
|
existingTxPr = '',
|
|
260
289
|
existingDLblPos = '',
|
|
261
290
|
existingNumFmt = '',
|
|
262
|
-
existingShowTags = {}
|
|
291
|
+
existingShowTags = {},
|
|
292
|
+
existingSpPr = '',
|
|
293
|
+
existingDLblSpPrs = {}
|
|
263
294
|
) {
|
|
264
295
|
const { labels, labelsFromCells, template, position, labelStyle, labelMap } = options
|
|
265
296
|
|
|
@@ -372,6 +403,25 @@ class ChartCacheGenerator {
|
|
|
372
403
|
}
|
|
373
404
|
}
|
|
374
405
|
|
|
406
|
+
// Restore individual point-level spPr (background fill, border, line) from template.
|
|
407
|
+
// Priority: individual dLbl spPr → first available individual dLbl spPr → series-level spPr.
|
|
408
|
+
// The series-level spPr fallback ensures fills are preserved even when the template stores
|
|
409
|
+
// styling only at the dLbls level (not per individual dLbl override).
|
|
410
|
+
if (existingDLblSpPrs && existingDLblSpPrs[i]) {
|
|
411
|
+
xml += existingDLblSpPrs[i]
|
|
412
|
+
} else {
|
|
413
|
+
const firstIdx =
|
|
414
|
+
existingDLblSpPrs && Object.keys(existingDLblSpPrs).length > 0
|
|
415
|
+
? Object.keys(existingDLblSpPrs)[0]
|
|
416
|
+
: undefined
|
|
417
|
+
if (firstIdx !== undefined && existingDLblSpPrs[firstIdx]) {
|
|
418
|
+
xml += existingDLblSpPrs[firstIdx]
|
|
419
|
+
} else if (existingSpPr) {
|
|
420
|
+
// Fall back to series-level spPr so fills are preserved on each label
|
|
421
|
+
xml += existingSpPr
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
375
425
|
if (labelStyle) {
|
|
376
426
|
xml += this.generateTxPrXml(labelStyle)
|
|
377
427
|
} else if (existingTxPr) {
|
|
@@ -399,6 +449,11 @@ class ChartCacheGenerator {
|
|
|
399
449
|
xml += existingNumFmt
|
|
400
450
|
}
|
|
401
451
|
|
|
452
|
+
// Restore series-level spPr (background fill, border, line) from template
|
|
453
|
+
if (existingSpPr) {
|
|
454
|
+
xml += existingSpPr
|
|
455
|
+
}
|
|
456
|
+
|
|
402
457
|
if (labelStyle) {
|
|
403
458
|
xml += this.generateTxPrXml(labelStyle)
|
|
404
459
|
} else if (existingTxPr) {
|
|
@@ -123,6 +123,22 @@ class ChartWorkbookUpdater {
|
|
|
123
123
|
static #updateCellGrid(cells, data) {
|
|
124
124
|
const { categories = [], series = [] } = data
|
|
125
125
|
|
|
126
|
+
// Clear cells that are outside the new category/series grid
|
|
127
|
+
const maxRow = categories.length + 1
|
|
128
|
+
const maxCol = series.length // Column A is 0, Column B is 1, etc.
|
|
129
|
+
|
|
130
|
+
for (const ref of Object.keys(cells)) {
|
|
131
|
+
const match = /^([A-Z]+)(\d+)$/.exec(ref)
|
|
132
|
+
if (match) {
|
|
133
|
+
const colLetter = match[1]
|
|
134
|
+
const row = parseInt(match[2], 10)
|
|
135
|
+
const col = this.colLetterToNum(colLetter)
|
|
136
|
+
if (row > maxRow || col > maxCol) {
|
|
137
|
+
delete cells[ref]
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
126
142
|
// 1. Write Header A1 as empty
|
|
127
143
|
cells['A1'] = ''
|
|
128
144
|
|
|
@@ -142,7 +158,7 @@ class ChartWorkbookUpdater {
|
|
|
142
158
|
const colLetter = this.getColumnLetter(colIndex + 1)
|
|
143
159
|
if (ser.values) {
|
|
144
160
|
ser.values.forEach((val, rowIndex) => {
|
|
145
|
-
cells[`${colLetter}${rowIndex + 2}`] = val !== undefined ? val :
|
|
161
|
+
cells[`${colLetter}${rowIndex + 2}`] = val !== undefined ? val : null
|
|
146
162
|
})
|
|
147
163
|
}
|
|
148
164
|
})
|
|
@@ -203,7 +219,9 @@ class ChartWorkbookUpdater {
|
|
|
203
219
|
if (colNum > maxColNum) maxColNum = colNum
|
|
204
220
|
|
|
205
221
|
const val = cells[ref]
|
|
206
|
-
if (
|
|
222
|
+
if (val === null || val === undefined) {
|
|
223
|
+
sheetData += `<c r="${ref}" t="inlineStr"><is><t></t></is></c>`
|
|
224
|
+
} else if (typeof val === 'number') {
|
|
207
225
|
sheetData += `<c r="${ref}"><v>${val}</v></c>`
|
|
208
226
|
} else {
|
|
209
227
|
sheetData += `<c r="${ref}" t="inlineStr"><is><t>${this.#escapeXml(String(val))}</t></is></c>`
|