1use std::io::{Cursor, Read as _};
9
10use crate::error::{Error, Result};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum VbaModuleType {
15 Standard,
17 Class,
19 Form,
21 Document,
23 ThisWorkbook,
25}
26
27#[derive(Debug, Clone)]
29pub struct VbaModule {
30 pub name: String,
31 pub source_code: String,
32 pub module_type: VbaModuleType,
33}
34
35#[derive(Debug, Clone)]
41pub struct VbaProject {
42 pub modules: Vec<VbaModule>,
43 pub warnings: Vec<String>,
44}
45
46struct ModuleEntry {
48 name: String,
49 stream_name: String,
50 text_offset: u32,
51 module_type: VbaModuleType,
52}
53
54struct DirInfo {
56 entries: Vec<ModuleEntry>,
57 codepage: u16,
58}
59
60pub fn extract_vba_modules(vba_bin: &[u8]) -> Result<VbaProject> {
69 let cursor = Cursor::new(vba_bin);
70 let mut cfb = cfb::CompoundFile::open(cursor)
71 .map_err(|e| Error::Internal(format!("failed to open VBA project as CFB: {e}")))?;
72
73 let vba_prefix = find_vba_prefix(&mut cfb)?;
75
76 let dir_path = format!("{vba_prefix}dir");
78 let dir_data = read_cfb_stream(&mut cfb, &dir_path)?;
79
80 let decompressed_dir = decompress_vba_stream(&dir_data)?;
82
83 let dir_info = parse_dir_stream(&decompressed_dir)?;
85
86 let mut modules = Vec::with_capacity(dir_info.entries.len());
87 let mut warnings = Vec::new();
88
89 for entry in dir_info.entries {
90 let stream_path = format!("{vba_prefix}{}", entry.stream_name);
91 let compressed_data = match read_cfb_stream(&mut cfb, &stream_path) {
92 Ok(data) => data,
93 Err(e) => {
94 warnings.push(format!(
95 "skipped module '{}': failed to read stream '{}': {}",
96 entry.name, stream_path, e
97 ));
98 continue;
99 }
100 };
101
102 if (entry.text_offset as usize) > compressed_data.len() {
105 warnings.push(format!(
106 "skipped module '{}': text_offset {} exceeds stream length {}",
107 entry.name,
108 entry.text_offset,
109 compressed_data.len()
110 ));
111 continue;
112 }
113 let source_compressed = &compressed_data[entry.text_offset as usize..];
114 let source_bytes = match decompress_vba_stream(source_compressed) {
115 Ok(b) => b,
116 Err(e) => {
117 warnings.push(format!(
118 "skipped module '{}': decompression failed: {}",
119 entry.name, e
120 ));
121 continue;
122 }
123 };
124
125 let source_code = decode_source_bytes(&source_bytes, dir_info.codepage, &mut warnings);
126
127 modules.push(VbaModule {
128 name: entry.name,
129 source_code,
130 module_type: entry.module_type,
131 });
132 }
133
134 Ok(VbaProject { modules, warnings })
135}
136
137fn decode_source_bytes(bytes: &[u8], codepage: u16, warnings: &mut Vec<String>) -> String {
143 match codepage {
144 65001 | 0 => String::from_utf8_lossy(bytes).into_owned(),
145 1252 => decode_single_byte(bytes, &WINDOWS_1252_HIGH),
146 932 => decode_shift_jis(bytes),
147 949 => decode_euc_kr(bytes),
148 936 => decode_gbk(bytes),
149 _ => {
150 warnings.push(format!(
151 "unsupported codepage {codepage}, falling back to UTF-8 lossy"
152 ));
153 String::from_utf8_lossy(bytes).into_owned()
154 }
155 }
156}
157
158static WINDOWS_1252_HIGH: [char; 128] = [
161 '\u{20AC}', '\u{0081}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}', '\u{2021}',
162 '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{008D}', '\u{017D}', '\u{008F}',
163 '\u{0090}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}', '\u{2022}', '\u{2013}', '\u{2014}',
164 '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}', '\u{0153}', '\u{009D}', '\u{017E}', '\u{0178}',
165 '\u{00A0}', '\u{00A1}', '\u{00A2}', '\u{00A3}', '\u{00A4}', '\u{00A5}', '\u{00A6}', '\u{00A7}',
166 '\u{00A8}', '\u{00A9}', '\u{00AA}', '\u{00AB}', '\u{00AC}', '\u{00AD}', '\u{00AE}', '\u{00AF}',
167 '\u{00B0}', '\u{00B1}', '\u{00B2}', '\u{00B3}', '\u{00B4}', '\u{00B5}', '\u{00B6}', '\u{00B7}',
168 '\u{00B8}', '\u{00B9}', '\u{00BA}', '\u{00BB}', '\u{00BC}', '\u{00BD}', '\u{00BE}', '\u{00BF}',
169 '\u{00C0}', '\u{00C1}', '\u{00C2}', '\u{00C3}', '\u{00C4}', '\u{00C5}', '\u{00C6}', '\u{00C7}',
170 '\u{00C8}', '\u{00C9}', '\u{00CA}', '\u{00CB}', '\u{00CC}', '\u{00CD}', '\u{00CE}', '\u{00CF}',
171 '\u{00D0}', '\u{00D1}', '\u{00D2}', '\u{00D3}', '\u{00D4}', '\u{00D5}', '\u{00D6}', '\u{00D7}',
172 '\u{00D8}', '\u{00D9}', '\u{00DA}', '\u{00DB}', '\u{00DC}', '\u{00DD}', '\u{00DE}', '\u{00DF}',
173 '\u{00E0}', '\u{00E1}', '\u{00E2}', '\u{00E3}', '\u{00E4}', '\u{00E5}', '\u{00E6}', '\u{00E7}',
174 '\u{00E8}', '\u{00E9}', '\u{00EA}', '\u{00EB}', '\u{00EC}', '\u{00ED}', '\u{00EE}', '\u{00EF}',
175 '\u{00F0}', '\u{00F1}', '\u{00F2}', '\u{00F3}', '\u{00F4}', '\u{00F5}', '\u{00F6}', '\u{00F7}',
176 '\u{00F8}', '\u{00F9}', '\u{00FA}', '\u{00FB}', '\u{00FC}', '\u{00FD}', '\u{00FE}', '\u{00FF}',
177];
178
179fn decode_single_byte(bytes: &[u8], high_table: &[char; 128]) -> String {
181 let mut out = String::with_capacity(bytes.len());
182 for &b in bytes {
183 if b < 0x80 {
184 out.push(b as char);
185 } else {
186 out.push(high_table[(b - 0x80) as usize]);
187 }
188 }
189 out
190}
191
192fn decode_shift_jis(bytes: &[u8]) -> String {
196 let mut out = String::with_capacity(bytes.len());
197 let mut i = 0;
198 while i < bytes.len() {
199 let b = bytes[i];
200 if b < 0x80 {
201 out.push(b as char);
202 i += 1;
203 } else if b == 0x80 || b == 0xA0 || b >= 0xFD {
204 out.push('\u{FFFD}');
205 i += 1;
206 } else if (0xA1..=0xDF).contains(&b) {
207 out.push(char::from_u32(0xFF61 + (b as u32 - 0xA1)).unwrap_or('\u{FFFD}'));
209 i += 1;
210 } else if i + 1 < bytes.len() {
211 out.push('\u{FFFD}');
214 i += 2;
215 } else {
216 out.push('\u{FFFD}');
217 i += 1;
218 }
219 }
220 out
221}
222
223fn decode_euc_kr(bytes: &[u8]) -> String {
226 let mut out = String::with_capacity(bytes.len());
227 let mut i = 0;
228 while i < bytes.len() {
229 let b = bytes[i];
230 if b < 0x80 {
231 out.push(b as char);
232 i += 1;
233 } else if i + 1 < bytes.len() {
234 out.push('\u{FFFD}');
235 i += 2;
236 } else {
237 out.push('\u{FFFD}');
238 i += 1;
239 }
240 }
241 out
242}
243
244fn decode_gbk(bytes: &[u8]) -> String {
247 let mut out = String::with_capacity(bytes.len());
248 let mut i = 0;
249 while i < bytes.len() {
250 let b = bytes[i];
251 if b < 0x80 {
252 out.push(b as char);
253 i += 1;
254 } else if i + 1 < bytes.len() {
255 out.push('\u{FFFD}');
256 i += 2;
257 } else {
258 out.push('\u{FFFD}');
259 i += 1;
260 }
261 }
262 out
263}
264
265fn find_vba_prefix(cfb: &mut cfb::CompoundFile<Cursor<&[u8]>>) -> Result<String> {
268 let entries: Vec<String> = cfb
270 .walk()
271 .map(|e| e.path().to_string_lossy().into_owned())
272 .collect();
273
274 for entry_path in &entries {
276 let normalized = entry_path.replace('\\', "/");
277 if normalized.ends_with("/dir") || normalized.ends_with("/DIR") {
278 let prefix = &normalized[..normalized.len() - 3];
279 return Ok(prefix.to_string());
280 }
281 }
282
283 for prefix in ["/VBA/", "VBA/", "/"] {
285 let dir_path = format!("{prefix}dir");
286 if cfb.is_stream(&dir_path) {
287 return Ok(prefix.to_string());
288 }
289 }
290
291 Err(Error::Internal(
292 "could not find VBA dir stream in vbaProject.bin".to_string(),
293 ))
294}
295
296fn read_cfb_stream(cfb: &mut cfb::CompoundFile<Cursor<&[u8]>>, path: &str) -> Result<Vec<u8>> {
298 let mut stream = cfb
299 .open_stream(path)
300 .map_err(|e| Error::Internal(format!("failed to open CFB stream '{path}': {e}")))?;
301 let mut data = Vec::new();
302 stream
303 .read_to_end(&mut data)
304 .map_err(|e| Error::Internal(format!("failed to read CFB stream '{path}': {e}")))?;
305 Ok(data)
306}
307
308pub fn decompress_vba_stream(data: &[u8]) -> Result<Vec<u8>> {
315 if data.is_empty() {
316 return Ok(Vec::new());
317 }
318
319 if data[0] != 0x01 {
320 return Err(Error::Internal(format!(
321 "invalid VBA compression signature: expected 0x01, got 0x{:02X}",
322 data[0]
323 )));
324 }
325
326 let mut output = Vec::with_capacity(data.len() * 2);
327 let mut pos = 1; while pos < data.len() {
330 if pos + 1 >= data.len() {
331 break;
332 }
333
334 let header = u16::from_le_bytes([data[pos], data[pos + 1]]);
336 pos += 2;
337
338 let chunk_size = (header & 0x0FFF) as usize + 3;
339 let is_compressed = (header & 0x8000) != 0;
340
341 let chunk_end = (pos + chunk_size - 2).min(data.len());
342
343 if !is_compressed {
344 let raw_end = chunk_end.min(pos + 4096);
346 if raw_end > data.len() {
347 break;
348 }
349 output.extend_from_slice(&data[pos..raw_end]);
350 pos = chunk_end;
351 continue;
352 }
353
354 let chunk_start_output = output.len();
356 while pos < chunk_end {
357 if pos >= data.len() {
358 break;
359 }
360
361 let flag_byte = data[pos];
362 pos += 1;
363
364 for bit_index in 0..8 {
365 if pos >= chunk_end {
366 break;
367 }
368
369 if (flag_byte >> bit_index) & 1 == 0 {
370 output.push(data[pos]);
372 pos += 1;
373 } else {
374 if pos + 1 >= data.len() {
376 pos = chunk_end;
377 break;
378 }
379 let token = u16::from_le_bytes([data[pos], data[pos + 1]]);
380 pos += 2;
381
382 let decompressed_current = output.len() - chunk_start_output;
384 let bit_count = max_bit_count(decompressed_current);
385 let length_mask = 0xFFFF >> bit_count;
386 let offset_mask = !length_mask;
387
388 let length = ((token & length_mask) + 3) as usize;
389 let offset = (((token & offset_mask) >> (16 - bit_count)) + 1) as usize;
390
391 if offset > output.len() {
392 break;
394 }
395
396 let copy_start = output.len() - offset;
397 for i in 0..length {
398 let byte = output[copy_start + (i % offset)];
399 output.push(byte);
400 }
401 }
402 }
403 }
404 }
405
406 Ok(output)
407}
408
409fn max_bit_count(decompressed_current: usize) -> u16 {
413 if decompressed_current <= 16 {
414 return 12;
415 }
416 if decompressed_current <= 32 {
417 return 11;
418 }
419 if decompressed_current <= 64 {
420 return 10;
421 }
422 if decompressed_current <= 128 {
423 return 9;
424 }
425 if decompressed_current <= 256 {
426 return 8;
427 }
428 if decompressed_current <= 512 {
429 return 7;
430 }
431 if decompressed_current <= 1024 {
432 return 6;
433 }
434 if decompressed_current <= 2048 {
435 return 5;
436 }
437 4 }
439
440fn parse_dir_stream(data: &[u8]) -> Result<DirInfo> {
452 let mut pos = 0;
453 let mut modules = Vec::new();
454 let mut codepage: u16 = 1252; let mut current_name: Option<String> = None;
458 let mut current_stream_name: Option<String> = None;
459 let mut current_offset: u32 = 0;
460 let mut current_type = VbaModuleType::Standard;
461 let mut in_module = false;
462
463 while pos + 6 <= data.len() {
464 let record_id = u16::from_le_bytes([data[pos], data[pos + 1]]);
465 let record_size =
466 u32::from_le_bytes([data[pos + 2], data[pos + 3], data[pos + 4], data[pos + 5]])
467 as usize;
468 pos += 6;
469
470 if pos + record_size > data.len() {
471 break;
472 }
473
474 let record_data = &data[pos..pos + record_size];
475
476 match record_id {
477 0x0003 => {
479 if record_size >= 2 {
480 codepage = u16::from_le_bytes([record_data[0], record_data[1]]);
481 }
482 }
483 0x0019 => {
485 if in_module {
486 if let (Some(name), Some(stream)) =
488 (current_name.take(), current_stream_name.take())
489 {
490 let refined_type = refine_module_type(¤t_type, &name);
491 modules.push(ModuleEntry {
492 name,
493 stream_name: stream,
494 text_offset: current_offset,
495 module_type: refined_type,
496 });
497 }
498 }
499 in_module = true;
500 current_name = Some(String::from_utf8_lossy(record_data).into_owned());
501 current_stream_name = None;
502 current_offset = 0;
503 current_type = VbaModuleType::Standard;
504 }
505 0x0047 => {
507 if record_size >= 2 {
511 let even_len = record_data.len() & !1;
512 let u16_data: Vec<u16> = record_data[..even_len]
513 .chunks_exact(2)
514 .map(|c| u16::from_le_bytes([c[0], c[1]]))
515 .collect();
516 let name = String::from_utf16_lossy(&u16_data);
517 let name = name.trim_end_matches('\0').to_string();
519 if !name.is_empty() {
520 current_name = Some(name);
521 }
522 }
523 }
524 0x001A => {
526 current_stream_name = Some(String::from_utf8_lossy(record_data).into_owned());
527 if pos + record_size + 6 <= data.len() {
530 let next_id =
531 u16::from_le_bytes([data[pos + record_size], data[pos + record_size + 1]]);
532 if next_id == 0x0032 {
533 let next_size = u32::from_le_bytes([
534 data[pos + record_size + 2],
535 data[pos + record_size + 3],
536 data[pos + record_size + 4],
537 data[pos + record_size + 5],
538 ]) as usize;
539 pos += record_size + 6 + next_size;
541 continue;
542 }
543 }
544 }
545 0x0031 => {
547 if record_size >= 4 {
548 current_offset = u32::from_le_bytes([
549 record_data[0],
550 record_data[1],
551 record_data[2],
552 record_data[3],
553 ]);
554 }
555 }
556 0x0021 => {
558 current_type = VbaModuleType::Standard;
559 }
560 0x0022 => {
562 current_type = VbaModuleType::Class;
567 }
568 0x002B => {
570 }
572 _ => {}
573 }
574
575 pos += record_size;
576 }
577
578 if in_module {
580 if let (Some(name), Some(stream)) = (current_name, current_stream_name) {
581 let refined_type = refine_module_type(¤t_type, &name);
582 modules.push(ModuleEntry {
583 name,
584 stream_name: stream,
585 text_offset: current_offset,
586 module_type: refined_type,
587 });
588 }
589 }
590
591 Ok(DirInfo {
592 entries: modules,
593 codepage,
594 })
595}
596
597fn refine_module_type(base_type: &VbaModuleType, name: &str) -> VbaModuleType {
600 if *base_type == VbaModuleType::Standard {
601 return VbaModuleType::Standard;
602 }
603 let name_lower = name.to_lowercase();
604 if name_lower == "thisworkbook" {
605 VbaModuleType::ThisWorkbook
606 } else if name_lower.starts_with("sheet") {
607 VbaModuleType::Document
608 } else {
609 VbaModuleType::Class
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
619 fn test_decompress_empty_input() {
620 let result = decompress_vba_stream(&[]);
621 assert!(result.is_ok());
622 assert!(result.unwrap().is_empty());
623 }
624
625 #[test]
626 fn test_decompress_invalid_signature() {
627 let result = decompress_vba_stream(&[0x00, 0x01, 0x02]);
628 assert!(result.is_err());
629 let err_msg = result.unwrap_err().to_string();
630 assert!(err_msg.contains("invalid VBA compression signature"));
631 }
632
633 #[test]
634 fn test_decompress_uncompressed_chunk() {
635 let mut data = vec![0x01]; let header: u16 = 0x0001; data.extend_from_slice(&header.to_le_bytes());
647 data.extend_from_slice(b"AB");
648 let result = decompress_vba_stream(&data).unwrap();
650 assert_eq!(&result, b"AB");
651 }
652
653 #[test]
654 fn test_decompress_real_compressed_data() {
655 let mut compressed = vec![0x01u8];
667 let flag = 0x02u8; let literal = b'a';
675 let copy_token: u16 = 0x0000; let mut chunk_payload = Vec::new();
678 chunk_payload.push(flag);
679 chunk_payload.push(literal);
680 chunk_payload.extend_from_slice(©_token.to_le_bytes());
681
682 let chunk_size = chunk_payload.len() + 2; let header: u16 = 0x8000 | ((chunk_size as u16 - 3) & 0x0FFF);
684 compressed.extend_from_slice(&header.to_le_bytes());
685 compressed.extend_from_slice(&chunk_payload);
686
687 let result = decompress_vba_stream(&compressed).unwrap();
688 assert_eq!(&result, b"aaaa"); }
690
691 #[test]
692 fn test_max_bit_count() {
693 assert_eq!(max_bit_count(0), 12);
694 assert_eq!(max_bit_count(1), 12);
695 assert_eq!(max_bit_count(16), 12);
696 assert_eq!(max_bit_count(17), 11);
697 assert_eq!(max_bit_count(32), 11);
698 assert_eq!(max_bit_count(33), 10);
699 assert_eq!(max_bit_count(64), 10);
700 assert_eq!(max_bit_count(65), 9);
701 assert_eq!(max_bit_count(128), 9);
702 assert_eq!(max_bit_count(129), 8);
703 assert_eq!(max_bit_count(256), 8);
704 assert_eq!(max_bit_count(257), 7);
705 assert_eq!(max_bit_count(512), 7);
706 assert_eq!(max_bit_count(513), 6);
707 assert_eq!(max_bit_count(1024), 6);
708 assert_eq!(max_bit_count(1025), 5);
709 assert_eq!(max_bit_count(2048), 5);
710 assert_eq!(max_bit_count(2049), 4);
711 assert_eq!(max_bit_count(4096), 4);
712 }
713
714 #[test]
715 fn test_parse_dir_stream_empty() {
716 let result = parse_dir_stream(&[]);
717 assert!(result.is_ok());
718 let info = result.unwrap();
719 assert!(info.entries.is_empty());
720 assert_eq!(info.codepage, 1252);
721 }
722
723 #[test]
724 fn test_extract_vba_modules_invalid_cfb() {
725 let result = extract_vba_modules(b"not a CFB file");
726 assert!(result.is_err());
727 let err_msg = result.unwrap_err().to_string();
728 assert!(err_msg.contains("failed to open VBA project as CFB"));
729 }
730
731 #[test]
732 fn test_vba_module_type_clone() {
733 let t = VbaModuleType::Standard;
734 let t2 = t.clone();
735 assert_eq!(t, t2);
736 }
737
738 #[test]
739 fn test_vba_module_debug() {
740 let m = VbaModule {
741 name: "Module1".to_string(),
742 source_code: "Sub Test()\nEnd Sub".to_string(),
743 module_type: VbaModuleType::Standard,
744 };
745 let debug = format!("{:?}", m);
746 assert!(debug.contains("Module1"));
747 }
748
749 #[test]
750 fn test_vba_roundtrip_with_xlsm() {
751 use std::io::{Read as _, Write as _};
752
753 let vba_bin = build_test_vba_project();
755
756 let base_wb = crate::workbook::Workbook::new();
758 let base_buf = base_wb.save_to_buffer().unwrap();
759
760 let mut buf = Vec::new();
762 {
763 let base_cursor = std::io::Cursor::new(&base_buf);
764 let mut base_archive = zip::ZipArchive::new(base_cursor).unwrap();
765
766 let out_cursor = std::io::Cursor::new(&mut buf);
767 let mut zip = zip::ZipWriter::new(out_cursor);
768 let options = zip::write::SimpleFileOptions::default()
769 .compression_method(zip::CompressionMethod::Deflated);
770
771 for i in 0..base_archive.len() {
772 let mut entry = base_archive.by_index(i).unwrap();
773 let name = entry.name().to_string();
774 zip.start_file(&name, options).unwrap();
775 let mut data = Vec::new();
776 entry.read_to_end(&mut data).unwrap();
777 zip.write_all(&data).unwrap();
778 }
779
780 zip.start_file("xl/vbaProject.bin", options).unwrap();
781 zip.write_all(&vba_bin).unwrap();
782 zip.finish().unwrap();
783 }
784
785 let wb = crate::workbook::Workbook::open_from_buffer(&buf).unwrap();
787
788 let raw = wb.get_vba_project();
790 assert!(raw.is_some(), "VBA project binary should be present");
791 assert_eq!(raw.unwrap(), vba_bin);
792 }
793
794 #[test]
795 fn test_xlsx_without_vba_returns_none() {
796 let wb = crate::workbook::Workbook::new();
797 assert!(wb.get_vba_project().is_none());
798 assert!(wb.get_vba_modules().unwrap().is_none());
799 }
800
801 #[test]
802 fn test_xlsx_roundtrip_no_vba() {
803 let wb = crate::workbook::Workbook::new();
804 let buf = wb.save_to_buffer().unwrap();
805 let wb2 = crate::workbook::Workbook::open_from_buffer(&buf).unwrap();
806 assert!(wb2.get_vba_project().is_none());
807 }
808
809 #[test]
810 fn test_get_vba_modules_from_test_project() {
811 use std::io::{Read as _, Write as _};
812
813 let vba_bin = build_test_vba_project();
814
815 let base_wb = crate::workbook::Workbook::new();
817 let base_buf = base_wb.save_to_buffer().unwrap();
818
819 let mut buf = Vec::new();
820 {
821 let base_cursor = std::io::Cursor::new(&base_buf);
822 let mut base_archive = zip::ZipArchive::new(base_cursor).unwrap();
823
824 let out_cursor = std::io::Cursor::new(&mut buf);
825 let mut zip = zip::ZipWriter::new(out_cursor);
826 let options = zip::write::SimpleFileOptions::default()
827 .compression_method(zip::CompressionMethod::Deflated);
828
829 for i in 0..base_archive.len() {
830 let mut entry = base_archive.by_index(i).unwrap();
831 let name = entry.name().to_string();
832 zip.start_file(&name, options).unwrap();
833 let mut data = Vec::new();
834 entry.read_to_end(&mut data).unwrap();
835 zip.write_all(&data).unwrap();
836 }
837
838 zip.start_file("xl/vbaProject.bin", options).unwrap();
839 zip.write_all(&vba_bin).unwrap();
840 zip.finish().unwrap();
841 }
842
843 let wb = crate::workbook::Workbook::open_from_buffer(&buf).unwrap();
844 let project = wb.get_vba_modules().unwrap();
845 assert!(project.is_some(), "should have VBA modules");
846 let project = project.unwrap();
847 assert_eq!(project.modules.len(), 1);
848 assert_eq!(project.modules[0].name, "Module1");
849 assert_eq!(project.modules[0].module_type, VbaModuleType::Standard);
850 assert!(
851 project.modules[0].source_code.contains("Sub Hello()"),
852 "source should contain Sub Hello(), got: {}",
853 project.modules[0].source_code
854 );
855 }
856
857 #[test]
858 fn test_vba_project_preserved_in_save_roundtrip() {
859 use std::io::{Read as _, Write as _};
860
861 let vba_bin = build_test_vba_project();
862
863 let base_wb = crate::workbook::Workbook::new();
864 let base_buf = base_wb.save_to_buffer().unwrap();
865
866 let mut buf = Vec::new();
867 {
868 let base_cursor = std::io::Cursor::new(&base_buf);
869 let mut base_archive = zip::ZipArchive::new(base_cursor).unwrap();
870
871 let out_cursor = std::io::Cursor::new(&mut buf);
872 let mut zip = zip::ZipWriter::new(out_cursor);
873 let options = zip::write::SimpleFileOptions::default()
874 .compression_method(zip::CompressionMethod::Deflated);
875
876 for i in 0..base_archive.len() {
877 let mut entry = base_archive.by_index(i).unwrap();
878 let name = entry.name().to_string();
879 zip.start_file(&name, options).unwrap();
880 let mut data = Vec::new();
881 entry.read_to_end(&mut data).unwrap();
882 zip.write_all(&data).unwrap();
883 }
884
885 zip.start_file("xl/vbaProject.bin", options).unwrap();
886 zip.write_all(&vba_bin).unwrap();
887 zip.finish().unwrap();
888 }
889
890 let wb = crate::workbook::Workbook::open_from_buffer(&buf).unwrap();
892 let saved_buf = wb.save_to_buffer().unwrap();
893
894 let wb2 = crate::workbook::Workbook::open_from_buffer(&saved_buf).unwrap();
896 let raw = wb2.get_vba_project();
897 assert!(raw.is_some(), "VBA project should survive save roundtrip");
898 assert_eq!(raw.unwrap(), vba_bin);
899
900 let project = wb2.get_vba_modules().unwrap().unwrap();
902 assert_eq!(project.modules.len(), 1);
903 assert_eq!(project.modules[0].name, "Module1");
904 }
905
906 fn build_test_vba_project() -> Vec<u8> {
908 let mut buf = Vec::new();
909 let cursor = std::io::Cursor::new(&mut buf);
910 let mut cfb = cfb::CompoundFile::create(cursor).unwrap();
911
912 cfb.create_storage("/VBA").unwrap();
914
915 let dir_data = build_minimal_dir_stream("Module1");
917
918 let compressed_dir = compress_for_test(&dir_data);
920
921 {
923 let mut stream = cfb.create_stream("/VBA/dir").unwrap();
924 std::io::Write::write_all(&mut stream, &compressed_dir).unwrap();
925 }
926
927 let source = b"Sub Hello()\r\nEnd Sub\r\n";
929 let compressed_source = compress_for_test(source);
930
931 {
934 let mut stream = cfb.create_stream("/VBA/Module1").unwrap();
935 std::io::Write::write_all(&mut stream, &compressed_source).unwrap();
936 }
937
938 {
940 let mut stream = cfb.create_stream("/VBA/_VBA_PROJECT").unwrap();
941 let header = [0xCC, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00];
943 std::io::Write::write_all(&mut stream, &header).unwrap();
944 }
945
946 cfb.flush().unwrap();
947 buf
948 }
949
950 fn build_minimal_dir_stream(module_name: &str) -> Vec<u8> {
952 let mut data = Vec::new();
953 let name_bytes = module_name.as_bytes();
954
955 write_dir_record(&mut data, 0x0001, &1u32.to_le_bytes());
957
958 write_dir_record(&mut data, 0x0002, &0x0409u32.to_le_bytes());
960
961 write_dir_record(&mut data, 0x0014, &0x0409u32.to_le_bytes());
963
964 write_dir_record(&mut data, 0x0003, &1252u16.to_le_bytes());
966
967 write_dir_record(&mut data, 0x0004, b"VBAProject");
969
970 write_dir_record(&mut data, 0x0005, &[]);
972 write_dir_record(&mut data, 0x0040, &[]);
974
975 write_dir_record(&mut data, 0x0006, &[]);
977 write_dir_record(&mut data, 0x003D, &[]);
979
980 write_dir_record(&mut data, 0x0007, &0u32.to_le_bytes());
982
983 write_dir_record(&mut data, 0x0008, &0u32.to_le_bytes());
985
986 let mut version = Vec::new();
988 version.extend_from_slice(&1u32.to_le_bytes());
989 version.extend_from_slice(&0u16.to_le_bytes());
990 write_dir_record(&mut data, 0x0009, &version);
992
993 write_dir_record(&mut data, 0x000C, &[]);
995 write_dir_record(&mut data, 0x003C, &[]);
997
998 let module_count: u16 = 1;
1000 write_dir_record(&mut data, 0x000F, &module_count.to_le_bytes());
1001
1002 write_dir_record(&mut data, 0x0013, &0u16.to_le_bytes());
1004
1005 write_dir_record(&mut data, 0x0019, name_bytes);
1007
1008 write_dir_record(&mut data, 0x001A, name_bytes);
1010 let name_utf16: Vec<u8> = module_name
1012 .encode_utf16()
1013 .flat_map(|c| c.to_le_bytes())
1014 .collect();
1015 write_dir_record(&mut data, 0x0032, &name_utf16);
1016
1017 write_dir_record(&mut data, 0x0031, &0u32.to_le_bytes());
1019
1020 write_dir_record(&mut data, 0x0021, &[]);
1022
1023 write_dir_record(&mut data, 0x002B, &[]);
1025
1026 write_dir_record(&mut data, 0x0010, &[]);
1029
1030 data
1031 }
1032
1033 fn write_dir_record(buf: &mut Vec<u8>, id: u16, data: &[u8]) {
1034 buf.extend_from_slice(&id.to_le_bytes());
1035 buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
1036 buf.extend_from_slice(data);
1037 }
1038
1039 fn compress_for_test(data: &[u8]) -> Vec<u8> {
1042 let mut result = vec![0x01u8]; let mut pos = 0;
1044 while pos < data.len() {
1045 let chunk_len = (data.len() - pos).min(4096);
1046 let chunk_data = &data[pos..pos + chunk_len];
1047 let header: u16 = (chunk_len as u16 + 2).wrapping_sub(3) & 0x0FFF;
1049 result.extend_from_slice(&header.to_le_bytes());
1050 result.extend_from_slice(chunk_data);
1051 for _ in chunk_len..4096 {
1053 result.push(0x00);
1054 }
1055 pos += chunk_len;
1056 }
1057 result
1058 }
1059}