@js-ak/excel-toolbox 1.5.0 → 1.7.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 (53) hide show
  1. package/README.md +41 -62
  2. package/build/cjs/lib/merge-sheets-to-base-file-process-sync.js +105 -0
  3. package/build/cjs/lib/merge-sheets-to-base-file-process.js +3 -3
  4. package/build/cjs/lib/merge-sheets-to-base-file-sync.js +2 -2
  5. package/build/cjs/lib/merge-sheets-to-base-file.js +1 -1
  6. package/build/cjs/lib/template/template-fs.js +143 -63
  7. package/build/cjs/lib/template/template-memory.js +281 -59
  8. package/build/cjs/lib/template/utils/index.js +25 -0
  9. package/build/cjs/lib/template/utils/prepare-row-to-cells.js +5 -1
  10. package/build/cjs/lib/template/utils/regexp.js +32 -0
  11. package/build/cjs/lib/template/utils/update-dimension.js +15 -0
  12. package/build/cjs/lib/template/utils/validate-worksheet-xml.js +74 -74
  13. package/build/cjs/lib/template/utils/write-rows-to-stream.js +57 -17
  14. package/build/cjs/lib/xml/extract-rows-from-sheet-sync.js +67 -0
  15. package/build/cjs/lib/xml/extract-rows-from-sheet.js +4 -2
  16. package/build/cjs/lib/xml/extract-xml-from-sheet-sync.js +43 -0
  17. package/build/cjs/lib/xml/extract-xml-from-sheet.js +15 -15
  18. package/build/cjs/lib/xml/index.js +2 -1
  19. package/build/esm/lib/merge-sheets-to-base-file-process-sync.js +69 -0
  20. package/build/esm/lib/merge-sheets-to-base-file-process.js +3 -3
  21. package/build/esm/lib/merge-sheets-to-base-file-sync.js +2 -2
  22. package/build/esm/lib/merge-sheets-to-base-file.js +1 -1
  23. package/build/esm/lib/template/template-fs.js +140 -63
  24. package/build/esm/lib/template/template-memory.js +281 -59
  25. package/build/esm/lib/template/utils/index.js +2 -0
  26. package/build/esm/lib/template/utils/prepare-row-to-cells.js +5 -1
  27. package/build/esm/lib/template/utils/regexp.js +28 -0
  28. package/build/esm/lib/template/utils/update-dimension.js +15 -0
  29. package/build/esm/lib/template/utils/validate-worksheet-xml.js +74 -74
  30. package/build/esm/lib/template/utils/write-rows-to-stream.js +57 -17
  31. package/build/esm/lib/xml/extract-rows-from-sheet-sync.js +64 -0
  32. package/build/esm/lib/xml/extract-rows-from-sheet.js +4 -2
  33. package/build/esm/lib/xml/extract-xml-from-sheet-sync.js +40 -0
  34. package/build/esm/lib/xml/extract-xml-from-sheet.js +12 -15
  35. package/build/esm/lib/xml/index.js +2 -1
  36. package/build/types/lib/merge-sheets-to-base-file-process-sync.d.ts +27 -0
  37. package/build/types/lib/merge-sheets-to-base-file-process.d.ts +1 -1
  38. package/build/types/lib/template/template-fs.d.ts +2 -0
  39. package/build/types/lib/template/template-memory.d.ts +61 -0
  40. package/build/types/lib/template/utils/index.d.ts +2 -0
  41. package/build/types/lib/template/utils/prepare-row-to-cells.d.ts +5 -1
  42. package/build/types/lib/template/utils/regexp.d.ts +24 -0
  43. package/build/types/lib/template/utils/update-dimension.d.ts +15 -0
  44. package/build/types/lib/template/utils/write-rows-to-stream.d.ts +22 -9
  45. package/build/types/lib/xml/extract-rows-from-sheet-sync.d.ts +28 -0
  46. package/build/types/lib/xml/extract-rows-from-sheet.d.ts +2 -2
  47. package/build/types/lib/xml/extract-xml-from-sheet-sync.d.ts +14 -0
  48. package/build/types/lib/xml/extract-xml-from-sheet.d.ts +2 -2
  49. package/build/types/lib/xml/index.d.ts +2 -1
  50. package/package.json +1 -5
  51. package/build/cjs/lib/xml/extract-xml-from-system-content.js +0 -53
  52. package/build/esm/lib/xml/extract-xml-from-system-content.js +0 -49
  53. package/build/types/lib/xml/extract-xml-from-system-content.d.ts +0 -15
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as fsSync from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import * as readline from "node:readline";
5
+ import crypto from "node:crypto";
5
6
  import * as Xml from "../xml/index.js";
6
7
  import * as Zip from "../zip/index.js";
7
8
  import * as Utils from "./utils/index.js";
@@ -31,6 +32,16 @@ export class TemplateFs {
31
32
  * @type {boolean}
32
33
  */
33
34
  #isProcessing = false;
35
+ /**
36
+ * The keys for the Excel files in the template.
37
+ */
38
+ #excelKeys = {
39
+ contentTypes: "[Content_Types].xml",
40
+ sharedStrings: "xl/sharedStrings.xml",
41
+ styles: "xl/styles.xml",
42
+ workbook: "xl/workbook.xml",
43
+ workbookRels: "xl/_rels/workbook.xml.rels",
44
+ };
34
45
  /**
35
46
  * Creates a Template instance.
36
47
  *
@@ -113,17 +124,19 @@ export class TemplateFs {
113
124
  * @returns The path of the sheet inside the workbook.
114
125
  * @throws {Error} If the sheet is not found.
115
126
  */
116
- async #getSheetPath(sheetName) {
127
+ async #getSheetPathByName(sheetName) {
117
128
  // 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)
129
+ const workbookXml = await Xml.extractXmlFromSheet(await this.#readFile(this.#excelKeys.workbook));
130
+ const sheetMatch = workbookXml.match(Utils.sheetMatch(sheetName));
131
+ if (!sheetMatch || !sheetMatch[1]) {
121
132
  throw new Error(`Sheet "${sheetName}" not found`);
133
+ }
122
134
  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)
135
+ const relsXml = await Xml.extractXmlFromSheet(await this.#readFile(this.#excelKeys.workbookRels));
136
+ const relMatch = relsXml.match(Utils.relationshipMatch(rId));
137
+ if (!relMatch || !relMatch[1]) {
126
138
  throw new Error(`Relationship "${rId}" not found`);
139
+ }
127
140
  return "xl/" + relMatch[1].replace(/^\/?xl\//, "");
128
141
  }
129
142
  /**
@@ -173,15 +186,15 @@ export class TemplateFs {
173
186
  await fs.writeFile(fullPath, Buffer.isBuffer(content) ? content : Buffer.from(content));
174
187
  }
175
188
  async #substitute(sheetName, replacements) {
176
- const sharedStringsPath = "xl/sharedStrings.xml";
177
- const sheetPath = await this.#getSheetPath(sheetName);
189
+ const sharedStringsPath = this.#excelKeys.sharedStrings;
190
+ const sheetPath = await this.#getSheetPathByName(sheetName);
178
191
  let sharedStringsContent = "";
179
192
  let sheetContent = "";
180
193
  if (this.fileKeys.has(sharedStringsPath)) {
181
- sharedStringsContent = Xml.extractXmlFromSheet(await this.#readFile(sharedStringsPath));
194
+ sharedStringsContent = await Xml.extractXmlFromSheet(await this.#readFile(sharedStringsPath));
182
195
  }
183
196
  if (this.fileKeys.has(sheetPath)) {
184
- sheetContent = Xml.extractXmlFromSheet(await this.#readFile(sheetPath));
197
+ sheetContent = await Xml.extractXmlFromSheet(await this.#readFile(sheetPath));
185
198
  const TABLE_REGEX = /\$\{table:([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\}/g;
186
199
  const hasTablePlaceholders = TABLE_REGEX.test(sharedStringsContent) || TABLE_REGEX.test(sheetContent);
187
200
  if (hasTablePlaceholders) {
@@ -233,30 +246,31 @@ export class TemplateFs {
233
246
  this.#ensureNotDestroyed();
234
247
  this.#isProcessing = true;
235
248
  try {
249
+ if (sourceName === newName) {
250
+ throw new Error("Source and new sheet names cannot be the same");
251
+ }
236
252
  // Read workbook.xml and find the source sheet
237
- const workbookXmlPath = "xl/workbook.xml";
238
- const workbookXml = Xml.extractXmlFromSheet(await this.#readFile(workbookXmlPath));
253
+ const workbookXmlPath = this.#excelKeys.workbook;
254
+ const workbookXml = await Xml.extractXmlFromSheet(await this.#readFile(workbookXmlPath));
239
255
  // Find the source sheet
240
- const sheetRegex = new RegExp(`<sheet[^>]+name="${sourceName}"[^>]+r:id="([^"]+)"[^>]*/>`);
241
- const sheetMatch = workbookXml.match(sheetRegex);
242
- if (!sheetMatch)
256
+ const sheetMatch = workbookXml.match(Utils.sheetMatch(sourceName));
257
+ if (!sheetMatch || !sheetMatch[1]) {
243
258
  throw new Error(`Sheet "${sourceName}" not found`);
244
- const sourceRId = sheetMatch[1];
259
+ }
245
260
  // Check if a sheet with the new name already exists
246
261
  if (new RegExp(`<sheet[^>]+name="${newName}"`).test(workbookXml)) {
247
262
  throw new Error(`Sheet "${newName}" already exists`);
248
263
  }
249
264
  // Read workbook.rels
250
265
  // 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`);
266
+ const rId = sheetMatch[1];
267
+ const relsXmlPath = this.#excelKeys.workbookRels;
268
+ const relsXml = await Xml.extractXmlFromSheet(await this.#readFile(relsXmlPath));
269
+ const relMatch = relsXml.match(Utils.relationshipMatch(rId));
270
+ if (!relMatch || !relMatch[1]) {
271
+ throw new Error(`Relationship "${rId}" not found`);
272
+ }
257
273
  const sourceTarget = relMatch[1]; // sheetN.xml
258
- if (!sourceTarget)
259
- throw new Error(`Relationship "${sourceRId}" not found`);
260
274
  const sourceSheetPath = "xl/" + sourceTarget.replace(/^\/?.*xl\//, "");
261
275
  // Get the index of the new sheet
262
276
  const sheetNumbers = [...this.fileKeys]
@@ -285,8 +299,8 @@ export class TemplateFs {
285
299
  await this.#set(relsXmlPath, updatedRelsXml);
286
300
  // Read [Content_Types].xml
287
301
  // Update [Content_Types].xml
288
- const contentTypesPath = "[Content_Types].xml";
289
- const contentTypesXml = Xml.extractXmlFromSheet(await this.#readFile(contentTypesPath));
302
+ const contentTypesPath = this.#excelKeys.contentTypes;
303
+ const contentTypesXml = await Xml.extractXmlFromSheet(await this.#readFile(contentTypesPath));
290
304
  const overrideTag = `<Override PartName="/xl/worksheets/${newSheetFilename}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`;
291
305
  const updatedContentTypesXml = contentTypesXml.replace("</Types>", overrideTag + "</Types>");
292
306
  await this.#set(contentTypesPath, updatedContentTypesXml);
@@ -343,20 +357,9 @@ export class TemplateFs {
343
357
  Utils.checkStartRow(startRowNumber);
344
358
  Utils.checkRows(preparedRows);
345
359
  // 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`);
356
- }
357
- const sheetPath = "xl/" + relMatch[1].replace(/^\/?xl\//, "");
360
+ const sheetPath = await this.#getSheetPathByName(sheetName);
358
361
  const sheetXmlRaw = await this.#readFile(sheetPath);
359
- const sheetXml = Xml.extractXmlFromSheet(sheetXmlRaw);
362
+ const sheetXml = await Xml.extractXmlFromSheet(sheetXmlRaw);
360
363
  let nextRow = 0;
361
364
  if (!startRowNumber) {
362
365
  // Find the last row
@@ -390,7 +393,7 @@ export class TemplateFs {
390
393
  else {
391
394
  updatedXml = sheetXml.replace(/<worksheet[^>]*>/, (match) => `${match}<sheetData>${rowsXml}</sheetData>`);
392
395
  }
393
- await this.#set(sheetPath, updatedXml);
396
+ await this.#set(sheetPath, Utils.updateDimension(updatedXml));
394
397
  }
395
398
  finally {
396
399
  this.#isProcessing = false;
@@ -418,18 +421,8 @@ export class TemplateFs {
418
421
  const { rows, sheetName, startRowNumber } = data;
419
422
  if (!sheetName)
420
423
  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\//, "");
424
+ // Get the path to the sheet
425
+ const sheetPath = await this.#getSheetPathByName(sheetName);
433
426
  // The temporary file for writing
434
427
  const fullPath = path.join(this.destination, ...sheetPath.split("/"));
435
428
  const tempPath = fullPath + ".tmp";
@@ -438,6 +431,13 @@ export class TemplateFs {
438
431
  const output = fsSync.createWriteStream(tempPath, { encoding: "utf-8" });
439
432
  // Inserted rows flag
440
433
  let inserted = false;
434
+ let initialDimension = "";
435
+ const dimension = {
436
+ maxColumn: "A",
437
+ maxRow: 1,
438
+ minColumn: "A",
439
+ minRow: 1,
440
+ };
441
441
  const rl = readline.createInterface({
442
442
  // Process all line breaks
443
443
  crlfDelay: Infinity,
@@ -446,6 +446,21 @@ export class TemplateFs {
446
446
  let isCollecting = false;
447
447
  let collection = "";
448
448
  for await (const line of rl) {
449
+ // Process <dimension>
450
+ if (!initialDimension && /<dimension\s+ref="[^"]*"/.test(line)) {
451
+ const dimensionMatch = line.match(/<dimension\s+ref="([^"]*)"/);
452
+ if (dimensionMatch) {
453
+ const dimensionRef = dimensionMatch[1];
454
+ if (dimensionRef) {
455
+ const [min, max] = dimensionRef.split(":");
456
+ dimension.minColumn = min.slice(0, 1);
457
+ dimension.minRow = parseInt(min.slice(1));
458
+ dimension.maxColumn = max.slice(0, 1);
459
+ dimension.maxRow = parseInt(max.slice(1));
460
+ }
461
+ initialDimension = line.match(/<dimension\s+ref="[^"]*"/)?.[0] || "";
462
+ }
463
+ }
449
464
  // Collect lines between <sheetData> and </sheetData>
450
465
  if (!inserted && isCollecting) {
451
466
  collection += line;
@@ -472,7 +487,13 @@ export class TemplateFs {
472
487
  output.write(innerRows);
473
488
  }
474
489
  }
475
- const { rowNumber: actualRowNumber } = await Utils.writeRowsToStream(output, rows, maxRowNumber);
490
+ const { dimension: newDimension, rowNumber: actualRowNumber } = await Utils.writeRowsToStream(output, rows, maxRowNumber);
491
+ if (compareColumns(newDimension.maxColumn, dimension.maxColumn) > 0) {
492
+ dimension.maxColumn = newDimension.maxColumn;
493
+ }
494
+ if (newDimension.maxRow > dimension.maxRow) {
495
+ dimension.maxRow = newDimension.maxRow;
496
+ }
476
497
  if (innerRows) {
477
498
  const filteredRows = Utils.getRowsAbove(innerRowsMap, actualRowNumber);
478
499
  if (filteredRows)
@@ -513,7 +534,13 @@ export class TemplateFs {
513
534
  }
514
535
  }
515
536
  // new <row>
516
- const { rowNumber: actualRowNumber } = await Utils.writeRowsToStream(output, rows, maxRowNumber);
537
+ const { dimension: newDimension, rowNumber: actualRowNumber } = await Utils.writeRowsToStream(output, rows, maxRowNumber);
538
+ if (compareColumns(newDimension.maxColumn, dimension.maxColumn) > 0) {
539
+ dimension.maxColumn = newDimension.maxColumn;
540
+ }
541
+ if (newDimension.maxRow > dimension.maxRow) {
542
+ dimension.maxRow = newDimension.maxRow;
543
+ }
517
544
  if (innerRows) {
518
545
  const filteredRows = Utils.getRowsAbove(innerRowsMap, actualRowNumber);
519
546
  if (filteredRows) {
@@ -540,7 +567,13 @@ export class TemplateFs {
540
567
  // Insert opening tag
541
568
  output.write("<sheetData>");
542
569
  // Prepare the rows
543
- await Utils.writeRowsToStream(output, rows, maxRowNumber);
570
+ const { dimension: newDimension } = await Utils.writeRowsToStream(output, rows, maxRowNumber);
571
+ if (compareColumns(newDimension.maxColumn, dimension.maxColumn) > 0) {
572
+ dimension.maxColumn = newDimension.maxColumn;
573
+ }
574
+ if (newDimension.maxRow > dimension.maxRow) {
575
+ dimension.maxRow = newDimension.maxRow;
576
+ }
544
577
  // Insert closing tag
545
578
  output.write("</sheetData>");
546
579
  if (after) {
@@ -561,8 +594,42 @@ export class TemplateFs {
561
594
  // Close the streams
562
595
  rl.close();
563
596
  output.end();
564
- // Move the temporary file to the original location
565
- await fs.rename(tempPath, fullPath);
597
+ // update dimension
598
+ {
599
+ const target = initialDimension;
600
+ const refRange = `${dimension.minColumn}${dimension.minRow}:${dimension.maxColumn}${dimension.maxRow}`;
601
+ const replacement = `<dimension ref="${refRange}"`;
602
+ let buffer = "";
603
+ let replaced = false;
604
+ const input = fsSync.createReadStream(tempPath, { encoding: "utf8" });
605
+ const output = fsSync.createWriteStream(fullPath);
606
+ await new Promise((resolve, reject) => {
607
+ input.on("data", chunk => {
608
+ buffer += chunk;
609
+ if (!replaced) {
610
+ const index = buffer.indexOf(target);
611
+ if (index !== -1) {
612
+ // Заменяем только первое вхождение
613
+ buffer = buffer.replace(target, replacement);
614
+ replaced = true;
615
+ }
616
+ }
617
+ output.write(buffer);
618
+ buffer = ""; // очищаем, т.к. мы уже записали
619
+ });
620
+ input.on("error", reject);
621
+ output.on("error", reject);
622
+ input.on("end", () => {
623
+ // на всякий случай дописываем остаток
624
+ if (buffer) {
625
+ output.write(buffer);
626
+ }
627
+ output.end();
628
+ resolve(true);
629
+ });
630
+ });
631
+ }
632
+ await fs.unlink(tempPath);
566
633
  }
567
634
  finally {
568
635
  this.#isProcessing = false;
@@ -663,28 +730,38 @@ export class TemplateFs {
663
730
  * @param {Object} data - The data to create the template from.
664
731
  * @param {string} data.source - The path or buffer of the Excel file.
665
732
  * @param {string} data.destination - The path to save the template to.
733
+ * @param {boolean} data.isUniqueDestination - Whether to add a random UUID to the destination path.
666
734
  * @returns {Promise<Template>} A new Template instance.
667
735
  * @throws {Error} If reading or writing files fails.
668
736
  * @experimental This API is experimental and might change in future versions.
669
737
  */
670
738
  static async from(data) {
671
- const { destination, source } = data;
739
+ const { destination, isUniqueDestination = true, source } = data;
672
740
  if (!destination) {
673
741
  throw new Error("Destination is required");
674
742
  }
743
+ // add random uuid to destination
744
+ const destinationWithUuid = isUniqueDestination
745
+ ? path.join(destination, crypto.randomUUID())
746
+ : destination;
675
747
  const buffer = typeof source === "string"
676
748
  ? await fs.readFile(source)
677
749
  : source;
678
750
  const files = await Zip.read(buffer);
679
751
  // if destination exists, remove it
680
- await fs.rm(destination, { force: true, recursive: true });
752
+ await fs.rm(destinationWithUuid, { force: true, recursive: true });
681
753
  // Write all files to the file system, preserving exact paths
682
- await fs.mkdir(destination, { recursive: true });
754
+ await fs.mkdir(destinationWithUuid, { recursive: true });
683
755
  await Promise.all(Object.entries(files).map(async ([filePath, content]) => {
684
- const fullPath = path.join(destination, ...filePath.split("/"));
756
+ const fullPath = path.join(destinationWithUuid, ...filePath.split("/"));
685
757
  await fs.mkdir(path.dirname(fullPath), { recursive: true });
686
758
  await fs.writeFile(fullPath, content);
687
759
  }));
688
- return new TemplateFs(new Set(Object.keys(files)), destination);
760
+ return new TemplateFs(new Set(Object.keys(files)), destinationWithUuid);
689
761
  }
690
762
  }
763
+ const compareColumns = (a, b) => {
764
+ if (a === b)
765
+ return 0;
766
+ return a.length === b.length ? (a < b ? -1 : 1) : (a.length < b.length ? -1 : 1);
767
+ };