@js-ak/excel-toolbox 1.3.2 → 1.4.1

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 (48) hide show
  1. package/build/cjs/lib/template/template-fs.js +458 -196
  2. package/build/cjs/lib/template/utils/apply-replacements.js +26 -0
  3. package/build/cjs/lib/template/utils/check-row.js +6 -11
  4. package/build/cjs/lib/template/utils/column-index-to-letter.js +14 -3
  5. package/build/cjs/lib/template/utils/extract-xml-declaration.js +22 -0
  6. package/build/cjs/lib/template/utils/get-by-path.js +18 -0
  7. package/build/cjs/lib/template/utils/get-max-row-number.js +1 -1
  8. package/build/cjs/lib/template/utils/index.js +9 -0
  9. package/build/cjs/lib/template/utils/process-merge-cells.js +40 -0
  10. package/build/cjs/lib/template/utils/process-merge-finalize.js +51 -0
  11. package/build/cjs/lib/template/utils/process-rows.js +160 -0
  12. package/build/cjs/lib/template/utils/process-shared-strings.js +45 -0
  13. package/build/cjs/lib/template/utils/to-excel-column-object.js +2 -10
  14. package/build/cjs/lib/template/utils/update-dimension.js +40 -0
  15. package/build/cjs/lib/template/utils/validate-worksheet-xml.js +231 -0
  16. package/build/cjs/lib/template/utils/write-rows-to-stream.js +2 -1
  17. package/build/cjs/lib/zip/create-with-stream.js +2 -2
  18. package/build/esm/lib/template/template-fs.js +458 -196
  19. package/build/esm/lib/template/utils/apply-replacements.js +22 -0
  20. package/build/esm/lib/template/utils/check-row.js +6 -11
  21. package/build/esm/lib/template/utils/column-index-to-letter.js +14 -3
  22. package/build/esm/lib/template/utils/extract-xml-declaration.js +19 -0
  23. package/build/esm/lib/template/utils/get-by-path.js +15 -0
  24. package/build/esm/lib/template/utils/get-max-row-number.js +1 -1
  25. package/build/esm/lib/template/utils/index.js +9 -0
  26. package/build/esm/lib/template/utils/process-merge-cells.js +37 -0
  27. package/build/esm/lib/template/utils/process-merge-finalize.js +48 -0
  28. package/build/esm/lib/template/utils/process-rows.js +157 -0
  29. package/build/esm/lib/template/utils/process-shared-strings.js +42 -0
  30. package/build/esm/lib/template/utils/to-excel-column-object.js +2 -10
  31. package/build/esm/lib/template/utils/update-dimension.js +37 -0
  32. package/build/esm/lib/template/utils/validate-worksheet-xml.js +228 -0
  33. package/build/esm/lib/template/utils/write-rows-to-stream.js +2 -1
  34. package/build/esm/lib/zip/create-with-stream.js +2 -2
  35. package/build/types/lib/template/template-fs.d.ts +24 -0
  36. package/build/types/lib/template/utils/apply-replacements.d.ts +13 -0
  37. package/build/types/lib/template/utils/check-row.d.ts +5 -10
  38. package/build/types/lib/template/utils/column-index-to-letter.d.ts +11 -3
  39. package/build/types/lib/template/utils/extract-xml-declaration.d.ts +14 -0
  40. package/build/types/lib/template/utils/get-by-path.d.ts +8 -0
  41. package/build/types/lib/template/utils/index.d.ts +9 -0
  42. package/build/types/lib/template/utils/process-merge-cells.d.ts +20 -0
  43. package/build/types/lib/template/utils/process-merge-finalize.d.ts +38 -0
  44. package/build/types/lib/template/utils/process-rows.d.ts +31 -0
  45. package/build/types/lib/template/utils/process-shared-strings.d.ts +20 -0
  46. package/build/types/lib/template/utils/update-dimension.d.ts +1 -0
  47. package/build/types/lib/template/utils/validate-worksheet-xml.d.ts +25 -0
  48. package/package.json +6 -4
@@ -62,6 +62,11 @@ class TemplateFs {
62
62
  * @type {boolean}
63
63
  */
64
64
  destroyed = false;
65
+ /**
66
+ * Flag indicating whether this template instance is currently being processed.
67
+ * @type {boolean}
68
+ */
69
+ #isProcessing = false;
65
70
  /**
66
71
  * Creates a Template instance.
67
72
  *
@@ -93,6 +98,70 @@ class TemplateFs {
93
98
  throw new Error("This Template instance has already been saved and destroyed.");
94
99
  }
95
100
  }
101
+ /**
102
+ * Ensures that this Template instance is not currently being processed.
103
+ * @throws {Error} If the template instance is currently being processed.
104
+ * @experimental This API is experimental and might change in future versions.
105
+ */
106
+ #ensureNotProcessing() {
107
+ if (this.#isProcessing) {
108
+ throw new Error("This Template instance is currently being processed.");
109
+ }
110
+ }
111
+ /**
112
+ * Expand table rows in the given sheet and shared strings XML.
113
+ *
114
+ * @param {string} sheetXml - The XML content of the sheet.
115
+ * @param {string} sharedStringsXml - The XML content of the shared strings.
116
+ * @param {Record<string, unknown>} replacements - An object containing replacement values.
117
+ *
118
+ * @returns {Object} An object with two properties:
119
+ * - sheet: The expanded sheet XML.
120
+ * - shared: The expanded shared strings XML.
121
+ * @experimental This API is experimental and might change in future versions.
122
+ */
123
+ #expandTableRows(sheetXml, sharedStringsXml, replacements) {
124
+ const { initialMergeCells, mergeCellMatches, modifiedXml, } = Utils.processMergeCells(sheetXml);
125
+ const { sharedIndexMap, sharedStrings, sharedStringsHeader, sheetMergeCells, } = Utils.processSharedStrings(sharedStringsXml);
126
+ const { lastIndex, resultRows, rowShift } = Utils.processRows({
127
+ mergeCellMatches,
128
+ replacements,
129
+ sharedIndexMap,
130
+ sharedStrings,
131
+ sheetMergeCells,
132
+ sheetXml: modifiedXml,
133
+ });
134
+ return Utils.processMergeFinalize({
135
+ initialMergeCells,
136
+ lastIndex,
137
+ mergeCellMatches,
138
+ resultRows,
139
+ rowShift,
140
+ sharedStrings,
141
+ sharedStringsHeader,
142
+ sheetMergeCells,
143
+ sheetXml: modifiedXml,
144
+ });
145
+ }
146
+ /**
147
+ * Get the path of the sheet with the given name inside the workbook.
148
+ * @param sheetName The name of the sheet to find.
149
+ * @returns The path of the sheet inside the workbook.
150
+ * @throws {Error} If the sheet is not found.
151
+ */
152
+ async #getSheetPath(sheetName) {
153
+ // Read XML workbook to find sheet name and path
154
+ const workbookXml = Xml.extractXmlFromSheet(await this.#readFile("xl/workbook.xml"));
155
+ const sheetMatch = workbookXml.match(new RegExp(`<sheet[^>]+name="${sheetName}"[^>]+r:id="([^"]+)"[^>]*/>`));
156
+ if (!sheetMatch)
157
+ throw new Error(`Sheet "${sheetName}" not found`);
158
+ const rId = sheetMatch[1];
159
+ const relsXml = Xml.extractXmlFromSheet(await this.#readFile("xl/_rels/workbook.xml.rels"));
160
+ const relMatch = relsXml.match(new RegExp(`<Relationship[^>]+Id="${rId}"[^>]+Target="([^"]+)"[^>]*/>`));
161
+ if (!relMatch)
162
+ throw new Error(`Relationship "${rId}" not found`);
163
+ return "xl/" + relMatch[1].replace(/^\/?xl\//, "");
164
+ }
96
165
  /**
97
166
  * Reads all files from the destination directory.
98
167
  * @private
@@ -122,6 +191,169 @@ class TemplateFs {
122
191
  const fullPath = path.join(this.destination, ...pathKey.split("/"));
123
192
  return await fs.readFile(fullPath);
124
193
  }
194
+ /**
195
+ * Replaces the contents of a file in the template.
196
+ *
197
+ * @param {string} key - The Excel path of the file to replace.
198
+ * @param {Buffer|string} content - The new content.
199
+ * @returns {Promise<void>}
200
+ * @throws {Error} If the template instance has been destroyed.
201
+ * @throws {Error} If the file does not exist in the template.
202
+ * @experimental This API is experimental and might change in future versions.
203
+ */
204
+ async #set(key, content) {
205
+ if (!this.fileKeys.has(key)) {
206
+ throw new Error(`File "${key}" is not part of the original template.`);
207
+ }
208
+ const fullPath = path.join(this.destination, ...key.split("/"));
209
+ await fs.writeFile(fullPath, Buffer.isBuffer(content) ? content : Buffer.from(content));
210
+ }
211
+ async #substitute(sheetName, replacements) {
212
+ const sharedStringsPath = "xl/sharedStrings.xml";
213
+ const sheetPath = await this.#getSheetPath(sheetName);
214
+ let sharedStringsContent = "";
215
+ let sheetContent = "";
216
+ if (this.fileKeys.has(sharedStringsPath)) {
217
+ sharedStringsContent = Xml.extractXmlFromSheet(await this.#readFile(sharedStringsPath));
218
+ }
219
+ if (this.fileKeys.has(sheetPath)) {
220
+ sheetContent = Xml.extractXmlFromSheet(await this.#readFile(sheetPath));
221
+ const TABLE_REGEX = /\$\{table:([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\}/g;
222
+ const hasTablePlaceholders = TABLE_REGEX.test(sharedStringsContent) || TABLE_REGEX.test(sheetContent);
223
+ if (hasTablePlaceholders) {
224
+ const result = this.#expandTableRows(sheetContent, sharedStringsContent, replacements);
225
+ sheetContent = result.sheet;
226
+ sharedStringsContent = result.shared;
227
+ }
228
+ }
229
+ if (this.fileKeys.has(sharedStringsPath)) {
230
+ sharedStringsContent = Utils.applyReplacements(sharedStringsContent, replacements);
231
+ await this.#set(sharedStringsPath, sharedStringsContent);
232
+ }
233
+ if (this.fileKeys.has(sheetPath)) {
234
+ sheetContent = Utils.applyReplacements(sheetContent, replacements);
235
+ await this.#set(sheetPath, sheetContent);
236
+ }
237
+ }
238
+ /**
239
+ * Validates the template by checking all required files exist.
240
+ *
241
+ * @returns {Promise<void>}
242
+ * @throws {Error} If the template instance has been destroyed.
243
+ * @throws {Error} If any required files are missing.
244
+ * @experimental This API is experimental and might change in future versions.
245
+ */
246
+ async #validate() {
247
+ for (const key of this.fileKeys) {
248
+ const fullPath = path.join(this.destination, ...key.split("/"));
249
+ try {
250
+ await fs.access(fullPath);
251
+ }
252
+ catch {
253
+ throw new Error(`Missing file in template directory: ${key}`);
254
+ }
255
+ }
256
+ }
257
+ /**
258
+ * Copies a sheet from the template to a new name.
259
+ *
260
+ * @param {string} sourceName - The name of the sheet to copy.
261
+ * @param {string} newName - The new name for the sheet.
262
+ * @returns {Promise<void>}
263
+ * @throws {Error} If the sheet with the source name does not exist.
264
+ * @throws {Error} If a sheet with the new name already exists.
265
+ * @experimental This API is experimental and might change in future versions.
266
+ */
267
+ async copySheet(sourceName, newName) {
268
+ this.#ensureNotProcessing();
269
+ this.#ensureNotDestroyed();
270
+ this.#isProcessing = true;
271
+ try {
272
+ // Read workbook.xml and find the source sheet
273
+ const workbookXmlPath = "xl/workbook.xml";
274
+ const workbookXml = Xml.extractXmlFromSheet(await this.#readFile(workbookXmlPath));
275
+ // Find the source sheet
276
+ const sheetRegex = new RegExp(`<sheet[^>]+name="${sourceName}"[^>]+r:id="([^"]+)"[^>]*/>`);
277
+ const sheetMatch = workbookXml.match(sheetRegex);
278
+ if (!sheetMatch)
279
+ throw new Error(`Sheet "${sourceName}" not found`);
280
+ const sourceRId = sheetMatch[1];
281
+ // Check if a sheet with the new name already exists
282
+ if (new RegExp(`<sheet[^>]+name="${newName}"`).test(workbookXml)) {
283
+ throw new Error(`Sheet "${newName}" already exists`);
284
+ }
285
+ // Read workbook.rels
286
+ // Find the source sheet path by rId
287
+ const relsXmlPath = "xl/_rels/workbook.xml.rels";
288
+ const relsXml = Xml.extractXmlFromSheet(await this.#readFile(relsXmlPath));
289
+ const relRegex = new RegExp(`<Relationship[^>]+Id="${sourceRId}"[^>]+Target="([^"]+)"[^>]*/>`);
290
+ const relMatch = relsXml.match(relRegex);
291
+ if (!relMatch)
292
+ throw new Error(`Relationship "${sourceRId}" not found`);
293
+ const sourceTarget = relMatch[1]; // sheetN.xml
294
+ if (!sourceTarget)
295
+ throw new Error(`Relationship "${sourceRId}" not found`);
296
+ const sourceSheetPath = "xl/" + sourceTarget.replace(/^\/?.*xl\//, "");
297
+ // Get the index of the new sheet
298
+ const sheetNumbers = [...this.fileKeys]
299
+ .map((key) => key.match(/^xl\/worksheets\/sheet(\d+)\.xml$/))
300
+ .filter(Boolean)
301
+ .map((match) => parseInt(match[1], 10));
302
+ const nextSheetIndex = sheetNumbers.length > 0 ? Math.max(...sheetNumbers) + 1 : 1;
303
+ const newSheetFilename = `sheet${nextSheetIndex}.xml`;
304
+ const newSheetPath = `xl/worksheets/${newSheetFilename}`;
305
+ const newTarget = `worksheets/${newSheetFilename}`;
306
+ // Generate a unique rId
307
+ const usedRIds = [...relsXml.matchAll(/Id="(rId\d+)"/g)].map(m => m[1]);
308
+ let nextRIdNum = 1;
309
+ while (usedRIds.includes(`rId${nextRIdNum}`))
310
+ nextRIdNum++;
311
+ const newRId = `rId${nextRIdNum}`;
312
+ // Copy the source sheet file
313
+ const sheetContent = await this.#readFile(sourceSheetPath);
314
+ await fs.writeFile(path.join(this.destination, ...newSheetPath.split("/")), sheetContent);
315
+ this.fileKeys.add(newSheetPath);
316
+ // Update workbook.xml
317
+ const updatedWorkbookXml = workbookXml.replace("</sheets>", `<sheet name="${newName}" sheetId="${nextSheetIndex}" r:id="${newRId}"/></sheets>`);
318
+ await this.#set(workbookXmlPath, updatedWorkbookXml);
319
+ // Update workbook.xml.rels
320
+ const updatedRelsXml = relsXml.replace("</Relationships>", `<Relationship Id="${newRId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="${newTarget}"/></Relationships>`);
321
+ await this.#set(relsXmlPath, updatedRelsXml);
322
+ // Read [Content_Types].xml
323
+ // Update [Content_Types].xml
324
+ const contentTypesPath = "[Content_Types].xml";
325
+ const contentTypesXml = Xml.extractXmlFromSheet(await this.#readFile(contentTypesPath));
326
+ const overrideTag = `<Override PartName="/xl/worksheets/${newSheetFilename}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`;
327
+ const updatedContentTypesXml = contentTypesXml.replace("</Types>", overrideTag + "</Types>");
328
+ await this.#set(contentTypesPath, updatedContentTypesXml);
329
+ }
330
+ finally {
331
+ this.#isProcessing = false;
332
+ }
333
+ }
334
+ /**
335
+ * Replaces placeholders in the given sheet with values from the replacements map.
336
+ *
337
+ * The function searches for placeholders in the format `${key}` within the sheet
338
+ * content, where `key` corresponds to a path in the replacements object.
339
+ * If a value is found for the key, it replaces the placeholder with the value.
340
+ * If no value is found, the original placeholder remains unchanged.
341
+ *
342
+ * @param sheetName - The name of the sheet to be replaced.
343
+ * @param replacements - An object where keys represent placeholder paths and values are the replacements.
344
+ * @returns A promise that resolves when the substitution is complete.
345
+ */
346
+ substitute(sheetName, replacements) {
347
+ this.#ensureNotProcessing();
348
+ this.#ensureNotDestroyed();
349
+ this.#isProcessing = true;
350
+ try {
351
+ return this.#substitute(sheetName, replacements);
352
+ }
353
+ finally {
354
+ this.#isProcessing = false;
355
+ }
356
+ }
125
357
  /**
126
358
  * Inserts rows into a specific sheet in the template.
127
359
  *
@@ -137,61 +369,68 @@ class TemplateFs {
137
369
  * @experimental This API is experimental and might change in future versions.
138
370
  */
139
371
  async insertRows(data) {
372
+ this.#ensureNotProcessing();
140
373
  this.#ensureNotDestroyed();
141
- const { rows, sheetName, startRowNumber } = data;
142
- const preparedRows = rows.map(row => Utils.toExcelColumnObject(row));
143
- // Validation
144
- Utils.checkStartRow(startRowNumber);
145
- Utils.checkRows(preparedRows);
146
- // Find the sheet
147
- const workbookXml = Xml.extractXmlFromSheet(await this.#readFile("xl/workbook.xml"));
148
- const sheetMatch = workbookXml.match(new RegExp(`<sheet[^>]+name="${sheetName}"[^>]+r:id="([^"]+)"[^>]*/>`));
149
- if (!sheetMatch || !sheetMatch[1]) {
150
- throw new Error(`Sheet "${sheetName}" not found`);
151
- }
152
- const rId = sheetMatch[1];
153
- const relsXml = Xml.extractXmlFromSheet(await this.#readFile("xl/_rels/workbook.xml.rels"));
154
- const relMatch = relsXml.match(new RegExp(`<Relationship[^>]+Id="${rId}"[^>]+Target="([^"]+)"[^>]*/>`));
155
- if (!relMatch || !relMatch[1]) {
156
- throw new Error(`Relationship "${rId}" not found`);
157
- }
158
- const sheetPath = "xl/" + relMatch[1].replace(/^\/?xl\//, "");
159
- const sheetXmlRaw = await this.#readFile(sheetPath);
160
- const sheetXml = Xml.extractXmlFromSheet(sheetXmlRaw);
161
- let nextRow = 0;
162
- if (!startRowNumber) {
163
- // Find the last row
164
- let lastRowNumber = 0;
165
- const rowMatches = [...sheetXml.matchAll(/<row[^>]+r="(\d+)"[^>]*>/g)];
166
- if (rowMatches.length > 0) {
167
- lastRowNumber = Math.max(...rowMatches.map((m) => parseInt(m[1], 10)));
374
+ this.#isProcessing = true;
375
+ try {
376
+ const { rows, sheetName, startRowNumber } = data;
377
+ const preparedRows = rows.map(row => Utils.toExcelColumnObject(row));
378
+ // Validation
379
+ Utils.checkStartRow(startRowNumber);
380
+ Utils.checkRows(preparedRows);
381
+ // Find the sheet
382
+ const workbookXml = Xml.extractXmlFromSheet(await this.#readFile("xl/workbook.xml"));
383
+ const sheetMatch = workbookXml.match(new RegExp(`<sheet[^>]+name="${sheetName}"[^>]+r:id="([^"]+)"[^>]*/>`));
384
+ if (!sheetMatch || !sheetMatch[1]) {
385
+ throw new Error(`Sheet "${sheetName}" not found`);
168
386
  }
169
- nextRow = lastRowNumber + 1;
170
- }
171
- else {
172
- nextRow = startRowNumber;
173
- }
174
- // Generate XML for all rows
175
- const rowsXml = preparedRows.map((cells, i) => {
176
- const rowNumber = nextRow + i;
177
- const cellTags = Object.entries(cells).map(([col, value]) => {
178
- const colUpper = col.toUpperCase();
179
- const ref = `${colUpper}${rowNumber}`;
180
- return `<c r="${ref}" t="inlineStr"><is><t>${Utils.escapeXml(value)}</t></is></c>`;
387
+ const rId = sheetMatch[1];
388
+ const relsXml = Xml.extractXmlFromSheet(await this.#readFile("xl/_rels/workbook.xml.rels"));
389
+ const relMatch = relsXml.match(new RegExp(`<Relationship[^>]+Id="${rId}"[^>]+Target="([^"]+)"[^>]*/>`));
390
+ if (!relMatch || !relMatch[1]) {
391
+ throw new Error(`Relationship "${rId}" not found`);
392
+ }
393
+ const sheetPath = "xl/" + relMatch[1].replace(/^\/?xl\//, "");
394
+ const sheetXmlRaw = await this.#readFile(sheetPath);
395
+ const sheetXml = Xml.extractXmlFromSheet(sheetXmlRaw);
396
+ let nextRow = 0;
397
+ if (!startRowNumber) {
398
+ // Find the last row
399
+ let lastRowNumber = 0;
400
+ const rowMatches = [...sheetXml.matchAll(/<row[^>]+r="(\d+)"[^>]*>/g)];
401
+ if (rowMatches.length > 0) {
402
+ lastRowNumber = Math.max(...rowMatches.map((m) => parseInt(m[1], 10)));
403
+ }
404
+ nextRow = lastRowNumber + 1;
405
+ }
406
+ else {
407
+ nextRow = startRowNumber;
408
+ }
409
+ // Generate XML for all rows
410
+ const rowsXml = preparedRows.map((cells, i) => {
411
+ const rowNumber = nextRow + i;
412
+ const cellTags = Object.entries(cells).map(([col, value]) => {
413
+ const colUpper = col.toUpperCase();
414
+ const ref = `${colUpper}${rowNumber}`;
415
+ return `<c r="${ref}" t="inlineStr"><is><t>${Utils.escapeXml(value)}</t></is></c>`;
416
+ }).join("");
417
+ return `<row r="${rowNumber}">${cellTags}</row>`;
181
418
  }).join("");
182
- return `<row r="${rowNumber}">${cellTags}</row>`;
183
- }).join("");
184
- let updatedXml;
185
- if (/<sheetData\s*\/>/.test(sheetXml)) {
186
- updatedXml = sheetXml.replace(/<sheetData\s*\/>/, `<sheetData>${rowsXml}</sheetData>`);
187
- }
188
- else if (/<sheetData>([\s\S]*?)<\/sheetData>/.test(sheetXml)) {
189
- updatedXml = sheetXml.replace(/<\/sheetData>/, `${rowsXml}</sheetData>`);
419
+ let updatedXml;
420
+ if (/<sheetData\s*\/>/.test(sheetXml)) {
421
+ updatedXml = sheetXml.replace(/<sheetData\s*\/>/, `<sheetData>${rowsXml}</sheetData>`);
422
+ }
423
+ else if (/<sheetData>([\s\S]*?)<\/sheetData>/.test(sheetXml)) {
424
+ updatedXml = sheetXml.replace(/<\/sheetData>/, `${rowsXml}</sheetData>`);
425
+ }
426
+ else {
427
+ updatedXml = sheetXml.replace(/<worksheet[^>]*>/, (match) => `${match}<sheetData>${rowsXml}</sheetData>`);
428
+ }
429
+ await this.#set(sheetPath, updatedXml);
190
430
  }
191
- else {
192
- updatedXml = sheetXml.replace(/<worksheet[^>]*>/, (match) => `${match}<sheetData>${rowsXml}</sheetData>`);
431
+ finally {
432
+ this.#isProcessing = false;
193
433
  }
194
- await this.set(sheetPath, updatedXml);
195
434
  }
196
435
  /**
197
436
  * Inserts rows into a specific sheet in the template using an async stream.
@@ -208,155 +447,162 @@ class TemplateFs {
208
447
  * @experimental This API is experimental and might change in future versions.
209
448
  */
210
449
  async insertRowsStream(data) {
450
+ this.#ensureNotProcessing();
211
451
  this.#ensureNotDestroyed();
212
- const { rows, sheetName, startRowNumber } = data;
213
- if (!sheetName)
214
- throw new Error("Sheet name is required");
215
- // Read XML workbook to find sheet name and path
216
- const workbookXml = Xml.extractXmlFromSheet(await this.#readFile("xl/workbook.xml"));
217
- const sheetMatch = workbookXml.match(new RegExp(`<sheet[^>]+name="${sheetName}"[^>]+r:id="([^"]+)"[^>]*/>`));
218
- if (!sheetMatch)
219
- throw new Error(`Sheet "${sheetName}" not found`);
220
- const rId = sheetMatch[1];
221
- const relsXml = Xml.extractXmlFromSheet(await this.#readFile("xl/_rels/workbook.xml.rels"));
222
- const relMatch = relsXml.match(new RegExp(`<Relationship[^>]+Id="${rId}"[^>]+Target="([^"]+)"[^>]*/>`));
223
- if (!relMatch)
224
- throw new Error(`Relationship "${rId}" not found`);
225
- // Path to the desired sheet (sheet1.xml)
226
- const sheetPath = "xl/" + relMatch[1].replace(/^\/?xl\//, "");
227
- // The temporary file for writing
228
- const fullPath = path.join(this.destination, ...sheetPath.split("/"));
229
- const tempPath = fullPath + ".tmp";
230
- // Streams for reading and writing
231
- const input = fsSync.createReadStream(fullPath, { encoding: "utf-8" });
232
- const output = fsSync.createWriteStream(tempPath, { encoding: "utf-8" });
233
- // Inserted rows flag
234
- let inserted = false;
235
- const rl = readline.createInterface({
236
- // Process all line breaks
237
- crlfDelay: Infinity,
238
- input,
239
- });
240
- let isCollecting = false;
241
- let collection = "";
242
- for await (const line of rl) {
243
- // Collect lines between <sheetData> and </sheetData>
244
- if (!inserted && isCollecting) {
245
- collection += line;
246
- if (line.includes("</sheetData>")) {
452
+ this.#isProcessing = true;
453
+ try {
454
+ const { rows, sheetName, startRowNumber } = data;
455
+ if (!sheetName)
456
+ throw new Error("Sheet name is required");
457
+ // Read XML workbook to find sheet name and path
458
+ const workbookXml = Xml.extractXmlFromSheet(await this.#readFile("xl/workbook.xml"));
459
+ const sheetMatch = workbookXml.match(new RegExp(`<sheet[^>]+name="${sheetName}"[^>]+r:id="([^"]+)"[^>]*/>`));
460
+ if (!sheetMatch)
461
+ throw new Error(`Sheet "${sheetName}" not found`);
462
+ const rId = sheetMatch[1];
463
+ const relsXml = Xml.extractXmlFromSheet(await this.#readFile("xl/_rels/workbook.xml.rels"));
464
+ const relMatch = relsXml.match(new RegExp(`<Relationship[^>]+Id="${rId}"[^>]+Target="([^"]+)"[^>]*/>`));
465
+ if (!relMatch)
466
+ throw new Error(`Relationship "${rId}" not found`);
467
+ // Path to the desired sheet (sheet1.xml)
468
+ const sheetPath = "xl/" + relMatch[1].replace(/^\/?xl\//, "");
469
+ // The temporary file for writing
470
+ const fullPath = path.join(this.destination, ...sheetPath.split("/"));
471
+ const tempPath = fullPath + ".tmp";
472
+ // Streams for reading and writing
473
+ const input = fsSync.createReadStream(fullPath, { encoding: "utf-8" });
474
+ const output = fsSync.createWriteStream(tempPath, { encoding: "utf-8" });
475
+ // Inserted rows flag
476
+ let inserted = false;
477
+ const rl = readline.createInterface({
478
+ // Process all line breaks
479
+ crlfDelay: Infinity,
480
+ input,
481
+ });
482
+ let isCollecting = false;
483
+ let collection = "";
484
+ for await (const line of rl) {
485
+ // Collect lines between <sheetData> and </sheetData>
486
+ if (!inserted && isCollecting) {
487
+ collection += line;
488
+ if (line.includes("</sheetData>")) {
489
+ const maxRowNumber = startRowNumber ?? Utils.getMaxRowNumber(line);
490
+ isCollecting = false;
491
+ inserted = true;
492
+ const openTag = collection.match(/<sheetData[^>]*>/)?.[0] ?? "<sheetData>";
493
+ const closeTag = "</sheetData>";
494
+ const openIdx = collection.indexOf(openTag);
495
+ const closeIdx = collection.lastIndexOf(closeTag);
496
+ const beforeRows = collection.slice(0, openIdx + openTag.length);
497
+ const innerRows = collection.slice(openIdx + openTag.length, closeIdx).trim();
498
+ const afterRows = collection.slice(closeIdx);
499
+ output.write(beforeRows);
500
+ const innerRowsMap = Utils.parseRows(innerRows);
501
+ if (innerRows) {
502
+ if (startRowNumber) {
503
+ const filteredRows = Utils.getRowsBelow(innerRowsMap, startRowNumber);
504
+ if (filteredRows)
505
+ output.write(filteredRows);
506
+ }
507
+ else {
508
+ output.write(innerRows);
509
+ }
510
+ }
511
+ const { rowNumber: actualRowNumber } = await Utils.writeRowsToStream(output, rows, maxRowNumber);
512
+ if (innerRows) {
513
+ const filteredRows = Utils.getRowsAbove(innerRowsMap, actualRowNumber);
514
+ if (filteredRows)
515
+ output.write(filteredRows);
516
+ }
517
+ output.write(afterRows);
518
+ }
519
+ continue;
520
+ }
521
+ // Case 1: <sheetData> and </sheetData> on one line
522
+ const singleLineMatch = line.match(/(<sheetData[^>]*>)(.*)(<\/sheetData>)/);
523
+ if (!inserted && singleLineMatch) {
247
524
  const maxRowNumber = startRowNumber ?? Utils.getMaxRowNumber(line);
248
- isCollecting = false;
249
- inserted = true;
250
- const openTag = collection.match(/<sheetData[^>]*>/)?.[0] ?? "<sheetData>";
525
+ const fullMatch = singleLineMatch[0];
526
+ const before = line.slice(0, singleLineMatch.index);
527
+ const after = line.slice(singleLineMatch.index + fullMatch.length);
528
+ const openTag = "<sheetData>";
251
529
  const closeTag = "</sheetData>";
252
- const openIdx = collection.indexOf(openTag);
253
- const closeIdx = collection.lastIndexOf(closeTag);
254
- const beforeRows = collection.slice(0, openIdx + openTag.length);
255
- const innerRows = collection.slice(openIdx + openTag.length, closeIdx).trim();
256
- const afterRows = collection.slice(closeIdx);
530
+ const openIdx = fullMatch.indexOf(openTag);
531
+ const closeIdx = fullMatch.indexOf(closeTag);
532
+ const beforeRows = fullMatch.slice(0, openIdx + openTag.length);
533
+ const innerRows = fullMatch.slice(openIdx + openTag.length, closeIdx).trim();
534
+ const afterRows = fullMatch.slice(closeIdx);
535
+ if (before) {
536
+ output.write(before);
537
+ }
257
538
  output.write(beforeRows);
258
539
  const innerRowsMap = Utils.parseRows(innerRows);
259
540
  if (innerRows) {
260
541
  if (startRowNumber) {
261
542
  const filteredRows = Utils.getRowsBelow(innerRowsMap, startRowNumber);
262
- if (filteredRows)
543
+ if (filteredRows) {
263
544
  output.write(filteredRows);
545
+ }
264
546
  }
265
547
  else {
266
548
  output.write(innerRows);
267
549
  }
268
550
  }
551
+ // new <row>
269
552
  const { rowNumber: actualRowNumber } = await Utils.writeRowsToStream(output, rows, maxRowNumber);
270
553
  if (innerRows) {
271
554
  const filteredRows = Utils.getRowsAbove(innerRowsMap, actualRowNumber);
272
- if (filteredRows)
273
- output.write(filteredRows);
274
- }
275
- output.write(afterRows);
276
- }
277
- continue;
278
- }
279
- // Case 1: <sheetData> and </sheetData> on one line
280
- const singleLineMatch = line.match(/(<sheetData[^>]*>)(.*)(<\/sheetData>)/);
281
- if (!inserted && singleLineMatch) {
282
- const maxRowNumber = startRowNumber ?? Utils.getMaxRowNumber(line);
283
- const fullMatch = singleLineMatch[0];
284
- const before = line.slice(0, singleLineMatch.index);
285
- const after = line.slice(singleLineMatch.index + fullMatch.length);
286
- const openTag = "<sheetData>";
287
- const closeTag = "</sheetData>";
288
- const openIdx = fullMatch.indexOf(openTag);
289
- const closeIdx = fullMatch.indexOf(closeTag);
290
- const beforeRows = fullMatch.slice(0, openIdx + openTag.length);
291
- const innerRows = fullMatch.slice(openIdx + openTag.length, closeIdx).trim();
292
- const afterRows = fullMatch.slice(closeIdx);
293
- if (before) {
294
- output.write(before);
295
- }
296
- output.write(beforeRows);
297
- const innerRowsMap = Utils.parseRows(innerRows);
298
- if (innerRows) {
299
- if (startRowNumber) {
300
- const filteredRows = Utils.getRowsBelow(innerRowsMap, startRowNumber);
301
555
  if (filteredRows) {
302
556
  output.write(filteredRows);
303
557
  }
304
558
  }
305
- else {
306
- output.write(innerRows);
559
+ output.write(afterRows);
560
+ if (after) {
561
+ output.write(after);
307
562
  }
563
+ inserted = true;
564
+ continue;
308
565
  }
309
- // new <row>
310
- const { rowNumber: actualRowNumber } = await Utils.writeRowsToStream(output, rows, maxRowNumber);
311
- if (innerRows) {
312
- const filteredRows = Utils.getRowsAbove(innerRowsMap, actualRowNumber);
313
- if (filteredRows) {
314
- output.write(filteredRows);
566
+ // Case 2: <sheetData/>
567
+ if (!inserted && /<sheetData\s*\/>/.test(line)) {
568
+ const maxRowNumber = startRowNumber ?? Utils.getMaxRowNumber(line);
569
+ const fullMatch = line.match(/<sheetData\s*\/>/)?.[0] || "";
570
+ const matchIndex = line.indexOf(fullMatch);
571
+ const before = line.slice(0, matchIndex);
572
+ const after = line.slice(matchIndex + fullMatch.length);
573
+ if (before) {
574
+ output.write(before);
315
575
  }
576
+ // Insert opening tag
577
+ output.write("<sheetData>");
578
+ // Prepare the rows
579
+ await Utils.writeRowsToStream(output, rows, maxRowNumber);
580
+ // Insert closing tag
581
+ output.write("</sheetData>");
582
+ if (after) {
583
+ output.write(after);
584
+ }
585
+ inserted = true;
586
+ continue;
316
587
  }
317
- output.write(afterRows);
318
- if (after) {
319
- output.write(after);
320
- }
321
- inserted = true;
322
- continue;
323
- }
324
- // Case 2: <sheetData/>
325
- if (!inserted && /<sheetData\s*\/>/.test(line)) {
326
- const maxRowNumber = startRowNumber ?? Utils.getMaxRowNumber(line);
327
- const fullMatch = line.match(/<sheetData\s*\/>/)?.[0] || "";
328
- const matchIndex = line.indexOf(fullMatch);
329
- const before = line.slice(0, matchIndex);
330
- const after = line.slice(matchIndex + fullMatch.length);
331
- if (before) {
332
- output.write(before);
333
- }
334
- // Insert opening tag
335
- output.write("<sheetData>");
336
- // Prepare the rows
337
- await Utils.writeRowsToStream(output, rows, maxRowNumber);
338
- // Insert closing tag
339
- output.write("</sheetData>");
340
- if (after) {
341
- output.write(after);
588
+ // Case 3: <sheetData>
589
+ if (!inserted && /<sheetData[^>]*>/.test(line)) {
590
+ isCollecting = true;
591
+ collection += line;
592
+ continue;
342
593
  }
343
- inserted = true;
344
- continue;
594
+ // After inserting rows, just copy the remaining lines
595
+ output.write(line);
345
596
  }
346
- // Case 3: <sheetData>
347
- if (!inserted && /<sheetData[^>]*>/.test(line)) {
348
- isCollecting = true;
349
- collection += line;
350
- continue;
351
- }
352
- // After inserting rows, just copy the remaining lines
353
- output.write(line);
597
+ // Close the streams
598
+ rl.close();
599
+ output.end();
600
+ // Move the temporary file to the original location
601
+ await fs.rename(tempPath, fullPath);
602
+ }
603
+ finally {
604
+ this.#isProcessing = false;
354
605
  }
355
- // Close the streams
356
- rl.close();
357
- output.end();
358
- // Move the temporary file to the original location
359
- await fs.rename(tempPath, fullPath);
360
606
  }
361
607
  /**
362
608
  * Saves the modified Excel template to a buffer.
@@ -366,15 +612,22 @@ class TemplateFs {
366
612
  * @experimental This API is experimental and might change in future versions.
367
613
  */
368
614
  async save() {
615
+ this.#ensureNotProcessing();
369
616
  this.#ensureNotDestroyed();
370
- // Guarantee integrity
371
- await this.validate();
372
- // Read the current files from the directory(in case they were changed manually)
373
- const updatedFiles = await this.#readAllFromDestination();
374
- const zipBuffer = await Zip.create(updatedFiles);
375
- await this.#cleanup();
376
- this.destroyed = true;
377
- return zipBuffer;
617
+ this.#isProcessing = true;
618
+ try {
619
+ // Guarantee integrity
620
+ await this.#validate();
621
+ // Read the current files from the directory(in case they were changed manually)
622
+ const updatedFiles = await this.#readAllFromDestination();
623
+ const zipBuffer = await Zip.create(updatedFiles);
624
+ await this.#cleanup();
625
+ this.destroyed = true;
626
+ return zipBuffer;
627
+ }
628
+ finally {
629
+ this.#isProcessing = false;
630
+ }
378
631
  }
379
632
  /**
380
633
  * Writes the modified Excel template to a writable stream.
@@ -385,12 +638,19 @@ class TemplateFs {
385
638
  * @experimental This API is experimental and might change in future versions.
386
639
  */
387
640
  async saveStream(output) {
641
+ this.#ensureNotProcessing();
388
642
  this.#ensureNotDestroyed();
389
- // Guarantee integrity
390
- await this.validate();
391
- await Zip.createWithStream(Array.from(this.fileKeys), this.destination, output);
392
- await this.#cleanup();
393
- this.destroyed = true;
643
+ this.#isProcessing = true;
644
+ try {
645
+ // Guarantee integrity
646
+ await this.#validate();
647
+ await Zip.createWithStream(Array.from(this.fileKeys), this.destination, output);
648
+ await this.#cleanup();
649
+ this.destroyed = true;
650
+ }
651
+ finally {
652
+ this.#isProcessing = false;
653
+ }
394
654
  }
395
655
  /**
396
656
  * Replaces the contents of a file in the template.
@@ -403,12 +663,15 @@ class TemplateFs {
403
663
  * @experimental This API is experimental and might change in future versions.
404
664
  */
405
665
  async set(key, content) {
666
+ this.#ensureNotProcessing();
406
667
  this.#ensureNotDestroyed();
407
- if (!this.fileKeys.has(key)) {
408
- throw new Error(`File "${key}" is not part of the original template.`);
668
+ this.#isProcessing = true;
669
+ try {
670
+ await this.#set(key, content);
671
+ }
672
+ finally {
673
+ this.#isProcessing = false;
409
674
  }
410
- const fullPath = path.join(this.destination, ...key.split("/"));
411
- await fs.writeFile(fullPath, Buffer.isBuffer(content) ? content : Buffer.from(content));
412
675
  }
413
676
  /**
414
677
  * Validates the template by checking all required files exist.
@@ -419,15 +682,14 @@ class TemplateFs {
419
682
  * @experimental This API is experimental and might change in future versions.
420
683
  */
421
684
  async validate() {
685
+ this.#ensureNotProcessing();
422
686
  this.#ensureNotDestroyed();
423
- for (const key of this.fileKeys) {
424
- const fullPath = path.join(this.destination, ...key.split("/"));
425
- try {
426
- await fs.access(fullPath);
427
- }
428
- catch {
429
- throw new Error(`Missing file in template directory: ${key}`);
430
- }
687
+ this.#isProcessing = true;
688
+ try {
689
+ await this.#validate();
690
+ }
691
+ finally {
692
+ this.#isProcessing = false;
431
693
  }
432
694
  }
433
695
  /**