Change form module export from .cls to .frm extension to match standard VBA file conventions and align with import_vba.py expectations. This eliminates ambiguity between class modules and form modules. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
438 lines
15 KiB
Python
438 lines
15 KiB
Python
"""
|
||
VBA代码提取工具
|
||
从xlsm文件中提取模块和类模块代码,分类保存到VBA文件夹
|
||
自动清理Attribute信息并生成元数据JSON文件
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Tuple, Dict
|
||
|
||
# 加载 .env 配置文件
|
||
try:
|
||
from dotenv import load_dotenv
|
||
load_dotenv()
|
||
except ImportError:
|
||
print("警告: 未安装 python-dotenv 库,将使用默认配置")
|
||
print("建议运行: pip install python-dotenv")
|
||
|
||
# ==================== 配置区域 ====================
|
||
# 从 .env 文件读取配置,如果未设置则使用 None(交互模式)
|
||
TARGET_XLSM_FILE = os.getenv("TARGET_XLSM_FILE", "").strip() or None
|
||
# VBA代码输出目录(如果未设置,则使用源文件同目录下的VBA文件夹)
|
||
VBA_OUTPUT_DIR = os.getenv("VBA_OUTPUT_DIR", "").strip() or None
|
||
# =================================================
|
||
|
||
# VBA项目相关常量
|
||
STANDARD_MODULE_DIR = "Modules"
|
||
CLASS_MODULE_DIR = "ClassModules"
|
||
DOCUMENT_MODULE_DIR = "DocumentModules"
|
||
FORMS_DIR = "Forms"
|
||
|
||
|
||
class VBAExtractor:
|
||
"""VBA代码提取器"""
|
||
|
||
def __init__(self, xlsm_path: str, output_dir: str = None):
|
||
"""
|
||
初始化VBA提取器
|
||
|
||
Args:
|
||
xlsm_path: xlsm文件路径
|
||
output_dir: 输出目录(如果未指定,则使用VBA_OUTPUT_DIR配置或源文件同目录)
|
||
"""
|
||
self.xlsm_path = Path(xlsm_path)
|
||
|
||
# 确定输出目录的优先级:
|
||
# 1. 参数指定的 output_dir
|
||
# 2. 环境变量配置的 VBA_OUTPUT_DIR
|
||
# 3. 默认:源文件同目录下的VBA文件夹
|
||
if output_dir is not None:
|
||
self.output_dir = Path(output_dir)
|
||
elif VBA_OUTPUT_DIR is not None:
|
||
self.output_dir = Path(VBA_OUTPUT_DIR)
|
||
else:
|
||
# 使用目标文件同目录下的VBA文件夹
|
||
self.output_dir = self.xlsm_path.parent / "VBA"
|
||
|
||
# 创建输出目录结构
|
||
self.modules_dir = self.output_dir / STANDARD_MODULE_DIR
|
||
self.class_modules_dir = self.output_dir / CLASS_MODULE_DIR
|
||
self.document_modules_dir = self.output_dir / DOCUMENT_MODULE_DIR
|
||
self.forms_dir = self.output_dir / FORMS_DIR
|
||
|
||
self.modules_dir.mkdir(parents=True, exist_ok=True)
|
||
self.class_modules_dir.mkdir(parents=True, exist_ok=True)
|
||
self.document_modules_dir.mkdir(parents=True, exist_ok=True)
|
||
self.forms_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
def parse_attributes(self, code: str) -> Tuple[Dict[str, str], str]:
|
||
"""
|
||
解析VBA代码中的Attribute信息
|
||
|
||
Args:
|
||
code: VBA代码(包含Attribute行)
|
||
|
||
Returns:
|
||
(attributes_dict, clean_code) - 属性字典和清理后的代码
|
||
"""
|
||
attributes = {}
|
||
|
||
# [修复] 使用 splitlines() 自动处理 \r\n,避免保留 \r 导致的多余空行
|
||
lines = code.splitlines()
|
||
|
||
clean_lines = []
|
||
in_attributes = True
|
||
|
||
for line in lines:
|
||
# 检查是否为Attribute行
|
||
attr_match = re.match(r'^Attribute\s+(\w+)\s*=\s*(.+)$', line.strip())
|
||
if attr_match:
|
||
attr_name = attr_match.group(1)
|
||
attr_value = attr_match.group(2).strip().strip('"')
|
||
attributes[attr_name] = attr_value
|
||
# 继续收集Attribute,暂不添加到clean_lines
|
||
continue
|
||
|
||
# 遇到非Attribute行,Attribute收集结束
|
||
if not line.strip().startswith('Attribute'):
|
||
in_attributes = False
|
||
|
||
# 添加到清理后的代码(跳过空行和Attribute)
|
||
if not in_attributes or (line.strip() and not line.strip().startswith('Attribute')):
|
||
if not in_attributes:
|
||
# 使用 rstrip() 去除行尾可能存在的空白符,保持代码整洁
|
||
clean_lines.append(line.rstrip())
|
||
|
||
# 去除开头的空行
|
||
while clean_lines and not clean_lines[0].strip():
|
||
clean_lines.pop(0)
|
||
|
||
clean_code = '\n'.join(clean_lines)
|
||
return attributes, clean_code
|
||
|
||
def extract_vba_modules_olevba(self):
|
||
"""
|
||
使用olevba库提取VBA代码
|
||
|
||
需要安装: pip install oletools
|
||
"""
|
||
try:
|
||
from oletools.olevba import VBA_Parser
|
||
except ImportError:
|
||
print("错误: 未安装oletools库")
|
||
print("请运行: pip install oletools")
|
||
return False
|
||
|
||
print(f"正在解析文件: {self.xlsm_path.name}")
|
||
|
||
try:
|
||
vba_parser = VBA_Parser(str(self.xlsm_path))
|
||
|
||
if vba_parser.detect_vba_macros():
|
||
print("发现VBA代码,开始提取...\n")
|
||
|
||
# 遍历所有VBA模块
|
||
for (filename, stream_path, vba_filename, vba_code) in vba_parser.extract_macros():
|
||
self._process_module(vba_filename, vba_code, stream_path)
|
||
|
||
vba_parser.close()
|
||
|
||
print(f"\n提取完成!")
|
||
print(f"- 标准模块: {self.modules_dir}")
|
||
print(f"- 类模块: {self.class_modules_dir}")
|
||
print(f"- 文档模块: {self.document_modules_dir}")
|
||
return True
|
||
else:
|
||
print("未在文件中发现VBA代码")
|
||
vba_parser.close()
|
||
return False
|
||
|
||
except Exception as e:
|
||
print(f"提取VBA代码时出错: {e}")
|
||
return False
|
||
|
||
def extract_vba_modules_com(self):
|
||
"""
|
||
使用COM接口提取VBA代码(需要安装Excel)
|
||
|
||
优点: 更可靠,支持更多特性
|
||
缺点: 需要安装Microsoft Excel
|
||
"""
|
||
try:
|
||
import win32com.client as win32
|
||
except ImportError:
|
||
print("错误: 未安装pywin32库")
|
||
print("请运行: pip install pywin32")
|
||
return False
|
||
|
||
print(f"正在使用COM接口解析: {self.xlsm_path.name}")
|
||
|
||
try:
|
||
excel = win32.Dispatch("Excel.Application")
|
||
excel.Visible = False
|
||
excel.DisplayAlerts = False
|
||
|
||
workbook = excel.Workbooks.Open(str(self.xlsm_path.absolute()))
|
||
|
||
# 获取VBA项目
|
||
if not workbook.VBProject:
|
||
print("错误: 无法访问VBA项目")
|
||
print("请确保: 1) Excel信任中心设置'信任对VBA工程对象模型的访问'")
|
||
print(" 2) 文件中包含VBA代码")
|
||
workbook.Close(False)
|
||
excel.Quit()
|
||
return False
|
||
|
||
vb_project = workbook.VBProject
|
||
|
||
print("开始提取VBA组件...\n")
|
||
|
||
# 遍历所有VBA组件
|
||
for component in vb_project.VBComponents:
|
||
module_name = component.Name
|
||
module_type = component.Type
|
||
|
||
# 获取代码
|
||
code_module = component.CodeModule
|
||
line_count = code_module.CountOfLines
|
||
|
||
if line_count > 0:
|
||
vba_code = code_module.Lines(1, line_count)
|
||
else:
|
||
vba_code = ""
|
||
|
||
# 根据类型分类保存
|
||
# 1=标准模块, 2=类模块, 3=MSForm, 11=Document/工作表/工作簿
|
||
type_name = {
|
||
1: STANDARD_MODULE_DIR,
|
||
2: CLASS_MODULE_DIR,
|
||
3: FORMS_DIR,
|
||
11: DOCUMENT_MODULE_DIR
|
||
}.get(module_type, "Unknown")
|
||
|
||
target_dir = {
|
||
1: self.modules_dir,
|
||
2: self.class_modules_dir,
|
||
3: self.forms_dir,
|
||
11: self.document_modules_dir
|
||
}.get(module_type, self.modules_dir)
|
||
|
||
target_dir.mkdir(exist_ok=True)
|
||
self._process_module(module_name, vba_code, type_name)
|
||
|
||
workbook.Close(False)
|
||
excel.Quit()
|
||
|
||
print(f"\n提取完成!")
|
||
print(f"- 标准模块: {self.modules_dir}")
|
||
print(f"- 类模块: {self.class_modules_dir}")
|
||
print(f"- 文档模块: {self.document_modules_dir}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"使用COM提取VBA代码时出错: {e}")
|
||
print("\n提示:")
|
||
print("1. 确保已安装Microsoft Excel")
|
||
print("2. 打开Excel -> 文件 -> 选项 -> 信任中心 -> 信任中心设置")
|
||
print("3. 勾选'信任对VBA工程对象模型的访问'")
|
||
try:
|
||
excel.Quit()
|
||
except:
|
||
pass
|
||
return False
|
||
|
||
def _determine_module_type(self, module_name: str, stream_path: str) -> str:
|
||
"""
|
||
根据模块名称和流路径确定模块类型
|
||
|
||
Args:
|
||
module_name: 模块名称
|
||
stream_path: 流路径
|
||
|
||
Returns:
|
||
模块类型: Modules, ClassModules, DocumentModules, Forms
|
||
"""
|
||
name_lower = module_name.lower()
|
||
|
||
# 工作表和工作簿模块
|
||
if name_lower.startswith('sheet') or name_lower == 'thisworkbook':
|
||
return DOCUMENT_MODULE_DIR
|
||
|
||
# 标准模块
|
||
if name_lower.startswith('mod_') or name_lower.startswith('mod'):
|
||
return STANDARD_MODULE_DIR
|
||
|
||
# 类模块
|
||
if name_lower.startswith('cls') or name_lower.startswith('class'):
|
||
return CLASS_MODULE_DIR
|
||
|
||
# 窗体模块
|
||
if name_lower.startswith('userform') or name_lower.startswith('frm_') or name_lower.startswith('frm'):
|
||
return FORMS_DIR
|
||
|
||
# 根据stream_path判断
|
||
if stream_path:
|
||
path_lower = stream_path.lower()
|
||
if 'sheet' in path_lower or 'thisworkbook' in path_lower:
|
||
return DOCUMENT_MODULE_DIR
|
||
elif 'class' in path_lower or 'cls' in path_lower:
|
||
return CLASS_MODULE_DIR
|
||
elif 'form' in path_lower or 'userform' in path_lower:
|
||
return FORMS_DIR
|
||
|
||
# 默认为标准模块
|
||
return STANDARD_MODULE_DIR
|
||
|
||
def _process_module(self, module_name: str, vba_code: str, category: str):
|
||
"""
|
||
处理模块:解析属性、清理代码、保存文件
|
||
|
||
Args:
|
||
module_name: 模块名称
|
||
vba_code: VBA代码内容
|
||
category: 模块类别(可能是stream_path或类型名称)
|
||
"""
|
||
# 解析Attribute信息
|
||
attributes, clean_code = self.parse_attributes(vba_code)
|
||
|
||
# 检查是否有实际代码内容(跳过空模块)
|
||
if not clean_code.strip():
|
||
print(f" [跳过] {module_name} - 无实际代码内容")
|
||
return
|
||
|
||
# 确定实际的模块类型
|
||
module_type = self._determine_module_type(module_name, category)
|
||
|
||
# 确定目标目录
|
||
target_dir = {
|
||
STANDARD_MODULE_DIR: self.modules_dir,
|
||
CLASS_MODULE_DIR: self.class_modules_dir,
|
||
DOCUMENT_MODULE_DIR: self.document_modules_dir,
|
||
FORMS_DIR: self.forms_dir
|
||
}.get(module_type, self.modules_dir)
|
||
|
||
# 确定文件扩展名
|
||
if module_type == FORMS_DIR:
|
||
ext = '.frm'
|
||
elif module_type in [CLASS_MODULE_DIR, DOCUMENT_MODULE_DIR]:
|
||
ext = '.cls'
|
||
else:
|
||
ext = '.bas'
|
||
|
||
# 清理文件名(移除已有扩展名)
|
||
clean_name = module_name.replace('/', '_').replace('\\', '_')
|
||
# 移除已存在的扩展名
|
||
for suffix in ['.cls', '.bas', '.frm']:
|
||
if clean_name.endswith(suffix):
|
||
clean_name = clean_name[:-len(suffix)]
|
||
break
|
||
clean_name += ext
|
||
|
||
# 保存清理后的代码
|
||
file_path = target_dir / clean_name
|
||
with open(file_path, 'w', encoding='utf-8') as f:
|
||
f.write(clean_code)
|
||
|
||
print(f" [OK] 已保存: {clean_name} ({module_type})")
|
||
|
||
|
||
def main():
|
||
"""主函数"""
|
||
print("=" * 60)
|
||
print("VBA代码提取工具")
|
||
print("=" * 60)
|
||
print()
|
||
|
||
# 检查是否配置了目标文件
|
||
if TARGET_XLSM_FILE and TARGET_XLSM_FILE.strip():
|
||
# 使用配置的文件路径
|
||
script_dir = Path(__file__).parent
|
||
target_path = Path(TARGET_XLSM_FILE)
|
||
|
||
# 如果是相对路径,则相对于脚本所在目录
|
||
if not target_path.is_absolute():
|
||
target_path = script_dir / target_path
|
||
|
||
if not target_path.exists():
|
||
print(f"错误: 配置的文件不存在: {target_path}")
|
||
return
|
||
|
||
if not target_path.suffix.lower() == '.xlsm':
|
||
print(f"警告: 文件扩展名不是.xlsm: {target_path.name}")
|
||
|
||
xlsm_file = target_path
|
||
print(f"使用配置文件: {xlsm_file.name}")
|
||
print()
|
||
else:
|
||
# 交互模式:查找xlsm文件
|
||
excel_dir = Path("Excel")
|
||
if not excel_dir.exists():
|
||
print("错误: 未找到Excel文件夹")
|
||
return
|
||
|
||
xlsm_files = list(excel_dir.glob("*.xlsm"))
|
||
if not xlsm_files:
|
||
print("错误: Excel文件夹中没有xlsm文件")
|
||
return
|
||
|
||
# 如果有多个文件,让用户选择
|
||
if len(xlsm_files) > 1:
|
||
print("发现多个xlsm文件:")
|
||
for i, f in enumerate(xlsm_files, 1):
|
||
print(f" {i}. {f.name}")
|
||
print()
|
||
choice = input("请选择文件编号 (直接回车选择第1个): ").strip()
|
||
if not choice:
|
||
xlsm_file = xlsm_files[0]
|
||
else:
|
||
try:
|
||
idx = int(choice) - 1
|
||
xlsm_file = xlsm_files[idx]
|
||
except:
|
||
print("无效选择,使用第一个文件")
|
||
xlsm_file = xlsm_files[0]
|
||
else:
|
||
xlsm_file = xlsm_files[0]
|
||
|
||
print()
|
||
print(f"选择文件: {xlsm_file.name}")
|
||
print()
|
||
|
||
# 创建提取器
|
||
# 输出目录优先级: 1. .env中的VBA_OUTPUT_DIR配置 2. 源文件同目录下的VBA文件夹
|
||
extractor = VBAExtractor(str(xlsm_file))
|
||
|
||
# 显示输出目录信息
|
||
print(f"输出目录: {extractor.output_dir}")
|
||
print()
|
||
|
||
# 选择提取方法
|
||
print("请选择提取方法:")
|
||
print(" 1. COM接口 (推荐 - 需要安装Excel)")
|
||
print(" 2. olevba库 (不需要Excel)")
|
||
print()
|
||
|
||
method = input("请选择 (直接回车使用方法1): ").strip()
|
||
|
||
if method == "2":
|
||
print("\n使用olevba库提取...")
|
||
success = extractor.extract_vba_modules_olevba()
|
||
else:
|
||
print("\n使用COM接口提取...")
|
||
success = extractor.extract_vba_modules_com()
|
||
|
||
if success:
|
||
print("\n" + "=" * 60)
|
||
print("提取成功完成!")
|
||
print("=" * 60)
|
||
else:
|
||
print("\n" + "=" * 60)
|
||
print("提取失败")
|
||
print("=" * 60)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main() |