@js-ak/excel-toolbox 1.2.7 → 1.3.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.
- package/build/cjs/lib/index.js +1 -0
- package/build/cjs/lib/template/index.js +17 -0
- package/build/cjs/lib/template/template-fs.js +465 -0
- package/build/cjs/lib/template/utils/check-row.js +23 -0
- package/build/cjs/lib/template/utils/check-rows.js +19 -0
- package/build/cjs/lib/template/utils/check-start-row.js +18 -0
- package/build/cjs/lib/template/utils/column-index-to-letter.js +17 -0
- package/build/cjs/lib/template/utils/escape-xml.js +24 -0
- package/build/cjs/lib/template/utils/get-max-row-number.js +20 -0
- package/build/cjs/lib/template/utils/get-rows-above.js +23 -0
- package/build/cjs/lib/template/utils/get-rows-below.js +23 -0
- package/build/cjs/lib/template/utils/index.js +27 -0
- package/build/cjs/lib/template/utils/parse-rows.js +30 -0
- package/build/cjs/lib/template/utils/to-excel-column-object.js +29 -0
- package/build/cjs/lib/template/utils/write-rows-to-stream.js +41 -0
- package/build/cjs/lib/xml/build-merged-sheet.js +1 -1
- package/build/cjs/lib/zip/constants.js +16 -1
- package/build/cjs/lib/zip/create-with-stream.js +150 -0
- package/build/cjs/lib/zip/index.js +1 -0
- package/build/cjs/lib/zip/utils/crc-32-stream.js +36 -0
- package/build/cjs/lib/zip/utils/crc-32.js +2 -35
- package/build/cjs/lib/zip/utils/index.js +1 -0
- package/build/esm/lib/index.js +1 -0
- package/build/esm/lib/template/index.js +1 -0
- package/build/esm/lib/template/template-fs.js +428 -0
- package/build/esm/lib/template/utils/check-row.js +20 -0
- package/build/esm/lib/template/utils/check-rows.js +16 -0
- package/build/esm/lib/template/utils/check-start-row.js +15 -0
- package/build/esm/lib/template/utils/column-index-to-letter.js +14 -0
- package/build/esm/lib/template/utils/escape-xml.js +21 -0
- package/build/esm/lib/template/utils/get-max-row-number.js +17 -0
- package/build/esm/lib/template/utils/get-rows-above.js +20 -0
- package/build/esm/lib/template/utils/get-rows-below.js +20 -0
- package/build/esm/lib/template/utils/index.js +11 -0
- package/build/esm/lib/template/utils/parse-rows.js +27 -0
- package/build/esm/lib/template/utils/to-excel-column-object.js +26 -0
- package/build/esm/lib/template/utils/write-rows-to-stream.js +38 -0
- package/build/esm/lib/xml/build-merged-sheet.js +1 -1
- package/build/esm/lib/zip/constants.js +15 -0
- package/build/esm/lib/zip/create-with-stream.js +111 -0
- package/build/esm/lib/zip/index.js +1 -0
- package/build/esm/lib/zip/utils/crc-32-stream.js +33 -0
- package/build/esm/lib/zip/utils/crc-32.js +2 -35
- package/build/esm/lib/zip/utils/index.js +1 -0
- package/build/types/lib/index.d.ts +1 -0
- package/build/types/lib/template/index.d.ts +1 -0
- package/build/types/lib/template/template-fs.d.ts +122 -0
- package/build/types/lib/template/utils/check-row.d.ts +14 -0
- package/build/types/lib/template/utils/check-rows.d.ts +11 -0
- package/build/types/lib/template/utils/check-start-row.d.ts +8 -0
- package/build/types/lib/template/utils/column-index-to-letter.d.ts +7 -0
- package/build/types/lib/template/utils/escape-xml.d.ts +14 -0
- package/build/types/lib/template/utils/get-max-row-number.d.ts +7 -0
- package/build/types/lib/template/utils/get-rows-above.d.ts +12 -0
- package/build/types/lib/template/utils/get-rows-below.d.ts +12 -0
- package/build/types/lib/template/utils/index.d.ts +11 -0
- package/build/types/lib/template/utils/parse-rows.d.ts +1 -0
- package/build/types/lib/template/utils/to-excel-column-object.d.ts +10 -0
- package/build/types/lib/template/utils/write-rows-to-stream.d.ts +25 -0
- package/build/types/lib/zip/constants.d.ts +9 -0
- package/build/types/lib/zip/create-with-stream.d.ts +13 -0
- package/build/types/lib/zip/index.d.ts +1 -0
- package/build/types/lib/zip/utils/crc-32-stream.d.ts +11 -0
- package/build/types/lib/zip/utils/index.d.ts +1 -0
- package/package.json +1 -1
@@ -0,0 +1,428 @@
|
|
1
|
+
import * as fs from "node:fs/promises";
|
2
|
+
import * as fsSync from "node:fs";
|
3
|
+
import * as path from "node:path";
|
4
|
+
import * as readline from "node:readline";
|
5
|
+
import * as Xml from "../xml/index.js";
|
6
|
+
import * as Zip from "../zip/index.js";
|
7
|
+
import * as Utils from "./utils/index.js";
|
8
|
+
/**
|
9
|
+
* A class for manipulating Excel templates by extracting, modifying, and repacking Excel files.
|
10
|
+
*
|
11
|
+
* @experimental This API is experimental and might change in future versions.
|
12
|
+
*/
|
13
|
+
export class TemplateFs {
|
14
|
+
/**
|
15
|
+
* Set of file paths (relative to the template) that will be used to create a new workbook.
|
16
|
+
* @type {Set<string>}
|
17
|
+
*/
|
18
|
+
fileKeys;
|
19
|
+
/**
|
20
|
+
* The path where the template will be expanded.
|
21
|
+
* @type {string}
|
22
|
+
*/
|
23
|
+
destination;
|
24
|
+
/**
|
25
|
+
* Flag indicating whether this template instance has been destroyed.
|
26
|
+
* @type {boolean}
|
27
|
+
*/
|
28
|
+
destroyed = false;
|
29
|
+
/**
|
30
|
+
* Creates a Template instance.
|
31
|
+
*
|
32
|
+
* @param {Set<string>} fileKeys - Set of file paths (relative to the template) that will be used to create a new workbook.
|
33
|
+
* @param {string} destination - The path where the template will be expanded.
|
34
|
+
* @experimental This API is experimental and might change in future versions.
|
35
|
+
*/
|
36
|
+
constructor(fileKeys, destination) {
|
37
|
+
this.fileKeys = fileKeys;
|
38
|
+
this.destination = destination;
|
39
|
+
}
|
40
|
+
/**
|
41
|
+
* Removes the temporary directory created by this Template instance.
|
42
|
+
* @private
|
43
|
+
* @returns {Promise<void>}
|
44
|
+
* @experimental This API is experimental and might change in future versions.
|
45
|
+
*/
|
46
|
+
async #cleanup() {
|
47
|
+
await fs.rm(this.destination, { force: true, recursive: true });
|
48
|
+
}
|
49
|
+
/**
|
50
|
+
* Ensures that this Template instance has not been destroyed.
|
51
|
+
* @private
|
52
|
+
* @throws {Error} If the template instance has already been destroyed.
|
53
|
+
* @experimental This API is experimental and might change in future versions.
|
54
|
+
*/
|
55
|
+
#ensureNotDestroyed() {
|
56
|
+
if (this.destroyed) {
|
57
|
+
throw new Error("This Template instance has already been saved and destroyed.");
|
58
|
+
}
|
59
|
+
}
|
60
|
+
/**
|
61
|
+
* Reads all files from the destination directory.
|
62
|
+
* @private
|
63
|
+
* @returns {Promise<Record<string, Buffer>>} An object with file keys and their contents as Buffers.
|
64
|
+
* @experimental This API is experimental and might change in future versions.
|
65
|
+
*/
|
66
|
+
async #readAllFromDestination() {
|
67
|
+
const result = {};
|
68
|
+
for (const key of this.fileKeys) {
|
69
|
+
const fullPath = path.join(this.destination, ...key.split("/"));
|
70
|
+
result[key] = await fs.readFile(fullPath);
|
71
|
+
}
|
72
|
+
return result;
|
73
|
+
}
|
74
|
+
/**
|
75
|
+
* Reads a single file from the destination directory.
|
76
|
+
* @private
|
77
|
+
* @param {string} pathKey - The Excel path of the file to read.
|
78
|
+
* @returns {Promise<Buffer>} The contents of the file as a Buffer.
|
79
|
+
* @throws {Error} If the file does not exist in the template.
|
80
|
+
* @experimental This API is experimental and might change in future versions.
|
81
|
+
*/
|
82
|
+
async #readFile(pathKey) {
|
83
|
+
if (!this.fileKeys.has(pathKey)) {
|
84
|
+
throw new Error(`File "${pathKey}" not found in template.`);
|
85
|
+
}
|
86
|
+
const fullPath = path.join(this.destination, ...pathKey.split("/"));
|
87
|
+
return await fs.readFile(fullPath);
|
88
|
+
}
|
89
|
+
/**
|
90
|
+
* Inserts rows into a specific sheet in the template.
|
91
|
+
*
|
92
|
+
* @param {Object} data - The data for row insertion.
|
93
|
+
* @param {string} data.sheetName - The name of the sheet to insert rows into.
|
94
|
+
* @param {number} [data.startRowNumber] - The row number to start inserting from.
|
95
|
+
* @param {unknown[][]} data.rows - The rows to insert.
|
96
|
+
* @returns {Promise<void>}
|
97
|
+
* @throws {Error} If the template instance has been destroyed.
|
98
|
+
* @throws {Error} If the sheet does not exist.
|
99
|
+
* @throws {Error} If the row number is out of range.
|
100
|
+
* @throws {Error} If a column is out of range.
|
101
|
+
* @experimental This API is experimental and might change in future versions.
|
102
|
+
*/
|
103
|
+
async insertRows(data) {
|
104
|
+
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)));
|
132
|
+
}
|
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>`;
|
145
|
+
}).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>`);
|
154
|
+
}
|
155
|
+
else {
|
156
|
+
updatedXml = sheetXml.replace(/<worksheet[^>]*>/, (match) => `${match}<sheetData>${rowsXml}</sheetData>`);
|
157
|
+
}
|
158
|
+
await this.set(sheetPath, updatedXml);
|
159
|
+
}
|
160
|
+
/**
|
161
|
+
* Inserts rows into a specific sheet in the template using an async stream.
|
162
|
+
*
|
163
|
+
* @param {Object} data - The data for row insertion.
|
164
|
+
* @param {string} data.sheetName - The name of the sheet to insert rows into.
|
165
|
+
* @param {number} [data.startRowNumber] - The row number to start inserting from.
|
166
|
+
* @param {AsyncIterable<unknown[]>} data.rows - Async iterable of rows to insert.
|
167
|
+
* @returns {Promise<void>}
|
168
|
+
* @throws {Error} If the template instance has been destroyed.
|
169
|
+
* @throws {Error} If the sheet does not exist.
|
170
|
+
* @throws {Error} If the row number is out of range.
|
171
|
+
* @throws {Error} If a column is out of range.
|
172
|
+
* @experimental This API is experimental and might change in future versions.
|
173
|
+
*/
|
174
|
+
async insertRowsStream(data) {
|
175
|
+
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>")) {
|
211
|
+
const maxRowNumber = startRowNumber ?? Utils.getMaxRowNumber(line);
|
212
|
+
isCollecting = false;
|
213
|
+
inserted = true;
|
214
|
+
const openTag = collection.match(/<sheetData[^>]*>/)?.[0] ?? "<sheetData>";
|
215
|
+
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);
|
221
|
+
output.write(beforeRows);
|
222
|
+
const innerRowsMap = Utils.parseRows(innerRows);
|
223
|
+
if (innerRows) {
|
224
|
+
if (startRowNumber) {
|
225
|
+
const filteredRows = Utils.getRowsBelow(innerRowsMap, startRowNumber);
|
226
|
+
if (filteredRows)
|
227
|
+
output.write(filteredRows);
|
228
|
+
}
|
229
|
+
else {
|
230
|
+
output.write(innerRows);
|
231
|
+
}
|
232
|
+
}
|
233
|
+
const { rowNumber: actualRowNumber } = await Utils.writeRowsToStream(output, rows, maxRowNumber);
|
234
|
+
if (innerRows) {
|
235
|
+
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
|
+
if (filteredRows) {
|
266
|
+
output.write(filteredRows);
|
267
|
+
}
|
268
|
+
}
|
269
|
+
else {
|
270
|
+
output.write(innerRows);
|
271
|
+
}
|
272
|
+
}
|
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);
|
279
|
+
}
|
280
|
+
}
|
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);
|
306
|
+
}
|
307
|
+
inserted = true;
|
308
|
+
continue;
|
309
|
+
}
|
310
|
+
// Case 3: <sheetData>
|
311
|
+
if (!inserted && /<sheetData[^>]*>/.test(line)) {
|
312
|
+
isCollecting = true;
|
313
|
+
collection += line;
|
314
|
+
continue;
|
315
|
+
}
|
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);
|
324
|
+
}
|
325
|
+
/**
|
326
|
+
* Saves the modified Excel template to a buffer.
|
327
|
+
*
|
328
|
+
* @returns {Promise<Buffer>} The modified Excel template as a buffer.
|
329
|
+
* @throws {Error} If the template instance has been destroyed.
|
330
|
+
* @experimental This API is experimental and might change in future versions.
|
331
|
+
*/
|
332
|
+
async save() {
|
333
|
+
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;
|
342
|
+
}
|
343
|
+
/**
|
344
|
+
* Writes the modified Excel template to a writable stream.
|
345
|
+
*
|
346
|
+
* @param {Writable} output - The writable stream to write to.
|
347
|
+
* @returns {Promise<void>}
|
348
|
+
* @throws {Error} If the template instance has been destroyed.
|
349
|
+
* @experimental This API is experimental and might change in future versions.
|
350
|
+
*/
|
351
|
+
async saveStream(output) {
|
352
|
+
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;
|
358
|
+
}
|
359
|
+
/**
|
360
|
+
* Replaces the contents of a file in the template.
|
361
|
+
*
|
362
|
+
* @param {string} key - The Excel path of the file to replace.
|
363
|
+
* @param {Buffer|string} content - The new content.
|
364
|
+
* @returns {Promise<void>}
|
365
|
+
* @throws {Error} If the template instance has been destroyed.
|
366
|
+
* @throws {Error} If the file does not exist in the template.
|
367
|
+
* @experimental This API is experimental and might change in future versions.
|
368
|
+
*/
|
369
|
+
async set(key, content) {
|
370
|
+
this.#ensureNotDestroyed();
|
371
|
+
if (!this.fileKeys.has(key)) {
|
372
|
+
throw new Error(`File "${key}" is not part of the original template.`);
|
373
|
+
}
|
374
|
+
const fullPath = path.join(this.destination, ...key.split("/"));
|
375
|
+
await fs.writeFile(fullPath, Buffer.isBuffer(content) ? content : Buffer.from(content));
|
376
|
+
}
|
377
|
+
/**
|
378
|
+
* Validates the template by checking all required files exist.
|
379
|
+
*
|
380
|
+
* @returns {Promise<void>}
|
381
|
+
* @throws {Error} If the template instance has been destroyed.
|
382
|
+
* @throws {Error} If any required files are missing.
|
383
|
+
* @experimental This API is experimental and might change in future versions.
|
384
|
+
*/
|
385
|
+
async validate() {
|
386
|
+
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
|
+
}
|
395
|
+
}
|
396
|
+
}
|
397
|
+
/**
|
398
|
+
* Creates a Template instance from an Excel file source.
|
399
|
+
* Removes any existing files in the destination directory.
|
400
|
+
*
|
401
|
+
* @param {Object} data - The data to create the template from.
|
402
|
+
* @param {string} data.source - The path or buffer of the Excel file.
|
403
|
+
* @param {string} data.destination - The path to save the template to.
|
404
|
+
* @returns {Promise<Template>} A new Template instance.
|
405
|
+
* @throws {Error} If reading or writing files fails.
|
406
|
+
* @experimental This API is experimental and might change in future versions.
|
407
|
+
*/
|
408
|
+
static async from(data) {
|
409
|
+
const { destination, source } = data;
|
410
|
+
if (!destination) {
|
411
|
+
throw new Error("Destination is required");
|
412
|
+
}
|
413
|
+
const buffer = typeof source === "string"
|
414
|
+
? await fs.readFile(source)
|
415
|
+
: source;
|
416
|
+
const files = await Zip.read(buffer);
|
417
|
+
// if destination exists, remove it
|
418
|
+
await fs.rm(destination, { force: true, recursive: true });
|
419
|
+
// Write all files to the file system, preserving exact paths
|
420
|
+
await fs.mkdir(destination, { recursive: true });
|
421
|
+
await Promise.all(Object.entries(files).map(async ([filePath, content]) => {
|
422
|
+
const fullPath = path.join(destination, ...filePath.split("/"));
|
423
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
424
|
+
await fs.writeFile(fullPath, content);
|
425
|
+
}));
|
426
|
+
return new TemplateFs(new Set(Object.keys(files)), destination);
|
427
|
+
}
|
428
|
+
}
|
@@ -0,0 +1,20 @@
|
|
1
|
+
/**
|
2
|
+
* Validates that each key in the given row object is a valid cell reference.
|
3
|
+
*
|
4
|
+
* This function checks that all keys in the provided row object are composed
|
5
|
+
* only of column letters (A-Z, case insensitive). If a key is found that does
|
6
|
+
* not match this pattern, an error is thrown with a message indicating the
|
7
|
+
* invalid cell reference.
|
8
|
+
*
|
9
|
+
* @param row - An object representing a row of data, where keys are cell
|
10
|
+
* references and values are strings.
|
11
|
+
*
|
12
|
+
* @throws {Error} If any key in the row is not a valid column letter.
|
13
|
+
*/
|
14
|
+
export function checkRow(row) {
|
15
|
+
for (const key of Object.keys(row)) {
|
16
|
+
if (!/^[A-Z]+$/i.test(key)) {
|
17
|
+
throw new Error(`Invalid cell reference "${key}" in row. Only column letters (like "A", "B", "C") are allowed.`);
|
18
|
+
}
|
19
|
+
}
|
20
|
+
}
|
@@ -0,0 +1,16 @@
|
|
1
|
+
import { checkRow } from "./check-row.js";
|
2
|
+
/**
|
3
|
+
* Validates an array of row objects to ensure each cell reference is valid.
|
4
|
+
* Each row object is checked to ensure that its keys (cell references) are
|
5
|
+
* composed of valid column letters (e.g., "A", "B", "C").
|
6
|
+
*
|
7
|
+
* @param rows An array of row objects, where each object represents a row
|
8
|
+
* of data with cell references as keys and cell values as strings.
|
9
|
+
*
|
10
|
+
* @throws {Error} If any cell reference in the rows is invalid.
|
11
|
+
*/
|
12
|
+
export function checkRows(rows) {
|
13
|
+
for (const row of rows) {
|
14
|
+
checkRow(row);
|
15
|
+
}
|
16
|
+
}
|
@@ -0,0 +1,15 @@
|
|
1
|
+
/**
|
2
|
+
* Checks if startRow is a positive integer.
|
3
|
+
*
|
4
|
+
* @param startRow The start row to check.
|
5
|
+
*
|
6
|
+
* @throws {Error} If startRow is not a positive integer.
|
7
|
+
*/
|
8
|
+
export function checkStartRow(startRow) {
|
9
|
+
if (startRow === undefined) {
|
10
|
+
return;
|
11
|
+
}
|
12
|
+
if (!Number.isInteger(startRow) || startRow < 1) {
|
13
|
+
throw new Error(`Invalid startRow "${startRow}". Must be a positive integer.`);
|
14
|
+
}
|
15
|
+
}
|
@@ -0,0 +1,14 @@
|
|
1
|
+
/**
|
2
|
+
* Converts a 0-based column index to an Excel-style letter (A, B, ..., Z, AA, AB, ...).
|
3
|
+
*
|
4
|
+
* @param index - The 0-based column index.
|
5
|
+
* @returns The Excel-style letter for the given column index.
|
6
|
+
*/
|
7
|
+
export function columnIndexToLetter(index) {
|
8
|
+
let letters = "";
|
9
|
+
while (index >= 0) {
|
10
|
+
letters = String.fromCharCode((index % 26) + 65) + letters;
|
11
|
+
index = Math.floor(index / 26) - 1;
|
12
|
+
}
|
13
|
+
return letters;
|
14
|
+
}
|
@@ -0,0 +1,21 @@
|
|
1
|
+
/**
|
2
|
+
* Escapes special characters in a string for use in an XML document.
|
3
|
+
*
|
4
|
+
* Replaces:
|
5
|
+
* - `&` with `&`
|
6
|
+
* - `<` with `<`
|
7
|
+
* - `>` with `>`
|
8
|
+
* - `"` with `"`
|
9
|
+
* - `'` with `'`
|
10
|
+
*
|
11
|
+
* @param str - The string to escape.
|
12
|
+
* @returns The escaped string.
|
13
|
+
*/
|
14
|
+
export function escapeXml(str) {
|
15
|
+
return str
|
16
|
+
.replace(/&/g, "&")
|
17
|
+
.replace(/</g, "<")
|
18
|
+
.replace(/>/g, ">")
|
19
|
+
.replace(/"/g, """)
|
20
|
+
.replace(/'/g, "'");
|
21
|
+
}
|
@@ -0,0 +1,17 @@
|
|
1
|
+
/**
|
2
|
+
* Finds the maximum row number in a list of <row> elements
|
3
|
+
* and returns the maximum row number + 1.
|
4
|
+
* @param {string} line - The line of XML to parse.
|
5
|
+
* @returns {number} - The maximum row number found + 1.
|
6
|
+
*/
|
7
|
+
export function getMaxRowNumber(line) {
|
8
|
+
let result = 1;
|
9
|
+
const rowMatches = [...line.matchAll(/<row[^>]+r="(\d+)"[^>]*>/g)];
|
10
|
+
for (const match of rowMatches) {
|
11
|
+
const rowNum = parseInt(match[1], 10);
|
12
|
+
if (rowNum >= result) {
|
13
|
+
result = rowNum + 1;
|
14
|
+
}
|
15
|
+
}
|
16
|
+
return result;
|
17
|
+
}
|
@@ -0,0 +1,20 @@
|
|
1
|
+
/**
|
2
|
+
* Filters out all rows in the given map that have a row number
|
3
|
+
* lower than or equal to the given minRow, and returns the
|
4
|
+
* filtered rows as a single string. Useful for removing rows
|
5
|
+
* from a template that are positioned above a certain row
|
6
|
+
* number.
|
7
|
+
*
|
8
|
+
* @param {Map<number, string>} map - The map of row numbers to row content
|
9
|
+
* @param {number} minRow - The minimum row number to include in the output
|
10
|
+
* @returns {string} The filtered rows, concatenated into a single string
|
11
|
+
*/
|
12
|
+
export function getRowsAbove(map, minRow) {
|
13
|
+
const filteredRows = [];
|
14
|
+
for (const [key, value] of map.entries()) {
|
15
|
+
if (key > minRow) {
|
16
|
+
filteredRows.push(value);
|
17
|
+
}
|
18
|
+
}
|
19
|
+
return filteredRows.join("");
|
20
|
+
}
|
@@ -0,0 +1,20 @@
|
|
1
|
+
/**
|
2
|
+
* Filters out all rows in the given map that have a row number
|
3
|
+
* greater than or equal to the given maxRow, and returns the
|
4
|
+
* filtered rows as a single string. Useful for removing rows
|
5
|
+
* from a template that are positioned below a certain row
|
6
|
+
* number.
|
7
|
+
*
|
8
|
+
* @param {Map<number, string>} map - The map of row numbers to row content
|
9
|
+
* @param {number} maxRow - The maximum row number to include in the output
|
10
|
+
* @returns {string} The filtered rows, concatenated into a single string
|
11
|
+
*/
|
12
|
+
export function getRowsBelow(map, maxRow) {
|
13
|
+
const filteredRows = [];
|
14
|
+
for (const [key, value] of map.entries()) {
|
15
|
+
if (key < maxRow) {
|
16
|
+
filteredRows.push(value);
|
17
|
+
}
|
18
|
+
}
|
19
|
+
return filteredRows.join("");
|
20
|
+
}
|
@@ -0,0 +1,11 @@
|
|
1
|
+
export * from "./check-row.js";
|
2
|
+
export * from "./check-rows.js";
|
3
|
+
export * from "./check-start-row.js";
|
4
|
+
export * from "./column-index-to-letter.js";
|
5
|
+
export * from "./escape-xml.js";
|
6
|
+
export * from "./get-max-row-number.js";
|
7
|
+
export * from "./get-rows-above.js";
|
8
|
+
export * from "./get-rows-below.js";
|
9
|
+
export * from "./parse-rows.js";
|
10
|
+
export * from "./to-excel-column-object.js";
|
11
|
+
export * from "./write-rows-to-stream.js";
|
@@ -0,0 +1,27 @@
|
|
1
|
+
export function parseRows(innerRows) {
|
2
|
+
const rowsMap = new Map();
|
3
|
+
// Regex to match all <row> elements
|
4
|
+
// 1. `(<row[^>]+r="(\d+)"[^>]*\/>)` - for self-closing tags (<row ... />)
|
5
|
+
// 2. `|(<row[^>]+r="(\d+)"[^>]*>[\s\S]*?<\/row>)` — for self-closing tags (<row ... />)
|
6
|
+
const rowRegex = /(<row[^>]+r="(\d+)"[^>]*\/>)|(<row[^>]+r="(\d+)"[^>]*>[\s\S]*?<\/row>)/g;
|
7
|
+
let match;
|
8
|
+
while ((match = rowRegex.exec(innerRows)) !== null) {
|
9
|
+
// if this is a self-closing tag (<row ... />)
|
10
|
+
if (match[1]) {
|
11
|
+
const fullRow = match[1];
|
12
|
+
const rowNumber = match[2];
|
13
|
+
if (!rowNumber)
|
14
|
+
throw new Error("Row number not found");
|
15
|
+
rowsMap.set(Number(rowNumber), fullRow);
|
16
|
+
}
|
17
|
+
// if this is a regular tag (<row>...</row>)
|
18
|
+
else if (match[3]) {
|
19
|
+
const fullRow = match[3];
|
20
|
+
const rowNumber = match[4];
|
21
|
+
if (!rowNumber)
|
22
|
+
throw new Error("Row number not found");
|
23
|
+
rowsMap.set(Number(rowNumber), fullRow);
|
24
|
+
}
|
25
|
+
}
|
26
|
+
return rowsMap;
|
27
|
+
}
|
@@ -0,0 +1,26 @@
|
|
1
|
+
/**
|
2
|
+
* Converts an array of values into a Record<string, string> with Excel column names as keys.
|
3
|
+
*
|
4
|
+
* The column names are generated in the standard Excel column naming convention (A, B, ..., Z, AA, AB, ...).
|
5
|
+
* The corresponding values are converted to strings using the String() function.
|
6
|
+
*
|
7
|
+
* @param values - The array of values to convert
|
8
|
+
* @returns The resulting Record<string, string>
|
9
|
+
*/
|
10
|
+
export function toExcelColumnObject(values) {
|
11
|
+
const toExcelColumn = (index) => {
|
12
|
+
let column = "";
|
13
|
+
let i = index;
|
14
|
+
while (i >= 0) {
|
15
|
+
column = String.fromCharCode((i % 26) + 65) + column;
|
16
|
+
i = Math.floor(i / 26) - 1;
|
17
|
+
}
|
18
|
+
return column;
|
19
|
+
};
|
20
|
+
const result = {};
|
21
|
+
for (let i = 0; i < values.length; i++) {
|
22
|
+
const key = toExcelColumn(i);
|
23
|
+
result[key] = String(values[i]);
|
24
|
+
}
|
25
|
+
return result;
|
26
|
+
}
|