stegdoc 1.0.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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Create metadata object for encoding
3
+ * @param {object} options - Metadata options
4
+ * @param {string} options.originalFilename - Original filename
5
+ * @param {string} options.originalExtension - Original file extension
6
+ * @param {string} options.hash - Hash identifier for this file
7
+ * @param {number} options.partNumber - Part number (for split files)
8
+ * @param {number} options.totalParts - Total number of parts
9
+ * @param {number} options.originalSize - Original file size in bytes
10
+ * @param {string} options.format - Output format ('xlsx' or 'docx')
11
+ * @param {boolean} options.encrypted - Whether the content is encrypted
12
+ * @param {boolean} options.compressed - Whether the content is compressed
13
+ * @param {string} options.contentHash - SHA-256 hash of original file for integrity verification
14
+ * @returns {object} Metadata object
15
+ */
16
+ function createMetadata({
17
+ originalFilename,
18
+ originalExtension,
19
+ hash,
20
+ partNumber = null,
21
+ totalParts = null,
22
+ originalSize = 0,
23
+ format = 'xlsx',
24
+ encrypted = true,
25
+ compressed = false,
26
+ contentHash = null,
27
+ }) {
28
+ return {
29
+ originalFilename,
30
+ originalExtension,
31
+ hash,
32
+ partNumber,
33
+ totalParts,
34
+ originalSize,
35
+ format,
36
+ encrypted,
37
+ compressed,
38
+ contentHash,
39
+ encodingDate: new Date().toISOString(),
40
+ version: '1.0.0',
41
+ tool: 'stegdoc',
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Serialize metadata to string format for storage in DOCX
47
+ * @param {object} metadata - Metadata object
48
+ * @returns {string} JSON string
49
+ */
50
+ function serializeMetadata(metadata) {
51
+ return JSON.stringify(metadata);
52
+ }
53
+
54
+ /**
55
+ * Parse metadata from string
56
+ * @param {string} metadataStr - JSON string
57
+ * @returns {object} Metadata object
58
+ */
59
+ function parseMetadata(metadataStr) {
60
+ try {
61
+ return JSON.parse(metadataStr);
62
+ } catch (error) {
63
+ throw new Error(`Failed to parse metadata: ${error.message}`);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Validate metadata object
69
+ * @param {object} metadata - Metadata to validate
70
+ * @returns {boolean} True if valid
71
+ * @throws {Error} If metadata is invalid
72
+ */
73
+ function validateMetadata(metadata) {
74
+ const required = ['originalFilename', 'originalExtension', 'hash', 'tool'];
75
+
76
+ for (const field of required) {
77
+ if (!metadata[field]) {
78
+ throw new Error(`Missing required metadata field: ${field}`);
79
+ }
80
+ }
81
+
82
+ if (metadata.tool !== 'stegdoc' && metadata.tool !== 'docstash' && metadata.tool !== 'whitener') {
83
+ throw new Error('Invalid tool identifier in metadata');
84
+ }
85
+
86
+ // If it's a multi-part file, validate part info
87
+ if (metadata.totalParts !== null && metadata.totalParts > 1) {
88
+ if (metadata.partNumber === null || metadata.partNumber < 1 || metadata.partNumber > metadata.totalParts) {
89
+ throw new Error('Invalid part number in metadata');
90
+ }
91
+ }
92
+
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * Check if metadata indicates a multi-part file
98
+ * @param {object} metadata - Metadata object
99
+ * @returns {boolean} True if multi-part
100
+ */
101
+ function isMultiPart(metadata) {
102
+ return metadata.totalParts !== null && metadata.totalParts > 1;
103
+ }
104
+
105
+ module.exports = {
106
+ createMetadata,
107
+ serializeMetadata,
108
+ parseMetadata,
109
+ validateMetadata,
110
+ isMultiPart,
111
+ };
@@ -0,0 +1,227 @@
1
+ const crypto = require('crypto');
2
+
3
+ /**
4
+ * Generate a random hash for file naming
5
+ * @param {number} length - Length of hash in bytes (default 8)
6
+ * @returns {string} Hexadecimal hash string
7
+ */
8
+ function generateHash(length = 8) {
9
+ return crypto.randomBytes(length).toString('hex');
10
+ }
11
+
12
+ /**
13
+ * Generate SHA-256 hash of a buffer for integrity verification
14
+ * @param {Buffer} buffer - Data to hash
15
+ * @returns {string} Hexadecimal SHA-256 hash
16
+ */
17
+ function generateContentHash(buffer) {
18
+ return crypto.createHash('sha256').update(buffer).digest('hex');
19
+ }
20
+
21
+ /**
22
+ * Parse size string to bytes
23
+ * @param {string} sizeStr - Size string (e.g., "5MB", "100KB", "1GB")
24
+ * @returns {number} Size in bytes
25
+ */
26
+ function parseSizeToBytes(sizeStr) {
27
+ if (typeof sizeStr === 'number') {
28
+ return sizeStr;
29
+ }
30
+
31
+ const units = {
32
+ B: 1,
33
+ KB: 1024,
34
+ MB: 1024 * 1024,
35
+ GB: 1024 * 1024 * 1024,
36
+ };
37
+
38
+ const match = sizeStr.trim().toUpperCase().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/);
39
+
40
+ if (!match) {
41
+ throw new Error(`Invalid size format: ${sizeStr}. Use format like "5MB", "100KB", etc.`);
42
+ }
43
+
44
+ const value = parseFloat(match[1]);
45
+ const unit = match[2] || 'B';
46
+
47
+ return Math.floor(value * units[unit]);
48
+ }
49
+
50
+ /**
51
+ * Format bytes to human-readable string
52
+ * @param {number} bytes - Number of bytes
53
+ * @param {number} decimals - Number of decimal places (default 2)
54
+ * @returns {string} Formatted size string
55
+ */
56
+ function formatBytes(bytes, decimals = 2) {
57
+ if (bytes === 0) return '0 B';
58
+
59
+ const k = 1024;
60
+ const dm = decimals < 0 ? 0 : decimals;
61
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
62
+
63
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
64
+
65
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
66
+ }
67
+
68
+ /**
69
+ * Generate a realistic-looking filename that matches the decoy theme
70
+ * Decoy data contains ~4 hours of server logs, so filenames reflect periodic reports
71
+ *
72
+ * @param {string} hash - Hash identifier (used internally for matching parts)
73
+ * @param {number} partNumber - Part number (optional, for split files)
74
+ * @param {number} totalParts - Total number of parts (optional)
75
+ * @param {string} format - Output format ('xlsx' or 'docx'), default 'xlsx'
76
+ * @returns {string} Realistic filename WITH extension
77
+ */
78
+ function generateFilename(hash, partNumber = null, totalParts = null, format = 'xlsx') {
79
+ const ext = format.toLowerCase() === 'docx' ? 'docx' : 'xlsx';
80
+
81
+ // Generate deterministic timestamp based on hash (so all parts match)
82
+ // Use different parts of hash for day and hour to get better distribution
83
+ const hashNumLow = parseInt(hash.slice(0, 8), 16) >>> 0;
84
+ const hashNumHigh = parseInt(hash.slice(8, 16), 16) >>> 0;
85
+ const daysAgo = hashNumLow % 7; // 0-6 days ago
86
+ const hourBlock = (hashNumHigh % 6) * 4; // 0, 4, 8, 12, 16, 20
87
+
88
+ const now = new Date();
89
+ const date = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000);
90
+
91
+ // Format date in local timezone (YYYYMMDD)
92
+ const year = date.getFullYear();
93
+ const month = String(date.getMonth() + 1).padStart(2, '0');
94
+ const day = String(date.getDate()).padStart(2, '0');
95
+ const dateStr = `${year}${month}${day}`;
96
+ const timeStr = String(hourBlock).padStart(2, '0') + '00'; // HH00
97
+
98
+ // Use last 4 chars of hash as a "report ID" to keep files linked
99
+ const reportId = hash.slice(-4).toUpperCase();
100
+
101
+ if (format === 'xlsx') {
102
+ // Server metrics theme - looks like periodic 4-hour monitoring exports
103
+ if (partNumber !== null && totalParts !== null) {
104
+ return `server_metrics_${dateStr}_${timeStr}_${reportId}_part${partNumber}.${ext}`;
105
+ }
106
+ return `server_metrics_${dateStr}_${timeStr}_${reportId}.${ext}`;
107
+ } else {
108
+ // DOCX - use a different theme
109
+ if (partNumber !== null && totalParts !== null) {
110
+ return `system_report_${dateStr}_${timeStr}_${reportId}_part${partNumber}.${ext}`;
111
+ }
112
+ return `system_report_${dateStr}_${timeStr}_${reportId}.${ext}`;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Legacy alias for backward compatibility
118
+ */
119
+ function generateDocxFilename(hash, partNumber = null, totalParts = null) {
120
+ return generateFilename(hash, partNumber, totalParts, 'docx').replace(/\.docx$/, '');
121
+ }
122
+
123
+ /**
124
+ * Parse filename to extract hash, part information, and format
125
+ * @param {string} filename - Filename to parse
126
+ * @returns {object} Object with hash, partNumber, totalParts, baseHash, format
127
+ */
128
+ function parseFilename(filename) {
129
+ // Detect format from extension
130
+ let format = 'xlsx';
131
+ let name = filename;
132
+
133
+ if (/\.docx$/i.test(filename)) {
134
+ format = 'docx';
135
+ name = filename.replace(/\.docx$/i, '');
136
+ } else if (/\.xlsx$/i.test(filename)) {
137
+ format = 'xlsx';
138
+ name = filename.replace(/\.xlsx$/i, '');
139
+ }
140
+
141
+ // Try new realistic filename format first
142
+ // Pattern: server_metrics_YYYYMMDD_HH00_XXXX[_partN]
143
+ // Or: system_report_YYYYMMDD_HH00_XXXX[_partN]
144
+ const realisticPattern = /^(server_metrics|system_report)_(\d{8})_(\d{4})_([A-F0-9]{4})(?:_part(\d+))?$/i;
145
+ const realisticMatch = name.match(realisticPattern);
146
+
147
+ if (realisticMatch) {
148
+ const [, prefix, dateStr, timeStr, reportId, partStr] = realisticMatch;
149
+ // Create a pseudo-hash from the components for matching purposes
150
+ // The reportId is the last 4 chars of the original hash
151
+ const baseIdentifier = `${dateStr}_${timeStr}_${reportId}`;
152
+
153
+ return {
154
+ hash: name,
155
+ baseHash: baseIdentifier,
156
+ partNumber: partStr ? parseInt(partStr, 10) : null,
157
+ totalParts: null, // Will be determined from metadata
158
+ format,
159
+ // Extra info for matching
160
+ reportId: reportId.toUpperCase(),
161
+ dateStr,
162
+ timeStr,
163
+ };
164
+ }
165
+
166
+ // Legacy: Check if it's a hex string (old format)
167
+ if (/^[a-f0-9]+$/i.test(name)) {
168
+ // Could be either single file or multi-part
169
+ // Single files are 16 chars (8 bytes), multi-part are 18+ chars (16 + 2 digit part number)
170
+
171
+ if (name.length === 16) {
172
+ // Single file
173
+ return {
174
+ hash: name,
175
+ baseHash: name,
176
+ partNumber: null,
177
+ totalParts: null,
178
+ format,
179
+ };
180
+ } else if (name.length >= 18) {
181
+ // Multi-part: last 2 chars are the part number
182
+ const baseHash = name.slice(0, -2);
183
+ const partNumber = parseInt(name.slice(-2), 10);
184
+
185
+ return {
186
+ hash: name,
187
+ baseHash: baseHash,
188
+ partNumber: partNumber,
189
+ totalParts: null, // Will be determined from metadata
190
+ format,
191
+ };
192
+ }
193
+ }
194
+
195
+ return null;
196
+ }
197
+
198
+ /**
199
+ * Legacy alias for backward compatibility
200
+ */
201
+ function parseDocxFilename(filename) {
202
+ return parseFilename(filename);
203
+ }
204
+
205
+ /**
206
+ * Detect file format from extension
207
+ * @param {string} filename - Filename to check
208
+ * @returns {string} 'xlsx', 'docx', or null if unknown
209
+ */
210
+ function detectFormat(filename) {
211
+ if (/\.xlsx$/i.test(filename)) return 'xlsx';
212
+ if (/\.docx$/i.test(filename)) return 'docx';
213
+ return null;
214
+ }
215
+
216
+ module.exports = {
217
+ generateHash,
218
+ generateContentHash,
219
+ parseSizeToBytes,
220
+ formatBytes,
221
+ generateFilename,
222
+ parseFilename,
223
+ detectFormat,
224
+ // Legacy aliases for backward compatibility
225
+ generateDocxFilename,
226
+ parseDocxFilename,
227
+ };
@@ -0,0 +1,359 @@
1
+ const ExcelJS = require('exceljs');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const AdmZip = require('adm-zip');
5
+ const { generateDecoyHeaders, generateDecoyData, calculateDecoyRowCount, rowToArray, resetTimeWindow } = require('./decoy-generator');
6
+ const { parseXmlFromZip, ensureArray, extractTextContent } = require('./xml-utils');
7
+
8
+ // Constants for data storage
9
+ const HIDDEN_SHEET_NAME = 'Data';
10
+ const VISIBLE_SHEET_NAME = 'Server Metrics';
11
+ const CELL_CHUNK_SIZE = 32000; // Max characters per cell (~32KB)
12
+
13
+ /**
14
+ * Create an XLSX file with encrypted base64 content hidden in a veryHidden sheet
15
+ * @param {object} options - Options for creating the XLSX
16
+ * @param {string} options.base64Content - Encrypted base64 content to store
17
+ * @param {string} options.encryptionMeta - Packed encryption metadata (iv:salt:authTag)
18
+ * @param {object} options.metadata - Metadata object (serialized JSON)
19
+ * @param {string} options.outputPath - Output file path
20
+ * @returns {Promise<string>} Path to created file
21
+ */
22
+ async function createXlsxWithBase64(options) {
23
+ const { base64Content, encryptionMeta, metadata, outputPath } = options;
24
+
25
+ const workbook = new ExcelJS.Workbook();
26
+
27
+ // Set workbook properties to look legitimate
28
+ workbook.creator = 'Microsoft Excel';
29
+ workbook.lastModifiedBy = 'Microsoft Excel';
30
+ workbook.created = new Date();
31
+ workbook.modified = new Date();
32
+
33
+ // === Sheet 1: Visible decoy data (server metrics) ===
34
+ const visibleSheet = workbook.addWorksheet(VISIBLE_SHEET_NAME, {
35
+ properties: { tabColor: { argb: '4472C4' } },
36
+ });
37
+
38
+ // Add headers
39
+ const headers = generateDecoyHeaders();
40
+ const headerRow = visibleSheet.addRow(headers);
41
+
42
+ // Style header columns (10 columns for metrics data)
43
+ const headerCount = headers.length;
44
+ for (let col = 1; col <= headerCount; col++) {
45
+ const cell = headerRow.getCell(col);
46
+ cell.font = { bold: true, size: 11, color: { argb: 'FFFFFF' } };
47
+ cell.fill = {
48
+ type: 'pattern',
49
+ pattern: 'solid',
50
+ fgColor: { argb: '2E7D32' }, // Green for metrics/monitoring theme
51
+ };
52
+ }
53
+
54
+ // Calculate decoy row count based on payload size
55
+ const payloadSize = base64Content.length;
56
+ const rowCount = calculateDecoyRowCount(payloadSize);
57
+
58
+ // Generate and add decoy data
59
+ const decoyData = generateDecoyData(rowCount);
60
+ decoyData.forEach((row) => {
61
+ visibleSheet.addRow(rowToArray(row));
62
+ });
63
+
64
+ // Auto-fit columns for metrics data
65
+ visibleSheet.columns = [
66
+ { width: 20 }, // Timestamp
67
+ { width: 16 }, // Server ID
68
+ { width: 12 }, // Status
69
+ { width: 8 }, // CPU %
70
+ { width: 10 }, // Memory %
71
+ { width: 8 }, // Disk %
72
+ { width: 14 }, // Network (MB/s)
73
+ { width: 10 }, // Requests
74
+ { width: 14 }, // Resp Time (ms)
75
+ { width: 12 }, // Uptime (hrs)
76
+ ];
77
+
78
+ // Add filters to look more professional
79
+ visibleSheet.autoFilter = {
80
+ from: 'A1',
81
+ to: `J${rowCount + 1}`,
82
+ };
83
+
84
+ // === Sheet 2: Hidden payload (veryHidden) ===
85
+ const hiddenSheet = workbook.addWorksheet(HIDDEN_SHEET_NAME, {
86
+ state: 'veryHidden', // Not visible in Excel UI at all
87
+ });
88
+
89
+ // Store encryption metadata in cell A1
90
+ hiddenSheet.getCell('A1').value = encryptionMeta;
91
+
92
+ // Store serialized metadata in cell B1
93
+ hiddenSheet.getCell('B1').value = metadata;
94
+
95
+ // Split base64 content into chunks and store in cells
96
+ const chunks = splitIntoChunks(base64Content, CELL_CHUNK_SIZE);
97
+
98
+ // Store chunk count in C1 for reconstruction
99
+ hiddenSheet.getCell('C1').value = chunks.length.toString();
100
+
101
+ // Store chunks starting from A2
102
+ chunks.forEach((chunk, index) => {
103
+ const row = Math.floor(index / 26) + 2; // Start from row 2
104
+ const col = (index % 26) + 1; // Columns A-Z
105
+ hiddenSheet.getCell(row, col).value = chunk;
106
+ });
107
+
108
+ // Ensure output directory exists
109
+ const outputDir = path.dirname(outputPath);
110
+ if (!fs.existsSync(outputDir)) {
111
+ fs.mkdirSync(outputDir, { recursive: true });
112
+ }
113
+
114
+ // Write workbook to file
115
+ await workbook.xlsx.writeFile(outputPath);
116
+
117
+ // Post-process: ensure veryHidden state is set correctly
118
+ // ExcelJS sometimes has issues with veryHidden, so we fix it via XML
119
+ await ensureVeryHidden(outputPath);
120
+
121
+ return outputPath;
122
+ }
123
+
124
+ /**
125
+ * Read an XLSX file and extract encrypted base64 content and metadata
126
+ * @param {string} xlsxPath - Path to XLSX file
127
+ * @returns {Promise<object>} Object containing base64Content, encryptionMeta, and metadata
128
+ */
129
+ async function readXlsxBase64(xlsxPath) {
130
+ if (!fs.existsSync(xlsxPath)) {
131
+ throw new Error(`XLSX file not found: ${xlsxPath}`);
132
+ }
133
+
134
+ // Try ExcelJS first, fall back to direct XML extraction if it fails
135
+ // (ExcelJS fails when namespace prefixes are changed, e.g., ns0:)
136
+ let hiddenSheet = null;
137
+ try {
138
+ const workbook = new ExcelJS.Workbook();
139
+ await workbook.xlsx.readFile(xlsxPath);
140
+
141
+ // Find the hidden sheet
142
+ workbook.eachSheet((sheet) => {
143
+ if (sheet.name === HIDDEN_SHEET_NAME || sheet.state === 'veryHidden') {
144
+ hiddenSheet = sheet;
145
+ }
146
+ });
147
+ } catch (excelError) {
148
+ // ExcelJS failed (likely namespace issue), use direct XML extraction
149
+ return await extractFromXml(xlsxPath);
150
+ }
151
+
152
+ // If not found via ExcelJS (veryHidden might be invisible), try direct XML extraction
153
+ if (!hiddenSheet) {
154
+ return await extractFromXml(xlsxPath);
155
+ }
156
+
157
+ // Extract encryption metadata from A1 (may be empty if unencrypted)
158
+ const encryptionMeta = hiddenSheet.getCell('A1').value || '';
159
+
160
+ // Extract serialized metadata from B1
161
+ const metadata = hiddenSheet.getCell('B1').value;
162
+ if (!metadata) {
163
+ throw new Error('No metadata found in XLSX file.');
164
+ }
165
+
166
+ // Get chunk count from C1
167
+ const chunkCountStr = hiddenSheet.getCell('C1').value;
168
+ const chunkCount = parseInt(chunkCountStr, 10);
169
+
170
+ if (isNaN(chunkCount) || chunkCount <= 0) {
171
+ throw new Error('Invalid chunk count in XLSX file.');
172
+ }
173
+
174
+ // Reconstruct base64 content from chunks
175
+ const chunks = [];
176
+ for (let i = 0; i < chunkCount; i++) {
177
+ const row = Math.floor(i / 26) + 2;
178
+ const col = (i % 26) + 1;
179
+ const chunk = hiddenSheet.getCell(row, col).value;
180
+ if (chunk) {
181
+ chunks.push(chunk);
182
+ }
183
+ }
184
+
185
+ const base64Content = chunks.join('');
186
+
187
+ return {
188
+ base64Content,
189
+ encryptionMeta,
190
+ metadata,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Extract data directly from XLSX XML using proper XML parser
196
+ * Handles any namespace prefix (default, ns0:, ns1:, etc.)
197
+ * @param {string} xlsxPath - Path to XLSX file
198
+ * @returns {Promise<object>} Extracted data
199
+ */
200
+ async function extractFromXml(xlsxPath) {
201
+ // Parse shared strings using namespace-agnostic parser
202
+ let sharedStrings = [];
203
+ const ssParsed = parseXmlFromZip(xlsxPath, 'xl/sharedStrings.xml');
204
+
205
+ if (ssParsed && ssParsed.sst && ssParsed.sst.si) {
206
+ const siArray = ensureArray(ssParsed.sst.si);
207
+ sharedStrings = siArray.map((si) => extractTextContent(si.t));
208
+ }
209
+
210
+ // Parse the hidden sheet (sheet2.xml is our default location)
211
+ const sheetParsed = parseXmlFromZip(xlsxPath, 'xl/worksheets/sheet2.xml');
212
+
213
+ if (!sheetParsed) {
214
+ throw new Error('Hidden sheet not found in XLSX file. This may not be a whitener-encoded file.');
215
+ }
216
+
217
+ // Build cell map from worksheet > sheetData > row > c
218
+ const cellValues = new Map();
219
+ const sheetData = sheetParsed.worksheet?.sheetData;
220
+
221
+ if (sheetData && sheetData.row) {
222
+ const rows = ensureArray(sheetData.row);
223
+
224
+ for (const row of rows) {
225
+ if (!row.c) continue;
226
+ const cells = ensureArray(row.c);
227
+
228
+ for (const cell of cells) {
229
+ const cellRef = cell['@_r']; // e.g., "A1", "B1"
230
+ const cellType = cell['@_t']; // "s" for shared string
231
+ const cellValue = cell.v;
232
+
233
+ if (cellRef === undefined) continue;
234
+
235
+ if (cellType === 's' && cellValue !== undefined) {
236
+ // Shared string reference
237
+ const ssIndex = parseInt(cellValue, 10);
238
+ if (ssIndex < sharedStrings.length) {
239
+ cellValues.set(cellRef, sharedStrings[ssIndex]);
240
+ }
241
+ } else if (cellValue !== undefined) {
242
+ cellValues.set(cellRef, String(cellValue));
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ // Extract our data
249
+ const encryptionMeta = cellValues.get('A1') || ''; // May be empty if unencrypted
250
+ const metadata = cellValues.get('B1');
251
+ const chunkCountStr = cellValues.get('C1');
252
+
253
+ if (!metadata) {
254
+ throw new Error('No metadata found in XLSX file.');
255
+ }
256
+
257
+ const chunkCount = parseInt(chunkCountStr, 10);
258
+ if (isNaN(chunkCount) || chunkCount <= 0) {
259
+ throw new Error('Invalid chunk count in XLSX file.');
260
+ }
261
+
262
+ // Reconstruct chunks
263
+ const chunks = [];
264
+ for (let i = 0; i < chunkCount; i++) {
265
+ const row = Math.floor(i / 26) + 2;
266
+ const col = (i % 26) + 1;
267
+ const cellRef = `${columnToLetter(col)}${row}`;
268
+ const chunk = cellValues.get(cellRef);
269
+ if (chunk) {
270
+ chunks.push(chunk);
271
+ }
272
+ }
273
+
274
+ return {
275
+ base64Content: chunks.join(''),
276
+ encryptionMeta,
277
+ metadata,
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Ensure the hidden sheet is truly veryHidden by modifying the workbook.xml
283
+ * @param {string} xlsxPath - Path to XLSX file
284
+ */
285
+ async function ensureVeryHidden(xlsxPath) {
286
+ const zip = new AdmZip(xlsxPath);
287
+
288
+ // Modify workbook.xml to ensure veryHidden state
289
+ const workbookEntry = zip.getEntry('xl/workbook.xml');
290
+ if (workbookEntry) {
291
+ let workbookXml = workbookEntry.getData().toString('utf8');
292
+
293
+ // Find the Data sheet and fix its state to veryHidden
294
+ // First, remove any existing state attribute from the Data sheet
295
+ workbookXml = workbookXml.replace(
296
+ /(<sheet[^>]*name="Data"[^>]*)\s+state="[^"]*"([^>]*\/>)/gi,
297
+ '$1 state="veryHidden"$2'
298
+ );
299
+
300
+ // If no state attribute was present, add one
301
+ if (!workbookXml.match(/<sheet[^>]*name="Data"[^>]*state="/i)) {
302
+ workbookXml = workbookXml.replace(
303
+ /(<sheet[^>]*name="Data")([^>]*\/>)/gi,
304
+ '$1 state="veryHidden"$2'
305
+ );
306
+ }
307
+
308
+ zip.updateFile('xl/workbook.xml', Buffer.from(workbookXml, 'utf8'));
309
+ zip.writeZip(xlsxPath);
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Split a string into chunks of specified size
315
+ * @param {string} str - String to split
316
+ * @param {number} size - Max chunk size
317
+ * @returns {Array<string>} Array of chunks
318
+ */
319
+ function splitIntoChunks(str, size) {
320
+ const chunks = [];
321
+ for (let i = 0; i < str.length; i += size) {
322
+ chunks.push(str.slice(i, i + size));
323
+ }
324
+ return chunks;
325
+ }
326
+
327
+ /**
328
+ * Convert column number to letter (1=A, 2=B, ..., 27=AA)
329
+ * @param {number} col - Column number (1-based)
330
+ * @returns {string} Column letter
331
+ */
332
+ function columnToLetter(col) {
333
+ let letter = '';
334
+ while (col > 0) {
335
+ const mod = (col - 1) % 26;
336
+ letter = String.fromCharCode(65 + mod) + letter;
337
+ col = Math.floor((col - 1) / 26);
338
+ }
339
+ return letter;
340
+ }
341
+
342
+ /**
343
+ * Decode XML entities
344
+ * @param {string} str - String with XML entities
345
+ * @returns {string} Decoded string
346
+ */
347
+ function decodeXmlEntities(str) {
348
+ return str
349
+ .replace(/&quot;/g, '"')
350
+ .replace(/&apos;/g, "'")
351
+ .replace(/&lt;/g, '<')
352
+ .replace(/&gt;/g, '>')
353
+ .replace(/&amp;/g, '&');
354
+ }
355
+
356
+ module.exports = {
357
+ createXlsxWithBase64,
358
+ readXlsxBase64,
359
+ };