@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
@@ -26,6 +26,11 @@ export class TemplateFs {
26
26
  * @type {boolean}
27
27
  */
28
28
  destroyed = false;
29
+ /**
30
+ * Flag indicating whether this template instance is currently being processed.
31
+ * @type {boolean}
32
+ */
33
+ #isProcessing = false;
29
34
  /**
30
35
  * Creates a Template instance.
31
36
  *
@@ -57,6 +62,70 @@ export class TemplateFs {
57
62
  throw new Error("This Template instance has already been saved and destroyed.");
58
63
  }
59
64
  }
65
+ /**
66
+ * Ensures that this Template instance is not currently being processed.
67
+ * @throws {Error} If the template instance is currently being processed.
68
+ * @experimental This API is experimental and might change in future versions.
69
+ */
70
+ #ensureNotProcessing() {
71
+ if (this.#isProcessing) {
72
+ throw new Error("This Template instance is currently being processed.");
73
+ }
74
+ }
75
+ /**
76
+ * Expand table rows in the given sheet and shared strings XML.
77
+ *
78
+ * @param {string} sheetXml - The XML content of the sheet.
79
+ * @param {string} sharedStringsXml - The XML content of the shared strings.
80
+ * @param {Record<string, unknown>} replacements - An object containing replacement values.
81
+ *
82
+ * @returns {Object} An object with two properties:
83
+ * - sheet: The expanded sheet XML.
84
+ * - shared: The expanded shared strings XML.
85
+ * @experimental This API is experimental and might change in future versions.
86
+ */
87
+ #expandTableRows(sheetXml, sharedStringsXml, replacements) {
88
+ const { initialMergeCells, mergeCellMatches, modifiedXml, } = processMergeCells(sheetXml);
89
+ const { sharedIndexMap, sharedStrings, sharedStringsHeader, sheetMergeCells, } = processSharedStrings(sharedStringsXml);
90
+ const { lastIndex, resultRows, rowShift } = processRows({
91
+ mergeCellMatches,
92
+ replacements,
93
+ sharedIndexMap,
94
+ sharedStrings,
95
+ sheetMergeCells,
96
+ sheetXml: modifiedXml,
97
+ });
98
+ return processBuild({
99
+ initialMergeCells,
100
+ lastIndex,
101
+ mergeCellMatches,
102
+ resultRows,
103
+ rowShift,
104
+ sharedStrings,
105
+ sharedStringsHeader,
106
+ sheetMergeCells,
107
+ sheetXml: modifiedXml,
108
+ });
109
+ }
110
+ /**
111
+ * Get the path of the sheet with the given name inside the workbook.
112
+ * @param sheetName The name of the sheet to find.
113
+ * @returns The path of the sheet inside the workbook.
114
+ * @throws {Error} If the sheet is not found.
115
+ */
116
+ async #getSheetPath(sheetName) {
117
+ // Read XML workbook to find sheet name and path
118
+ const workbookXml = Xml.extractXmlFromSheet(await this.#readFile("xl/workbook.xml"));
119
+ const sheetMatch = workbookXml.match(new RegExp(`<sheet[^>]+name="${sheetName}"[^>]+r:id="([^"]+)"[^>]*/>`));
120
+ if (!sheetMatch)
121
+ throw new Error(`Sheet "${sheetName}" not found`);
122
+ const rId = sheetMatch[1];
123
+ const relsXml = Xml.extractXmlFromSheet(await this.#readFile("xl/_rels/workbook.xml.rels"));
124
+ const relMatch = relsXml.match(new RegExp(`<Relationship[^>]+Id="${rId}"[^>]+Target="([^"]+)"[^>]*/>`));
125
+ if (!relMatch)
126
+ throw new Error(`Relationship "${rId}" not found`);
127
+ return "xl/" + relMatch[1].replace(/^\/?xl\//, "");
128
+ }
60
129
  /**
61
130
  * Reads all files from the destination directory.
62
131
  * @private
@@ -86,6 +155,169 @@ export class TemplateFs {
86
155
  const fullPath = path.join(this.destination, ...pathKey.split("/"));
87
156
  return await fs.readFile(fullPath);
88
157
  }
158
+ /**
159
+ * Replaces the contents of a file in the template.
160
+ *
161
+ * @param {string} key - The Excel path of the file to replace.
162
+ * @param {Buffer|string} content - The new content.
163
+ * @returns {Promise<void>}
164
+ * @throws {Error} If the template instance has been destroyed.
165
+ * @throws {Error} If the file does not exist in the template.
166
+ * @experimental This API is experimental and might change in future versions.
167
+ */
168
+ async #set(key, content) {
169
+ if (!this.fileKeys.has(key)) {
170
+ throw new Error(`File "${key}" is not part of the original template.`);
171
+ }
172
+ const fullPath = path.join(this.destination, ...key.split("/"));
173
+ await fs.writeFile(fullPath, Buffer.isBuffer(content) ? content : Buffer.from(content));
174
+ }
175
+ async #substitute(sheetName, replacements) {
176
+ const sharedStringsPath = "xl/sharedStrings.xml";
177
+ const sheetPath = await this.#getSheetPath(sheetName);
178
+ let sharedStringsContent = "";
179
+ let sheetContent = "";
180
+ if (this.fileKeys.has(sharedStringsPath)) {
181
+ sharedStringsContent = Xml.extractXmlFromSheet(await this.#readFile(sharedStringsPath));
182
+ }
183
+ if (this.fileKeys.has(sheetPath)) {
184
+ sheetContent = Xml.extractXmlFromSheet(await this.#readFile(sheetPath));
185
+ const TABLE_REGEX = /\$\{table:([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\}/g;
186
+ const hasTablePlaceholders = TABLE_REGEX.test(sharedStringsContent) || TABLE_REGEX.test(sheetContent);
187
+ if (hasTablePlaceholders) {
188
+ const result = this.#expandTableRows(sheetContent, sharedStringsContent, replacements);
189
+ sheetContent = result.sheet;
190
+ sharedStringsContent = result.shared;
191
+ }
192
+ }
193
+ if (this.fileKeys.has(sharedStringsPath)) {
194
+ sharedStringsContent = Utils.applyReplacements(sharedStringsContent, replacements);
195
+ await this.#set(sharedStringsPath, sharedStringsContent);
196
+ }
197
+ if (this.fileKeys.has(sheetPath)) {
198
+ sheetContent = Utils.applyReplacements(sheetContent, replacements);
199
+ await this.#set(sheetPath, sheetContent);
200
+ }
201
+ }
202
+ /**
203
+ * Validates the template by checking all required files exist.
204
+ *
205
+ * @returns {Promise<void>}
206
+ * @throws {Error} If the template instance has been destroyed.
207
+ * @throws {Error} If any required files are missing.
208
+ * @experimental This API is experimental and might change in future versions.
209
+ */
210
+ async #validate() {
211
+ for (const key of this.fileKeys) {
212
+ const fullPath = path.join(this.destination, ...key.split("/"));
213
+ try {
214
+ await fs.access(fullPath);
215
+ }
216
+ catch {
217
+ throw new Error(`Missing file in template directory: ${key}`);
218
+ }
219
+ }
220
+ }
221
+ /**
222
+ * Copies a sheet from the template to a new name.
223
+ *
224
+ * @param {string} sourceName - The name of the sheet to copy.
225
+ * @param {string} newName - The new name for the sheet.
226
+ * @returns {Promise<void>}
227
+ * @throws {Error} If the sheet with the source name does not exist.
228
+ * @throws {Error} If a sheet with the new name already exists.
229
+ * @experimental This API is experimental and might change in future versions.
230
+ */
231
+ async copySheet(sourceName, newName) {
232
+ this.#ensureNotProcessing();
233
+ this.#ensureNotDestroyed();
234
+ this.#isProcessing = true;
235
+ try {
236
+ // Read workbook.xml and find the source sheet
237
+ const workbookXmlPath = "xl/workbook.xml";
238
+ const workbookXml = Xml.extractXmlFromSheet(await this.#readFile(workbookXmlPath));
239
+ // Find the source sheet
240
+ const sheetRegex = new RegExp(`<sheet[^>]+name="${sourceName}"[^>]+r:id="([^"]+)"[^>]*/>`);
241
+ const sheetMatch = workbookXml.match(sheetRegex);
242
+ if (!sheetMatch)
243
+ throw new Error(`Sheet "${sourceName}" not found`);
244
+ const sourceRId = sheetMatch[1];
245
+ // Check if a sheet with the new name already exists
246
+ if (new RegExp(`<sheet[^>]+name="${newName}"`).test(workbookXml)) {
247
+ throw new Error(`Sheet "${newName}" already exists`);
248
+ }
249
+ // Read workbook.rels
250
+ // Find the source sheet path by rId
251
+ const relsXmlPath = "xl/_rels/workbook.xml.rels";
252
+ const relsXml = Xml.extractXmlFromSheet(await this.#readFile(relsXmlPath));
253
+ const relRegex = new RegExp(`<Relationship[^>]+Id="${sourceRId}"[^>]+Target="([^"]+)"[^>]*/>`);
254
+ const relMatch = relsXml.match(relRegex);
255
+ if (!relMatch)
256
+ throw new Error(`Relationship "${sourceRId}" not found`);
257
+ const sourceTarget = relMatch[1]; // sheetN.xml
258
+ if (!sourceTarget)
259
+ throw new Error(`Relationship "${sourceRId}" not found`);
260
+ const sourceSheetPath = "xl/" + sourceTarget.replace(/^\/?.*xl\//, "");
261
+ // Get the index of the new sheet
262
+ const sheetNumbers = [...this.fileKeys]
263
+ .map((key) => key.match(/^xl\/worksheets\/sheet(\d+)\.xml$/))
264
+ .filter(Boolean)
265
+ .map((match) => parseInt(match[1], 10));
266
+ const nextSheetIndex = sheetNumbers.length > 0 ? Math.max(...sheetNumbers) + 1 : 1;
267
+ const newSheetFilename = `sheet${nextSheetIndex}.xml`;
268
+ const newSheetPath = `xl/worksheets/${newSheetFilename}`;
269
+ const newTarget = `worksheets/${newSheetFilename}`;
270
+ // Generate a unique rId
271
+ const usedRIds = [...relsXml.matchAll(/Id="(rId\d+)"/g)].map(m => m[1]);
272
+ let nextRIdNum = 1;
273
+ while (usedRIds.includes(`rId${nextRIdNum}`))
274
+ nextRIdNum++;
275
+ const newRId = `rId${nextRIdNum}`;
276
+ // Copy the source sheet file
277
+ const sheetContent = await this.#readFile(sourceSheetPath);
278
+ await fs.writeFile(path.join(this.destination, ...newSheetPath.split("/")), sheetContent);
279
+ this.fileKeys.add(newSheetPath);
280
+ // Update workbook.xml
281
+ const updatedWorkbookXml = workbookXml.replace("</sheets>", `<sheet name="${newName}" sheetId="${nextSheetIndex}" r:id="${newRId}"/></sheets>`);
282
+ await this.#set(workbookXmlPath, updatedWorkbookXml);
283
+ // Update workbook.xml.rels
284
+ const updatedRelsXml = relsXml.replace("</Relationships>", `<Relationship Id="${newRId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="${newTarget}"/></Relationships>`);
285
+ await this.#set(relsXmlPath, updatedRelsXml);
286
+ // Read [Content_Types].xml
287
+ // Update [Content_Types].xml
288
+ const contentTypesPath = "[Content_Types].xml";
289
+ const contentTypesXml = Xml.extractXmlFromSheet(await this.#readFile(contentTypesPath));
290
+ const overrideTag = `<Override PartName="/xl/worksheets/${newSheetFilename}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`;
291
+ const updatedContentTypesXml = contentTypesXml.replace("</Types>", overrideTag + "</Types>");
292
+ await this.#set(contentTypesPath, updatedContentTypesXml);
293
+ }
294
+ finally {
295
+ this.#isProcessing = false;
296
+ }
297
+ }
298
+ /**
299
+ * Replaces placeholders in the given sheet with values from the replacements map.
300
+ *
301
+ * The function searches for placeholders in the format `${key}` within the sheet
302
+ * content, where `key` corresponds to a path in the replacements object.
303
+ * If a value is found for the key, it replaces the placeholder with the value.
304
+ * If no value is found, the original placeholder remains unchanged.
305
+ *
306
+ * @param sheetName - The name of the sheet to be replaced.
307
+ * @param replacements - An object where keys represent placeholder paths and values are the replacements.
308
+ * @returns A promise that resolves when the substitution is complete.
309
+ */
310
+ substitute(sheetName, replacements) {
311
+ this.#ensureNotProcessing();
312
+ this.#ensureNotDestroyed();
313
+ this.#isProcessing = true;
314
+ try {
315
+ return this.#substitute(sheetName, replacements);
316
+ }
317
+ finally {
318
+ this.#isProcessing = false;
319
+ }
320
+ }
89
321
  /**
90
322
  * Inserts rows into a specific sheet in the template.
91
323
  *
@@ -101,61 +333,68 @@ export class TemplateFs {
101
333
  * @experimental This API is experimental and might change in future versions.
102
334
  */
103
335
  async insertRows(data) {
336
+ this.#ensureNotProcessing();
104
337
  this.#ensureNotDestroyed();
105
- const { rows, sheetName, startRowNumber } = data;
106
- const preparedRows = rows.map(row => Utils.toExcelColumnObject(row));
107
- // Validation
108
- Utils.checkStartRow(startRowNumber);
109
- Utils.checkRows(preparedRows);
110
- // Find the sheet
111
- const workbookXml = Xml.extractXmlFromSheet(await this.#readFile("xl/workbook.xml"));
112
- const sheetMatch = workbookXml.match(new RegExp(`<sheet[^>]+name="${sheetName}"[^>]+r:id="([^"]+)"[^>]*/>`));
113
- if (!sheetMatch || !sheetMatch[1]) {
114
- throw new Error(`Sheet "${sheetName}" not found`);
115
- }
116
- const rId = sheetMatch[1];
117
- const relsXml = Xml.extractXmlFromSheet(await this.#readFile("xl/_rels/workbook.xml.rels"));
118
- const relMatch = relsXml.match(new RegExp(`<Relationship[^>]+Id="${rId}"[^>]+Target="([^"]+)"[^>]*/>`));
119
- if (!relMatch || !relMatch[1]) {
120
- throw new Error(`Relationship "${rId}" not found`);
121
- }
122
- const sheetPath = "xl/" + relMatch[1].replace(/^\/?xl\//, "");
123
- const sheetXmlRaw = await this.#readFile(sheetPath);
124
- const sheetXml = Xml.extractXmlFromSheet(sheetXmlRaw);
125
- let nextRow = 0;
126
- if (!startRowNumber) {
127
- // Find the last row
128
- let lastRowNumber = 0;
129
- const rowMatches = [...sheetXml.matchAll(/<row[^>]+r="(\d+)"[^>]*>/g)];
130
- if (rowMatches.length > 0) {
131
- lastRowNumber = Math.max(...rowMatches.map((m) => parseInt(m[1], 10)));
338
+ this.#isProcessing = true;
339
+ try {
340
+ const { rows, sheetName, startRowNumber } = data;
341
+ const preparedRows = rows.map(row => Utils.toExcelColumnObject(row));
342
+ // Validation
343
+ Utils.checkStartRow(startRowNumber);
344
+ Utils.checkRows(preparedRows);
345
+ // Find the sheet
346
+ const workbookXml = Xml.extractXmlFromSheet(await this.#readFile("xl/workbook.xml"));
347
+ const sheetMatch = workbookXml.match(new RegExp(`<sheet[^>]+name="${sheetName}"[^>]+r:id="([^"]+)"[^>]*/>`));
348
+ if (!sheetMatch || !sheetMatch[1]) {
349
+ throw new Error(`Sheet "${sheetName}" not found`);
350
+ }
351
+ const rId = sheetMatch[1];
352
+ const relsXml = Xml.extractXmlFromSheet(await this.#readFile("xl/_rels/workbook.xml.rels"));
353
+ const relMatch = relsXml.match(new RegExp(`<Relationship[^>]+Id="${rId}"[^>]+Target="([^"]+)"[^>]*/>`));
354
+ if (!relMatch || !relMatch[1]) {
355
+ throw new Error(`Relationship "${rId}" not found`);
132
356
  }
133
- nextRow = lastRowNumber + 1;
134
- }
135
- else {
136
- nextRow = startRowNumber;
137
- }
138
- // Generate XML for all rows
139
- const rowsXml = preparedRows.map((cells, i) => {
140
- const rowNumber = nextRow + i;
141
- const cellTags = Object.entries(cells).map(([col, value]) => {
142
- const colUpper = col.toUpperCase();
143
- const ref = `${colUpper}${rowNumber}`;
144
- return `<c r="${ref}" t="inlineStr"><is><t>${Utils.escapeXml(value)}</t></is></c>`;
357
+ const sheetPath = "xl/" + relMatch[1].replace(/^\/?xl\//, "");
358
+ const sheetXmlRaw = await this.#readFile(sheetPath);
359
+ const sheetXml = Xml.extractXmlFromSheet(sheetXmlRaw);
360
+ let nextRow = 0;
361
+ if (!startRowNumber) {
362
+ // Find the last row
363
+ let lastRowNumber = 0;
364
+ const rowMatches = [...sheetXml.matchAll(/<row[^>]+r="(\d+)"[^>]*>/g)];
365
+ if (rowMatches.length > 0) {
366
+ lastRowNumber = Math.max(...rowMatches.map((m) => parseInt(m[1], 10)));
367
+ }
368
+ nextRow = lastRowNumber + 1;
369
+ }
370
+ else {
371
+ nextRow = startRowNumber;
372
+ }
373
+ // Generate XML for all rows
374
+ const rowsXml = preparedRows.map((cells, i) => {
375
+ const rowNumber = nextRow + i;
376
+ const cellTags = Object.entries(cells).map(([col, value]) => {
377
+ const colUpper = col.toUpperCase();
378
+ const ref = `${colUpper}${rowNumber}`;
379
+ return `<c r="${ref}" t="inlineStr"><is><t>${Utils.escapeXml(value)}</t></is></c>`;
380
+ }).join("");
381
+ return `<row r="${rowNumber}">${cellTags}</row>`;
145
382
  }).join("");
146
- return `<row r="${rowNumber}">${cellTags}</row>`;
147
- }).join("");
148
- let updatedXml;
149
- if (/<sheetData\s*\/>/.test(sheetXml)) {
150
- updatedXml = sheetXml.replace(/<sheetData\s*\/>/, `<sheetData>${rowsXml}</sheetData>`);
151
- }
152
- else if (/<sheetData>([\s\S]*?)<\/sheetData>/.test(sheetXml)) {
153
- updatedXml = sheetXml.replace(/<\/sheetData>/, `${rowsXml}</sheetData>`);
383
+ let updatedXml;
384
+ if (/<sheetData\s*\/>/.test(sheetXml)) {
385
+ updatedXml = sheetXml.replace(/<sheetData\s*\/>/, `<sheetData>${rowsXml}</sheetData>`);
386
+ }
387
+ else if (/<sheetData>([\s\S]*?)<\/sheetData>/.test(sheetXml)) {
388
+ updatedXml = sheetXml.replace(/<\/sheetData>/, `${rowsXml}</sheetData>`);
389
+ }
390
+ else {
391
+ updatedXml = sheetXml.replace(/<worksheet[^>]*>/, (match) => `${match}<sheetData>${rowsXml}</sheetData>`);
392
+ }
393
+ await this.#set(sheetPath, updatedXml);
154
394
  }
155
- else {
156
- updatedXml = sheetXml.replace(/<worksheet[^>]*>/, (match) => `${match}<sheetData>${rowsXml}</sheetData>`);
395
+ finally {
396
+ this.#isProcessing = false;
157
397
  }
158
- await this.set(sheetPath, updatedXml);
159
398
  }
160
399
  /**
161
400
  * Inserts rows into a specific sheet in the template using an async stream.
@@ -172,155 +411,162 @@ export class TemplateFs {
172
411
  * @experimental This API is experimental and might change in future versions.
173
412
  */
174
413
  async insertRowsStream(data) {
414
+ this.#ensureNotProcessing();
175
415
  this.#ensureNotDestroyed();
176
- const { rows, sheetName, startRowNumber } = data;
177
- if (!sheetName)
178
- throw new Error("Sheet name is required");
179
- // Read XML workbook to find sheet name and path
180
- const workbookXml = Xml.extractXmlFromSheet(await this.#readFile("xl/workbook.xml"));
181
- const sheetMatch = workbookXml.match(new RegExp(`<sheet[^>]+name="${sheetName}"[^>]+r:id="([^"]+)"[^>]*/>`));
182
- if (!sheetMatch)
183
- throw new Error(`Sheet "${sheetName}" not found`);
184
- const rId = sheetMatch[1];
185
- const relsXml = Xml.extractXmlFromSheet(await this.#readFile("xl/_rels/workbook.xml.rels"));
186
- const relMatch = relsXml.match(new RegExp(`<Relationship[^>]+Id="${rId}"[^>]+Target="([^"]+)"[^>]*/>`));
187
- if (!relMatch)
188
- throw new Error(`Relationship "${rId}" not found`);
189
- // Path to the desired sheet (sheet1.xml)
190
- const sheetPath = "xl/" + relMatch[1].replace(/^\/?xl\//, "");
191
- // The temporary file for writing
192
- const fullPath = path.join(this.destination, ...sheetPath.split("/"));
193
- const tempPath = fullPath + ".tmp";
194
- // Streams for reading and writing
195
- const input = fsSync.createReadStream(fullPath, { encoding: "utf-8" });
196
- const output = fsSync.createWriteStream(tempPath, { encoding: "utf-8" });
197
- // Inserted rows flag
198
- let inserted = false;
199
- const rl = readline.createInterface({
200
- // Process all line breaks
201
- crlfDelay: Infinity,
202
- input,
203
- });
204
- let isCollecting = false;
205
- let collection = "";
206
- for await (const line of rl) {
207
- // Collect lines between <sheetData> and </sheetData>
208
- if (!inserted && isCollecting) {
209
- collection += line;
210
- if (line.includes("</sheetData>")) {
416
+ this.#isProcessing = true;
417
+ try {
418
+ const { rows, sheetName, startRowNumber } = data;
419
+ if (!sheetName)
420
+ throw new Error("Sheet name is required");
421
+ // Read XML workbook to find sheet name and path
422
+ const workbookXml = Xml.extractXmlFromSheet(await this.#readFile("xl/workbook.xml"));
423
+ const sheetMatch = workbookXml.match(new RegExp(`<sheet[^>]+name="${sheetName}"[^>]+r:id="([^"]+)"[^>]*/>`));
424
+ if (!sheetMatch)
425
+ throw new Error(`Sheet "${sheetName}" not found`);
426
+ const rId = sheetMatch[1];
427
+ const relsXml = Xml.extractXmlFromSheet(await this.#readFile("xl/_rels/workbook.xml.rels"));
428
+ const relMatch = relsXml.match(new RegExp(`<Relationship[^>]+Id="${rId}"[^>]+Target="([^"]+)"[^>]*/>`));
429
+ if (!relMatch)
430
+ throw new Error(`Relationship "${rId}" not found`);
431
+ // Path to the desired sheet (sheet1.xml)
432
+ const sheetPath = "xl/" + relMatch[1].replace(/^\/?xl\//, "");
433
+ // The temporary file for writing
434
+ const fullPath = path.join(this.destination, ...sheetPath.split("/"));
435
+ const tempPath = fullPath + ".tmp";
436
+ // Streams for reading and writing
437
+ const input = fsSync.createReadStream(fullPath, { encoding: "utf-8" });
438
+ const output = fsSync.createWriteStream(tempPath, { encoding: "utf-8" });
439
+ // Inserted rows flag
440
+ let inserted = false;
441
+ const rl = readline.createInterface({
442
+ // Process all line breaks
443
+ crlfDelay: Infinity,
444
+ input,
445
+ });
446
+ let isCollecting = false;
447
+ let collection = "";
448
+ for await (const line of rl) {
449
+ // Collect lines between <sheetData> and </sheetData>
450
+ if (!inserted && isCollecting) {
451
+ collection += line;
452
+ if (line.includes("</sheetData>")) {
453
+ const maxRowNumber = startRowNumber ?? Utils.getMaxRowNumber(line);
454
+ isCollecting = false;
455
+ inserted = true;
456
+ const openTag = collection.match(/<sheetData[^>]*>/)?.[0] ?? "<sheetData>";
457
+ const closeTag = "</sheetData>";
458
+ const openIdx = collection.indexOf(openTag);
459
+ const closeIdx = collection.lastIndexOf(closeTag);
460
+ const beforeRows = collection.slice(0, openIdx + openTag.length);
461
+ const innerRows = collection.slice(openIdx + openTag.length, closeIdx).trim();
462
+ const afterRows = collection.slice(closeIdx);
463
+ output.write(beforeRows);
464
+ const innerRowsMap = Utils.parseRows(innerRows);
465
+ if (innerRows) {
466
+ if (startRowNumber) {
467
+ const filteredRows = Utils.getRowsBelow(innerRowsMap, startRowNumber);
468
+ if (filteredRows)
469
+ output.write(filteredRows);
470
+ }
471
+ else {
472
+ output.write(innerRows);
473
+ }
474
+ }
475
+ const { rowNumber: actualRowNumber } = await Utils.writeRowsToStream(output, rows, maxRowNumber);
476
+ if (innerRows) {
477
+ const filteredRows = Utils.getRowsAbove(innerRowsMap, actualRowNumber);
478
+ if (filteredRows)
479
+ output.write(filteredRows);
480
+ }
481
+ output.write(afterRows);
482
+ }
483
+ continue;
484
+ }
485
+ // Case 1: <sheetData> and </sheetData> on one line
486
+ const singleLineMatch = line.match(/(<sheetData[^>]*>)(.*)(<\/sheetData>)/);
487
+ if (!inserted && singleLineMatch) {
211
488
  const maxRowNumber = startRowNumber ?? Utils.getMaxRowNumber(line);
212
- isCollecting = false;
213
- inserted = true;
214
- const openTag = collection.match(/<sheetData[^>]*>/)?.[0] ?? "<sheetData>";
489
+ const fullMatch = singleLineMatch[0];
490
+ const before = line.slice(0, singleLineMatch.index);
491
+ const after = line.slice(singleLineMatch.index + fullMatch.length);
492
+ const openTag = "<sheetData>";
215
493
  const closeTag = "</sheetData>";
216
- const openIdx = collection.indexOf(openTag);
217
- const closeIdx = collection.lastIndexOf(closeTag);
218
- const beforeRows = collection.slice(0, openIdx + openTag.length);
219
- const innerRows = collection.slice(openIdx + openTag.length, closeIdx).trim();
220
- const afterRows = collection.slice(closeIdx);
494
+ const openIdx = fullMatch.indexOf(openTag);
495
+ const closeIdx = fullMatch.indexOf(closeTag);
496
+ const beforeRows = fullMatch.slice(0, openIdx + openTag.length);
497
+ const innerRows = fullMatch.slice(openIdx + openTag.length, closeIdx).trim();
498
+ const afterRows = fullMatch.slice(closeIdx);
499
+ if (before) {
500
+ output.write(before);
501
+ }
221
502
  output.write(beforeRows);
222
503
  const innerRowsMap = Utils.parseRows(innerRows);
223
504
  if (innerRows) {
224
505
  if (startRowNumber) {
225
506
  const filteredRows = Utils.getRowsBelow(innerRowsMap, startRowNumber);
226
- if (filteredRows)
507
+ if (filteredRows) {
227
508
  output.write(filteredRows);
509
+ }
228
510
  }
229
511
  else {
230
512
  output.write(innerRows);
231
513
  }
232
514
  }
515
+ // new <row>
233
516
  const { rowNumber: actualRowNumber } = await Utils.writeRowsToStream(output, rows, maxRowNumber);
234
517
  if (innerRows) {
235
518
  const filteredRows = Utils.getRowsAbove(innerRowsMap, actualRowNumber);
236
- if (filteredRows)
237
- output.write(filteredRows);
238
- }
239
- output.write(afterRows);
240
- }
241
- continue;
242
- }
243
- // Case 1: <sheetData> and </sheetData> on one line
244
- const singleLineMatch = line.match(/(<sheetData[^>]*>)(.*)(<\/sheetData>)/);
245
- if (!inserted && singleLineMatch) {
246
- const maxRowNumber = startRowNumber ?? Utils.getMaxRowNumber(line);
247
- const fullMatch = singleLineMatch[0];
248
- const before = line.slice(0, singleLineMatch.index);
249
- const after = line.slice(singleLineMatch.index + fullMatch.length);
250
- const openTag = "<sheetData>";
251
- const closeTag = "</sheetData>";
252
- const openIdx = fullMatch.indexOf(openTag);
253
- const closeIdx = fullMatch.indexOf(closeTag);
254
- const beforeRows = fullMatch.slice(0, openIdx + openTag.length);
255
- const innerRows = fullMatch.slice(openIdx + openTag.length, closeIdx).trim();
256
- const afterRows = fullMatch.slice(closeIdx);
257
- if (before) {
258
- output.write(before);
259
- }
260
- output.write(beforeRows);
261
- const innerRowsMap = Utils.parseRows(innerRows);
262
- if (innerRows) {
263
- if (startRowNumber) {
264
- const filteredRows = Utils.getRowsBelow(innerRowsMap, startRowNumber);
265
519
  if (filteredRows) {
266
520
  output.write(filteredRows);
267
521
  }
268
522
  }
269
- else {
270
- output.write(innerRows);
523
+ output.write(afterRows);
524
+ if (after) {
525
+ output.write(after);
271
526
  }
527
+ inserted = true;
528
+ continue;
272
529
  }
273
- // new <row>
274
- const { rowNumber: actualRowNumber } = await Utils.writeRowsToStream(output, rows, maxRowNumber);
275
- if (innerRows) {
276
- const filteredRows = Utils.getRowsAbove(innerRowsMap, actualRowNumber);
277
- if (filteredRows) {
278
- output.write(filteredRows);
530
+ // Case 2: <sheetData/>
531
+ if (!inserted && /<sheetData\s*\/>/.test(line)) {
532
+ const maxRowNumber = startRowNumber ?? Utils.getMaxRowNumber(line);
533
+ const fullMatch = line.match(/<sheetData\s*\/>/)?.[0] || "";
534
+ const matchIndex = line.indexOf(fullMatch);
535
+ const before = line.slice(0, matchIndex);
536
+ const after = line.slice(matchIndex + fullMatch.length);
537
+ if (before) {
538
+ output.write(before);
279
539
  }
540
+ // Insert opening tag
541
+ output.write("<sheetData>");
542
+ // Prepare the rows
543
+ await Utils.writeRowsToStream(output, rows, maxRowNumber);
544
+ // Insert closing tag
545
+ output.write("</sheetData>");
546
+ if (after) {
547
+ output.write(after);
548
+ }
549
+ inserted = true;
550
+ continue;
280
551
  }
281
- output.write(afterRows);
282
- if (after) {
283
- output.write(after);
284
- }
285
- inserted = true;
286
- continue;
287
- }
288
- // Case 2: <sheetData/>
289
- if (!inserted && /<sheetData\s*\/>/.test(line)) {
290
- const maxRowNumber = startRowNumber ?? Utils.getMaxRowNumber(line);
291
- const fullMatch = line.match(/<sheetData\s*\/>/)?.[0] || "";
292
- const matchIndex = line.indexOf(fullMatch);
293
- const before = line.slice(0, matchIndex);
294
- const after = line.slice(matchIndex + fullMatch.length);
295
- if (before) {
296
- output.write(before);
297
- }
298
- // Insert opening tag
299
- output.write("<sheetData>");
300
- // Prepare the rows
301
- await Utils.writeRowsToStream(output, rows, maxRowNumber);
302
- // Insert closing tag
303
- output.write("</sheetData>");
304
- if (after) {
305
- output.write(after);
552
+ // Case 3: <sheetData>
553
+ if (!inserted && /<sheetData[^>]*>/.test(line)) {
554
+ isCollecting = true;
555
+ collection += line;
556
+ continue;
306
557
  }
307
- inserted = true;
308
- continue;
309
- }
310
- // Case 3: <sheetData>
311
- if (!inserted && /<sheetData[^>]*>/.test(line)) {
312
- isCollecting = true;
313
- collection += line;
314
- continue;
558
+ // After inserting rows, just copy the remaining lines
559
+ output.write(line);
315
560
  }
316
- // After inserting rows, just copy the remaining lines
317
- output.write(line);
318
- }
319
- // Close the streams
320
- rl.close();
321
- output.end();
322
- // Move the temporary file to the original location
323
- await fs.rename(tempPath, fullPath);
561
+ // Close the streams
562
+ rl.close();
563
+ output.end();
564
+ // Move the temporary file to the original location
565
+ await fs.rename(tempPath, fullPath);
566
+ }
567
+ finally {
568
+ this.#isProcessing = false;
569
+ }
324
570
  }
325
571
  /**
326
572
  * Saves the modified Excel template to a buffer.
@@ -330,15 +576,22 @@ export class TemplateFs {
330
576
  * @experimental This API is experimental and might change in future versions.
331
577
  */
332
578
  async save() {
579
+ this.#ensureNotProcessing();
333
580
  this.#ensureNotDestroyed();
334
- // Guarantee integrity
335
- await this.validate();
336
- // Read the current files from the directory(in case they were changed manually)
337
- const updatedFiles = await this.#readAllFromDestination();
338
- const zipBuffer = await Zip.create(updatedFiles);
339
- await this.#cleanup();
340
- this.destroyed = true;
341
- return zipBuffer;
581
+ this.#isProcessing = true;
582
+ try {
583
+ // Guarantee integrity
584
+ await this.#validate();
585
+ // Read the current files from the directory(in case they were changed manually)
586
+ const updatedFiles = await this.#readAllFromDestination();
587
+ const zipBuffer = await Zip.create(updatedFiles);
588
+ await this.#cleanup();
589
+ this.destroyed = true;
590
+ return zipBuffer;
591
+ }
592
+ finally {
593
+ this.#isProcessing = false;
594
+ }
342
595
  }
343
596
  /**
344
597
  * Writes the modified Excel template to a writable stream.
@@ -349,12 +602,19 @@ export class TemplateFs {
349
602
  * @experimental This API is experimental and might change in future versions.
350
603
  */
351
604
  async saveStream(output) {
605
+ this.#ensureNotProcessing();
352
606
  this.#ensureNotDestroyed();
353
- // Guarantee integrity
354
- await this.validate();
355
- await Zip.createWithStream(Array.from(this.fileKeys), this.destination, output);
356
- await this.#cleanup();
357
- this.destroyed = true;
607
+ this.#isProcessing = true;
608
+ try {
609
+ // Guarantee integrity
610
+ await this.#validate();
611
+ await Zip.createWithStream(Array.from(this.fileKeys), this.destination, output);
612
+ await this.#cleanup();
613
+ this.destroyed = true;
614
+ }
615
+ finally {
616
+ this.#isProcessing = false;
617
+ }
358
618
  }
359
619
  /**
360
620
  * Replaces the contents of a file in the template.
@@ -367,12 +627,15 @@ export class TemplateFs {
367
627
  * @experimental This API is experimental and might change in future versions.
368
628
  */
369
629
  async set(key, content) {
630
+ this.#ensureNotProcessing();
370
631
  this.#ensureNotDestroyed();
371
- if (!this.fileKeys.has(key)) {
372
- throw new Error(`File "${key}" is not part of the original template.`);
632
+ this.#isProcessing = true;
633
+ try {
634
+ await this.#set(key, content);
635
+ }
636
+ finally {
637
+ this.#isProcessing = false;
373
638
  }
374
- const fullPath = path.join(this.destination, ...key.split("/"));
375
- await fs.writeFile(fullPath, Buffer.isBuffer(content) ? content : Buffer.from(content));
376
639
  }
377
640
  /**
378
641
  * Validates the template by checking all required files exist.
@@ -383,15 +646,14 @@ export class TemplateFs {
383
646
  * @experimental This API is experimental and might change in future versions.
384
647
  */
385
648
  async validate() {
649
+ this.#ensureNotProcessing();
386
650
  this.#ensureNotDestroyed();
387
- for (const key of this.fileKeys) {
388
- const fullPath = path.join(this.destination, ...key.split("/"));
389
- try {
390
- await fs.access(fullPath);
391
- }
392
- catch {
393
- throw new Error(`Missing file in template directory: ${key}`);
394
- }
651
+ this.#isProcessing = true;
652
+ try {
653
+ await this.#validate();
654
+ }
655
+ finally {
656
+ this.#isProcessing = false;
395
657
  }
396
658
  }
397
659
  /**
@@ -426,3 +688,222 @@ export class TemplateFs {
426
688
  return new TemplateFs(new Set(Object.keys(files)), destination);
427
689
  }
428
690
  }
691
+ function processMergeCells(sheetXml) {
692
+ // Regular expression for finding <mergeCells> block
693
+ const mergeCellsBlockRegex = /<mergeCells[^>]*>[\s\S]*?<\/mergeCells>/;
694
+ // Find the first <mergeCells> block (if there are multiple, in xlsx usually there is only one)
695
+ const mergeCellsBlockMatch = sheetXml.match(mergeCellsBlockRegex);
696
+ const initialMergeCells = [];
697
+ const mergeCellMatches = [];
698
+ if (mergeCellsBlockMatch) {
699
+ const mergeCellsBlock = mergeCellsBlockMatch[0];
700
+ initialMergeCells.push(mergeCellsBlock);
701
+ // Extract <mergeCell ref="A1:B2"/> from this block
702
+ const mergeCellRegex = /<mergeCell ref="([A-Z]+\d+):([A-Z]+\d+)"\/>/g;
703
+ for (const match of mergeCellsBlock.matchAll(mergeCellRegex)) {
704
+ mergeCellMatches.push({ from: match[1], to: match[2] });
705
+ }
706
+ }
707
+ // Remove the <mergeCells> block from the XML
708
+ const modifiedXml = sheetXml.replace(mergeCellsBlockRegex, "");
709
+ return {
710
+ initialMergeCells,
711
+ mergeCellMatches,
712
+ modifiedXml,
713
+ };
714
+ }
715
+ ;
716
+ function processSharedStrings(sharedStringsXml) {
717
+ // Final list of merged cells with all changes
718
+ const sheetMergeCells = [];
719
+ // Array for storing shared strings
720
+ const sharedStrings = [];
721
+ const sharedStringsHeader = Utils.extractXmlDeclaration(sharedStringsXml);
722
+ // Map for fast lookup of shared string index by content
723
+ const sharedIndexMap = new Map();
724
+ // Regular expression for finding <si> elements (shared string items)
725
+ const siRegex = /<si>([\s\S]*?)<\/si>/g;
726
+ // Parse sharedStringsXml and fill sharedStrings and sharedIndexMap
727
+ for (const match of sharedStringsXml.matchAll(siRegex)) {
728
+ const content = match[1];
729
+ if (!content)
730
+ throw new Error("Shared index not found");
731
+ const fullSi = `<si>${content}</si>`;
732
+ sharedIndexMap.set(content, sharedStrings.length);
733
+ sharedStrings.push(fullSi);
734
+ }
735
+ return {
736
+ sharedIndexMap,
737
+ sharedStrings,
738
+ sharedStringsHeader,
739
+ sheetMergeCells,
740
+ };
741
+ }
742
+ ;
743
+ function processRows(data) {
744
+ const { mergeCellMatches, replacements, sharedIndexMap, sharedStrings, sheetMergeCells, sheetXml, } = data;
745
+ const TABLE_REGEX = /\$\{table:([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\}/g;
746
+ // Array for storing resulting XML rows
747
+ const resultRows = [];
748
+ // Previous position of processed part of XML
749
+ let lastIndex = 0;
750
+ // Shift for row numbers
751
+ let rowShift = 0;
752
+ // Regular expression for finding <row> elements
753
+ const rowRegex = /<row[^>]*?>[\s\S]*?<\/row>/g;
754
+ // Process each <row> element
755
+ for (const match of sheetXml.matchAll(rowRegex)) {
756
+ // Full XML row
757
+ const fullRow = match[0];
758
+ // Start position of the row in XML
759
+ const matchStart = match.index;
760
+ // End position of the row in XML
761
+ const matchEnd = matchStart + fullRow.length;
762
+ // Add the intermediate XML chunk (if any) between the previous and the current row
763
+ if (lastIndex !== matchStart) {
764
+ resultRows.push(sheetXml.slice(lastIndex, matchStart));
765
+ }
766
+ lastIndex = matchEnd;
767
+ // Get row number from r attribute
768
+ const originalRowNumber = parseInt(fullRow.match(/<row[^>]* r="(\d+)"/)?.[1] ?? "1", 10);
769
+ // Update row number based on rowShift
770
+ const shiftedRowNumber = originalRowNumber + rowShift;
771
+ // Find shared string indexes in cells of the current row
772
+ const sharedValueIndexes = [];
773
+ // Regular expression for finding a cell
774
+ const cellRegex = /<c[^>]*?r="([A-Z]+\d+)"[^>]*?>([\s\S]*?)<\/c>/g;
775
+ for (const cell of fullRow.matchAll(cellRegex)) {
776
+ const cellTag = cell[0];
777
+ // Check if the cell is a shared string
778
+ const isShared = /t="s"/.test(cellTag);
779
+ const valueMatch = cellTag.match(/<v>(\d+)<\/v>/);
780
+ if (isShared && valueMatch) {
781
+ sharedValueIndexes.push(parseInt(valueMatch[1], 10));
782
+ }
783
+ }
784
+ // Get the text content of shared strings by their indexes
785
+ const sharedTexts = sharedValueIndexes.map(i => sharedStrings[i]?.replace(/<\/?si>/g, "") ?? "");
786
+ // Find table placeholders in shared strings
787
+ const tablePlaceholders = sharedTexts.flatMap(e => [...e.matchAll(TABLE_REGEX)]);
788
+ // If there are no table placeholders, just shift the row
789
+ if (tablePlaceholders.length === 0) {
790
+ const updatedRow = fullRow
791
+ .replace(/(<row[^>]* r=")(\d+)(")/, `$1${shiftedRowNumber}$3`)
792
+ .replace(/<c r="([A-Z]+)(\d+)"/g, (_, col) => `<c r="${col}${shiftedRowNumber}"`);
793
+ resultRows.push(updatedRow);
794
+ // Update mergeCells for regular row with rowShift
795
+ const calculatedRowNumber = originalRowNumber + rowShift;
796
+ for (const { from, to } of mergeCellMatches) {
797
+ const [, fromCol, fromRow] = from.match(/^([A-Z]+)(\d+)$/);
798
+ const [, toCol] = to.match(/^([A-Z]+)(\d+)$/);
799
+ if (Number(fromRow) === calculatedRowNumber) {
800
+ const newFrom = `${fromCol}${shiftedRowNumber}`;
801
+ const newTo = `${toCol}${shiftedRowNumber}`;
802
+ sheetMergeCells.push(`<mergeCell ref="${newFrom}:${newTo}"/>`);
803
+ }
804
+ }
805
+ continue;
806
+ }
807
+ // Get the table name from the first placeholder
808
+ const firstMatch = tablePlaceholders[0];
809
+ const tableName = firstMatch?.[1];
810
+ if (!tableName)
811
+ throw new Error("Table name not found");
812
+ // Get data for replacement from replacements
813
+ const array = replacements[tableName];
814
+ if (!array)
815
+ continue;
816
+ if (!Array.isArray(array))
817
+ throw new Error("Table data is not an array");
818
+ const tableRowStart = shiftedRowNumber;
819
+ // Find mergeCells to duplicate (mergeCells that start with the current row)
820
+ const mergeCellsToDuplicate = mergeCellMatches.filter(({ from }) => {
821
+ const match = from.match(/^([A-Z]+)(\d+)$/);
822
+ if (!match)
823
+ return false;
824
+ // Row number of the merge cell start position is in the second group
825
+ const rowNumber = match[2];
826
+ return Number(rowNumber) === tableRowStart;
827
+ });
828
+ // Change the current row to multiple rows from the data array
829
+ for (let i = 0; i < array.length; i++) {
830
+ const rowData = array[i];
831
+ let newRow = fullRow;
832
+ // Replace placeholders in shared strings with real data
833
+ sharedValueIndexes.forEach((originalIdx, idx) => {
834
+ const originalText = sharedTexts[idx];
835
+ if (!originalText)
836
+ throw new Error("Shared value not found");
837
+ // Replace placeholders ${tableName.field} with real data from array data
838
+ const replacedText = originalText.replace(TABLE_REGEX, (_, tbl, field) => tbl === tableName ? String(rowData?.[field] ?? "") : "");
839
+ // Add new text to shared strings if it doesn't exist
840
+ let newIndex;
841
+ if (sharedIndexMap.has(replacedText)) {
842
+ newIndex = sharedIndexMap.get(replacedText);
843
+ }
844
+ else {
845
+ newIndex = sharedStrings.length;
846
+ sharedIndexMap.set(replacedText, newIndex);
847
+ sharedStrings.push(`<si>${replacedText}</si>`);
848
+ }
849
+ // Replace the shared string index in the cell
850
+ newRow = newRow.replace(`<v>${originalIdx}</v>`, `<v>${newIndex}</v>`);
851
+ });
852
+ // Update row number and cell references
853
+ const newRowNum = shiftedRowNumber + i;
854
+ newRow = newRow
855
+ .replace(/<row[^>]* r="\d+"/, rowTag => rowTag.replace(/r="\d+"/, `r="${newRowNum}"`))
856
+ .replace(/<c r="([A-Z]+)\d+"/g, (_, col) => `<c r="${col}${newRowNum}"`);
857
+ resultRows.push(newRow);
858
+ // Add duplicate mergeCells for new rows
859
+ for (const { from, to } of mergeCellsToDuplicate) {
860
+ const [, colFrom, rowFrom] = from.match(/^([A-Z]+)(\d+)$/);
861
+ const [, colTo, rowTo] = to.match(/^([A-Z]+)(\d+)$/);
862
+ const newFrom = `${colFrom}${Number(rowFrom) + i}`;
863
+ const newTo = `${colTo}${Number(rowTo) + i}`;
864
+ sheetMergeCells.push(`<mergeCell ref="${newFrom}:${newTo}"/>`);
865
+ }
866
+ }
867
+ // It increases the row shift by the number of added rows minus one replaced
868
+ rowShift += array.length - 1;
869
+ const delta = array.length - 1;
870
+ const calculatedRowNumber = originalRowNumber + rowShift - array.length + 1;
871
+ if (delta > 0) {
872
+ for (const merge of mergeCellMatches) {
873
+ const fromRow = parseInt(merge.from.match(/\d+$/)[0], 10);
874
+ if (fromRow > calculatedRowNumber) {
875
+ merge.from = merge.from.replace(/\d+$/, r => `${parseInt(r) + delta}`);
876
+ merge.to = merge.to.replace(/\d+$/, r => `${parseInt(r) + delta}`);
877
+ }
878
+ }
879
+ }
880
+ }
881
+ return { lastIndex, resultRows, rowShift };
882
+ }
883
+ ;
884
+ function processBuild(data) {
885
+ const { initialMergeCells, lastIndex, mergeCellMatches, resultRows, rowShift, sharedStrings, sharedStringsHeader, sheetMergeCells, sheetXml, } = data;
886
+ for (const { from, to } of mergeCellMatches) {
887
+ const [, fromCol, fromRow] = from.match(/^([A-Z]+)(\d+)$/);
888
+ const [, toCol, toRow] = to.match(/^([A-Z]+)(\d+)$/);
889
+ const fromRowNum = Number(fromRow);
890
+ // These rows have already been processed, don't add duplicates
891
+ if (fromRowNum <= lastIndex)
892
+ continue;
893
+ const newFrom = `${fromCol}${fromRowNum + rowShift}`;
894
+ const newTo = `${toCol}${Number(toRow) + rowShift}`;
895
+ sheetMergeCells.push(`<mergeCell ref="${newFrom}:${newTo}"/>`);
896
+ }
897
+ resultRows.push(sheetXml.slice(lastIndex));
898
+ // Form XML for mergeCells if there are any
899
+ const mergeXml = sheetMergeCells.length
900
+ ? `<mergeCells count="${sheetMergeCells.length}">${sheetMergeCells.join("")}</mergeCells>`
901
+ : initialMergeCells;
902
+ // Insert mergeCells before the closing sheetData tag
903
+ const sheetWithMerge = resultRows.join("").replace(/<\/sheetData>/, `</sheetData>${mergeXml}`);
904
+ // Return modified sheet XML and shared strings
905
+ return {
906
+ shared: `${sharedStringsHeader}\n<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="${sharedStrings.length}" uniqueCount="${sharedStrings.length}">${sharedStrings.join("")}</sst>`,
907
+ sheet: Utils.updateDimension(sheetWithMerge),
908
+ };
909
+ }