first commit
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
#!/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}")
|
||||
Reference in New Issue
Block a user