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)]
615#[allow(clippy::same_item_push)]
616mod tests {
617 use super::*;
618
619 #[test]
620 fn test_decompress_empty_input() {
621 let result = decompress_vba_stream(&[]);
622 assert!(result.is_ok());
623 assert!(result.unwrap().is_empty());
624 }
625
626 #[test]
627 fn test_decompress_invalid_signature() {
628 let result = decompress_vba_stream(&[0x00, 0x01, 0x02]);
629 assert!(result.is_err());
630 let err_msg = result.unwrap_err().to_string();
631 assert!(err_msg.contains("invalid VBA compression signature"));
632 }
633
634 #[test]
635 fn test_decompress_uncompressed_chunk() {
636 let mut data = vec![0x01]; let header: u16 = 0x0001; data.extend_from_slice(&header.to_le_bytes());
648 data.extend_from_slice(b"AB");
649 let result = decompress_vba_stream(&data).unwrap();
651 assert_eq!(&result, b"AB");
652 }
653
654 #[test]
655 fn test_decompress_real_compressed_data() {
656 let mut compressed = vec![0x01u8];
668 let flag = 0x02u8; let literal = b'a';
676 let copy_token: u16 = 0x0000; let mut chunk_payload = Vec::new();
679 chunk_payload.push(flag);
680 chunk_payload.push(literal);
681 chunk_payload.extend_from_slice(©_token.to_le_bytes());
682
683 let chunk_size = chunk_payload.len() + 2; let header: u16 = 0x8000 | ((chunk_size as u16 - 3) & 0x0FFF);
685 compressed.extend_from_slice(&header.to_le_bytes());
686 compressed.extend_from_slice(&chunk_payload);
687
688 let result = decompress_vba_stream(&compressed).unwrap();
689 assert_eq!(&result, b"aaaa"); }
691
692 #[test]
693 fn test_max_bit_count() {
694 assert_eq!(max_bit_count(0), 12);
695 assert_eq!(max_bit_count(1), 12);
696 assert_eq!(max_bit_count(16), 12);
697 assert_eq!(max_bit_count(17), 11);
698 assert_eq!(max_bit_count(32), 11);
699 assert_eq!(max_bit_count(33), 10);
700 assert_eq!(max_bit_count(64), 10);
701 assert_eq!(max_bit_count(65), 9);
702 assert_eq!(max_bit_count(128), 9);
703 assert_eq!(max_bit_count(129), 8);
704 assert_eq!(max_bit_count(256), 8);
705 assert_eq!(max_bit_count(257), 7);
706 assert_eq!(max_bit_count(512), 7);
707 assert_eq!(max_bit_count(513), 6);
708 assert_eq!(max_bit_count(1024), 6);
709 assert_eq!(max_bit_count(1025), 5);
710 assert_eq!(max_bit_count(2048), 5);
711 assert_eq!(max_bit_count(2049), 4);
712 assert_eq!(max_bit_count(4096), 4);
713 }
714
715 #[test]
716 fn test_parse_dir_stream_empty() {
717 let result = parse_dir_stream(&[]);
718 assert!(result.is_ok());
719 let info = result.unwrap();
720 assert!(info.entries.is_empty());
721 assert_eq!(info.codepage, 1252);
722 }
723
724 #[test]
725 fn test_extract_vba_modules_invalid_cfb() {
726 let result = extract_vba_modules(b"not a CFB file");
727 assert!(result.is_err());
728 let err_msg = result.unwrap_err().to_string();
729 assert!(err_msg.contains("failed to open VBA project as CFB"));
730 }
731
732 #[test]
733 fn test_vba_module_type_clone() {
734 let t = VbaModuleType::Standard;
735 let t2 = t.clone();
736 assert_eq!(t, t2);
737 }
738
739 #[test]
740 fn test_vba_module_debug() {
741 let m = VbaModule {
742 name: "Module1".to_string(),
743 source_code: "Sub Test()\nEnd Sub".to_string(),
744 module_type: VbaModuleType::Standard,
745 };
746 let debug = format!("{:?}", m);
747 assert!(debug.contains("Module1"));
748 }
749
750 #[test]
751 fn test_vba_roundtrip_with_xlsm() {
752 use std::io::{Read as _, Write as _};
753
754 let vba_bin = build_test_vba_project();
756
757 let base_wb = crate::workbook::Workbook::new();
759 let base_buf = base_wb.save_to_buffer().unwrap();
760
761 let mut buf = Vec::new();
763 {
764 let base_cursor = std::io::Cursor::new(&base_buf);
765 let mut base_archive = zip::ZipArchive::new(base_cursor).unwrap();
766
767 let out_cursor = std::io::Cursor::new(&mut buf);
768 let mut zip = zip::ZipWriter::new(out_cursor);
769 let options = zip::write::SimpleFileOptions::default()
770 .compression_method(zip::CompressionMethod::Deflated);
771
772 for i in 0..base_archive.len() {
773 let mut entry = base_archive.by_index(i).unwrap();
774 let name = entry.name().to_string();
775 zip.start_file(&name, options).unwrap();
776 let mut data = Vec::new();
777 entry.read_to_end(&mut data).unwrap();
778 zip.write_all(&data).unwrap();
779 }
780
781 zip.start_file("xl/vbaProject.bin", options).unwrap();
782 zip.write_all(&vba_bin).unwrap();
783 zip.finish().unwrap();
784 }
785
786 let opts = crate::workbook::OpenOptions::new()
788 .read_mode(crate::workbook::ReadMode::Eager)
789 .aux_parts(crate::workbook::AuxParts::EagerLoad);
790 let wb = crate::workbook::Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
791
792 let raw = wb.get_vba_project();
794 assert!(raw.is_some(), "VBA project binary should be present");
795 assert_eq!(raw.unwrap(), vba_bin);
796 }
797
798 #[test]
799 fn test_xlsx_without_vba_returns_none() {
800 let wb = crate::workbook::Workbook::new();
801 assert!(wb.get_vba_project().is_none());
802 assert!(wb.get_vba_modules().unwrap().is_none());
803 }
804
805 #[test]
806 fn test_xlsx_roundtrip_no_vba() {
807 let wb = crate::workbook::Workbook::new();
808 let buf = wb.save_to_buffer().unwrap();
809 let wb2 = crate::workbook::Workbook::open_from_buffer(&buf).unwrap();
810 assert!(wb2.get_vba_project().is_none());
811 }
812
813 #[test]
814 fn test_get_vba_modules_from_test_project() {
815 use std::io::{Read as _, Write as _};
816
817 let vba_bin = build_test_vba_project();
818
819 let base_wb = crate::workbook::Workbook::new();
821 let base_buf = base_wb.save_to_buffer().unwrap();
822
823 let mut buf = Vec::new();
824 {
825 let base_cursor = std::io::Cursor::new(&base_buf);
826 let mut base_archive = zip::ZipArchive::new(base_cursor).unwrap();
827
828 let out_cursor = std::io::Cursor::new(&mut buf);
829 let mut zip = zip::ZipWriter::new(out_cursor);
830 let options = zip::write::SimpleFileOptions::default()
831 .compression_method(zip::CompressionMethod::Deflated);
832
833 for i in 0..base_archive.len() {
834 let mut entry = base_archive.by_index(i).unwrap();
835 let name = entry.name().to_string();
836 zip.start_file(&name, options).unwrap();
837 let mut data = Vec::new();
838 entry.read_to_end(&mut data).unwrap();
839 zip.write_all(&data).unwrap();
840 }
841
842 zip.start_file("xl/vbaProject.bin", options).unwrap();
843 zip.write_all(&vba_bin).unwrap();
844 zip.finish().unwrap();
845 }
846
847 let opts = crate::workbook::OpenOptions::new()
848 .read_mode(crate::workbook::ReadMode::Eager)
849 .aux_parts(crate::workbook::AuxParts::EagerLoad);
850 let wb = crate::workbook::Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
851 let project = wb.get_vba_modules().unwrap();
852 assert!(project.is_some(), "should have VBA modules");
853 let project = project.unwrap();
854 assert_eq!(project.modules.len(), 1);
855 assert_eq!(project.modules[0].name, "Module1");
856 assert_eq!(project.modules[0].module_type, VbaModuleType::Standard);
857 assert!(
858 project.modules[0].source_code.contains("Sub Hello()"),
859 "source should contain Sub Hello(), got: {}",
860 project.modules[0].source_code
861 );
862 }
863
864 #[test]
865 fn test_vba_project_preserved_in_save_roundtrip() {
866 use std::io::{Read as _, Write as _};
867
868 let vba_bin = build_test_vba_project();
869
870 let base_wb = crate::workbook::Workbook::new();
871 let base_buf = base_wb.save_to_buffer().unwrap();
872
873 let mut buf = Vec::new();
874 {
875 let base_cursor = std::io::Cursor::new(&base_buf);
876 let mut base_archive = zip::ZipArchive::new(base_cursor).unwrap();
877
878 let out_cursor = std::io::Cursor::new(&mut buf);
879 let mut zip = zip::ZipWriter::new(out_cursor);
880 let options = zip::write::SimpleFileOptions::default()
881 .compression_method(zip::CompressionMethod::Deflated);
882
883 for i in 0..base_archive.len() {
884 let mut entry = base_archive.by_index(i).unwrap();
885 let name = entry.name().to_string();
886 zip.start_file(&name, options).unwrap();
887 let mut data = Vec::new();
888 entry.read_to_end(&mut data).unwrap();
889 zip.write_all(&data).unwrap();
890 }
891
892 zip.start_file("xl/vbaProject.bin", options).unwrap();
893 zip.write_all(&vba_bin).unwrap();
894 zip.finish().unwrap();
895 }
896
897 let opts = crate::workbook::OpenOptions::new()
899 .read_mode(crate::workbook::ReadMode::Eager)
900 .aux_parts(crate::workbook::AuxParts::EagerLoad);
901 let wb = crate::workbook::Workbook::open_from_buffer_with_options(&buf, &opts).unwrap();
902 let saved_buf = wb.save_to_buffer().unwrap();
903
904 let wb2 =
906 crate::workbook::Workbook::open_from_buffer_with_options(&saved_buf, &opts).unwrap();
907 let raw = wb2.get_vba_project();
908 assert!(raw.is_some(), "VBA project should survive save roundtrip");
909 assert_eq!(raw.unwrap(), vba_bin);
910
911 let project = wb2.get_vba_modules().unwrap().unwrap();
913 assert_eq!(project.modules.len(), 1);
914 assert_eq!(project.modules[0].name, "Module1");
915 }
916
917 fn build_test_vba_project() -> Vec<u8> {
919 let mut buf = Vec::new();
920 let cursor = std::io::Cursor::new(&mut buf);
921 let mut cfb = cfb::CompoundFile::create(cursor).unwrap();
922
923 cfb.create_storage("/VBA").unwrap();
925
926 let dir_data = build_minimal_dir_stream("Module1");
928
929 let compressed_dir = compress_for_test(&dir_data);
931
932 {
934 let mut stream = cfb.create_stream("/VBA/dir").unwrap();
935 std::io::Write::write_all(&mut stream, &compressed_dir).unwrap();
936 }
937
938 let source = b"Sub Hello()\r\nEnd Sub\r\n";
940 let compressed_source = compress_for_test(source);
941
942 {
945 let mut stream = cfb.create_stream("/VBA/Module1").unwrap();
946 std::io::Write::write_all(&mut stream, &compressed_source).unwrap();
947 }
948
949 {
951 let mut stream = cfb.create_stream("/VBA/_VBA_PROJECT").unwrap();
952 let header = [0xCC, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00];
954 std::io::Write::write_all(&mut stream, &header).unwrap();
955 }
956
957 cfb.flush().unwrap();
958 buf
959 }
960
961 fn build_minimal_dir_stream(module_name: &str) -> Vec<u8> {
963 let mut data = Vec::new();
964 let name_bytes = module_name.as_bytes();
965
966 write_dir_record(&mut data, 0x0001, &1u32.to_le_bytes());
968
969 write_dir_record(&mut data, 0x0002, &0x0409u32.to_le_bytes());
971
972 write_dir_record(&mut data, 0x0014, &0x0409u32.to_le_bytes());
974
975 write_dir_record(&mut data, 0x0003, &1252u16.to_le_bytes());
977
978 write_dir_record(&mut data, 0x0004, b"VBAProject");
980
981 write_dir_record(&mut data, 0x0005, &[]);
983 write_dir_record(&mut data, 0x0040, &[]);
985
986 write_dir_record(&mut data, 0x0006, &[]);
988 write_dir_record(&mut data, 0x003D, &[]);
990
991 write_dir_record(&mut data, 0x0007, &0u32.to_le_bytes());
993
994 write_dir_record(&mut data, 0x0008, &0u32.to_le_bytes());
996
997 let mut version = Vec::new();
999 version.extend_from_slice(&1u32.to_le_bytes());
1000 version.extend_from_slice(&0u16.to_le_bytes());
1001 write_dir_record(&mut data, 0x0009, &version);
1003
1004 write_dir_record(&mut data, 0x000C, &[]);
1006 write_dir_record(&mut data, 0x003C, &[]);
1008
1009 let module_count: u16 = 1;
1011 write_dir_record(&mut data, 0x000F, &module_count.to_le_bytes());
1012
1013 write_dir_record(&mut data, 0x0013, &0u16.to_le_bytes());
1015
1016 write_dir_record(&mut data, 0x0019, name_bytes);
1018
1019 write_dir_record(&mut data, 0x001A, name_bytes);
1021 let name_utf16: Vec<u8> = module_name
1023 .encode_utf16()
1024 .flat_map(|c| c.to_le_bytes())
1025 .collect();
1026 write_dir_record(&mut data, 0x0032, &name_utf16);
1027
1028 write_dir_record(&mut data, 0x0031, &0u32.to_le_bytes());
1030
1031 write_dir_record(&mut data, 0x0021, &[]);
1033
1034 write_dir_record(&mut data, 0x002B, &[]);
1036
1037 write_dir_record(&mut data, 0x0010, &[]);
1040
1041 data
1042 }
1043
1044 fn write_dir_record(buf: &mut Vec<u8>, id: u16, data: &[u8]) {
1045 buf.extend_from_slice(&id.to_le_bytes());
1046 buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
1047 buf.extend_from_slice(data);
1048 }
1049
1050 fn compress_for_test(data: &[u8]) -> Vec<u8> {
1053 let mut result = vec![0x01u8]; let mut pos = 0;
1055 while pos < data.len() {
1056 let chunk_len = (data.len() - pos).min(4096);
1057 let chunk_data = &data[pos..pos + chunk_len];
1058 let header: u16 = (chunk_len as u16 + 2).wrapping_sub(3) & 0x0FFF;
1060 result.extend_from_slice(&header.to_le_bytes());
1061 result.extend_from_slice(chunk_data);
1062 for _ in chunk_len..4096 {
1064 result.push(0x00);
1065 }
1066 pos += chunk_len;
1067 }
1068 result
1069 }
1070}