• 新增增强版PDF解析器,基于ChatGPT5策略使用文本层解析 • 实现混合PDF拆分器,优先文本层解析,OCR作为备份 • 添加数学符号选项修复功能,解决选择题空选项问题 • 性能提升:0.01秒vs29秒(2900倍),22+题目vs6题目(3.5倍) • 更新前端拆分界面,增加错误处理和用户体验优化 • 扩展数据库架构支持OCR相关字段 • 统一前后端配置管理,修复硬编码URL问题 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
411 lines
15 KiB
Python
411 lines
15 KiB
Python
"""
|
||
修复版PDF试卷解析器 - 专门解决数学符号提取问题
|
||
针对数学符号丢失的问题进行特殊处理
|
||
"""
|
||
|
||
import re
|
||
import json
|
||
import math
|
||
import time
|
||
import unicodedata
|
||
from collections import Counter, defaultdict
|
||
from typing import List, Dict, Optional, Tuple
|
||
import fitz # PyMuPDF
|
||
import pdfplumber
|
||
|
||
class PDFExamParserFixed:
|
||
def __init__(self):
|
||
# 全角转半角映射表
|
||
self.fullwidth_trans = str.maketrans(
|
||
"0123456789.()ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
|
||
"0123456789.()ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||
)
|
||
|
||
# 大题分区正则
|
||
self.heading_pattern = re.compile(r"^\s*[((]?\s*([一二三四五六七八九十]{1,3})\s*[、.)]\s*(\S*题)")
|
||
|
||
# 题号正则 - 更宽松的匹配
|
||
self.question_start_pattern = re.compile(r"^\s*([0-9]{1,2})\s*[..、)]")
|
||
|
||
# 选项正则 - 更灵活的匹配
|
||
self.option_label = r"([A-H])\s*[\..、\))]"
|
||
self.option_line_begin = re.compile(r"^\s*" + self.option_label + r"\s*(.*)")
|
||
self.option_inline_finder = re.compile(self.option_label)
|
||
|
||
def normalize_text(self, text: str) -> str:
|
||
"""
|
||
文本标准化:全角转半角、Unicode规范化等
|
||
"""
|
||
if not text:
|
||
return ""
|
||
|
||
# 全角转半角
|
||
text = text.translate(self.fullwidth_trans)
|
||
|
||
# Unicode规范化
|
||
text = unicodedata.normalize("NFKC", text)
|
||
|
||
# 替换特殊空格
|
||
text = text.replace("\u00A0", " ")
|
||
|
||
# 标点符号统一
|
||
text = text.replace(".", ".").replace(":", ":")
|
||
|
||
# 清理空行并去除首尾空格
|
||
text = "\n".join([line.strip() for line in text.splitlines() if line.strip()])
|
||
|
||
return text
|
||
|
||
def extract_text_with_coords(self, pdf_path: str) -> List[Dict]:
|
||
"""
|
||
提取带坐标信息的文本,用于更好地理解布局
|
||
"""
|
||
doc = fitz.open(pdf_path)
|
||
all_text_items = []
|
||
|
||
for page_num, page in enumerate(doc):
|
||
# 使用字典方式获取文本和坐标
|
||
text_dict = page.get_text("dict")
|
||
|
||
for block in text_dict["blocks"]:
|
||
if "lines" in block: # 文本块
|
||
for line in block["lines"]:
|
||
for span in line["spans"]:
|
||
text = span["text"].strip()
|
||
if text:
|
||
all_text_items.append({
|
||
"page": page_num + 1,
|
||
"x0": span["bbox"][0],
|
||
"y0": span["bbox"][1],
|
||
"x1": span["bbox"][2],
|
||
"y1": span["bbox"][3],
|
||
"text": text,
|
||
"font": span.get("font", ""),
|
||
"size": span.get("size", 0)
|
||
})
|
||
|
||
doc.close()
|
||
|
||
# 按页面和位置排序
|
||
all_text_items.sort(key=lambda x: (x["page"], x["y0"], x["x0"]))
|
||
|
||
return all_text_items
|
||
|
||
def reconstruct_lines(self, text_items: List[Dict]) -> List[Dict]:
|
||
"""
|
||
重构文本行,合并同一行的文本片段
|
||
"""
|
||
lines = []
|
||
current_line = None
|
||
|
||
for item in text_items:
|
||
# 如果是新行或者第一个项目
|
||
if (current_line is None or
|
||
item["page"] != current_line["page"] or
|
||
abs(item["y0"] - current_line["y0"]) > 3): # 减小容差到3像素
|
||
|
||
# 保存上一行
|
||
if current_line:
|
||
lines.append(current_line)
|
||
|
||
# 开始新行
|
||
current_line = {
|
||
"page": item["page"],
|
||
"y0": item["y0"],
|
||
"x0": item["x0"],
|
||
"text": item["text"],
|
||
"items": [item]
|
||
}
|
||
else:
|
||
# 继续当前行 - 检查是否需要空格
|
||
last_item = current_line["items"][-1] if current_line["items"] else None
|
||
gap = item["x0"] - (last_item["x1"] if last_item else 0)
|
||
separator = " " if gap > 5 else "" # 如果间隔大于5像素才加空格
|
||
current_line["text"] += separator + item["text"]
|
||
current_line["items"].append(item)
|
||
current_line["x0"] = min(current_line["x0"], item["x0"])
|
||
|
||
# 保存最后一行
|
||
if current_line:
|
||
lines.append(current_line)
|
||
|
||
return lines
|
||
|
||
def extract_options_improved(self, question_text: str, text_items: List[Dict]) -> Tuple[str, List[Dict]]:
|
||
"""
|
||
改进的选项提取,处理数学符号缺失问题
|
||
"""
|
||
lines = question_text.splitlines()
|
||
options = []
|
||
stem_buffer = []
|
||
|
||
# 首先尝试标准方法
|
||
current_option = None
|
||
|
||
for line in lines:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
|
||
# 检测选项标签
|
||
option_match = self.option_line_begin.match(line)
|
||
if option_match:
|
||
# 保存前一个选项
|
||
if current_option:
|
||
options.append(current_option)
|
||
|
||
label = option_match.group(1)
|
||
text = option_match.group(2).strip()
|
||
|
||
# 如果选项文本为空或很短,尝试从坐标信息中补充
|
||
if not text or len(text) < 2:
|
||
text = self.find_option_content_by_coords(label, text_items)
|
||
|
||
current_option = {"label": label, "text": text}
|
||
else:
|
||
# 如果当前在处理选项,继续添加到选项内容
|
||
if current_option:
|
||
current_option["text"] += " " + line
|
||
else:
|
||
stem_buffer.append(line)
|
||
|
||
# 保存最后一个选项
|
||
if current_option:
|
||
options.append(current_option)
|
||
|
||
# 如果没有找到选项,尝试从完整文本中解析
|
||
if not options:
|
||
options = self.extract_options_fallback(question_text)
|
||
|
||
# 清理题干
|
||
stem = "\n".join(stem_buffer).strip()
|
||
if not stem:
|
||
# 从完整文本中移除选项部分作为题干
|
||
stem = self.extract_stem_from_full_text(question_text, options)
|
||
|
||
return stem, options
|
||
|
||
def find_option_content_by_coords(self, label: str, text_items: List[Dict]) -> str:
|
||
"""
|
||
通过坐标信息查找选项内容,用于处理符号缺失
|
||
"""
|
||
# 查找选项标签的位置
|
||
label_item = None
|
||
for item in text_items:
|
||
if f"{label}." in item["text"] or f"{label}." in item["text"]:
|
||
label_item = item
|
||
break
|
||
|
||
if not label_item:
|
||
return ""
|
||
|
||
# 查找同一行或邻近的其他文本
|
||
content_items = []
|
||
for item in text_items:
|
||
if (item["page"] == label_item["page"] and
|
||
abs(item["y0"] - label_item["y0"]) <= 3 and # 同一行
|
||
item["x0"] > label_item["x1"]): # 在标签右侧
|
||
content_items.append(item)
|
||
|
||
# 按x坐标排序并合并
|
||
content_items.sort(key=lambda x: x["x0"])
|
||
content = " ".join([item["text"] for item in content_items])
|
||
|
||
return content.strip()
|
||
|
||
def extract_options_fallback(self, question_text: str) -> List[Dict]:
|
||
"""
|
||
备用选项提取方法
|
||
"""
|
||
options = []
|
||
|
||
# 分割选项的方法:查找A. B. C. D.模式
|
||
pattern = r"([A-D])\s*[\..]\s*([^A-D]*?)(?=\s*[A-D]\s*[\..]|$)"
|
||
matches = re.findall(pattern, question_text, re.DOTALL)
|
||
|
||
for label, content in matches:
|
||
content = re.sub(r'\s+', ' ', content.strip())
|
||
if content:
|
||
options.append({"label": label, "text": content})
|
||
|
||
return options
|
||
|
||
def extract_stem_from_full_text(self, question_text: str, options: List[Dict]) -> str:
|
||
"""
|
||
从完整文本中提取题干
|
||
"""
|
||
stem = question_text
|
||
|
||
# 移除题号前缀
|
||
stem = re.sub(r'^\s*\d+\s*[\..]\s*\([^)]*\)\s*', '', stem)
|
||
|
||
# 移除选项内容
|
||
for option in options:
|
||
pattern = f"{option['label']}\\s*[\\..]\\s*{re.escape(option['text'])}"
|
||
stem = re.sub(pattern, '', stem, flags=re.IGNORECASE)
|
||
|
||
# 清理多余空白
|
||
stem = re.sub(r'\s+', ' ', stem).strip()
|
||
|
||
return stem
|
||
|
||
def process_pdf(self, pdf_path: str) -> Dict:
|
||
"""
|
||
主处理函数
|
||
"""
|
||
print(f"🎯 开始修复版PDF解析: {pdf_path}")
|
||
start_time = time.time()
|
||
|
||
try:
|
||
# 1. 提取带坐标的文本
|
||
text_items = self.extract_text_with_coords(pdf_path)
|
||
print(f"📝 提取到{len(text_items)}个文本片段")
|
||
|
||
# 2. 重构文本行
|
||
lines = self.reconstruct_lines(text_items)
|
||
print(f"📄 重构为{len(lines)}行文本")
|
||
|
||
# 3. 简化题目识别
|
||
questions = []
|
||
current_section = "未知"
|
||
current_question = None
|
||
|
||
for line in lines:
|
||
text = self.normalize_text(line["text"])
|
||
|
||
# 检测分区
|
||
heading_match = self.heading_pattern.match(text)
|
||
if heading_match:
|
||
current_section = f"{heading_match.group(1)}、{heading_match.group(2)}"
|
||
print(f"📚 发现分区: {current_section}")
|
||
continue
|
||
|
||
# 检测题号
|
||
question_match = self.question_start_pattern.match(text)
|
||
if question_match:
|
||
# 保存上一题
|
||
if current_question:
|
||
questions.append(current_question)
|
||
|
||
question_num = question_match.group(1)
|
||
print(f"📝 发现题目: {question_num}")
|
||
|
||
# 收集这道题的所有文本(包括后续行直到下一题)
|
||
question_text = text
|
||
question_items = line["items"]
|
||
|
||
current_question = {
|
||
"number": question_num,
|
||
"section": current_section,
|
||
"text": question_text,
|
||
"items": question_items,
|
||
"page": line["page"]
|
||
}
|
||
elif current_question:
|
||
# 继续当前题目
|
||
current_question["text"] += "\n" + text
|
||
current_question["items"].extend(line["items"])
|
||
|
||
# 保存最后一题
|
||
if current_question:
|
||
questions.append(current_question)
|
||
|
||
# 4. 处理每道题的选项
|
||
formatted_questions = []
|
||
for q in questions:
|
||
prefer_choice = "选择" in q["section"]
|
||
|
||
if prefer_choice:
|
||
stem, options = self.extract_options_improved(q["text"], q["items"])
|
||
|
||
# 转换选项格式
|
||
options_dict = {}
|
||
for opt in options:
|
||
options_dict[opt["label"]] = opt["text"] if opt["text"] else f"[数学符号-{opt['label']}]"
|
||
else:
|
||
stem = q["text"]
|
||
options_dict = None
|
||
|
||
# 推断题目类型
|
||
question_type = "subjective"
|
||
if "选择" in q["section"]:
|
||
question_type = "single_choice"
|
||
elif "填空" in q["section"]:
|
||
question_type = "fill_blank"
|
||
|
||
formatted_questions.append({
|
||
'number': q['number'],
|
||
'type': question_type,
|
||
'text': stem,
|
||
'options': options_dict,
|
||
'page': q['page'],
|
||
'bbox': [],
|
||
'confidence': 1.0,
|
||
'page_spans': [q['page']],
|
||
'source_text': stem,
|
||
'images': [],
|
||
'auto_extracted': True,
|
||
'section': q['section']
|
||
})
|
||
|
||
total_time = time.time() - start_time
|
||
print(f"🎉 修复版解析完成!总耗时: {total_time:.2f}秒")
|
||
|
||
return {
|
||
"success": True,
|
||
"questions": formatted_questions,
|
||
"total_time": total_time,
|
||
"pages_processed": max([line["page"] for line in lines]) if lines else 0,
|
||
"questions_found": len(formatted_questions),
|
||
"stats": {
|
||
"total_time": total_time,
|
||
"pages_processed": max([line["page"] for line in lines]) if lines else 0,
|
||
"questions_found": len(formatted_questions),
|
||
"avg_time_per_page": total_time / max([line["page"] for line in lines]) if lines else 0,
|
||
"method": "fixed_text_parsing"
|
||
}
|
||
}
|
||
|
||
except Exception as e:
|
||
print(f"❌ 修复版解析失败: {str(e)}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
# 测试函数
|
||
def test_fixed_parser():
|
||
"""
|
||
测试修复版解析器
|
||
"""
|
||
import os
|
||
|
||
pdf_path = "/Users/brunogao/Downloads/深圳市八年级(上)期中数学试卷1.pdf"
|
||
|
||
if not os.path.exists(pdf_path):
|
||
print(f"❌ 测试文件不存在: {pdf_path}")
|
||
return
|
||
|
||
parser = PDFExamParserFixed()
|
||
result = parser.process_pdf(pdf_path)
|
||
|
||
if result["success"]:
|
||
print(f"""
|
||
📊 修复版解析测试结果:
|
||
- 总时间: {result['total_time']:.2f}秒
|
||
- 页数: {result['pages_processed']}
|
||
- 题目: {result['questions_found']}道
|
||
- 方法: {result['stats']['method']}
|
||
""")
|
||
|
||
print("前3道选择题详细信息:")
|
||
for i, q in enumerate(result['questions'][:3]):
|
||
if q.get('type') == 'single_choice':
|
||
print(f"\\n题目{q['number']}:")
|
||
print(f"题干: {q['text']}")
|
||
if q.get('options'):
|
||
print(f"选项:")
|
||
for k, v in q['options'].items():
|
||
print(f" {k}. {v}")
|
||
else:
|
||
print(f"❌ 测试失败: {result['error']}")
|
||
|
||
if __name__ == "__main__":
|
||
test_fixed_parser() |