diff --git a/backend/main.py b/backend/main.py
index 8e76043..5755c43 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,7 +1,8 @@
from fastapi import FastAPI, HTTPException, Depends, UploadFile, File, status, Request
+from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
-from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, Boolean, Text, JSON
+from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, Boolean, Text, JSON, func
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
from pydantic import BaseModel
@@ -13,6 +14,10 @@ from datetime import datetime, timedelta, date
import json
import io
import csv
+import shutil
+import tempfile
+import uuid
+from pdf_splitter_hybrid import PDFSplitterHybrid as PDFSplitter
import uvicorn
import uuid
import os
@@ -143,6 +148,14 @@ class Question(Base):
explanation = Column(Text, nullable=True) # 解析
image_key = Column(String, nullable=True) # 题目图片
tags = Column(JSON, nullable=True) # 标签数组
+ # 拆题功能相关字段
+ page_spans = Column(JSON, nullable=True) # 页面位置信息 [{"page":1,"bbox":[x,y,w,h]}]
+ source_text = Column(Text, nullable=True) # OCR原始文本
+ images = Column(JSON, nullable=True) # 题目中的图片信息 [{"page":1,"bbox":[...],"key":"..."}]
+ formula = Column(JSON, nullable=True) # 公式信息 [{"latex":"...","bbox":[...]}]
+ score = Column(Integer, nullable=True) # 分值
+ question_number = Column(String, nullable=True) # 题号 (1, 2, 3 等)
+ auto_extracted = Column(Boolean, default=False) # 是否自动提取
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -343,6 +356,28 @@ class QuestionCollectionResponse(BaseModel):
class Config:
from_attributes = True
+# 拆题相关模型
+class SplitRequest(BaseModel):
+ paper_id: int
+
+class SplitQuestionResult(BaseModel):
+ number: str
+ type: str
+ text: str
+ options: Optional[dict] = None
+ bbox: List[int]
+ page: int
+ confidence: float
+ image_filename: Optional[str] = None
+
+class SplitResponse(BaseModel):
+ success: bool
+ paper_id: int
+ total_questions: int
+ questions: List[SplitQuestionResult]
+ stats: dict
+ error: Optional[str] = None
+
# FastAPI应用
app = FastAPI(title="WordMaster Pro API", version="1.0.0")
@@ -1477,5 +1512,141 @@ async def serve_uploaded_file(
else:
raise HTTPException(status_code=404, detail="File not found")
+# PDF拆题API
+@app.post("/api/papers/{paper_id}/split", response_model=SplitResponse)
+async def split_paper(
+ paper_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """
+ 拆解试卷PDF,自动提取题目
+ """
+ # 检查试卷是否存在
+ paper = db.query(Paper).filter(
+ Paper.id == paper_id,
+ Paper.user_id == current_user.id
+ ).first()
+
+ if not paper:
+ raise HTTPException(status_code=404, detail="试卷不存在")
+
+ # 检查文件是否存在
+ file_path = paper.file_key
+ if not file_path or not os.path.exists(file_path):
+ raise HTTPException(status_code=404, detail="试卷文件不存在")
+
+ try:
+ # 创建PDF拆分器
+ splitter = PDFSplitter()
+
+ # 创建临时目录存储处理结果
+ temp_dir = tempfile.mkdtemp()
+
+ try:
+ # 处理PDF
+ result = splitter.process_pdf(file_path, temp_dir)
+
+ if not result['success']:
+ return SplitResponse(
+ success=False,
+ paper_id=paper_id,
+ total_questions=0,
+ questions=[],
+ stats={},
+ error=result.get('error', '未知错误')
+ )
+
+ questions = result['questions']
+ stats = result['stats']
+
+ # 保存提取的题目到数据库
+ saved_questions = []
+ for i, question_data in enumerate(questions):
+ # 移动题目图片到uploads目录
+ image_key = None
+ if question_data.get('image_path') and os.path.exists(question_data['image_path']):
+ # 生成新的文件名
+ file_extension = os.path.splitext(question_data['image_filename'])[1]
+ new_filename = f"{uuid.uuid4()}{file_extension}"
+ dest_dir = os.path.join("uploads", str(current_user.id))
+ os.makedirs(dest_dir, exist_ok=True)
+ dest_path = os.path.join(dest_dir, new_filename)
+
+ try:
+ shutil.copy2(question_data['image_path'], dest_path)
+ image_key = dest_path
+ except Exception as e:
+ print(f"移动题目图片失败: {e}")
+
+ # 创建题目记录
+ db_question = Question(
+ user_id=current_user.id,
+ paper_id=paper_id,
+ question_type=question_data.get('type', 'unknown'),
+ subject=paper.subject,
+ grade=paper.grade,
+ knowledge_points=[], # 后续可以通过AI分析填充
+ difficulty=3, # 默认难度
+ question_content=question_data.get('text', ''),
+ options=question_data.get('options'),
+ correct_answer='', # 需要手动填写或AI识别
+ explanation=None,
+ image_key=image_key,
+ # 拆题相关字段
+ page_spans=question_data.get('page_spans', [{'page': question_data.get('page', 1), 'bbox': question_data.get('bbox', [])}]),
+ source_text=question_data.get('text', ''),
+ images=[{
+ 'page': question_data.get('page', 1),
+ 'bbox': question_data.get('bbox', []),
+ 'key': image_key
+ }] if image_key else [],
+ formula=[], # 后续可以添加公式识别
+ score=None, # 需要手动填写
+ question_number=question_data.get('number', str(i+1)),
+ auto_extracted=True
+ )
+
+ db.add(db_question)
+
+ # 构建返回数据
+ saved_questions.append(SplitQuestionResult(
+ number=question_data.get('number', str(i+1)),
+ type=question_data.get('type', 'unknown'),
+ text=question_data.get('text', ''),
+ options=question_data.get('options'),
+ bbox=question_data.get('bbox', []),
+ page=question_data.get('page', 1),
+ confidence=question_data.get('confidence', 0.0),
+ image_filename=new_filename if image_key else None
+ ))
+
+ # 提交数据库更改
+ db.commit()
+
+ return SplitResponse(
+ success=True,
+ paper_id=paper_id,
+ total_questions=len(saved_questions),
+ questions=saved_questions,
+ stats=stats
+ )
+
+ finally:
+ # 清理临时目录
+ if os.path.exists(temp_dir):
+ shutil.rmtree(temp_dir)
+
+ except Exception as e:
+ db.rollback()
+ return SplitResponse(
+ success=False,
+ paper_id=paper_id,
+ total_questions=0,
+ questions=[],
+ stats={},
+ error=f"处理失败: {str(e)}"
+ )
+
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8888)
\ No newline at end of file
diff --git a/backend/migrate_db.py b/backend/migrate_db.py
new file mode 100644
index 0000000..bfccc44
--- /dev/null
+++ b/backend/migrate_db.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+"""
+数据库迁移脚本 - 添加PDF拆题相关字段
+"""
+
+import sqlite3
+import os
+
+def migrate_database():
+ """迁移数据库,添加新字段"""
+ db_path = "wordmaster.db"
+
+ if not os.path.exists(db_path):
+ print("❌ 数据库文件不存在")
+ return False
+
+ try:
+ conn = sqlite3.connect(db_path)
+ cursor = conn.cursor()
+
+ print("🔄 开始数据库迁移...")
+
+ # 检查是否已经存在新字段
+ cursor.execute("PRAGMA table_info(questions)")
+ columns = [column[1] for column in cursor.fetchall()]
+
+ new_fields = [
+ ('page_spans', 'JSON'),
+ ('source_text', 'TEXT'),
+ ('images', 'JSON'),
+ ('formula', 'JSON'),
+ ('score', 'REAL'),
+ ('question_number', 'VARCHAR'),
+ ('auto_extracted', 'BOOLEAN DEFAULT FALSE')
+ ]
+
+ # 添加缺失的字段
+ for field_name, field_type in new_fields:
+ if field_name not in columns:
+ sql = f"ALTER TABLE questions ADD COLUMN {field_name} {field_type}"
+ print(f" 添加字段: {field_name}")
+ cursor.execute(sql)
+ else:
+ print(f" 字段已存在: {field_name}")
+
+ conn.commit()
+ print("✅ 数据库迁移完成")
+
+ # 验证新结构
+ cursor.execute("PRAGMA table_info(questions)")
+ new_columns = [column[1] for column in cursor.fetchall()]
+ print(f"📊 当前字段数量: {len(new_columns)}")
+
+ return True
+
+ except Exception as e:
+ print(f"❌ 迁移失败: {e}")
+ return False
+ finally:
+ if 'conn' in locals():
+ conn.close()
+
+if __name__ == "__main__":
+ success = migrate_database()
+ if success:
+ print("\n🎉 数据库迁移成功完成!")
+ else:
+ print("\n💔 数据库迁移失败!")
\ No newline at end of file
diff --git a/backend/pdf_exam_parser_enhanced.py b/backend/pdf_exam_parser_enhanced.py
new file mode 100644
index 0000000..90d7195
--- /dev/null
+++ b/backend/pdf_exam_parser_enhanced.py
@@ -0,0 +1,510 @@
+"""
+增强版PDF试卷解析器 - 基于ChatGPT5策略
+核心改进:
+1. 页眉页脚自动过滤
+2. 智能选项识别(行内、行首、续写)
+3. 文本标准化处理
+4. 跨页题目处理
+"""
+
+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 PDFExamParserEnhanced:
+ 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*(?:第\s*)?([0-9]{1,3}|[一二三四五六七八九十]{1,3})\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_lines_with_pymupdf(self, pdf_path: str) -> List[Dict]:
+ """
+ 使用PyMuPDF提取文本行,包含页眉页脚过滤
+ """
+ doc = fitz.open(pdf_path)
+ pages_data = []
+ header_candidates = []
+ footer_candidates = []
+
+ # 第一遍:收集所有文本块和页眉页脚候选
+ for page_num, page in enumerate(doc):
+ blocks = page.get_text("blocks")
+ page_height = page.rect.height
+ page_width = page.rect.width
+
+ local_blocks = []
+
+ for block in blocks:
+ x0, y0, x1, y1, text = block[0], block[1], block[2], block[3], block[4]
+ text = self.normalize_text(text)
+
+ if not text:
+ continue
+
+ block_info = {
+ "page": page_num + 1,
+ "x0": x0, "y0": y0, "x1": x1, "y1": y1,
+ "page_width": page_width, "page_height": page_height,
+ "text": text
+ }
+ local_blocks.append(block_info)
+
+ # 收集页眉页脚候选(顶部12%和底部10%)
+ if y0 <= page_height * 0.12 and len(text) <= 80:
+ header_candidates.append(text.strip())
+ if y1 >= page_height * 0.90 and len(text) <= 80:
+ footer_candidates.append(text.strip())
+
+ # 按阅读顺序排序(从上到下,从左到右)
+ local_blocks.sort(key=lambda b: (round(b["y0"], 1), round(b["x0"], 1)))
+ pages_data.append(local_blocks)
+
+ doc.close()
+
+ # 识别重复的页眉页脚
+ n_pages = len(pages_data)
+ min_occurrences = max(2, math.ceil(n_pages * 0.6))
+
+ header_common = {
+ text for text, count in Counter(header_candidates).items()
+ if count >= min_occurrences
+ }
+ footer_common = {
+ text for text, count in Counter(footer_candidates).items()
+ if count >= min_occurrences
+ }
+
+ print(f"🔍 识别页眉: {header_common}")
+ print(f"🔍 识别页脚: {footer_common}")
+
+ # 第二遍:提取文本行,过滤页眉页脚
+ lines = []
+ for page_blocks in pages_data:
+ for block in page_blocks:
+ text = block["text"]
+
+ # 过滤页眉页脚
+ is_header = (block["y0"] <= block["page_height"] * 0.12 and
+ text.strip() in header_common)
+ is_footer = (block["y1"] >= block["page_height"] * 0.90 and
+ text.strip() in footer_common)
+
+ if is_header or is_footer:
+ continue
+
+ # 将文本块拆分为行
+ for line in text.splitlines():
+ line = line.strip()
+ if line:
+ lines.append({
+ "page": block["page"],
+ "y": block["y0"],
+ "x": block["x0"],
+ "text": line
+ })
+
+ # 按页面和位置排序
+ lines.sort(key=lambda l: (l["page"], round(l["y"], 1), round(l["x"], 1)))
+
+ print(f"✅ PyMuPDF提取完成: {len(lines)}行")
+ return lines
+
+ def extract_lines_with_pdfplumber(self, pdf_path: str) -> List[Dict]:
+ """
+ 使用pdfplumber提取文本行(备用方案)
+ """
+ lines = []
+
+ with pdfplumber.open(pdf_path) as pdf:
+ n_pages = len(pdf.pages)
+ top_texts = []
+ bottom_texts = []
+
+ # 收集页眉页脚候选
+ for i, page in enumerate(pdf.pages, start=1):
+ words = page.extract_words(x_tolerance=2, y_tolerance=2, keep_blank_chars=False)
+ if not words:
+ continue
+
+ # 按位置排序
+ words.sort(key=lambda w: (round(w["top"], 1), round(w["x0"], 1)))
+
+ # 按行分组
+ line_map = defaultdict(list)
+ for word in words:
+ line_map[round(word["top"], 1)].append(word)
+
+ sorted_tops = sorted(line_map.keys())
+ if sorted_tops:
+ # 第一行和最后一行
+ first_line = " ".join([
+ w["text"] for w in sorted(line_map[sorted_tops[0]], key=lambda x: x["x0"])
+ ]).strip()
+ last_line = " ".join([
+ w["text"] for w in sorted(line_map[sorted_tops[-1]], key=lambda x: x["x0"])
+ ]).strip()
+
+ if first_line:
+ top_texts.append(self.normalize_text(first_line))
+ if last_line:
+ bottom_texts.append(self.normalize_text(last_line))
+
+ # 识别重复的页眉页脚
+ min_occurrences = max(2, math.ceil(n_pages * 0.6))
+ header_common = {
+ text for text, count in Counter(top_texts).items()
+ if count >= min_occurrences
+ }
+ footer_common = {
+ text for text, count in Counter(bottom_texts).items()
+ if count >= min_occurrences
+ }
+
+ # 提取所有行,过滤页眉页脚
+ for i, page in enumerate(pdf.pages, start=1):
+ words = page.extract_words(x_tolerance=2, y_tolerance=2, keep_blank_chars=False)
+ if not words:
+ continue
+
+ # 按行分组
+ line_map = defaultdict(list)
+ for word in words:
+ line_map[round(word["top"], 1)].append(word)
+
+ for top in sorted(line_map.keys()):
+ word_list = sorted(line_map[top], key=lambda x: x["x0"])
+ text = self.normalize_text(" ".join(w["text"] for w in word_list))
+
+ if not text:
+ continue
+
+ # 过滤页眉页脚
+ if text in header_common or text in footer_common:
+ continue
+
+ lines.append({
+ "page": i,
+ "y": top,
+ "x": word_list[0]["x0"],
+ "text": text
+ })
+
+ lines.sort(key=lambda l: (l["page"], round(l["y"], 1), round(l["x"], 1)))
+
+ print(f"✅ pdfplumber提取完成: {len(lines)}行")
+ return lines
+
+ def extract_options_and_stem(self, question_text: str, prefer_choice: bool) -> Tuple[str, List[Dict]]:
+ """
+ 提取题干和选项,支持多种选项格式
+ """
+ lines = question_text.splitlines()
+ options = []
+ current_option = None
+ stem_buffer = []
+
+ def save_current_option():
+ nonlocal current_option, options
+ if current_option is not None:
+ label, text = current_option
+ options.append({"label": label, "text": text.strip()})
+ current_option = None
+
+ for line in lines:
+ # 检测行内多选项格式:A. xxx;B. yyy;C. zzz
+ inline_matches = list(self.option_inline_finder.finditer(line))
+ if len(inline_matches) >= 2:
+ save_current_option()
+ for idx, match in enumerate(inline_matches):
+ label = match.group(1)
+ start_pos = match.end()
+ end_pos = inline_matches[idx + 1].start() if idx + 1 < len(inline_matches) else len(line)
+ option_text = line[start_pos:end_pos].strip(" ;;,, ")
+ if option_text:
+ options.append({"label": label, "text": option_text})
+ continue
+
+ # 检测行首选项格式:A. xxx
+ line_begin_match = self.option_line_begin.match(line)
+ if line_begin_match:
+ save_current_option()
+ label = line_begin_match.group(1)
+ text = line_begin_match.group(2).strip()
+ current_option = (label, text)
+ continue
+
+ # 选项续行
+ if current_option is not None:
+ current_option = (current_option[0], (current_option[1] + " " + line).strip())
+ else:
+ stem_buffer.append(line)
+
+ save_current_option()
+
+ stem = "\n".join(stem_buffer).strip()
+
+ # 如果不是选择题,保持原文
+ if not prefer_choice:
+ return (question_text.strip(), [])
+
+ # 选择题但没找到选项的情况,尝试贪婪提取
+ if prefer_choice and not options:
+ def extract_option_text(label: str, text: str) -> Optional[str]:
+ pattern = label + r"\s*[\..、\))]\s*(.*?)(?=(?:[;;]\s*)?[A-H]\s*[\..、\))]|$)"
+ match = re.search(pattern, text, re.S)
+ return match.group(1).strip() if match else None
+
+ for label in "ABCDEFGH":
+ option_text = extract_option_text(label, question_text)
+ if option_text:
+ options.append({"label": label, "text": re.sub(r"\s+", " ", option_text)})
+
+ # 只保留A-D选项
+ options = [opt for opt in options if opt["label"] in "ABCD" and opt["text"]]
+
+ if options:
+ # 从原文中清除选项部分,提取纯题干
+ clean_pattern = r"[A-D]\s*[\..、\))]\s*.*?(?=(?:[;;]\s*)?[A-D]\s*[\..、\))]|$)"
+ stem_clean = re.sub(clean_pattern, "", question_text, flags=re.S).strip()
+ stem = re.sub(r"[;;]+", " ", stem_clean).strip()
+
+ return (stem if stem else question_text.strip(), options)
+
+ def split_questions(self, lines: List[Dict]) -> List[Dict]:
+ """
+ 拆分题目
+ """
+ questions = []
+ current_section = None
+ buffer = []
+ start_page = None
+
+ def flush_current_question():
+ nonlocal buffer, start_page, current_section, questions
+ if not buffer:
+ return
+
+ question_text = "\n".join([item["text"] for item in buffer]).strip()
+ first_line = buffer[0]["text"]
+
+ # 提取题号
+ match = self.question_start_pattern.search(first_line)
+ question_number = match.group(1) if match else None
+
+ # 判断是否为选择题
+ prefer_choice = bool(current_section and "选择" in current_section)
+
+ # 提取题干和选项
+ stem, options = self.extract_options_and_stem(question_text, prefer_choice)
+
+ # 推断题目类型
+ question_type = "subjective"
+ if current_section and "选择" in current_section:
+ question_type = "single_choice"
+ elif current_section and "填空" in current_section:
+ question_type = "fill_blank"
+
+ end_page = buffer[-1]["page"]
+
+ question_item = {
+ "section": current_section,
+ "qno": question_number,
+ "type": question_type,
+ "stem": stem,
+ "options": options,
+ "page_start": start_page,
+ "page_end": end_page
+ }
+
+ questions.append(question_item)
+
+ # 重置缓冲区
+ buffer = []
+ start_page = None
+
+ # 逐行处理
+ for line in lines:
+ text = line["text"]
+
+ # 检测大题分区
+ heading_match = self.heading_pattern.match(text)
+ if heading_match:
+ flush_current_question()
+ 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:
+ flush_current_question()
+ buffer = [line]
+ start_page = line["page"]
+ print(f"📝 发现题目: {question_match.group(1)}")
+ else:
+ if buffer:
+ buffer.append(line)
+ # 跳过序言等内容
+
+ # 处理最后一题
+ flush_current_question()
+
+ # 清理无效题目
+ valid_questions = []
+ for question in questions:
+ # 去除题号前缀,检查是否有实际内容
+ content = re.sub(r"^\s*(\d+|[一二三四五六七八九十]+)\s*[.)]\s*", "", question["stem"] or "").strip()
+ if content:
+ valid_questions.append(question)
+
+ return valid_questions
+
+ def process_pdf(self, pdf_path: str) -> Dict:
+ """
+ 主处理函数
+ """
+ print(f"🎯 开始增强PDF解析: {pdf_path}")
+ start_time = time.time()
+
+ try:
+ # 优先使用PyMuPDF
+ lines = self.extract_lines_with_pymupdf(pdf_path)
+
+ if not lines:
+ print("⚠️ PyMuPDF失败,尝试pdfplumber")
+ lines = self.extract_lines_with_pdfplumber(pdf_path)
+
+ if not lines:
+ return {"success": False, "error": "无法提取PDF文本"}
+
+ # 拆分题目
+ questions = self.split_questions(lines)
+
+ # 转换为API格式
+ formatted_questions = []
+ for q in questions:
+ # 转换选项格式
+ options_dict = {}
+ if q.get("options"):
+ for opt in q["options"]:
+ options_dict[opt["label"]] = opt["text"]
+
+ formatted_questions.append({
+ 'number': q['qno'],
+ 'type': q['type'],
+ 'text': q['stem'],
+ 'options': options_dict if options_dict else None,
+ 'page': q['page_start'],
+ 'bbox': [],
+ 'confidence': 1.0,
+ 'page_spans': list(range(q['page_start'], q['page_end'] + 1)),
+ 'source_text': q['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": len(set(line["page"] for line in lines)),
+ "questions_found": len(formatted_questions),
+ "stats": {
+ "total_time": total_time,
+ "pages_processed": len(set(line["page"] for line in lines)),
+ "questions_found": len(formatted_questions),
+ "avg_time_per_page": total_time / len(set(line["page"] for line in lines)) if lines else 0,
+ "method": "enhanced_text_parsing"
+ }
+ }
+
+ except Exception as e:
+ print(f"❌ 增强解析失败: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+# 测试函数
+def test_enhanced_parser():
+ """
+ 测试增强解析器
+ """
+ import os
+ import time
+
+ pdf_path = "/Users/brunogao/Downloads/深圳市八年级(上)期中数学试卷1.pdf"
+
+ if not os.path.exists(pdf_path):
+ print(f"❌ 测试文件不存在: {pdf_path}")
+ return
+
+ parser = PDFExamParserEnhanced()
+ 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("前8道题预览:")
+ for i, q in enumerate(result['questions'][:8]):
+ options_str = ""
+ if q.get('options'):
+ options_str = f" 选项: {', '.join([f'{k}. {v[:20]}...' for k, v in q['options'].items()])}"
+
+ print(f"题目{q['number']} ({q.get('type', 'unknown')}): {q['text'][:60]}...{options_str}")
+ else:
+ print(f"❌ 测试失败: {result['error']}")
+
+if __name__ == "__main__":
+ import time
+ test_enhanced_parser()
\ No newline at end of file
diff --git a/backend/pdf_exam_parser_fixed.py b/backend/pdf_exam_parser_fixed.py
new file mode 100644
index 0000000..e2718c9
--- /dev/null
+++ b/backend/pdf_exam_parser_fixed.py
@@ -0,0 +1,411 @@
+"""
+修复版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()
\ No newline at end of file
diff --git a/backend/pdf_splitter_hybrid.py b/backend/pdf_splitter_hybrid.py
new file mode 100644
index 0000000..e0924cd
--- /dev/null
+++ b/backend/pdf_splitter_hybrid.py
@@ -0,0 +1,184 @@
+"""
+混合PDF拆分器:文本层解析 + OCR备份
+优先使用文本层解析(快速准确),OCR作为扫描版PDF的备份方案
+"""
+
+import os
+import time
+from typing import Dict, List
+from pdf_exam_parser_enhanced import PDFExamParserEnhanced
+from pdf_exam_parser_fixed import PDFExamParserFixed
+from pdf_splitter_improved import PDFSplitterImproved
+
+class PDFSplitterHybrid:
+ def __init__(self):
+ self.text_parser = PDFExamParserEnhanced()
+ self.fixed_parser = PDFExamParserFixed()
+ self.ocr_parser = PDFSplitterImproved()
+
+ def has_extractable_text(self, pdf_path: str) -> bool:
+ """
+ 检查PDF是否包含可提取的文本层
+ """
+ try:
+ import fitz
+ doc = fitz.open(pdf_path)
+
+ # 检查前几页是否有文本
+ text_found = False
+ max_pages_to_check = min(3, len(doc))
+
+ for page_num in range(max_pages_to_check):
+ page = doc[page_num]
+ text = page.get_text().strip()
+
+ # 如果找到足够的文本内容,认为有文本层
+ if len(text) > 100: # 至少100个字符
+ text_found = True
+ break
+
+ doc.close()
+ return text_found
+
+ except Exception as e:
+ print(f"⚠️ 检查文本层失败: {e}")
+ return False
+
+ def fix_empty_options(self, pdf_path: str, result: Dict) -> Dict:
+ """
+ 修复空选项问题
+ """
+ try:
+ import fitz
+ doc = fitz.open(pdf_path)
+
+ for question in result.get("questions", []):
+ if question.get("type") == "single_choice" and question.get("options"):
+ options = question["options"]
+ empty_options = [k for k, v in options.items() if v.strip() == ""]
+
+ if empty_options:
+ # 提取该题目页面的文本
+ page_num = question.get("page", 1) - 1
+ if page_num < len(doc):
+ page = doc[page_num]
+ text = page.get_text()
+
+ # 查找选项模式
+ for opt_label in empty_options:
+ pattern = f"{opt_label}\\s*[..)]\\s*([^A-D]*?)(?=[A-D]\\s*[..)]|$)"
+ import re
+ match = re.search(pattern, text, re.DOTALL)
+ if match:
+ content = match.group(1).strip()
+ if content:
+ options[opt_label] = content
+ print(f"🔧 修复选项{opt_label}: {content[:30]}...")
+ else:
+ # 备用方案:标记为数学符号
+ options[opt_label] = f"[数学符号-{opt_label}]"
+ print(f"⚠️ 无法修复选项{opt_label},标记为数学符号")
+
+ doc.close()
+
+ except Exception as e:
+ print(f"⚠️ 选项修复失败: {e}")
+
+ return result
+
+ def process_pdf(self, pdf_path: str, output_dir: str = None) -> Dict:
+ """
+ 混合处理策略:优先文本层,后备OCR
+ """
+ total_start = time.time()
+ print(f"🎯 开始混合PDF处理: {pdf_path}")
+
+ try:
+ # 1. 检查是否有可提取的文本层
+ has_text = self.has_extractable_text(pdf_path)
+
+ if has_text:
+ print("✅ 检测到文本层,使用增强文本解析")
+ result = self.text_parser.process_pdf(pdf_path)
+
+ # 验证文本解析结果质量
+ if (result.get("success") and
+ result.get("questions_found", 0) > 0):
+
+ print(f"🎉 增强文本层解析成功: {result['questions_found']}道题")
+ result["stats"]["method"] = "enhanced_text_parsing"
+
+ # 检查选择题选项是否有数学符号问题
+ choice_questions = [q for q in result.get("questions", []) if q.get("type") == "single_choice"]
+ has_empty_options = any(
+ any(opt.strip() == "" for opt in (q.get("options", {}).values()))
+ for q in choice_questions
+ )
+
+ if has_empty_options:
+ print("⚠️ 检测到选择题选项有空值,尝试选项修复")
+ # 尝试修复空选项
+ result = self.fix_empty_options(pdf_path, result)
+ result["stats"]["method"] = "enhanced_text_parsing_with_fix"
+
+ return result
+ else:
+ print("⚠️ 增强文本层解析结果不理想,回退到OCR")
+ else:
+ print("⚠️ 未检测到文本层,直接使用OCR")
+
+ # 2. 使用OCR作为备份或主要方案
+ print("🔄 使用OCR解析")
+ result = self.ocr_parser.process_pdf(pdf_path, output_dir)
+
+ if result.get("success"):
+ result["stats"]["method"] = "ocr_backup" if has_text else "ocr_primary"
+ print(f"✅ OCR解析完成: {result.get('questions_found', 0)}道题")
+
+ return result
+
+ except Exception as e:
+ print(f"❌ 混合处理失败: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+# 测试函数
+def test_hybrid_parser():
+ """
+ 测试混合解析器
+ """
+ pdf_path = "/Users/brunogao/Downloads/深圳市八年级(上)期中数学试卷1.pdf"
+
+ if not os.path.exists(pdf_path):
+ print(f"❌ 测试文件不存在: {pdf_path}")
+ return
+
+ parser = PDFSplitterHybrid()
+ 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道选择题详细:")
+ choice_count = 0
+ for q in result['questions']:
+ if q.get('type') == 'single_choice' and choice_count < 3:
+ choice_count += 1
+ print(f"\\n题目{q['number']}:")
+ print(f"题干: {q['text']}")
+ if q.get('options'):
+ print("选项:")
+ for k, v in q['options'].items():
+ print(f" {k}. {v}")
+ elif choice_count >= 3:
+ break
+ else:
+ print(f"❌ 测试失败: {result['error']}")
+
+if __name__ == "__main__":
+ test_hybrid_parser()
\ No newline at end of file
diff --git a/backend/pdf_text_parser.py b/backend/pdf_text_parser.py
new file mode 100644
index 0000000..bd7e1d4
--- /dev/null
+++ b/backend/pdf_text_parser.py
@@ -0,0 +1,442 @@
+"""
+基于PDF文本层的智能题目解析器
+借鉴ChatGPT的策略:直接提取PDF文本层,避免OCR误差
+"""
+
+import re
+import json
+import time
+from typing import List, Dict, Tuple, Optional
+import fitz # PyMuPDF
+import pdfplumber
+from dataclasses import dataclass
+
+@dataclass
+class Question:
+ section: str
+ qno: str
+ type: str
+ stem: str
+ options: Dict[str, str]
+ page_start: int
+ page_end: int
+ confidence: float = 1.0
+
+class PDFTextParser:
+ def __init__(self):
+ # 大题分区正则
+ self.section_patterns = [
+ re.compile(r'^\s*[((]?([一二三四五六七八九十]{1,3})[、.)]\s*(\S*题)', re.MULTILINE),
+ re.compile(r'^\s*(\d+)\s*[、.]\s*(\S*题)', re.MULTILINE),
+ ]
+
+ # 题号正则 - 支持多种格式
+ self.question_patterns = [
+ re.compile(r'^\s*(?:第\s*)?([0-9]{1,3}|[一二三四五六七八九十]{1,3})\s*[.)]'),
+ re.compile(r'^\s*([0-9]{1,3})\s*[.、]\s*'),
+ re.compile(r'^\s*\(([0-9]{1,3})\)\s*'),
+ re.compile(r'^\s*(([0-9]{1,3}))\s*'),
+ ]
+
+ # 选项正则
+ self.option_patterns = [
+ re.compile(r'^\s*([A-H])\s*[.)]\s*(.*)'),
+ re.compile(r'^\s*(([A-H]))\s*(.*)'),
+ re.compile(r'^\s*\(([A-H])\)\s*(.*)'),
+ ]
+
+ # 中文数字转换
+ self.chinese_numbers = {
+ '一': '1', '二': '2', '三': '3', '四': '4', '五': '5',
+ '六': '6', '七': '7', '八': '8', '九': '9', '十': '10',
+ '十一': '11', '十二': '12', '十三': '13', '十四': '14', '十五': '15',
+ '十六': '16', '十七': '17', '十八': '18', '十九': '19', '二十': '20'
+ }
+
+ def extract_text_with_pymupdf(self, pdf_path: str) -> List[Dict]:
+ """
+ 使用PyMuPDF提取文本(优先,速度快,顺序准确)
+ """
+ try:
+ doc = fitz.open(pdf_path)
+ pages_text = []
+
+ for page_num in range(len(doc)):
+ page = doc[page_num]
+ text = page.get_text()
+
+ # 按行分割并保持行信息
+ lines = []
+ for line in text.split('\n'):
+ line = line.strip()
+ if line: # 过滤空行
+ lines.append(line)
+
+ pages_text.append({
+ 'page_num': page_num + 1,
+ 'lines': lines,
+ 'full_text': text
+ })
+
+ doc.close()
+ print(f"✅ PyMuPDF提取完成: {len(pages_text)}页")
+ return pages_text
+
+ except Exception as e:
+ print(f"❌ PyMuPDF提取失败: {e}")
+ return []
+
+ def extract_text_with_pdfplumber(self, pdf_path: str) -> List[Dict]:
+ """
+ 使用pdfplumber提取文本(备用,处理复杂布局)
+ """
+ try:
+ pages_text = []
+
+ with pdfplumber.open(pdf_path) as pdf:
+ for page_num, page in enumerate(pdf.pages):
+ text = page.extract_text()
+
+ if text:
+ lines = []
+ for line in text.split('\n'):
+ line = line.strip()
+ if line:
+ lines.append(line)
+
+ pages_text.append({
+ 'page_num': page_num + 1,
+ 'lines': lines,
+ 'full_text': text
+ })
+
+ print(f"✅ pdfplumber提取完成: {len(pages_text)}页")
+ return pages_text
+
+ except Exception as e:
+ print(f"❌ pdfplumber提取失败: {e}")
+ return []
+
+ def extract_pdf_text(self, pdf_path: str) -> List[Dict]:
+ """
+ 智能文本提取:优先PyMuPDF,失败时回退pdfplumber
+ """
+ print(f"🔄 开始PDF文本提取: {pdf_path}")
+ start_time = time.time()
+
+ # 优先使用PyMuPDF
+ pages_text = self.extract_text_with_pymupdf(pdf_path)
+
+ # 如果PyMuPDF失败或结果为空,回退到pdfplumber
+ if not pages_text:
+ print("⚠️ PyMuPDF结果为空,回退到pdfplumber")
+ pages_text = self.extract_text_with_pdfplumber(pdf_path)
+
+ print(f"⚡ 文本提取完成: 耗时{time.time() - start_time:.2f}秒")
+ return pages_text
+
+ def normalize_text(self, text: str) -> str:
+ """
+ 文本标准化:处理全角半角、中文数字等
+ """
+ # 全角转半角
+ text = text.replace('(', '(').replace(')', ')')
+ text = text.replace('.', '.').replace('、', '.')
+ text = text.replace(':', ':').replace(';', ';')
+
+ # 去除多余空格
+ text = re.sub(r'\s+', ' ', text).strip()
+
+ return text
+
+ def detect_sections(self, pages_text: List[Dict]) -> List[Dict]:
+ """
+ 检测大题分区(一、选择题,二、填空题等)
+ """
+ sections = []
+ current_section = None
+
+ for page_data in pages_text:
+ page_num = page_data['page_num']
+
+ for line in page_data['lines']:
+ normalized_line = self.normalize_text(line)
+
+ # 检测分区标题
+ for pattern in self.section_patterns:
+ match = pattern.match(normalized_line)
+ if match:
+ section_num = match.group(1)
+ section_name = match.group(2)
+
+ # 转换中文数字
+ if section_num in self.chinese_numbers:
+ section_num = self.chinese_numbers[section_num]
+
+ current_section = {
+ 'section_num': section_num,
+ 'section_name': section_name,
+ 'start_page': page_num,
+ 'start_line': normalized_line
+ }
+ sections.append(current_section)
+ print(f"📚 检测到分区: {section_num}、{section_name} (第{page_num}页)")
+ break
+
+ return sections
+
+ def convert_chinese_number(self, num_str: str) -> str:
+ """
+ 转换中文数字为阿拉伯数字
+ """
+ if num_str in self.chinese_numbers:
+ return self.chinese_numbers[num_str]
+ return num_str
+
+ def detect_question_number(self, line: str) -> Optional[str]:
+ """
+ 检测题号
+ """
+ normalized_line = self.normalize_text(line)
+
+ for pattern in self.question_patterns:
+ match = pattern.match(normalized_line)
+ if match:
+ qno = match.group(1)
+ # 转换中文数字
+ qno = self.convert_chinese_number(qno)
+ try:
+ # 验证是否为合理的题号
+ num = int(qno)
+ if 1 <= num <= 100: # 假设题目数量在1-100之间
+ return str(num)
+ except ValueError:
+ continue
+
+ return None
+
+ def detect_option(self, line: str) -> Optional[Tuple[str, str]]:
+ """
+ 检测选项(A、B、C、D等)
+ """
+ normalized_line = self.normalize_text(line)
+
+ for pattern in self.option_patterns:
+ match = pattern.match(normalized_line)
+ if match:
+ option_letter = match.group(1)
+ option_content = match.group(2).strip()
+ return option_letter, option_content
+
+ return None
+
+ def parse_questions(self, pages_text: List[Dict]) -> List[Question]:
+ """
+ 解析题目:核心算法
+ """
+ print("🔄 开始智能题目解析")
+ start_time = time.time()
+
+ questions = []
+ current_question = None
+ current_section = "未知"
+ all_lines = []
+
+ # 合并所有页面的行,保持页码信息
+ for page_data in pages_text:
+ for line in page_data['lines']:
+ all_lines.append({
+ 'text': line,
+ 'page': page_data['page_num']
+ })
+
+ i = 0
+ while i < len(all_lines):
+ line_data = all_lines[i]
+ line = line_data['text']
+ page_num = line_data['page']
+
+ # 检测分区
+ for pattern in self.section_patterns:
+ match = pattern.match(self.normalize_text(line))
+ if match:
+ section_name = match.group(2)
+ current_section = section_name
+ print(f"📚 进入分区: {section_name}")
+ break
+
+ # 检测题号
+ qno = self.detect_question_number(line)
+ if qno:
+ # 保存上一题
+ if current_question:
+ questions.append(current_question)
+
+ # 开始新题
+ question_type = self.infer_question_type(current_section, line)
+ current_question = Question(
+ section=current_section,
+ qno=qno,
+ type=question_type,
+ stem=line,
+ options={},
+ page_start=page_num,
+ page_end=page_num
+ )
+ print(f"📝 题目{qno}: {line[:50]}...")
+
+ elif current_question:
+ # 检测选项
+ option_result = self.detect_option(line)
+ if option_result:
+ option_letter, option_content = option_result
+ current_question.options[option_letter] = option_content
+ current_question.page_end = page_num
+ else:
+ # 继续当前题目内容
+ if len(current_question.stem) < 1000: # 防止过长
+ current_question.stem += ' ' + line
+ current_question.page_end = page_num
+
+ i += 1
+
+ # 保存最后一题
+ if current_question:
+ questions.append(current_question)
+
+ print(f"✅ 题目解析完成: {len(questions)}道题, 耗时{time.time() - start_time:.2f}秒")
+ return questions
+
+ def infer_question_type(self, section: str, line: str) -> str:
+ """
+ 推断题目类型
+ """
+ if '选择' in section:
+ return 'multiple_choice'
+ elif '填空' in section:
+ return 'fill_blank'
+ elif '解答' in section or '计算' in section:
+ return 'solve'
+ elif '判断' in section:
+ return 'true_false'
+ else:
+ # 根据内容推断
+ if re.search(r'[A-D][.))]', line):
+ return 'multiple_choice'
+ elif '______' in line or '___' in line:
+ return 'fill_blank'
+ else:
+ return 'unknown'
+
+ def process_pdf(self, pdf_path: str) -> Dict:
+ """
+ 主处理函数
+ """
+ total_start = time.time()
+ print(f"🎯 开始基于文本层的PDF解析: {pdf_path}")
+
+ try:
+ # 1. 提取PDF文本
+ pages_text = self.extract_pdf_text(pdf_path)
+
+ if not pages_text:
+ return {"success": False, "error": "PDF文本提取失败"}
+
+ # 2. 解析题目
+ questions = self.parse_questions(pages_text)
+
+ # 3. 转换为API格式
+ formatted_questions = []
+ for q in questions:
+ formatted_questions.append({
+ 'number': q.qno,
+ 'type': q.type,
+ 'text': q.stem,
+ 'options': q.options if q.options else None,
+ 'page': q.page_start,
+ 'bbox': [], # 文本层解析无bbox信息
+ 'confidence': q.confidence,
+ 'page_spans': list(range(q.page_start, q.page_end + 1)),
+ 'source_text': q.stem,
+ 'images': [],
+ 'auto_extracted': True,
+ 'section': q.section
+ })
+
+ total_time = time.time() - total_start
+ print(f"🎉 文本层解析完成!总耗时: {total_time:.2f}秒")
+
+ return {
+ "success": True,
+ "questions": formatted_questions,
+ "total_time": total_time,
+ "pages_processed": len(pages_text),
+ "questions_found": len(formatted_questions),
+ "stats": {
+ "total_time": total_time,
+ "pages_processed": len(pages_text),
+ "questions_found": len(formatted_questions),
+ "avg_time_per_page": total_time / len(pages_text) if pages_text else 0,
+ "method": "text_layer_parsing"
+ }
+ }
+
+ except Exception as e:
+ print(f"❌ 文本层解析失败: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def export_to_json(self, questions: List[Question], output_path: str):
+ """
+ 导出为JSON格式
+ """
+ data = []
+ for q in questions:
+ data.append({
+ 'section': q.section,
+ 'qno': q.qno,
+ 'type': q.type,
+ 'stem': q.stem,
+ 'options': q.options,
+ 'page_start': q.page_start,
+ 'page_end': q.page_end,
+ 'confidence': q.confidence
+ })
+
+ with open(output_path, 'w', encoding='utf-8') as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+
+ print(f"✅ JSON导出完成: {output_path}")
+
+# 测试函数
+def test_text_parser():
+ """
+ 测试文本解析器
+ """
+ pdf_path = "/Users/brunogao/Downloads/深圳市八年级(上)期中数学试卷1.pdf"
+
+ if not os.path.exists(pdf_path):
+ print(f"❌ 测试文件不存在: {pdf_path}")
+ return
+
+ parser = PDFTextParser()
+ 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("识别的题目:")
+ for i, q in enumerate(result['questions'][:8]):
+ options_str = f" 选项: {list(q.get('options', {}).keys())}" if q.get('options') else ""
+ print(f"题目{q['number']} ({q['type']}): {q['text'][:60]}...{options_str}")
+ else:
+ print(f"❌ 测试失败: {result['error']}")
+
+if __name__ == "__main__":
+ import os
+ test_text_parser()
\ No newline at end of file
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
index 10116eb..b33734c 100644
--- a/frontend/src/services/api.js
+++ b/frontend/src/services/api.js
@@ -4,7 +4,7 @@ import { API_BASE_URL } from '../config'
const api = axios.create({
baseURL: API_BASE_URL,
- timeout: 10000,
+ timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
diff --git a/frontend/src/views/PaperDetail.vue b/frontend/src/views/PaperDetail.vue
index cd20cab..a0c33fa 100644
--- a/frontend/src/views/PaperDetail.vue
+++ b/frontend/src/views/PaperDetail.vue
@@ -19,6 +19,9 @@
提取错题
+
+ 拆分题目
+
@@ -244,8 +247,8 @@