基于PDFMiner从试卷PDF提取题目及子题截图的技术难题
从PDF考试试卷提取题目及子题独立截图的解决方案
问题概述
需从PDF格式的A-Level物理试卷中提取题干与子题的独立截图,但使用PDFMiner时无法精准识别题号:
- 误将页面中的其他数字判定为题号
- 无法覆盖两种题号排版场景:主题号单独存在于
LTTextBoxHorizontal、主题号与子题(如(a))同属一个LTTextBoxHorizontal
现有代码问题分析
- 题号匹配逻辑过于简单:仅通过
text.startswith(str(q_num))判断,未考虑试卷题号的标准格式(如1.、(a)),导致误判其他数字 - 未结合排版特征:忽略了题号通常具备的左侧定位、更大字号/加粗等视觉特征
- 未处理题号与子题同框的场景:现有逻辑仅针对单独成框的题号,无法识别同框内的题号与子题分界
改进方案(无AI免费实现)
核心思路
结合PDFMiner的布局分析能力,通过正则匹配格式+排版特征筛选定位题号区域,再用pdf2image将PDF转为图片后,基于识别到的边界框(bbox)截取独立截图。
具体步骤
- 正则匹配题号格式:针对目标试卷的题号规则,用正则匹配主题号(如
^\d+\.\s)和子题号(如^\([a-z]\)\s) - 排版特征辅助筛选:提取文本框中字符的字号、粗细,题号通常使用更大字号或加粗字体;同时题号多位于页面左侧固定区域
- 区分两种题号场景:
- 单独成框的主题号:直接定位该文本框的bbox作为题干起始
- 与子题同框的主题号:拆分文本框内的行,找到主题号对应的行bbox
- 生成独立截图:将PDF转为图片后,根据每个题号/子题的bbox区域裁剪并保存
完整实现代码
import re from pdfminer.high_level import extract_pages from pdfminer.layout import LTTextBoxHorizontal, LTTextLineHorizontal, LTChar from pdf2image import convert_from_path from PIL import Image # 配置参数 PDF_PATH = "./PhysicsPdfs/9702_m23_qp_22.pdf" OUTPUT_DIR = "./extracted_questions" MIN_FONT_SIZE = 12 # 题号通常的最小字号 LEFT_X_THRESHOLD = 100 # 题号左侧x坐标阈值 # 创建输出目录 import os os.makedirs(OUTPUT_DIR, exist_ok=True) # 正则表达式:匹配主题号(如1. 2.)和子题号(如(a) (b)) MAIN_Q_PATTERN = re.compile(r"^\d+\.\s") SUB_Q_PATTERN = re.compile(r"^\([a-z]\)\s") def is_bold(char: LTChar) -> bool: """判断字符是否为加粗""" return "Bold" in char.fontname or "bold" in char.fontname def get_textline_font_info(textline: LTTextLineHorizontal) -> tuple[float, bool]: """获取文本行的最大字号和是否加粗""" max_size = 0 has_bold = False for char in textline: if isinstance(char, LTChar): if char.size > max_size: max_size = char.size if is_bold(char): has_bold = True return max_size, has_bold def extract_question_bboxes(pdf_path: str) -> list[dict]: """提取所有主题干和子题的边界框信息""" question_bboxes = [] current_page = 0 for page_layout in extract_pages(pdf_path): current_page += 1 for element in page_layout: if isinstance(element, LTTextBoxHorizontal): # 遍历文本框内的每一行 for textline in element: if isinstance(textline, LTTextLineHorizontal): text = textline.get_text().strip() if not text: continue # 获取文本行的字体信息和位置 x0, y0, x1, y1 = textline.bbox font_size, is_bold_text = get_textline_font_info(textline) # 匹配主题号:左侧位置、字号达标、格式匹配、加粗 if (x0 < LEFT_X_THRESHOLD and font_size >= MIN_FONT_SIZE and is_bold_text and MAIN_Q_PATTERN.match(text)): question_num = MAIN_Q_PATTERN.match(text).group().strip() question_bboxes.append({ "type": "main", "num": question_num, "page": current_page - 1, # pdf2image的页码从0开始 "bbox": (x0, y0, x1, y1) }) # 匹配子题号:格式匹配 elif SUB_Q_PATTERN.match(text): sub_num = SUB_Q_PATTERN.match(text).group().strip() question_bboxes.append({ "type": "sub", "num": sub_num, "page": current_page - 1, "bbox": (x0, y0, x1, y1) }) return question_bboxes def crop_questions(pdf_path: str, bboxes: list[dict], output_dir: str): """根据边界框裁剪图片并保存""" # 将PDF转为图片(dpi设置为300保证清晰度) pages = convert_from_path(pdf_path, dpi=300) for idx, bbox_info in enumerate(bboxes): page_idx = bbox_info["page"] img = pages[page_idx] img_width, img_height = img.size # PDF的bbox坐标是左下角为原点,PIL是左上角为原点,需要转换 x0, y0, x1, y1 = bbox_info["bbox"] # 转换y坐标:PDF的y0是底部,PIL的y0是顶部 pil_y0 = img_height - y1 pil_y1 = img_height - y0 # 裁剪区域:(left, upper, right, lower) crop_area = (x0*3, pil_y0*3, x1*3, pil_y1*3) # dpi=300,坐标放大3倍 cropped_img = img.crop(crop_area) # 保存图片 filename = f"{bbox_info['type']}_q_{bbox_info['num'].replace('.', '').replace('(', '').replace(')', '')}_{idx+1}.png" cropped_img.save(os.path.join(output_dir, filename)) # 执行流程 if __name__ == "__main__": bboxes = extract_question_bboxes(PDF_PATH) crop_questions(PDF_PATH, bboxes, OUTPUT_DIR) print(f"已提取{len(bboxes)}个题目区域,保存至{OUTPUT_DIR}")
使用说明
- 安装依赖:
注意:pip install pdfminer.six pdf2image pillowpdf2image依赖Poppler,需根据系统安装:- Windows:下载Poppler并添加到环境变量
- macOS:
brew install poppler - Linux:
sudo apt-get install poppler-utils
- 调整参数:根据目标试卷的排版,修改
MIN_FONT_SIZE、LEFT_X_THRESHOLD等参数 - 运行代码:将PDF路径改为实际路径,执行后在输出目录得到独立的题干/子题截图
关键优化点
- 通过正则匹配精准锁定题号格式,避免误判其他数字
- 结合字体大小、加粗、左侧位置三个特征,提升题号识别准确率
- 兼容主题号单独成框和与子题同框的两种场景
- 完成bbox坐标转换,确保截图区域准确
内容的提问来源于stack exchange,提问作者Mario_Dev
相关产品推荐
相关产品推荐

