stegdoc 5.2.0 → 5.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.
- package/package.json +1 -1
- package/src/commands/decode.js +3 -2
- package/src/commands/encode.js +57 -30
- package/src/index.js +1 -1
- package/src/lib/xlsx-handler.js +66 -47
package/package.json
CHANGED
package/src/commands/decode.js
CHANGED
|
@@ -198,7 +198,8 @@ async function decodeV5(inputFile, format, firstReadResult, options, spinner, qu
|
|
|
198
198
|
spinner.succeed && spinner.succeed(`Found all ${totalPartsFound} parts`);
|
|
199
199
|
|
|
200
200
|
for (let i = 0; i < allParts.length; i++) {
|
|
201
|
-
const
|
|
201
|
+
const pct = Math.round(((i + 1) / totalPartsFound) * 100);
|
|
202
|
+
const partSpinner = quiet ? spinner : ora(`Decoding part ${i + 1}/${totalPartsFound} (${pct}%)...`).start();
|
|
202
203
|
|
|
203
204
|
const partResult = await readFile(allParts[i].path, format);
|
|
204
205
|
|
|
@@ -228,7 +229,7 @@ async function decodeV5(inputFile, format, firstReadResult, options, spinner, qu
|
|
|
228
229
|
writeTarget.write(partPayload);
|
|
229
230
|
}
|
|
230
231
|
|
|
231
|
-
partSpinner.succeed && partSpinner.succeed(`Part ${i + 1} decoded`);
|
|
232
|
+
partSpinner.succeed && partSpinner.succeed(`Part ${i + 1}/${totalPartsFound} decoded`);
|
|
232
233
|
}
|
|
233
234
|
} else {
|
|
234
235
|
// Single file
|
package/src/commands/encode.js
CHANGED
|
@@ -178,16 +178,52 @@ async function encodeCommand(inputFile, options) {
|
|
|
178
178
|
// Generate session salt for encryption
|
|
179
179
|
const sessionSalt = useEncryption ? generateSalt() : null;
|
|
180
180
|
|
|
181
|
-
|
|
181
|
+
// === Phase 1: Compress and collect all chunks ===
|
|
182
|
+
spinner.text = useCompression ? 'Compressing...' : 'Reading...';
|
|
182
183
|
|
|
183
184
|
const partFiles = [];
|
|
184
|
-
|
|
185
|
-
// v5 pipeline: compress (brotli) → collect binary chunks → encrypt per-part → embed in log lines
|
|
186
185
|
const binaryChunkSize = chunkSizeBytes === Infinity ? Infinity : Math.floor(chunkSizeBytes * 3 / 4);
|
|
187
186
|
|
|
188
|
-
|
|
187
|
+
// Collect all chunks first (enables parallel part creation + known total)
|
|
188
|
+
const collectedChunks = [];
|
|
189
|
+
const collectChunk = async (binaryBuffer, index) => {
|
|
190
|
+
collectedChunks.push({ buffer: Buffer.from(binaryBuffer), index });
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const collector = new BinaryChunkCollector(binaryChunkSize, collectChunk);
|
|
194
|
+
|
|
195
|
+
const streams = [fs.createReadStream(streamSource)];
|
|
196
|
+
|
|
197
|
+
// Add progress tracking (tracks input bytes read)
|
|
198
|
+
if (!quiet) {
|
|
199
|
+
const progressStream = new ProgressTransform(fileSize, (processed, total) => {
|
|
200
|
+
const pct = Math.min(100, Math.round((processed / total) * 100));
|
|
201
|
+
const processedFmt = formatBytes(processed);
|
|
202
|
+
const totalFmt = formatBytes(total);
|
|
203
|
+
const phase = useCompression ? 'Compressing' : 'Reading';
|
|
204
|
+
spinner.text = `${phase}... ${processedFmt} / ${totalFmt} (${pct}%)`;
|
|
205
|
+
});
|
|
206
|
+
streams.push(progressStream);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (useCompression) {
|
|
210
|
+
streams.push(createBrotliCompressStream());
|
|
211
|
+
}
|
|
212
|
+
streams.push(collector);
|
|
213
|
+
|
|
214
|
+
await pipeline(...streams);
|
|
215
|
+
|
|
216
|
+
const totalParts = collectedChunks.length;
|
|
217
|
+
spinner.succeed && spinner.succeed(`${useCompression ? 'Compressed' : 'Read'}: ${totalParts} part${totalParts !== 1 ? 's' : ''} to create`);
|
|
218
|
+
|
|
219
|
+
// === Phase 2: Create parts in parallel ===
|
|
220
|
+
const os = require('os');
|
|
221
|
+
const concurrency = Math.min(totalParts, Math.max(1, os.cpus().length));
|
|
222
|
+
let completedParts = 0;
|
|
223
|
+
const partSpinner = quiet ? spinner : ora(`Creating parts... 0/${totalParts}`).start();
|
|
224
|
+
|
|
225
|
+
const createPart = async ({ buffer: binaryBuffer, index }) => {
|
|
189
226
|
const partNumber = index + 1;
|
|
190
|
-
const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
|
|
191
227
|
|
|
192
228
|
let payloadBuffer;
|
|
193
229
|
let encryptionMeta = '';
|
|
@@ -208,7 +244,7 @@ async function encodeCommand(inputFile, options) {
|
|
|
208
244
|
originalExtension: extension,
|
|
209
245
|
hash,
|
|
210
246
|
partNumber,
|
|
211
|
-
totalParts
|
|
247
|
+
totalParts,
|
|
212
248
|
originalSize: fileSize,
|
|
213
249
|
format,
|
|
214
250
|
encrypted: useEncryption,
|
|
@@ -262,35 +298,26 @@ async function encodeCommand(inputFile, options) {
|
|
|
262
298
|
|
|
263
299
|
partFiles.push(outputPath);
|
|
264
300
|
createdFiles.push(outputPath);
|
|
265
|
-
partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(payloadBuffer.length)} payload, ${dataLineCount} data lines)`);
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
const collector = new BinaryChunkCollector(binaryChunkSize, onBinaryChunkReady);
|
|
269
301
|
|
|
270
|
-
|
|
302
|
+
completedParts++;
|
|
303
|
+
const pct = Math.round((completedParts / totalParts) * 100);
|
|
304
|
+
partSpinner.text = `Creating parts... ${completedParts}/${totalParts} (${pct}%)`;
|
|
305
|
+
};
|
|
271
306
|
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const pct = Math.min(100, Math.round((processed / total) * 100));
|
|
276
|
-
const processedFmt = formatBytes(processed);
|
|
277
|
-
const totalFmt = formatBytes(total);
|
|
278
|
-
const phase = useCompression ? 'Compressing' : 'Processing';
|
|
279
|
-
spinner.text = `${phase}... ${processedFmt} / ${totalFmt} (${pct}%)`;
|
|
280
|
-
});
|
|
281
|
-
streams.push(progressStream);
|
|
282
|
-
}
|
|
307
|
+
// Run part creation in parallel with limited concurrency
|
|
308
|
+
const chunks = [...collectedChunks];
|
|
309
|
+
const executing = new Set();
|
|
283
310
|
|
|
284
|
-
|
|
285
|
-
|
|
311
|
+
for (const chunk of chunks) {
|
|
312
|
+
const promise = createPart(chunk).then(() => executing.delete(promise));
|
|
313
|
+
executing.add(promise);
|
|
314
|
+
if (executing.size >= concurrency) {
|
|
315
|
+
await Promise.race(executing);
|
|
316
|
+
}
|
|
286
317
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
await pipeline(...streams);
|
|
290
|
-
|
|
291
|
-
const totalParts = partFiles.length;
|
|
318
|
+
await Promise.all(executing);
|
|
292
319
|
|
|
293
|
-
|
|
320
|
+
partSpinner.succeed && partSpinner.succeed(`Created ${totalParts} part${totalParts !== 1 ? 's' : ''}`);
|
|
294
321
|
|
|
295
322
|
if (!quiet) {
|
|
296
323
|
console.log();
|
package/src/index.js
CHANGED
package/src/lib/xlsx-handler.js
CHANGED
|
@@ -117,6 +117,7 @@ async function createXlsxPartV5(options) {
|
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
119
|
* Read a v5 log-embed XLSX file and extract payload.
|
|
120
|
+
* Uses fast regex-based XML scanning instead of full DOM parsing for speed.
|
|
120
121
|
* @param {string} xlsxPath - Path to XLSX file
|
|
121
122
|
* @returns {object} { payloadBuffer, metadataJson, encryptionMeta, metadata }
|
|
122
123
|
*/
|
|
@@ -125,62 +126,67 @@ async function readXlsxV5(xlsxPath) {
|
|
|
125
126
|
throw new Error(`XLSX file not found: ${xlsxPath}`);
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
// Read file once and reuse the zip instance
|
|
130
|
+
const fileBuffer = fs.readFileSync(xlsxPath);
|
|
131
|
+
const zip = new AdmZip(fileBuffer);
|
|
132
|
+
|
|
133
|
+
// Parse shared strings (only if present — v5 files created with useSharedStrings:false may not have them)
|
|
134
|
+
let sharedStrings = null;
|
|
135
|
+
const ssEntry = zip.getEntry('xl/sharedStrings.xml');
|
|
136
|
+
if (ssEntry) {
|
|
137
|
+
sharedStrings = [];
|
|
138
|
+
const ssXml = ssEntry.getData().toString('utf8');
|
|
139
|
+
// Fast extract: match each <si><t>...</t></si> or <si><t ...>...</t></si>
|
|
140
|
+
const siRegex = /<si><t[^>]*>([^<]*)<\/t><\/si>/g;
|
|
141
|
+
let match;
|
|
142
|
+
while ((match = siRegex.exec(ssXml)) !== null) {
|
|
143
|
+
sharedStrings.push(match[1]);
|
|
144
|
+
}
|
|
134
145
|
}
|
|
135
146
|
|
|
136
|
-
//
|
|
137
|
-
const
|
|
138
|
-
if (!
|
|
147
|
+
// Extract sheet1.xml raw string
|
|
148
|
+
const sheetEntry = zip.getEntry('xl/worksheets/sheet1.xml');
|
|
149
|
+
if (!sheetEntry) {
|
|
139
150
|
throw new Error('Sheet not found in XLSX file.');
|
|
140
151
|
}
|
|
152
|
+
const sheetXml = sheetEntry.getData().toString('utf8');
|
|
141
153
|
|
|
142
|
-
//
|
|
154
|
+
// Fast row extraction using regex — much faster than full DOM parsing
|
|
143
155
|
const allRows = [];
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
value = extractTextContent(cell.is.t);
|
|
173
|
-
} else if (cellValue !== undefined) {
|
|
174
|
-
value = String(cellValue);
|
|
175
|
-
} else {
|
|
176
|
-
value = '';
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
rowValues[colIdx] = value;
|
|
156
|
+
const rowRegex = /<row [^>]*>(.*?)<\/row>/gs;
|
|
157
|
+
const cellRegex = /<c r="([A-Z]+)\d+"(?: t="([^"]*)")?[^>]*>(?:<v>([^<]*)<\/v>|<is><t[^>]*>([^<]*)<\/t><\/is>)?<\/c>/g;
|
|
158
|
+
|
|
159
|
+
let rowMatch;
|
|
160
|
+
while ((rowMatch = rowRegex.exec(sheetXml)) !== null) {
|
|
161
|
+
const rowXml = rowMatch[1];
|
|
162
|
+
const rowValues = [];
|
|
163
|
+
|
|
164
|
+
let cellMatch;
|
|
165
|
+
cellRegex.lastIndex = 0;
|
|
166
|
+
while ((cellMatch = cellRegex.exec(rowXml)) !== null) {
|
|
167
|
+
const colLetter = cellMatch[1];
|
|
168
|
+
const cellType = cellMatch[2] || '';
|
|
169
|
+
const vValue = cellMatch[3];
|
|
170
|
+
const inlineValue = cellMatch[4];
|
|
171
|
+
|
|
172
|
+
const colIdx = colLetterToIndex(colLetter);
|
|
173
|
+
|
|
174
|
+
let value;
|
|
175
|
+
if (cellType === 's' && vValue !== undefined && sharedStrings) {
|
|
176
|
+
const ssIndex = parseInt(vValue, 10);
|
|
177
|
+
value = ssIndex < sharedStrings.length ? sharedStrings[ssIndex] : '';
|
|
178
|
+
} else if (inlineValue !== undefined) {
|
|
179
|
+
value = decodeXmlEntities(inlineValue);
|
|
180
|
+
} else if (vValue !== undefined) {
|
|
181
|
+
value = decodeXmlEntities(vValue);
|
|
182
|
+
} else {
|
|
183
|
+
value = '';
|
|
180
184
|
}
|
|
181
185
|
|
|
182
|
-
|
|
186
|
+
rowValues[colIdx] = value;
|
|
183
187
|
}
|
|
188
|
+
|
|
189
|
+
allRows.push(rowValues);
|
|
184
190
|
}
|
|
185
191
|
|
|
186
192
|
// Skip the first row (column headers)
|
|
@@ -194,6 +200,19 @@ async function readXlsxV5(xlsxPath) {
|
|
|
194
200
|
return decodeLogLines(dataRows);
|
|
195
201
|
}
|
|
196
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Decode XML entities in a string value
|
|
205
|
+
*/
|
|
206
|
+
function decodeXmlEntities(str) {
|
|
207
|
+
if (!str || !str.includes('&')) return str;
|
|
208
|
+
return str
|
|
209
|
+
.replace(/&/g, '&')
|
|
210
|
+
.replace(/</g, '<')
|
|
211
|
+
.replace(/>/g, '>')
|
|
212
|
+
.replace(/'/g, "'")
|
|
213
|
+
.replace(/"/g, '"');
|
|
214
|
+
}
|
|
215
|
+
|
|
197
216
|
/**
|
|
198
217
|
* Convert column letter to 0-based index (A=0, B=1, ..., Z=25, AA=26)
|
|
199
218
|
*/
|