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,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&amp;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, '&amp;');
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
+ }