@js-ak/excel-toolbox 1.3.2 → 1.4.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 (30) hide show
  1. package/build/cjs/lib/template/template-fs.js +678 -197
  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/index.js +5 -0
  8. package/build/cjs/lib/template/utils/update-dimension.js +40 -0
  9. package/build/cjs/lib/template/utils/validate-worksheet-xml.js +217 -0
  10. package/build/cjs/lib/zip/create-with-stream.js +2 -2
  11. package/build/esm/lib/template/template-fs.js +678 -197
  12. package/build/esm/lib/template/utils/apply-replacements.js +22 -0
  13. package/build/esm/lib/template/utils/check-row.js +6 -11
  14. package/build/esm/lib/template/utils/column-index-to-letter.js +14 -3
  15. package/build/esm/lib/template/utils/extract-xml-declaration.js +19 -0
  16. package/build/esm/lib/template/utils/get-by-path.js +15 -0
  17. package/build/esm/lib/template/utils/index.js +5 -0
  18. package/build/esm/lib/template/utils/update-dimension.js +37 -0
  19. package/build/esm/lib/template/utils/validate-worksheet-xml.js +214 -0
  20. package/build/esm/lib/zip/create-with-stream.js +2 -2
  21. package/build/types/lib/template/template-fs.d.ts +24 -0
  22. package/build/types/lib/template/utils/apply-replacements.d.ts +13 -0
  23. package/build/types/lib/template/utils/check-row.d.ts +5 -10
  24. package/build/types/lib/template/utils/column-index-to-letter.d.ts +11 -3
  25. package/build/types/lib/template/utils/extract-xml-declaration.d.ts +14 -0
  26. package/build/types/lib/template/utils/get-by-path.d.ts +8 -0
  27. package/build/types/lib/template/utils/index.d.ts +5 -0
  28. package/build/types/lib/template/utils/update-dimension.d.ts +1 -0
  29. package/build/types/lib/template/utils/validate-worksheet-xml.d.ts +9 -0
  30. 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, } = processMergeCells(sheetXml);
125
+ const { sharedIndexMap, sharedStrings, sharedStringsHeader, sheetMergeCells, } = processSharedStrings(sharedStringsXml);
126
+ const { lastIndex, resultRows, rowShift } = processRows({
127
+ mergeCellMatches,
128
+ replacements,
129
+ sharedIndexMap,
130
+ sharedStrings,
131
+ sheetMergeCells,
132
+ sheetXml: modifiedXml,
133
+ });
134
+ return processBuild({
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`);
386
+ }
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;
168
405
  }
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>`;
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;
345
- }
346
- // Case 3: <sheetData>
347
- if (!inserted && /<sheetData[^>]*>/.test(line)) {
348
- isCollecting = true;
349
- collection += line;
350
- continue;
594
+ // After inserting rows, just copy the remaining lines
595
+ output.write(line);
351
596
  }
352
- // After inserting rows, just copy the remaining lines
353
- output.write(line);
354
- }
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);
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;
605
+ }
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
  /**
@@ -463,3 +725,222 @@ class TemplateFs {
463
725
  }
464
726
  }
465
727
  exports.TemplateFs = TemplateFs;
728
+ function processMergeCells(sheetXml) {
729
+ // Regular expression for finding <mergeCells> block
730
+ const mergeCellsBlockRegex = /<mergeCells[^>]*>[\s\S]*?<\/mergeCells>/;
731
+ // Find the first <mergeCells> block (if there are multiple, in xlsx usually there is only one)
732
+ const mergeCellsBlockMatch = sheetXml.match(mergeCellsBlockRegex);
733
+ const initialMergeCells = [];
734
+ const mergeCellMatches = [];
735
+ if (mergeCellsBlockMatch) {
736
+ const mergeCellsBlock = mergeCellsBlockMatch[0];
737
+ initialMergeCells.push(mergeCellsBlock);
738
+ // Extract <mergeCell ref="A1:B2"/> from this block
739
+ const mergeCellRegex = /<mergeCell ref="([A-Z]+\d+):([A-Z]+\d+)"\/>/g;
740
+ for (const match of mergeCellsBlock.matchAll(mergeCellRegex)) {
741
+ mergeCellMatches.push({ from: match[1], to: match[2] });
742
+ }
743
+ }
744
+ // Remove the <mergeCells> block from the XML
745
+ const modifiedXml = sheetXml.replace(mergeCellsBlockRegex, "");
746
+ return {
747
+ initialMergeCells,
748
+ mergeCellMatches,
749
+ modifiedXml,
750
+ };
751
+ }
752
+ ;
753
+ function processSharedStrings(sharedStringsXml) {
754
+ // Final list of merged cells with all changes
755
+ const sheetMergeCells = [];
756
+ // Array for storing shared strings
757
+ const sharedStrings = [];
758
+ const sharedStringsHeader = Utils.extractXmlDeclaration(sharedStringsXml);
759
+ // Map for fast lookup of shared string index by content
760
+ const sharedIndexMap = new Map();
761
+ // Regular expression for finding <si> elements (shared string items)
762
+ const siRegex = /<si>([\s\S]*?)<\/si>/g;
763
+ // Parse sharedStringsXml and fill sharedStrings and sharedIndexMap
764
+ for (const match of sharedStringsXml.matchAll(siRegex)) {
765
+ const content = match[1];
766
+ if (!content)
767
+ throw new Error("Shared index not found");
768
+ const fullSi = `<si>${content}</si>`;
769
+ sharedIndexMap.set(content, sharedStrings.length);
770
+ sharedStrings.push(fullSi);
771
+ }
772
+ return {
773
+ sharedIndexMap,
774
+ sharedStrings,
775
+ sharedStringsHeader,
776
+ sheetMergeCells,
777
+ };
778
+ }
779
+ ;
780
+ function processRows(data) {
781
+ const { mergeCellMatches, replacements, sharedIndexMap, sharedStrings, sheetMergeCells, sheetXml, } = data;
782
+ const TABLE_REGEX = /\$\{table:([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\}/g;
783
+ // Array for storing resulting XML rows
784
+ const resultRows = [];
785
+ // Previous position of processed part of XML
786
+ let lastIndex = 0;
787
+ // Shift for row numbers
788
+ let rowShift = 0;
789
+ // Regular expression for finding <row> elements
790
+ const rowRegex = /<row[^>]*?>[\s\S]*?<\/row>/g;
791
+ // Process each <row> element
792
+ for (const match of sheetXml.matchAll(rowRegex)) {
793
+ // Full XML row
794
+ const fullRow = match[0];
795
+ // Start position of the row in XML
796
+ const matchStart = match.index;
797
+ // End position of the row in XML
798
+ const matchEnd = matchStart + fullRow.length;
799
+ // Add the intermediate XML chunk (if any) between the previous and the current row
800
+ if (lastIndex !== matchStart) {
801
+ resultRows.push(sheetXml.slice(lastIndex, matchStart));
802
+ }
803
+ lastIndex = matchEnd;
804
+ // Get row number from r attribute
805
+ const originalRowNumber = parseInt(fullRow.match(/<row[^>]* r="(\d+)"/)?.[1] ?? "1", 10);
806
+ // Update row number based on rowShift
807
+ const shiftedRowNumber = originalRowNumber + rowShift;
808
+ // Find shared string indexes in cells of the current row
809
+ const sharedValueIndexes = [];
810
+ // Regular expression for finding a cell
811
+ const cellRegex = /<c[^>]*?r="([A-Z]+\d+)"[^>]*?>([\s\S]*?)<\/c>/g;
812
+ for (const cell of fullRow.matchAll(cellRegex)) {
813
+ const cellTag = cell[0];
814
+ // Check if the cell is a shared string
815
+ const isShared = /t="s"/.test(cellTag);
816
+ const valueMatch = cellTag.match(/<v>(\d+)<\/v>/);
817
+ if (isShared && valueMatch) {
818
+ sharedValueIndexes.push(parseInt(valueMatch[1], 10));
819
+ }
820
+ }
821
+ // Get the text content of shared strings by their indexes
822
+ const sharedTexts = sharedValueIndexes.map(i => sharedStrings[i]?.replace(/<\/?si>/g, "") ?? "");
823
+ // Find table placeholders in shared strings
824
+ const tablePlaceholders = sharedTexts.flatMap(e => [...e.matchAll(TABLE_REGEX)]);
825
+ // If there are no table placeholders, just shift the row
826
+ if (tablePlaceholders.length === 0) {
827
+ const updatedRow = fullRow
828
+ .replace(/(<row[^>]* r=")(\d+)(")/, `$1${shiftedRowNumber}$3`)
829
+ .replace(/<c r="([A-Z]+)(\d+)"/g, (_, col) => `<c r="${col}${shiftedRowNumber}"`);
830
+ resultRows.push(updatedRow);
831
+ // Update mergeCells for regular row with rowShift
832
+ const calculatedRowNumber = originalRowNumber + rowShift;
833
+ for (const { from, to } of mergeCellMatches) {
834
+ const [, fromCol, fromRow] = from.match(/^([A-Z]+)(\d+)$/);
835
+ const [, toCol] = to.match(/^([A-Z]+)(\d+)$/);
836
+ if (Number(fromRow) === calculatedRowNumber) {
837
+ const newFrom = `${fromCol}${shiftedRowNumber}`;
838
+ const newTo = `${toCol}${shiftedRowNumber}`;
839
+ sheetMergeCells.push(`<mergeCell ref="${newFrom}:${newTo}"/>`);
840
+ }
841
+ }
842
+ continue;
843
+ }
844
+ // Get the table name from the first placeholder
845
+ const firstMatch = tablePlaceholders[0];
846
+ const tableName = firstMatch?.[1];
847
+ if (!tableName)
848
+ throw new Error("Table name not found");
849
+ // Get data for replacement from replacements
850
+ const array = replacements[tableName];
851
+ if (!array)
852
+ continue;
853
+ if (!Array.isArray(array))
854
+ throw new Error("Table data is not an array");
855
+ const tableRowStart = shiftedRowNumber;
856
+ // Find mergeCells to duplicate (mergeCells that start with the current row)
857
+ const mergeCellsToDuplicate = mergeCellMatches.filter(({ from }) => {
858
+ const match = from.match(/^([A-Z]+)(\d+)$/);
859
+ if (!match)
860
+ return false;
861
+ // Row number of the merge cell start position is in the second group
862
+ const rowNumber = match[2];
863
+ return Number(rowNumber) === tableRowStart;
864
+ });
865
+ // Change the current row to multiple rows from the data array
866
+ for (let i = 0; i < array.length; i++) {
867
+ const rowData = array[i];
868
+ let newRow = fullRow;
869
+ // Replace placeholders in shared strings with real data
870
+ sharedValueIndexes.forEach((originalIdx, idx) => {
871
+ const originalText = sharedTexts[idx];
872
+ if (!originalText)
873
+ throw new Error("Shared value not found");
874
+ // Replace placeholders ${tableName.field} with real data from array data
875
+ const replacedText = originalText.replace(TABLE_REGEX, (_, tbl, field) => tbl === tableName ? String(rowData?.[field] ?? "") : "");
876
+ // Add new text to shared strings if it doesn't exist
877
+ let newIndex;
878
+ if (sharedIndexMap.has(replacedText)) {
879
+ newIndex = sharedIndexMap.get(replacedText);
880
+ }
881
+ else {
882
+ newIndex = sharedStrings.length;
883
+ sharedIndexMap.set(replacedText, newIndex);
884
+ sharedStrings.push(`<si>${replacedText}</si>`);
885
+ }
886
+ // Replace the shared string index in the cell
887
+ newRow = newRow.replace(`<v>${originalIdx}</v>`, `<v>${newIndex}</v>`);
888
+ });
889
+ // Update row number and cell references
890
+ const newRowNum = shiftedRowNumber + i;
891
+ newRow = newRow
892
+ .replace(/<row[^>]* r="\d+"/, rowTag => rowTag.replace(/r="\d+"/, `r="${newRowNum}"`))
893
+ .replace(/<c r="([A-Z]+)\d+"/g, (_, col) => `<c r="${col}${newRowNum}"`);
894
+ resultRows.push(newRow);
895
+ // Add duplicate mergeCells for new rows
896
+ for (const { from, to } of mergeCellsToDuplicate) {
897
+ const [, colFrom, rowFrom] = from.match(/^([A-Z]+)(\d+)$/);
898
+ const [, colTo, rowTo] = to.match(/^([A-Z]+)(\d+)$/);
899
+ const newFrom = `${colFrom}${Number(rowFrom) + i}`;
900
+ const newTo = `${colTo}${Number(rowTo) + i}`;
901
+ sheetMergeCells.push(`<mergeCell ref="${newFrom}:${newTo}"/>`);
902
+ }
903
+ }
904
+ // It increases the row shift by the number of added rows minus one replaced
905
+ rowShift += array.length - 1;
906
+ const delta = array.length - 1;
907
+ const calculatedRowNumber = originalRowNumber + rowShift - array.length + 1;
908
+ if (delta > 0) {
909
+ for (const merge of mergeCellMatches) {
910
+ const fromRow = parseInt(merge.from.match(/\d+$/)[0], 10);
911
+ if (fromRow > calculatedRowNumber) {
912
+ merge.from = merge.from.replace(/\d+$/, r => `${parseInt(r) + delta}`);
913
+ merge.to = merge.to.replace(/\d+$/, r => `${parseInt(r) + delta}`);
914
+ }
915
+ }
916
+ }
917
+ }
918
+ return { lastIndex, resultRows, rowShift };
919
+ }
920
+ ;
921
+ function processBuild(data) {
922
+ const { initialMergeCells, lastIndex, mergeCellMatches, resultRows, rowShift, sharedStrings, sharedStringsHeader, sheetMergeCells, sheetXml, } = data;
923
+ for (const { from, to } of mergeCellMatches) {
924
+ const [, fromCol, fromRow] = from.match(/^([A-Z]+)(\d+)$/);
925
+ const [, toCol, toRow] = to.match(/^([A-Z]+)(\d+)$/);
926
+ const fromRowNum = Number(fromRow);
927
+ // These rows have already been processed, don't add duplicates
928
+ if (fromRowNum <= lastIndex)
929
+ continue;
930
+ const newFrom = `${fromCol}${fromRowNum + rowShift}`;
931
+ const newTo = `${toCol}${Number(toRow) + rowShift}`;
932
+ sheetMergeCells.push(`<mergeCell ref="${newFrom}:${newTo}"/>`);
933
+ }
934
+ resultRows.push(sheetXml.slice(lastIndex));
935
+ // Form XML for mergeCells if there are any
936
+ const mergeXml = sheetMergeCells.length
937
+ ? `<mergeCells count="${sheetMergeCells.length}">${sheetMergeCells.join("")}</mergeCells>`
938
+ : initialMergeCells;
939
+ // Insert mergeCells before the closing sheetData tag
940
+ const sheetWithMerge = resultRows.join("").replace(/<\/sheetData>/, `</sheetData>${mergeXml}`);
941
+ // Return modified sheet XML and shared strings
942
+ return {
943
+ shared: `${sharedStringsHeader}\n<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="${sharedStrings.length}" uniqueCount="${sharedStrings.length}">${sharedStrings.join("")}</sst>`,
944
+ sheet: Utils.updateDimension(sheetWithMerge),
945
+ };
946
+ }