606 lines
18 KiB
Python
606 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
BibTeX and LaTeX Format Checker
|
||
|
||
独立的格式检查工具,用于验证 BibTeX 和 LaTeX 引用格式。
|
||
|
||
功能:
|
||
1. BibTeX 格式检查 - 验证条目结构、必填字段、字段格式
|
||
2. LaTeX 引用检查 - 提取引用、检查一致性
|
||
3. 快速格式验证 - 无需 API 调用的快速检查
|
||
|
||
使用方法:
|
||
python format-checker.py references.bib
|
||
python format-checker.py paper.tex --check-latex
|
||
python format-checker.py references.bib --strict
|
||
"""
|
||
|
||
import argparse
|
||
import sys
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Dict, List, Tuple, Optional
|
||
from dataclasses import dataclass
|
||
from enum import Enum
|
||
|
||
# 尝试导入 bibtexparser
|
||
try:
|
||
import bibtexparser
|
||
from bibtexparser.bparser import BibTexParser
|
||
BIBTEX_AVAILABLE = True
|
||
except ImportError:
|
||
print("警告: bibtexparser 未安装,BibTeX 解析功能受限")
|
||
print("运行: pip install bibtexparser")
|
||
BIBTEX_AVAILABLE = False
|
||
|
||
|
||
class ErrorLevel(Enum):
|
||
"""错误级别"""
|
||
ERROR = "error" # 严重错误,必须修复
|
||
WARNING = "warning" # 警告,建议修复
|
||
INFO = "info" # 信息,可选修复
|
||
|
||
|
||
@dataclass
|
||
class FormatError:
|
||
"""格式错误数据类"""
|
||
level: ErrorLevel
|
||
location: str # 文件位置 (如 "entry:smith2020" 或 "line:42")
|
||
field: Optional[str] # 字段名 (如 "author", "year")
|
||
message: str # 错误描述
|
||
suggestion: Optional[str] = None # 修复建议
|
||
|
||
|
||
def parse_arguments():
|
||
"""解析命令行参数"""
|
||
parser = argparse.ArgumentParser(
|
||
description='检查 BibTeX 和 LaTeX 引用格式',
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
示例:
|
||
%(prog)s references.bib
|
||
%(prog)s paper.tex --check-latex
|
||
%(prog)s references.bib --strict --output report.txt
|
||
%(prog)s references.bib --fix-common
|
||
"""
|
||
)
|
||
|
||
parser.add_argument(
|
||
'input_file',
|
||
type=str,
|
||
help='BibTeX 文件(.bib)或 LaTeX 文件(.tex)'
|
||
)
|
||
|
||
parser.add_argument(
|
||
'--check-latex',
|
||
action='store_true',
|
||
help='检查 LaTeX 引用(需要提供 .tex 文件)'
|
||
)
|
||
|
||
parser.add_argument(
|
||
'--strict',
|
||
action='store_true',
|
||
help='严格模式 - 将警告视为错误'
|
||
)
|
||
|
||
parser.add_argument(
|
||
'--output',
|
||
type=str,
|
||
help='输出报告文件路径'
|
||
)
|
||
|
||
parser.add_argument(
|
||
'--fix-common',
|
||
action='store_true',
|
||
help='自动修复常见格式问题'
|
||
)
|
||
|
||
parser.add_argument(
|
||
'--verbose',
|
||
action='store_true',
|
||
help='显示详细信息'
|
||
)
|
||
|
||
parser.add_argument(
|
||
'--entry-type',
|
||
type=str,
|
||
help='只检查特定类型的条目(如 article, inproceedings)'
|
||
)
|
||
|
||
return parser.parse_args()
|
||
|
||
|
||
def load_bibtex_file(file_path: str) -> List[Dict]:
|
||
"""加载 BibTeX 文件
|
||
|
||
Args:
|
||
file_path: BibTeX 文件路径
|
||
|
||
Returns:
|
||
BibTeX 条目列表
|
||
|
||
Raises:
|
||
FileNotFoundError: 文件不存在
|
||
ValueError: 文件格式错误
|
||
"""
|
||
if not BIBTEX_AVAILABLE:
|
||
raise ImportError("需要安装 bibtexparser: pip install bibtexparser")
|
||
|
||
try:
|
||
with open(file_path, 'r', encoding='utf-8') as f:
|
||
parser = BibTexParser(common_strings=True)
|
||
bib_database = bibtexparser.load(f, parser)
|
||
return bib_database.entries
|
||
except FileNotFoundError:
|
||
raise FileNotFoundError(f"文件不存在: {file_path}")
|
||
except Exception as e:
|
||
raise ValueError(f"无法解析 BibTeX 文件: {e}")
|
||
|
||
|
||
def load_latex_file(file_path: str) -> str:
|
||
"""加载 LaTeX 文件
|
||
|
||
Args:
|
||
file_path: LaTeX 文件路径
|
||
|
||
Returns:
|
||
文件内容
|
||
|
||
Raises:
|
||
FileNotFoundError: 文件不存在
|
||
"""
|
||
try:
|
||
with open(file_path, 'r', encoding='utf-8') as f:
|
||
return f.read()
|
||
except FileNotFoundError:
|
||
raise FileNotFoundError(f"文件不存在: {file_path}")
|
||
except Exception as e:
|
||
raise ValueError(f"无法读取 LaTeX 文件: {e}")
|
||
|
||
|
||
# ============================================================================
|
||
# BibTeX 格式检查函数
|
||
# ============================================================================
|
||
|
||
def get_required_fields(entry_type: str) -> List[str]:
|
||
"""获取 BibTeX 条目类型的必填字段
|
||
|
||
Args:
|
||
entry_type: 条目类型 (如 'article', 'inproceedings')
|
||
|
||
Returns:
|
||
必填字段列表
|
||
"""
|
||
required_fields = {
|
||
'article': ['author', 'title', 'journal', 'year'],
|
||
'inproceedings': ['author', 'title', 'booktitle', 'year'],
|
||
'book': ['title', 'publisher', 'year'],
|
||
'incollection': ['author', 'title', 'booktitle', 'publisher', 'year'],
|
||
'inbook': ['author', 'title', 'chapter', 'publisher', 'year'],
|
||
'proceedings': ['title', 'year'],
|
||
'phdthesis': ['author', 'title', 'school', 'year'],
|
||
'mastersthesis': ['author', 'title', 'school', 'year'],
|
||
'techreport': ['author', 'title', 'institution', 'year'],
|
||
'manual': ['title'],
|
||
'misc': ['title'],
|
||
'unpublished': ['author', 'title', 'note'],
|
||
}
|
||
return required_fields.get(entry_type.lower(), ['title'])
|
||
|
||
|
||
def get_optional_fields(entry_type: str) -> List[str]:
|
||
"""获取 BibTeX 条目类型的可选字段
|
||
|
||
Args:
|
||
entry_type: 条目类型
|
||
|
||
Returns:
|
||
可选字段列表
|
||
"""
|
||
optional_fields = {
|
||
'article': ['volume', 'number', 'pages', 'month', 'doi', 'url'],
|
||
'inproceedings': ['editor', 'volume', 'series', 'pages', 'address',
|
||
'month', 'organization', 'publisher', 'doi', 'url'],
|
||
'book': ['author', 'editor', 'volume', 'series', 'address',
|
||
'edition', 'month', 'isbn', 'doi', 'url'],
|
||
}
|
||
return optional_fields.get(entry_type.lower(), [])
|
||
|
||
|
||
def check_entry_structure(entry: Dict) -> List[FormatError]:
|
||
"""检查 BibTeX 条目基本结构
|
||
|
||
Args:
|
||
entry: BibTeX 条目字典
|
||
|
||
Returns:
|
||
错误列表
|
||
"""
|
||
errors = []
|
||
|
||
# 检查条目类型
|
||
if 'ENTRYTYPE' not in entry:
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.ERROR,
|
||
location=f"entry:{entry.get('ID', 'unknown')}",
|
||
field='ENTRYTYPE',
|
||
message="缺少条目类型",
|
||
suggestion="添加条目类型,如 @article, @inproceedings"
|
||
))
|
||
return errors
|
||
|
||
# 检查 ID
|
||
if 'ID' not in entry or not entry['ID'].strip():
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.ERROR,
|
||
location="entry:unknown",
|
||
field='ID',
|
||
message="缺少 citation key",
|
||
suggestion="添加唯一的 citation key"
|
||
))
|
||
|
||
# 检查必填字段
|
||
entry_type = entry.get('ENTRYTYPE', '')
|
||
required = get_required_fields(entry_type)
|
||
for field in required:
|
||
if field not in entry or not entry[field].strip():
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.ERROR,
|
||
location=f"entry:{entry.get('ID', 'unknown')}",
|
||
field=field,
|
||
message=f"缺少必填字段: {field}",
|
||
suggestion=f"添加 {field} 字段"
|
||
))
|
||
|
||
return errors
|
||
|
||
|
||
def check_field_formats(entry: Dict) -> List[FormatError]:
|
||
"""检查字段格式
|
||
|
||
Args:
|
||
entry: BibTeX 条目字典
|
||
|
||
Returns:
|
||
错误列表
|
||
"""
|
||
errors = []
|
||
entry_id = entry.get('ID', 'unknown')
|
||
|
||
# 年份格式检查
|
||
if 'year' in entry:
|
||
year = entry['year'].strip()
|
||
if not year.isdigit():
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.ERROR,
|
||
location=f"entry:{entry_id}",
|
||
field='year',
|
||
message=f"年份格式错误: {year} (应为4位数字)",
|
||
suggestion="使用4位数字年份,如 2023"
|
||
))
|
||
elif len(year) != 4:
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.ERROR,
|
||
location=f"entry:{entry_id}",
|
||
field='year',
|
||
message=f"年份格式错误: {year} (应为4位数字)",
|
||
suggestion="使用4位数字年份,如 2023"
|
||
))
|
||
else:
|
||
year_int = int(year)
|
||
if year_int < 1900 or year_int > 2030:
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.WARNING,
|
||
location=f"entry:{entry_id}",
|
||
field='year',
|
||
message=f"年份超出合理范围: {year}",
|
||
suggestion="检查年份是否正确"
|
||
))
|
||
|
||
# DOI 格式检查
|
||
if 'doi' in entry:
|
||
doi = entry['doi'].strip()
|
||
if not doi.startswith('10.'):
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.ERROR,
|
||
location=f"entry:{entry_id}",
|
||
field='doi',
|
||
message=f"DOI 格式错误: {doi}",
|
||
suggestion="DOI 应以 '10.' 开头,如 10.1038/nature12345"
|
||
))
|
||
# 检查是否包含 URL 前缀
|
||
if 'doi.org' in doi or 'dx.doi.org' in doi:
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.WARNING,
|
||
location=f"entry:{entry_id}",
|
||
field='doi',
|
||
message=f"DOI 包含 URL 前缀: {doi}",
|
||
suggestion="只保留 DOI 本身,移除 https://doi.org/ 前缀"
|
||
))
|
||
|
||
# 作者名格式检查
|
||
if 'author' in entry:
|
||
author = entry['author'].strip()
|
||
# 检查是否为空
|
||
if not author:
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.ERROR,
|
||
location=f"entry:{entry_id}",
|
||
field='author',
|
||
message="作者字段为空",
|
||
suggestion="添加作者信息"
|
||
))
|
||
# 检查格式一致性
|
||
elif ' and ' in author:
|
||
authors = author.split(' and ')
|
||
formats = []
|
||
for a in authors:
|
||
if ',' in a:
|
||
formats.append('last_first') # "Last, First"
|
||
else:
|
||
formats.append('first_last') # "First Last"
|
||
|
||
if len(set(formats)) > 1:
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.WARNING,
|
||
location=f"entry:{entry_id}",
|
||
field='author',
|
||
message="作者名格式不一致",
|
||
suggestion="统一使用 'Last, First' 或 'First Last' 格式"
|
||
))
|
||
|
||
# 页码格式检查
|
||
if 'pages' in entry:
|
||
pages = entry['pages'].strip()
|
||
# 检查是否使用了正确的分隔符
|
||
if '-' in pages and '--' not in pages:
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.INFO,
|
||
location=f"entry:{entry_id}",
|
||
field='pages',
|
||
message=f"页码使用单连字符: {pages}",
|
||
suggestion="建议使用双连字符 '--',如 123--145"
|
||
))
|
||
|
||
# URL 格式检查
|
||
if 'url' in entry:
|
||
url = entry['url'].strip()
|
||
if not url.startswith(('http://', 'https://')):
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.WARNING,
|
||
location=f"entry:{entry_id}",
|
||
field='url',
|
||
message=f"URL 缺少协议前缀: {url}",
|
||
suggestion="添加 http:// 或 https:// 前缀"
|
||
))
|
||
|
||
return errors
|
||
|
||
|
||
def check_consistency(entries: List[Dict]) -> List[FormatError]:
|
||
"""检查条目间的一致性
|
||
|
||
Args:
|
||
entries: BibTeX 条目列表
|
||
|
||
Returns:
|
||
错误列表
|
||
"""
|
||
errors = []
|
||
|
||
# 检查重复的 citation key
|
||
ids = [e.get('ID', '') for e in entries]
|
||
duplicates = [id for id in ids if ids.count(id) > 1]
|
||
if duplicates:
|
||
for dup_id in set(duplicates):
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.ERROR,
|
||
location=f"entry:{dup_id}",
|
||
field='ID',
|
||
message=f"重复的 citation key: {dup_id}",
|
||
suggestion="使用唯一的 citation key"
|
||
))
|
||
|
||
# 检查作者名格式一致性
|
||
author_formats = {}
|
||
for entry in entries:
|
||
if 'author' in entry and ' and ' in entry['author']:
|
||
entry_id = entry.get('ID', 'unknown')
|
||
authors = entry['author'].split(' and ')
|
||
for author in authors:
|
||
if ',' in author:
|
||
author_formats[entry_id] = 'last_first'
|
||
else:
|
||
author_formats[entry_id] = 'first_last'
|
||
break
|
||
|
||
if len(set(author_formats.values())) > 1:
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.WARNING,
|
||
location="global",
|
||
field='author',
|
||
message="不同条目使用了不同的作者名格式",
|
||
suggestion="统一使用 'Last, First' 或 'First Last' 格式"
|
||
))
|
||
|
||
return errors
|
||
|
||
|
||
# ============================================================================
|
||
# LaTeX 引用检查函数
|
||
# ============================================================================
|
||
|
||
def extract_latex_citations(tex_content: str) -> List[str]:
|
||
"""从 LaTeX 文件中提取引用
|
||
|
||
Args:
|
||
tex_content: LaTeX 文件内容
|
||
|
||
Returns:
|
||
引用 key 列表
|
||
"""
|
||
# 匹配 \cite{...} 命令
|
||
cite_pattern = r'\\cite(?:\[[^\]]*\])?(?:\[[^\]]*\])?\{([^}]+)\}'
|
||
citations = re.findall(cite_pattern, tex_content)
|
||
|
||
# 展开多个引用
|
||
all_keys = []
|
||
for cite in citations:
|
||
keys = [k.strip() for k in cite.split(',')]
|
||
all_keys.extend(keys)
|
||
|
||
return list(set(all_keys)) # 去重
|
||
|
||
|
||
def check_latex_consistency(tex_keys: List[str], bib_keys: List[str]) -> List[FormatError]:
|
||
"""检查 LaTeX 引用与 BibTeX 的一致性
|
||
|
||
Args:
|
||
tex_keys: LaTeX 中的引用 key 列表
|
||
bib_keys: BibTeX 中的 key 列表
|
||
|
||
Returns:
|
||
错误列表
|
||
"""
|
||
errors = []
|
||
|
||
tex_set = set(tex_keys)
|
||
bib_set = set(bib_keys)
|
||
|
||
# 未定义的引用
|
||
undefined = tex_set - bib_set
|
||
if undefined:
|
||
for key in sorted(undefined):
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.ERROR,
|
||
location=f"latex:cite",
|
||
field=key,
|
||
message=f"未定义的引用: {key}",
|
||
suggestion=f"在 BibTeX 文件中添加 {key} 条目"
|
||
))
|
||
|
||
# 未使用的引用
|
||
unused = bib_set - tex_set
|
||
if unused:
|
||
for key in sorted(unused):
|
||
errors.append(FormatError(
|
||
level=ErrorLevel.WARNING,
|
||
location=f"bibtex:entry",
|
||
field=key,
|
||
message=f"未使用的引用: {key}",
|
||
suggestion=f"在 LaTeX 文件中引用 {key} 或从 BibTeX 中删除"
|
||
))
|
||
|
||
return errors
|
||
|
||
|
||
# ============================================================================
|
||
# 报告生成函数
|
||
# ============================================================================
|
||
|
||
def print_errors(errors: List[FormatError], verbose: bool = False):
|
||
"""打印错误列表
|
||
|
||
Args:
|
||
errors: 错误列表
|
||
verbose: 是否显示详细信息
|
||
"""
|
||
if not errors:
|
||
print("✅ 未发现格式错误")
|
||
return
|
||
|
||
# 按级别分组
|
||
errors_by_level = {
|
||
ErrorLevel.ERROR: [],
|
||
ErrorLevel.WARNING: [],
|
||
ErrorLevel.INFO: []
|
||
}
|
||
|
||
for error in errors:
|
||
errors_by_level[error.level].append(error)
|
||
|
||
# 打印统计
|
||
print("\n" + "="*60)
|
||
print("格式检查结果")
|
||
print("="*60)
|
||
print(f"❌ 错误: {len(errors_by_level[ErrorLevel.ERROR])}")
|
||
print(f"⚠️ 警告: {len(errors_by_level[ErrorLevel.WARNING])}")
|
||
print(f"ℹ️ 信息: {len(errors_by_level[ErrorLevel.INFO])}")
|
||
print("="*60)
|
||
|
||
# 打印详细错误
|
||
for level in [ErrorLevel.ERROR, ErrorLevel.WARNING, ErrorLevel.INFO]:
|
||
level_errors = errors_by_level[level]
|
||
if not level_errors:
|
||
continue
|
||
|
||
level_symbol = {
|
||
ErrorLevel.ERROR: "❌",
|
||
ErrorLevel.WARNING: "⚠️",
|
||
ErrorLevel.INFO: "ℹ️"
|
||
}[level]
|
||
|
||
print(f"\n{level_symbol} {level.value.upper()} ({len(level_errors)}):\n")
|
||
|
||
for error in level_errors:
|
||
print(f" [{error.location}]", end="")
|
||
if error.field:
|
||
print(f" {error.field}:", end="")
|
||
print(f" {error.message}")
|
||
|
||
if verbose and error.suggestion:
|
||
print(f" 💡 建议: {error.suggestion}")
|
||
print()
|
||
|
||
|
||
def generate_report(errors: List[FormatError], output_file: str):
|
||
"""生成文本格式的检查报告
|
||
|
||
Args:
|
||
errors: 错误列表
|
||
output_file: 输出文件路径
|
||
"""
|
||
# 按级别分组
|
||
errors_by_level = {
|
||
ErrorLevel.ERROR: [],
|
||
ErrorLevel.WARNING: [],
|
||
ErrorLevel.INFO: []
|
||
}
|
||
|
||
for error in errors:
|
||
errors_by_level[error.level].append(error)
|
||
|
||
with open(output_file, 'w', encoding='utf-8') as f:
|
||
f.write("# BibTeX/LaTeX 格式检查报告\n\n")
|
||
|
||
# 总体统计
|
||
f.write("## 总体统计\n\n")
|
||
f.write(f"- **错误**: {len(errors_by_level[ErrorLevel.ERROR])}\n")
|
||
f.write(f"- **警告**: {len(errors_by_level[ErrorLevel.WARNING])}\n")
|
||
f.write(f"- **信息**: {len(errors_by_level[ErrorLevel.INFO])}\n\n")
|
||
|
||
# 详细错误
|
||
for level in [ErrorLevel.ERROR, ErrorLevel.WARNING, ErrorLevel.INFO]:
|
||
level_errors = errors_by_level[level]
|
||
if not level_errors:
|
||
continue
|
||
|
||
level_name = {
|
||
ErrorLevel.ERROR: "错误",
|
||
ErrorLevel.WARNING: "警告",
|
||
ErrorLevel.INFO: "信息"
|
||
}[level]
|
||
|
||
f.write(f"## {level_name} ({len(level_errors)})\n\n")
|
||
|
||
for error in level_errors:
|
||
f.write(f"### [{error.location}]")
|
||
if error.field:
|
||
f.write(f" {error.field}")
|
||
f.write("\n\n")
|
||
f.write(f"**问题**: {error.message}\n\n")
|
||
if error.suggestion:
|
||
f.write(f"**建议**: {error.suggestion}\n\n")
|
||
|
||
print(f"\n报告已保存到: {output_file}")
|