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,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Content Types helper - manages [Content_Types].xml.
|
|
3
|
+
*
|
|
4
|
+
* The [Content_Types].xml file tells the ZIP package parser what
|
|
5
|
+
* MIME type each file inside is. Every new part (slide, chart, image)
|
|
6
|
+
* must be registered here.
|
|
7
|
+
*
|
|
8
|
+
* Structure:
|
|
9
|
+
* <Types xmlns="...">
|
|
10
|
+
* <!-- Default: applies to all files with matching extension -->
|
|
11
|
+
* <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
12
|
+
* <Default Extension="xml" ContentType="application/xml"/>
|
|
13
|
+
*
|
|
14
|
+
* <!-- Override: specific part override -->
|
|
15
|
+
* <Override PartName="/ppt/slides/slide1.xml"
|
|
16
|
+
* ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
|
|
17
|
+
* </Types>
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createLogger } from './logger.js';
|
|
21
|
+
|
|
22
|
+
const logger = createLogger('ContentTypes');
|
|
23
|
+
|
|
24
|
+
/** MIME type for PPTX slide parts. */
|
|
25
|
+
const SLIDE_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml';
|
|
26
|
+
|
|
27
|
+
/** MIME type for chart parts. */
|
|
28
|
+
const CHART_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.drawingml.chart+xml';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Singleton helper for [Content_Types].xml manipulation.
|
|
32
|
+
*/
|
|
33
|
+
class ContentTypesHelper {
|
|
34
|
+
/**
|
|
35
|
+
* Adds a slide Override entry to [Content_Types].xml.
|
|
36
|
+
*
|
|
37
|
+
* @param {ZipManager} zipManager
|
|
38
|
+
* @param {string} slideFileName - e.g., 'slide5.xml'
|
|
39
|
+
*/
|
|
40
|
+
addSlideContentType(zipManager, slideFileName) {
|
|
41
|
+
this.#addOverride(
|
|
42
|
+
zipManager,
|
|
43
|
+
`/ppt/slides/${slideFileName}`,
|
|
44
|
+
SLIDE_CONTENT_TYPE
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Removes a slide Override entry from [Content_Types].xml.
|
|
50
|
+
*
|
|
51
|
+
* @param {ZipManager} zipManager
|
|
52
|
+
* @param {string} slideFileName - e.g., 'slide5.xml'
|
|
53
|
+
*/
|
|
54
|
+
removeSlideContentType(zipManager, slideFileName) {
|
|
55
|
+
this.#removeOverride(
|
|
56
|
+
zipManager,
|
|
57
|
+
`/ppt/slides/${slideFileName}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Adds a chart Override entry to [Content_Types].xml.
|
|
63
|
+
*
|
|
64
|
+
* @param {ZipManager} zipManager
|
|
65
|
+
* @param {string} chartFileName - e.g., 'chart3.xml'
|
|
66
|
+
*/
|
|
67
|
+
addChartContentType(zipManager, chartFileName) {
|
|
68
|
+
this.#addOverride(
|
|
69
|
+
zipManager,
|
|
70
|
+
`/ppt/charts/${chartFileName}`,
|
|
71
|
+
CHART_CONTENT_TYPE
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Adds a Default entry for a media extension.
|
|
77
|
+
*
|
|
78
|
+
* @param {ZipManager} zipManager
|
|
79
|
+
* @param {string} extension - File extension (e.g., 'png').
|
|
80
|
+
* @param {string} mimeType - MIME type.
|
|
81
|
+
*/
|
|
82
|
+
addMediaDefault(zipManager, extension, mimeType) {
|
|
83
|
+
this.#updateQueue = this.#updateQueue.then(async () => {
|
|
84
|
+
const xmlFile = zipManager.rawZip.file('[Content_Types].xml');
|
|
85
|
+
if (!xmlFile) return;
|
|
86
|
+
|
|
87
|
+
const content = await xmlFile.async('text');
|
|
88
|
+
const entry = `Extension="${extension}" ContentType="${mimeType}"`;
|
|
89
|
+
if (!content.includes(entry)) {
|
|
90
|
+
const updated = content.replace(
|
|
91
|
+
'</Types>',
|
|
92
|
+
` <Default ${entry}/>\n</Types>`
|
|
93
|
+
);
|
|
94
|
+
zipManager.writeFile('[Content_Types].xml', updated);
|
|
95
|
+
logger.debug(`Registered default content type for .${extension}`);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
zipManager.addPendingPromise(this.#updateQueue);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#updateQueue = Promise.resolve();
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Adds an Override entry to [Content_Types].xml.
|
|
105
|
+
* @private
|
|
106
|
+
*/
|
|
107
|
+
#addOverride(zipManager, partName, contentType) {
|
|
108
|
+
this.#updateQueue = this.#updateQueue.then(async () => {
|
|
109
|
+
const xmlFile = zipManager.rawZip.file('[Content_Types].xml');
|
|
110
|
+
if (!xmlFile) {
|
|
111
|
+
logger.warn('[Content_Types].xml not found');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const content = await xmlFile.async('text');
|
|
115
|
+
const entry = `PartName="${partName}"`;
|
|
116
|
+
if (!content.includes(entry)) {
|
|
117
|
+
const override = `<Override PartName="${partName}" ContentType="${contentType}"/>`;
|
|
118
|
+
const updated = content.replace('</Types>', ` ${override}\n</Types>`);
|
|
119
|
+
zipManager.writeFile('[Content_Types].xml', updated);
|
|
120
|
+
logger.debug(`Registered content type for ${partName}`);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
zipManager.addPendingPromise(this.#updateQueue);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Removes an Override entry from [Content_Types].xml.
|
|
128
|
+
* @private
|
|
129
|
+
*/
|
|
130
|
+
#removeOverride(zipManager, partName) {
|
|
131
|
+
this.#updateQueue = this.#updateQueue.then(async () => {
|
|
132
|
+
const xmlFile = zipManager.rawZip.file('[Content_Types].xml');
|
|
133
|
+
if (!xmlFile) {
|
|
134
|
+
logger.warn('[Content_Types].xml not found');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const content = await xmlFile.async('text');
|
|
138
|
+
const regex = new RegExp(`<Override[^>]*PartName="${partName}"[^>]*/>\\s*`, 'g');
|
|
139
|
+
if (regex.test(content)) {
|
|
140
|
+
const updated = content.replace(regex, '');
|
|
141
|
+
zipManager.writeFile('[Content_Types].xml', updated);
|
|
142
|
+
logger.debug(`Removed content type for ${partName}`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
zipManager.addPendingPromise(this.#updateQueue);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const contentTypesHelper = new ContentTypesHelper();
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Custom error classes for node-pptx-templater.
|
|
3
|
+
*
|
|
4
|
+
* All errors extend from PPTXError to allow consumers to catch all
|
|
5
|
+
* library errors with a single catch clause:
|
|
6
|
+
*
|
|
7
|
+
* try {
|
|
8
|
+
* await ppt.saveToFile('./out.pptx');
|
|
9
|
+
* } catch (err) {
|
|
10
|
+
* if (err instanceof PPTXError) {
|
|
11
|
+
* // Handle all node-pptx-templater errors
|
|
12
|
+
* }
|
|
13
|
+
* }
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @class PPTXError
|
|
18
|
+
* @description Base error class for all node-pptx-templater errors.
|
|
19
|
+
* @extends Error
|
|
20
|
+
*/
|
|
21
|
+
export class PPTXError extends Error {
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} message - Human-readable error description.
|
|
24
|
+
* @param {Error} [cause] - Original underlying error (for error chaining).
|
|
25
|
+
*/
|
|
26
|
+
constructor(message, cause) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'PPTXError';
|
|
29
|
+
if (cause) this.cause = cause;
|
|
30
|
+
|
|
31
|
+
// Maintains proper stack trace for where error was thrown (V8 only)
|
|
32
|
+
if (Error.captureStackTrace) {
|
|
33
|
+
Error.captureStackTrace(this, this.constructor);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @class SlideNotFoundError
|
|
40
|
+
* @description Thrown when a slide reference cannot be resolved.
|
|
41
|
+
* @extends PPTXError
|
|
42
|
+
*/
|
|
43
|
+
export class SlideNotFoundError extends PPTXError {
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} message
|
|
46
|
+
*/
|
|
47
|
+
constructor(message) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'SlideNotFoundError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @class ChartNotFoundError
|
|
55
|
+
* @description Thrown when a chart cannot be located in a slide.
|
|
56
|
+
* @extends PPTXError
|
|
57
|
+
*/
|
|
58
|
+
export class ChartNotFoundError extends PPTXError {
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} message
|
|
61
|
+
*/
|
|
62
|
+
constructor(message) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = 'ChartNotFoundError';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @class TableNotFoundError
|
|
70
|
+
* @description Thrown when a table cannot be located in a slide.
|
|
71
|
+
* @extends PPTXError
|
|
72
|
+
*/
|
|
73
|
+
export class TableNotFoundError extends PPTXError {
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} message
|
|
76
|
+
*/
|
|
77
|
+
constructor(message) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = 'TableNotFoundError';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @class XMLParseError
|
|
85
|
+
* @description Thrown when XML parsing or building fails.
|
|
86
|
+
* @extends PPTXError
|
|
87
|
+
*/
|
|
88
|
+
export class XMLParseError extends PPTXError {
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} message
|
|
91
|
+
* @param {Error} [cause]
|
|
92
|
+
*/
|
|
93
|
+
constructor(message, cause) {
|
|
94
|
+
super(message, cause);
|
|
95
|
+
this.name = 'XMLParseError';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @class InvalidTemplateError
|
|
101
|
+
* @description Thrown when a PPTX file has an invalid or unsupported structure.
|
|
102
|
+
* @extends PPTXError
|
|
103
|
+
*/
|
|
104
|
+
export class InvalidTemplateError extends PPTXError {
|
|
105
|
+
/**
|
|
106
|
+
* @param {string} message
|
|
107
|
+
* @param {Error} [cause]
|
|
108
|
+
*/
|
|
109
|
+
constructor(message, cause) {
|
|
110
|
+
super(message, cause);
|
|
111
|
+
this.name = 'InvalidTemplateError';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @class MediaEmbedError
|
|
117
|
+
* @description Thrown when a media file cannot be embedded.
|
|
118
|
+
* @extends PPTXError
|
|
119
|
+
*/
|
|
120
|
+
export class MediaEmbedError extends PPTXError {
|
|
121
|
+
/**
|
|
122
|
+
* @param {string} message
|
|
123
|
+
* @param {Error} [cause]
|
|
124
|
+
*/
|
|
125
|
+
constructor(message, cause) {
|
|
126
|
+
super(message, cause);
|
|
127
|
+
this.name = 'MediaEmbedError';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Unique ID generation utilities.
|
|
3
|
+
* Used for generating shape IDs, slide IDs, etc. in OpenXML.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomBytes } from 'crypto';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generates a unique integer ID for use as a shape or slide ID.
|
|
10
|
+
* OpenXML shape IDs are positive integers unique within a slide.
|
|
11
|
+
*
|
|
12
|
+
* @param {number[]} [existingIds] - Array of existing IDs to avoid.
|
|
13
|
+
* @returns {number} Unique positive integer.
|
|
14
|
+
*/
|
|
15
|
+
export function generateUniqueId(existingIds = []) {
|
|
16
|
+
const maxId = existingIds.length > 0 ? Math.max(...existingIds) : 0;
|
|
17
|
+
return maxId + 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generates a UUID v4 string.
|
|
22
|
+
* Used for chart GUID references and extension URIs in OpenXML.
|
|
23
|
+
*
|
|
24
|
+
* @returns {string} UUID v4 string (e.g., '{A1B2C3D4-E5F6-...}')
|
|
25
|
+
*/
|
|
26
|
+
export function generateGuid() {
|
|
27
|
+
const bytes = randomBytes(16);
|
|
28
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
|
|
29
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant RFC4122
|
|
30
|
+
|
|
31
|
+
const hex = bytes.toString('hex');
|
|
32
|
+
const guid = [
|
|
33
|
+
hex.slice(0, 8),
|
|
34
|
+
hex.slice(8, 12),
|
|
35
|
+
hex.slice(12, 16),
|
|
36
|
+
hex.slice(16, 20),
|
|
37
|
+
hex.slice(20, 32),
|
|
38
|
+
].join('-').toUpperCase();
|
|
39
|
+
|
|
40
|
+
return `{${guid}}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generates a slide ID (numeric, starts at 256 by convention in OpenXML).
|
|
45
|
+
* PowerPoint seems to start slide IDs at 256.
|
|
46
|
+
*
|
|
47
|
+
* @param {string[]} [existingSlideIds] - Existing slide ID strings.
|
|
48
|
+
* @returns {string} New slide ID string.
|
|
49
|
+
*/
|
|
50
|
+
export function generateSlideId(existingSlideIds = []) {
|
|
51
|
+
const existingNums = existingSlideIds.map(id => parseInt(id, 10)).filter(n => !isNaN(n));
|
|
52
|
+
const maxId = existingNums.length > 0 ? Math.max(...existingNums) : 255;
|
|
53
|
+
return String(maxId + 1);
|
|
54
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Logger utility - lightweight structured logging.
|
|
3
|
+
*
|
|
4
|
+
* Provides contextual logging with module names.
|
|
5
|
+
* Respects the PPTX_LOG_LEVEL environment variable.
|
|
6
|
+
*
|
|
7
|
+
* Log levels (lowest to highest severity):
|
|
8
|
+
* debug → info → warn → error
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const logger = createLogger('MyModule');
|
|
12
|
+
* logger.debug('Details here');
|
|
13
|
+
* logger.info('Something happened');
|
|
14
|
+
* logger.warn('Watch out');
|
|
15
|
+
* logger.error('Something failed');
|
|
16
|
+
*
|
|
17
|
+
* Environment:
|
|
18
|
+
* PPTX_LOG_LEVEL=debug → show all logs
|
|
19
|
+
* PPTX_LOG_LEVEL=info → show info, warn, error (default)
|
|
20
|
+
* PPTX_LOG_LEVEL=warn → show warn and error only
|
|
21
|
+
* PPTX_LOG_LEVEL=error → show only errors
|
|
22
|
+
* PPTX_LOG_LEVEL=silent → suppress all logs
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
|
|
26
|
+
|
|
27
|
+
const currentLevel = LOG_LEVELS[
|
|
28
|
+
(process.env.PPTX_LOG_LEVEL || 'warn').toLowerCase()
|
|
29
|
+
] ?? LOG_LEVELS.warn;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* ANSI color codes for terminal output.
|
|
33
|
+
*/
|
|
34
|
+
const COLORS = {
|
|
35
|
+
reset: '\x1b[0m',
|
|
36
|
+
dim: '\x1b[2m',
|
|
37
|
+
cyan: '\x1b[36m',
|
|
38
|
+
green: '\x1b[32m',
|
|
39
|
+
yellow: '\x1b[33m',
|
|
40
|
+
red: '\x1b[31m',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Formats the current timestamp as HH:MM:SS.mmm
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function timestamp() {
|
|
48
|
+
return new Date().toISOString().substring(11, 23);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {Object} Logger
|
|
53
|
+
* @property {Function} debug - Log debug message.
|
|
54
|
+
* @property {Function} info - Log info message.
|
|
55
|
+
* @property {Function} warn - Log warning.
|
|
56
|
+
* @property {Function} error - Log error.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Creates a named logger instance.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} moduleName - Name of the module (shown in log output).
|
|
63
|
+
* @returns {Logger}
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* const logger = createLogger('SlideManager');
|
|
67
|
+
* logger.info('Loaded 5 slides');
|
|
68
|
+
*/
|
|
69
|
+
export function createLogger(moduleName) {
|
|
70
|
+
const isTTY = process.stdout.isTTY;
|
|
71
|
+
|
|
72
|
+
const log = (level, levelNum, color, message, ...args) => {
|
|
73
|
+
if (levelNum < currentLevel) return;
|
|
74
|
+
|
|
75
|
+
const prefix = isTTY
|
|
76
|
+
? `${COLORS.dim}${timestamp()}${COLORS.reset} ${color}[${level.toUpperCase().padEnd(5)}]${COLORS.reset} ${COLORS.cyan}[${moduleName}]${COLORS.reset}`
|
|
77
|
+
: `${timestamp()} [${level.toUpperCase().padEnd(5)}] [${moduleName}]`;
|
|
78
|
+
|
|
79
|
+
const output = args.length > 0 ? `${message} ${args.map(a => JSON.stringify(a, null, 0)).join(' ')}` : message;
|
|
80
|
+
const stream = level === 'error' ? process.stderr : process.stdout;
|
|
81
|
+
stream.write(`${prefix} ${output}\n`);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
/**
|
|
86
|
+
* Logs a debug-level message. Only shown when PPTX_LOG_LEVEL=debug.
|
|
87
|
+
* @param {string} message
|
|
88
|
+
* @param {...*} args
|
|
89
|
+
*/
|
|
90
|
+
debug: (message, ...args) => log('debug', LOG_LEVELS.debug, COLORS.dim, message, ...args),
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Logs an info-level message.
|
|
94
|
+
* @param {string} message
|
|
95
|
+
* @param {...*} args
|
|
96
|
+
*/
|
|
97
|
+
info: (message, ...args) => log('info', LOG_LEVELS.info, COLORS.green, message, ...args),
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Logs a warning.
|
|
101
|
+
* @param {string} message
|
|
102
|
+
* @param {...*} args
|
|
103
|
+
*/
|
|
104
|
+
warn: (message, ...args) => log('warn', LOG_LEVELS.warn, COLORS.yellow, message, ...args),
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Logs an error.
|
|
108
|
+
* @param {string} message
|
|
109
|
+
* @param {...*} args
|
|
110
|
+
*/
|
|
111
|
+
error: (message, ...args) => log('error', LOG_LEVELS.error, COLORS.red, message, ...args),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Relationship ID utilities.
|
|
3
|
+
*
|
|
4
|
+
* OpenXML relationship IDs follow the format rId1, rId2, rId3, ...
|
|
5
|
+
* They must be unique within each .rels file.
|
|
6
|
+
*
|
|
7
|
+
* These utilities generate collision-free IDs when adding new relationships.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generates the next available relationship ID given an array of existing IDs.
|
|
12
|
+
* Always uses the format "rId{N}" where N is the next integer after the max.
|
|
13
|
+
*
|
|
14
|
+
* @param {string[]} existingIds - Array of existing rId strings (e.g., ['rId1', 'rId2']).
|
|
15
|
+
* @returns {string} New relationship ID (e.g., 'rId3').
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* generateRelationshipId(['rId1', 'rId3']) // → 'rId4'
|
|
19
|
+
* generateRelationshipId([]) // → 'rId1'
|
|
20
|
+
*/
|
|
21
|
+
export function generateRelationshipId(existingIds) {
|
|
22
|
+
if (!existingIds || existingIds.length === 0) return 'rId1';
|
|
23
|
+
|
|
24
|
+
const maxNum = existingIds.reduce((max, id) => {
|
|
25
|
+
const match = /^rId(\d+)$/.exec(id);
|
|
26
|
+
if (!match) return max;
|
|
27
|
+
return Math.max(max, parseInt(match[1], 10));
|
|
28
|
+
}, 0);
|
|
29
|
+
|
|
30
|
+
return `rId${maxNum + 1}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parses a relationship ID string and returns its numeric value.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} rId - Relationship ID (e.g., 'rId5').
|
|
37
|
+
* @returns {number} Numeric value (e.g., 5), or -1 if not a valid rId.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* parseRelationshipId('rId5') // → 5
|
|
41
|
+
* parseRelationshipId('foo') // → -1
|
|
42
|
+
*/
|
|
43
|
+
export function parseRelationshipId(rId) {
|
|
44
|
+
const match = /^rId(\d+)$/.exec(rId);
|
|
45
|
+
return match ? parseInt(match[1], 10) : -1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Checks if a string is a valid relationship ID.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} str
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
export function isValidRelationshipId(str) {
|
|
55
|
+
return /^rId\d+$/.test(str);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Remaps old relationship IDs to new ones within an XML string.
|
|
60
|
+
* Used when cloning slides to avoid rId conflicts.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} xml - XML content containing rId references.
|
|
63
|
+
* @param {Map<string, string>} idMap - Map of old rId → new rId.
|
|
64
|
+
* @returns {string} Updated XML with remapped rIds.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* remapRelationshipIds(xml, new Map([['rId1', 'rId5'], ['rId2', 'rId6']]));
|
|
68
|
+
*/
|
|
69
|
+
export function remapRelationshipIds(xml, idMap) {
|
|
70
|
+
let updated = xml;
|
|
71
|
+
|
|
72
|
+
// Sort by length descending to avoid partial replacements (e.g., rId1 replacing part of rId10)
|
|
73
|
+
const sortedEntries = Array.from(idMap.entries())
|
|
74
|
+
.sort(([a], [b]) => b.length - a.length);
|
|
75
|
+
|
|
76
|
+
for (const [oldId, newId] of sortedEntries) {
|
|
77
|
+
// Replace rId references in attribute values: r:id="rId1", r:embed="rId1"
|
|
78
|
+
const pattern = new RegExp(`(r:[a-zA-Z]+=")${oldId}(")|rId="${oldId}(")`, 'g');
|
|
79
|
+
updated = updated.replace(pattern, (match, pre, post) => {
|
|
80
|
+
if (pre) return `${pre}${newId}${post}`;
|
|
81
|
+
return match.replace(oldId, newId);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Simple global replace as fallback
|
|
85
|
+
updated = updated.split(`"${oldId}"`).join(`"${newId}"`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return updated;
|
|
89
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview XML validation and repair utilities.
|
|
3
|
+
*
|
|
4
|
+
* Provides tools to check if generated XML is well-formed and
|
|
5
|
+
* attempt automatic repairs for common PPTX corruption issues.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { XMLParser } from '../parsers/XMLParser.js';
|
|
9
|
+
|
|
10
|
+
const parser = new XMLParser();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validates that an XML string is well-formed.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} xmlString - XML to validate.
|
|
16
|
+
* @returns {{ valid: boolean, error: string|null }} Validation result.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const { valid, error } = validateXML(xml);
|
|
20
|
+
* if (!valid) console.error('XML error:', error);
|
|
21
|
+
*/
|
|
22
|
+
export function validateXML(xmlString) {
|
|
23
|
+
return parser.validate(xmlString);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Attempts to repair common XML corruption issues in PPTX files.
|
|
28
|
+
*
|
|
29
|
+
* Known issues this addresses:
|
|
30
|
+
* 1. Unescaped & in attribute values (e.g., href="a&b" → href="a&b")
|
|
31
|
+
* 2. Unclosed tags (limited heuristic repair)
|
|
32
|
+
* 3. Invalid XML characters (removes control chars below 0x20 except tab/LF/CR)
|
|
33
|
+
*
|
|
34
|
+
* @param {string} xmlString - Potentially broken XML.
|
|
35
|
+
* @returns {{ xml: string, repaired: boolean, changes: string[] }}
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* const { xml, repaired, changes } = repairXML(brokenXml);
|
|
39
|
+
* if (repaired) console.log('Repaired:', changes);
|
|
40
|
+
*/
|
|
41
|
+
export function repairXML(xmlString) {
|
|
42
|
+
const changes = [];
|
|
43
|
+
let xml = xmlString;
|
|
44
|
+
|
|
45
|
+
// Fix 1: Remove invalid XML control characters
|
|
46
|
+
const before = xml;
|
|
47
|
+
xml = xml.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
|
48
|
+
if (xml !== before) changes.push('Removed invalid control characters');
|
|
49
|
+
|
|
50
|
+
// Fix 2: Fix unescaped ampersands in text content (not in entities)
|
|
51
|
+
// Match & not followed by valid entity patterns
|
|
52
|
+
const fixedAmp = xml.replace(/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9a-fA-F]+;)/g, '&');
|
|
53
|
+
if (fixedAmp !== xml) {
|
|
54
|
+
xml = fixedAmp;
|
|
55
|
+
changes.push('Escaped unescaped ampersands');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Fix 3: Replace null bytes
|
|
59
|
+
if (xml.includes('\x00')) {
|
|
60
|
+
xml = xml.replace(/\x00/g, '');
|
|
61
|
+
changes.push('Removed null bytes');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fix 4: Ensure XML declaration is present
|
|
65
|
+
if (!xml.trimStart().startsWith('<?xml')) {
|
|
66
|
+
xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + xml;
|
|
67
|
+
changes.push('Added missing XML declaration');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
xml,
|
|
72
|
+
repaired: changes.length > 0,
|
|
73
|
+
changes,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Checks if an XML string contains a specific element.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} xmlString
|
|
81
|
+
* @param {string} elementName - Element tag name (e.g., 'a:tbl').
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
export function xmlContainsElement(xmlString, elementName) {
|
|
85
|
+
return xmlString.includes(`<${elementName}`) || xmlString.includes(`<${elementName}>`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Counts occurrences of an element in XML.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} xmlString
|
|
92
|
+
* @param {string} elementName
|
|
93
|
+
* @returns {number}
|
|
94
|
+
*/
|
|
95
|
+
export function countElements(xmlString, elementName) {
|
|
96
|
+
const pattern = new RegExp(`<${elementName}[\\s>/]`, 'g');
|
|
97
|
+
return (xmlString.match(pattern) || []).length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Extracts all attribute values for a given attribute name.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} xmlString - XML string to search.
|
|
104
|
+
* @param {string} attrName - Attribute name (e.g., 'r:id', 'name').
|
|
105
|
+
* @returns {string[]} Array of attribute values found.
|
|
106
|
+
*/
|
|
107
|
+
export function extractAttributeValues(xmlString, attrName) {
|
|
108
|
+
const pattern = new RegExp(`${attrName.replace(':', '\\:')}="([^"]*)"`, 'g');
|
|
109
|
+
const values = [];
|
|
110
|
+
let match;
|
|
111
|
+
while ((match = pattern.exec(xmlString)) !== null) {
|
|
112
|
+
values.push(match[1]);
|
|
113
|
+
}
|
|
114
|
+
return values;
|
|
115
|
+
}
|