|
|
@@ -7,6 +7,7 @@ import fitz
|
|
|
import re
|
|
|
import os
|
|
|
import csv
|
|
|
+from collections import Counter
|
|
|
from type_detector import detect_type
|
|
|
import math
|
|
|
import io
|
|
|
@@ -177,10 +178,11 @@ def extract_a1_data(text, page_texts=None):
|
|
|
if pct_match:
|
|
|
result[f'{dim}_pct'] = pct_match.group(1)
|
|
|
|
|
|
- # 方法2: 从detail页面提取原始分和百分位 (格式: "我的感知觉得分 41 | 14%")
|
|
|
+ # 方法2: 从detail页面提取原始分和百分位 (格式: "我的感知觉得分 41 | 14%" 或 "195ح91%")
|
|
|
+ # 注: 有些PDF用 ح (U+062D) 代替 | 作为分隔符 (PyMuPDF字体映射产物)
|
|
|
for dim in cognitive_dims:
|
|
|
detail_match = re.search(
|
|
|
- rf'我的{re.escape(dim)}得分\s*(\d+)\s*\|\s*(\d+)%',
|
|
|
+ rf'我的{re.escape(dim)}得分\s*(\d+)\s*[\|\u062D]\s*(\d+)%',
|
|
|
text
|
|
|
)
|
|
|
if detail_match:
|
|
|
@@ -188,6 +190,23 @@ def extract_a1_data(text, page_texts=None):
|
|
|
if f'{dim}_pct' not in result:
|
|
|
result[f'{dim}_pct'] = detail_match.group(2)
|
|
|
|
|
|
+ # 方法2b: 后备 - 用两段法处理更复杂的乱码格式 (同B4格式1)
|
|
|
+ if len([k for k in result if k.endswith('_score')]) < 6:
|
|
|
+ for dim in cognitive_dims:
|
|
|
+ if f'{dim}_score' not in result:
|
|
|
+ detail_section = re.search(
|
|
|
+ rf'我的{re.escape(dim)}得分(.{{1,300}})',
|
|
|
+ text, re.DOTALL
|
|
|
+ )
|
|
|
+ if detail_section:
|
|
|
+ section_text = detail_section.group(1)
|
|
|
+ pct_m = re.search(r'(\d+)%', section_text)
|
|
|
+ if pct_m and f'{dim}_pct' not in result:
|
|
|
+ result[f'{dim}_pct'] = pct_m.group(1)
|
|
|
+ score_m = re.search(r'(\d+)', section_text)
|
|
|
+ if score_m:
|
|
|
+ result[f'{dim}_score'] = score_m.group(1)
|
|
|
+
|
|
|
# 方法3: 后备 - 直接搜索 "维度名\n百分位(%)\n数字" (某些PDF格式)
|
|
|
if len([k for k in result if k.endswith('_pct')]) < 6:
|
|
|
for dim in cognitive_dims:
|
|
|
@@ -200,9 +219,103 @@ def extract_a1_data(text, page_texts=None):
|
|
|
if fallback:
|
|
|
result[f'{dim}_pct'] = fallback.group(1)
|
|
|
|
|
|
+ # 方法4: 后备 - 旧DAN格式 "感知觉\n91%\n感知觉(Perception Percentile):"
|
|
|
+ if len([k for k in result if k.endswith('_pct')]) < 6:
|
|
|
+ for dim in cognitive_dims:
|
|
|
+ if f'{dim}_pct' not in result:
|
|
|
+ dan_match = re.search(
|
|
|
+ rf'{re.escape(dim)}\n(\d+)%\n{re.escape(dim)}\(',
|
|
|
+ text
|
|
|
+ )
|
|
|
+ if dan_match:
|
|
|
+ result[f'{dim}_pct'] = dan_match.group(1)
|
|
|
+
|
|
|
+ # 注: 艾远等极少数A1报告PDF中无"我的记忆力得分"detail页,
|
|
|
+ # 记忆力_score无法通过文本提取(属数据源限制,非提取bug)
|
|
|
return result
|
|
|
|
|
|
-def extract_a2_data(text, page_texts=None):
|
|
|
+def _extract_a2_emotional_scores_from_page(page, page5_text=""):
|
|
|
+ """使用坐标感知从page.get_text('dict')提取A2情绪4维度分数
|
|
|
+
|
|
|
+ PDF布局:第5页底部有4条水平刻度条,每条代表1个情绪维度。
|
|
|
+ 每条刻度上有4个数字(实际分, 1, 10, 15)沿水平方向排列。
|
|
|
+ 用y坐标聚类确定哪些数字属于同一维度(行),用x坐标确定列顺序。
|
|
|
+
|
|
|
+ Returns: list of 4 floats (actual scores per dimension), or empty list on failure
|
|
|
+ """
|
|
|
+ # 收集所有数字文本块
|
|
|
+ numbers = [] # [(y, x, value)]
|
|
|
+ blocks = page.get_text("dict")["blocks"]
|
|
|
+ for block in blocks:
|
|
|
+ if block.get("type") != 0:
|
|
|
+ continue
|
|
|
+ for line in block.get("lines", []):
|
|
|
+ for span in line.get("spans", []):
|
|
|
+ txt = span["text"].strip()
|
|
|
+ try:
|
|
|
+ val = float(txt)
|
|
|
+ if 0.5 <= val <= 20:
|
|
|
+ numbers.append((span["bbox"][1], span["bbox"][0], val))
|
|
|
+ except ValueError:
|
|
|
+ pass
|
|
|
+
|
|
|
+ if len(numbers) < 12:
|
|
|
+ return []
|
|
|
+
|
|
|
+ # 按y坐标分组(聚类): y差<28pt视为同一行
|
|
|
+ # (实际分和刻度值1,10,15之间有≈18.6pt的垂直间距,需用大阈值聚类)
|
|
|
+ numbers.sort(key=lambda n: n[0]) # sort by y
|
|
|
+ rows = []
|
|
|
+ cur = [numbers[0]]
|
|
|
+ for n in numbers[1:]:
|
|
|
+ if abs(n[0] - cur[-1][0]) < 28:
|
|
|
+ cur.append(n)
|
|
|
+ else:
|
|
|
+ if len(cur) >= 3:
|
|
|
+ rows.append(cur)
|
|
|
+ cur = [n]
|
|
|
+ if len(cur) >= 3:
|
|
|
+ rows.append(cur)
|
|
|
+
|
|
|
+ if len(rows) < 3:
|
|
|
+ return []
|
|
|
+
|
|
|
+ # 取最后4行(刻度条在页面底部)
|
|
|
+ rows = rows[-4:]
|
|
|
+
|
|
|
+ # 每行按x排序,取前4个值
|
|
|
+ for row in rows:
|
|
|
+ row.sort(key=lambda n: n[1])
|
|
|
+
|
|
|
+ # 注:不再去重。PDF有时在同一坐标渲染两次相同数字(重叠文本),
|
|
|
+ # 但保持所有值可以让Counter检测到"实际分=刻度值"的重叠情况。
|
|
|
+
|
|
|
+ dims = []
|
|
|
+ for row in rows:
|
|
|
+ vals = [v for _, _, v in row[:5]] # 取前5个以防有重叠
|
|
|
+ # 过滤掉刻度值 {1, 10, 15},取非刻度值作为实际分
|
|
|
+ non_scale = [v for v in vals if v not in {1.0, 10.0, 15.0}]
|
|
|
+ if len(non_scale) == 1:
|
|
|
+ dims.append(non_scale[0])
|
|
|
+ elif len(non_scale) == 0:
|
|
|
+ # 所有值都是刻度值 → 实际分与刻度值重叠(实际分=15或10)
|
|
|
+ # 检查哪个值出现两次(实际分+刻度值=同一数字渲染两次)
|
|
|
+ counts = Counter(vals)
|
|
|
+ duplicates = [v for v, c in counts.items() if c >= 2 and v in {1.0, 10.0, 15.0}]
|
|
|
+ if duplicates:
|
|
|
+ # 出现两次的值就是实际分数(因为刻度值只出现一次,被实际分+刻度值重叠)
|
|
|
+ dims.append(duplicates[0])
|
|
|
+ else:
|
|
|
+ # 无法确定,跳过
|
|
|
+ dims.append(None)
|
|
|
+ else:
|
|
|
+ # 多个非刻度值,可能分组错误,跳过
|
|
|
+ dims.append(None)
|
|
|
+
|
|
|
+ return dims
|
|
|
+
|
|
|
+
|
|
|
+def extract_a2_data(text, page_texts=None, pdf_path=None):
|
|
|
"""提取A2报告数据 - 核心素养16+(认知+人格+情绪+关系+健康)"""
|
|
|
result = {}
|
|
|
|
|
|
@@ -220,24 +333,56 @@ def extract_a2_data(text, page_texts=None):
|
|
|
if page_texts and len(page_texts) > 4:
|
|
|
page5_text = page_texts[4]
|
|
|
|
|
|
- # 方法1: 原始格式 "数字\nInferiority"
|
|
|
- emotion_names = ['自卑-自信', '抑郁-安详', '焦虑-安详', '无力感-掌控感']
|
|
|
- emotion_eng = ['Inferiority', 'Depression', 'Anxiety', 'Helpless']
|
|
|
+ # 第4维名称统一为无力感-掌控感(早期报告Dependent标签也属同一维度)
|
|
|
+ emotion_names = ['自卑-自尊', '抑郁-愉快', '焦虑-安详', '无力感-掌控感']
|
|
|
|
|
|
- for eng, cn in zip(emotion_eng, emotion_names):
|
|
|
- em_match = re.search(rf'(\d+(?:\.?\d+)?)\s*\n\s*{re.escape(eng)}', page5_text)
|
|
|
- if em_match:
|
|
|
- result[cn] = em_match.group(1)
|
|
|
+ # 方法1: 坐标感知提取(优先,能处理刻度值重叠和实际分=刻度值的情况)
|
|
|
+ coord_dims = []
|
|
|
+ if pdf_path and os.path.exists(pdf_path):
|
|
|
+ try:
|
|
|
+ doc_coord = fitz.open(pdf_path)
|
|
|
+ if len(doc_coord) > 4:
|
|
|
+ coord_dims = _extract_a2_emotional_scores_from_page(doc_coord[4], page5_text)
|
|
|
+ doc_coord.close()
|
|
|
+ except Exception:
|
|
|
+ coord_dims = []
|
|
|
|
|
|
- # 方法2: 旧格式 (后备)
|
|
|
- if not any(k in result for k in emotion_names):
|
|
|
- emotion_pattern = re.findall(r'(\d+)\s+10\s+(\d+\.?\d*)\s+(\d+\.?\d*)', page5_text)
|
|
|
- if len(emotion_pattern) >= 1:
|
|
|
- for i in range(min(len(emotion_pattern), 4)):
|
|
|
- result[emotion_names[i]] = emotion_pattern[i][-1]
|
|
|
+ if len(coord_dims) == 4:
|
|
|
+ for i, val in enumerate(coord_dims):
|
|
|
+ if val is not None and i < len(emotion_names):
|
|
|
+ v_str = str(int(val)) if val == int(val) else str(val)
|
|
|
+ result[emotion_names[i]] = v_str
|
|
|
|
|
|
- # 情绪总分: "在4个分测验中的总得分是 XX分"
|
|
|
- total_em = re.search(r'在4个分测验中的总得分是\s*(\d+(?:\.?\d+)?)分', page5_text)
|
|
|
+ # 方法2: 文本4数字分组提取(后备,当坐标法未完全覆盖时)
|
|
|
+ for i in range(len(emotion_names)):
|
|
|
+ if emotion_names[i] in result:
|
|
|
+ continue # 已由坐标法提取
|
|
|
+ # 找第i组4数字:从page5_text中按顺序取
|
|
|
+ all_groups = re.findall(r'(\d+\.?\d*)\s*\n\s*(\d+\.?\d*)\s*\n\s*(\d+\.?\d*)\s*\n\s*(\d+\.?\d*)', page5_text)
|
|
|
+ if i < len(all_groups):
|
|
|
+ nums = [float(x) for x in all_groups[i]]
|
|
|
+ non_scale = [n for n in nums if n not in {1.0, 10.0, 15.0}]
|
|
|
+ if len(non_scale) == 1:
|
|
|
+ val = non_scale[0]
|
|
|
+ result[emotion_names[i]] = str(int(val)) if val == int(val) else str(val)
|
|
|
+ elif len(non_scale) == 0:
|
|
|
+ # 所有值都是刻度值 → 检查重复值(实际分=刻度值)
|
|
|
+ counts = Counter(nums)
|
|
|
+ dup = [v for v, c in counts.items() if c >= 2 and v in {1.0, 10.0, 15.0}]
|
|
|
+ if dup:
|
|
|
+ result[emotion_names[i]] = str(int(dup[0])) if dup[0] == int(dup[0]) else str(dup[0])
|
|
|
+ else:
|
|
|
+ result[emotion_names[i]] = '15' # fallback
|
|
|
+
|
|
|
+ # 情绪总分: 多种格式(可能跨行)
|
|
|
+ # 格式1: "在4个分测验中的总得分是 XX分" (标准)
|
|
|
+ # 格式2: "在这4个分测验中的得分为XX分" (16+)
|
|
|
+ # 格式3: "组之间的总分/平均分是" 等变体
|
|
|
+ # 注意: 文本可能跨行,故用re.DOTALL
|
|
|
+ total_em = re.search(r'(?:在|这)?4个分测验[^。]*?(?:总得分|得分|总分)\s*[是为]\s*(\d+(?:\.?\d+)?)\s*分', page5_text, re.DOTALL)
|
|
|
+ if not total_em:
|
|
|
+ # 更宽松: 找"4个分测验"后最近的数字+分
|
|
|
+ total_em = re.search(r'4个分测验[^。]*?(\d+(?:\.?\d+)?)\s*分', page5_text, re.DOTALL)
|
|
|
if total_em:
|
|
|
result['情绪总分'] = total_em.group(1)
|
|
|
|
|
|
@@ -246,9 +391,10 @@ def extract_a2_data(text, page_texts=None):
|
|
|
page7_text = page_texts[6]
|
|
|
|
|
|
# 方法1: 直接匹配 "您在"XX"上的得分是 X.X 分"
|
|
|
+ # 注意: PDF中引号可能是 " (U+201C) 或 " (U+201D) 或 " (U+0022) 或 「」
|
|
|
big5_names = ['开放性', '宜人性', '责任心', '外倾性', '神经质']
|
|
|
for dim in big5_names:
|
|
|
- b5_match = re.search(rf'您在[""「]{re.escape(dim)}[""」].*?得分是\s*(\d+\.?\d*)\s*分', page7_text)
|
|
|
+ b5_match = re.search(rf'您在["\u201c\u201d\u300c]{re.escape(dim)}["\u201c\u201d\u300d].*?得分是\s*(\d+\.?\d*)\s*分', page7_text)
|
|
|
if b5_match:
|
|
|
result[dim] = b5_match.group(1)
|
|
|
|
|
|
@@ -280,24 +426,37 @@ def extract_a2_data(text, page_texts=None):
|
|
|
result['与同伴亲近'] = valid_nums[8]
|
|
|
|
|
|
# ===== 身体健康 (第11页/索引10) =====
|
|
|
- # 格式: "BMI:22kg/m²" "身高:156cm" "体重:53kg" "10小时/周" "9小时/每天"
|
|
|
+ # 标准格式: "BMI:22kg/m²" "身高:156cm" "体重:53kg" "10小时/周" "9小时/每天"
|
|
|
+ # 16+格式: 可能没有kg/cm后缀,直接"BMI:22" "身高:156" "体重:53"
|
|
|
if page_texts and len(page_texts) > 10:
|
|
|
page11_text = page_texts[10]
|
|
|
|
|
|
- # BMI格式: "BMI:22kg/m²"
|
|
|
- bmi_match = re.search(r'BMI[::]*\s*(\d+(?:\.\d+)?)\s*kg', page11_text)
|
|
|
+ # BMI: "BMI:22kg/m²" 或 "BMI:22" 或 "BMI 22"
|
|
|
+ bmi_match = re.search(r'BMI[::]*\s*(\d+(?:\.\d+)?)\s*(?:kg|/|$)', page11_text)
|
|
|
if bmi_match:
|
|
|
result['BMI'] = bmi_match.group(1)
|
|
|
+ else:
|
|
|
+ bmi_fallback = re.search(r'BMI[::]\s*(\d+(?:\.\d+)?)', page11_text)
|
|
|
+ if bmi_fallback:
|
|
|
+ result['BMI'] = bmi_fallback.group(1)
|
|
|
|
|
|
- # 身高: "身高:156cm"
|
|
|
- height_match = re.search(r'身高[::]*\s*(\d+)\s*cm', page11_text)
|
|
|
+ # 身高: "身高:156cm" 或 "身高:156" 或 "身高 156cm"
|
|
|
+ height_match = re.search(r'身高[::]*\s*(\d+)\s*(?:cm|$)', page11_text)
|
|
|
if height_match:
|
|
|
result['身高'] = height_match.group(1)
|
|
|
+ else:
|
|
|
+ height_fb = re.search(r'身高[::]\s*(\d+)', page11_text)
|
|
|
+ if height_fb:
|
|
|
+ result['身高'] = height_fb.group(1)
|
|
|
|
|
|
- # 体重: "体重:53kg"
|
|
|
- weight_match = re.search(r'体重[::]*\s*(\d+(?:\.\d+)?)\s*kg', page11_text)
|
|
|
+ # 体重: "体重:53kg" 或 "体重:53" 或 "体重 53kg"
|
|
|
+ weight_match = re.search(r'体重[::]*\s*(\d+(?:\.\d+)?)\s*(?:kg|$)', page11_text)
|
|
|
if weight_match:
|
|
|
result['体重'] = weight_match.group(1)
|
|
|
+ else:
|
|
|
+ weight_fb = re.search(r'体重[::]\s*(\d+(?:\.\d+)?)', page11_text)
|
|
|
+ if weight_fb:
|
|
|
+ result['体重'] = weight_fb.group(1)
|
|
|
|
|
|
# 睡眠: "10小时/周" 或 "9小时/每天" (先找周再找天)
|
|
|
sleep_week = re.search(r'(\d+)\s*小时\s*/\s*周', page11_text)
|
|
|
@@ -313,10 +472,16 @@ def extract_a2_data(text, page_texts=None):
|
|
|
if diet and '饮食' not in result:
|
|
|
result['饮食_小时'] = diet.group(1)
|
|
|
|
|
|
- # 运动: 找"运动习惯"附近的"X小时/周"
|
|
|
+ # 运动: "运动习惯...X小时/周" 或 "X小时/周...运动习惯"(16+格式)
|
|
|
+ # 也是分两块提取或取周
|
|
|
exercise = re.search(r'运动.*?(\d+)\s*小时\s*/\s*周', page11_text, re.DOTALL)
|
|
|
if exercise:
|
|
|
result['运动_小时'] = exercise.group(1)
|
|
|
+ else:
|
|
|
+ # 反方向: "X小时/周"在"运动"前面(16+格式)
|
|
|
+ exercise_rev = re.search(r'(\d+)\s*小时\s*/\s*周.*?运动', page11_text, re.DOTALL)
|
|
|
+ if exercise_rev:
|
|
|
+ result['运动_小时'] = exercise_rev.group(1)
|
|
|
|
|
|
return result
|
|
|
|
|
|
@@ -408,36 +573,36 @@ def extract_b3_data(text, page_texts=None):
|
|
|
result[f'{dim}_pct'] = ef_match.group(1)
|
|
|
|
|
|
# ===== 学习动机 (3维度, 十分制) =====
|
|
|
- # 格式: "深层动机\n我的得分:8分" 或 "深层动机 8分"
|
|
|
+ # 格式: "深层动机\n我的得分:8分" 或 "深层动机 8分" 或 "深层动机\nDeep Motivation\n我的得分:10分"
|
|
|
motivation_dims = ['深层动机', '表面动机', '自我效能感']
|
|
|
for dim in motivation_dims:
|
|
|
- # 方法1: "深层动机...我的得分:8分"
|
|
|
+ # 方法1: "深层动机\nDeep Motivation\n我的得分:10分"(紧邻匹配,禁止跨段取到前一个维度)
|
|
|
+ # 注意: PDF含解释段落(维度名+分数)和表格(维度名+英文名+我的得分)两套文本,
|
|
|
+ # 旧版 {dim}.*?我的得分 会从解释段落跨段撞到表格里前一个维度的"我的得分"。
|
|
|
lm_match = re.search(
|
|
|
- rf'{re.escape(dim)}.*?我的得分[::]\s*(\d+(?:\.\d+)?)',
|
|
|
- text,
|
|
|
- re.DOTALL
|
|
|
+ rf'{re.escape(dim)}\s*(?:[A-Za-z][\w\- ]*\s*)?\n\s*我的得分[::]\s*(\d+(?:\.\d+)?)',
|
|
|
+ text
|
|
|
)
|
|
|
if lm_match:
|
|
|
result[dim] = lm_match.group(1)
|
|
|
else:
|
|
|
- # 方法2: "深层动机 8分"
|
|
|
- lm_match2 = re.search(rf'{re.escape(dim)}\s*(\d+(?:\.\d+)?)\s*分', text)
|
|
|
+ # 方法2: "深层动机 8分" 或 "深层动机\n8分"
|
|
|
+ lm_match2 = re.search(rf'{re.escape(dim)}\s*\n?\s*(\d+(?:\.\d+)?)\s*分', text)
|
|
|
if lm_match2:
|
|
|
result[dim] = lm_match2.group(1)
|
|
|
|
|
|
# ===== 学习策略 (3维度, 十分制) =====
|
|
|
- # 格式: "深层方法与策略...我的得分:6.8分"
|
|
|
+ # 格式: "深层方法与策略...我的得分:6.8分" 或 "学习深层方法与策略\nDeep Methods and Strategies\n我的得分:9.6分"
|
|
|
strategy_dims = ['深层方法与策略', '表面方法与策略', '学习自我调节']
|
|
|
for dim in strategy_dims:
|
|
|
ls_match = re.search(
|
|
|
- rf'{re.escape(dim)}.*?我的得分[::]\s*(\d+(?:\.\d+)?)',
|
|
|
- text,
|
|
|
- re.DOTALL
|
|
|
+ rf'{re.escape(dim)}\s*(?:[A-Za-z][\w\- ]*\s*)?\n\s*我的得分[::]\s*(\d+(?:\.\d+)?)',
|
|
|
+ text
|
|
|
)
|
|
|
if ls_match:
|
|
|
result[dim] = ls_match.group(1)
|
|
|
else:
|
|
|
- ls_match2 = re.search(rf'{re.escape(dim)}\s*(\d+(?:\.\d+)?)\s*分', text)
|
|
|
+ ls_match2 = re.search(rf'{re.escape(dim)}\s*\n?\s*(\d+(?:\.\d+)?)\s*分', text)
|
|
|
if ls_match2:
|
|
|
result[dim] = ls_match2.group(1)
|
|
|
|
|
|
@@ -887,24 +1052,64 @@ def extract_b4_data(text, page_texts=None, pdf_path=None):
|
|
|
|
|
|
# ===== 自我概念 (6维度, 0-10分制, 第11页柱状图) =====
|
|
|
# B4第11页(索引10)是横向柱状图, 每维度一个横条, 0-10刻度
|
|
|
- # 分数印制在横条右端上方, font_size≈9.6(区别于坐标轴标签6.2和维度名称8.6)
|
|
|
- # 使用positioned text提取, 禁止使用%匹配(会拿到认知页的百分位值)
|
|
|
+ # 分数印制在横条右端上方, 坐标轴标签(0,2,4,6,8,10)在同一行
|
|
|
+ # 两种子格式的font_size不同:
|
|
|
+ # Format A (16页+部分11页): 分数size≈6.4, 坐标轴size≈5.6
|
|
|
+ # Format B (部分11页): 分数size≈9.6, 坐标轴size≈6.2
|
|
|
+ # 使用y坐标分组去重: 坐标轴标签6个数字在同一y坐标, 分数数字独占一行
|
|
|
+ # 边缘case: 某些PDF渲染缺失单行分数(如第4行), 用双间距检测插入空值
|
|
|
self_concept_out = ['行为表现', '能力与学校', '躯体外貌', '情绪状态', '合群', '幸福与满足']
|
|
|
+
|
|
|
+ def _sc_ygroup_scores(raw_spans):
|
|
|
+ """从raw_spans [(y, val)]提取6个自我概念分数, 用y分组去重+双间距补缺"""
|
|
|
+ y_groups = {}
|
|
|
+ for y, val in raw_spans:
|
|
|
+ matched_key = None
|
|
|
+ for ky in y_groups:
|
|
|
+ if abs(ky - y) < 5:
|
|
|
+ matched_key = ky
|
|
|
+ break
|
|
|
+ if matched_key is not None:
|
|
|
+ y_groups[matched_key].append((y, val))
|
|
|
+ else:
|
|
|
+ y_groups[y] = [(y, val)]
|
|
|
+ singletons = []
|
|
|
+ for ky, items in sorted(y_groups.items()):
|
|
|
+ if len(items) == 1:
|
|
|
+ singletons.append((items[0][0], items[0][1]))
|
|
|
+ singletons.sort(key=lambda s: s[0])
|
|
|
+ if len(singletons) == 6:
|
|
|
+ return singletons
|
|
|
+ # 多间距补缺: 检测所有>1.5倍正常间距的间隔 → 每个插入对应数量的None
|
|
|
+ if 3 <= len(singletons) < 6 and len(singletons) >= 2:
|
|
|
+ gaps = [singletons[i+1][0] - singletons[i][0] for i in range(len(singletons)-1)]
|
|
|
+ if gaps:
|
|
|
+ # 用最小间距作为正常间距基准 (最小间距最可能是相邻行)
|
|
|
+ normal_gap = min(gaps)
|
|
|
+ # 从后往前插入避免索引偏移
|
|
|
+ for i in range(len(gaps) - 1, -1, -1):
|
|
|
+ g = gaps[i]
|
|
|
+ if g > normal_gap * 1.5:
|
|
|
+ n_missing = max(1, round(g / normal_gap) - 1)
|
|
|
+ for j in range(n_missing):
|
|
|
+ frac = (j + 1) / (n_missing + 1)
|
|
|
+ mid_y = singletons[i][0] + g * frac
|
|
|
+ singletons.insert(i + 1, (mid_y, None))
|
|
|
+ return singletons
|
|
|
+
|
|
|
if pdf_path:
|
|
|
try:
|
|
|
doc = fitz.open(pdf_path)
|
|
|
n_pages = len(doc)
|
|
|
- # 两种B4格式:
|
|
|
- # 16页格式: self-concept在第11页(索引10), 分数font_size≈9.6
|
|
|
- # 11页格式: self-concept在第6页(索引5), 分数font_size≈6.4
|
|
|
- candidates = [(10, 8, 11), (5, 6, 8)] if n_pages > 12 else [(5, 6, 8), (10, 8, 11)]
|
|
|
+ # 宽范围[5,11]覆盖所有子格式, 后续用y分组去重过滤坐标轴标签
|
|
|
+ candidates = [(10, 5, 11), (5, 5, 11)] if n_pages > 12 else [(5, 5, 11), (10, 5, 11)]
|
|
|
found = False
|
|
|
for page_idx, fs_min, fs_max in candidates:
|
|
|
if page_idx >= n_pages:
|
|
|
continue
|
|
|
page = doc[page_idx]
|
|
|
blocks = page.get_text('dict')['blocks']
|
|
|
- scores = []
|
|
|
+ raw_spans = []
|
|
|
for b in blocks:
|
|
|
if b['type'] == 0:
|
|
|
for line in b['lines']:
|
|
|
@@ -916,20 +1121,21 @@ def extract_b4_data(text, page_texts=None, pdf_path=None):
|
|
|
x = span['bbox'][0]
|
|
|
if (fs_min <= size <= fs_max and x > 250
|
|
|
and span_text.isdigit() and 0 <= int(span_text) <= 10):
|
|
|
- scores.append((span['bbox'][1], int(span_text)))
|
|
|
- scores.sort(key=lambda s: s[0])
|
|
|
- if len(scores) >= 6:
|
|
|
+ raw_spans.append((span['bbox'][1], int(span_text)))
|
|
|
+ scores = _sc_ygroup_scores(raw_spans)
|
|
|
+ if len(scores) >= 5:
|
|
|
for i, dim in enumerate(self_concept_out):
|
|
|
- result[f'自我概念_{dim}'] = str(scores[i][1])
|
|
|
+ if i < len(scores) and scores[i][1] is not None:
|
|
|
+ result[f'自我概念_{dim}'] = str(scores[i][1])
|
|
|
found = True
|
|
|
break
|
|
|
if not found:
|
|
|
- # 第三次尝试: 在所有页面中搜索"自我概念"字样, 提取相邻数字
|
|
|
+ # 第三次尝试: 在所有页面中搜索"自我概念"字样
|
|
|
for pi in range(n_pages):
|
|
|
page_text = doc[pi].get_text()
|
|
|
if '自我概念' in page_text and '行为表现' in page_text:
|
|
|
blocks = doc[pi].get_text('dict')['blocks']
|
|
|
- scores = []
|
|
|
+ raw_spans2 = []
|
|
|
for b in blocks:
|
|
|
if b['type'] == 0:
|
|
|
for line in b['lines']:
|
|
|
@@ -941,11 +1147,12 @@ def extract_b4_data(text, page_texts=None, pdf_path=None):
|
|
|
x = span['bbox'][0]
|
|
|
if (5 <= size <= 12 and x > 250
|
|
|
and st.isdigit() and 0 <= int(st) <= 10):
|
|
|
- scores.append((span['bbox'][1], int(st)))
|
|
|
- scores.sort(key=lambda s: s[0])
|
|
|
- if len(scores) >= 6:
|
|
|
+ raw_spans2.append((span['bbox'][1], int(st)))
|
|
|
+ scores2 = _sc_ygroup_scores(raw_spans2)
|
|
|
+ if len(scores2) >= 5:
|
|
|
for i, dim in enumerate(self_concept_out):
|
|
|
- result[f'自我概念_{dim}'] = str(scores[i][1])
|
|
|
+ if i < len(scores2) and scores2[i][1] is not None:
|
|
|
+ result[f'自我概念_{dim}'] = str(scores2[i][1])
|
|
|
break
|
|
|
doc.close()
|
|
|
except Exception as e:
|
|
|
@@ -1075,24 +1282,47 @@ def extract_b6_data(text, page_texts=None):
|
|
|
|
|
|
return result
|
|
|
|
|
|
-def extract_c1_data(text, page_texts=None):
|
|
|
- """提取C1报告数据 - 校园版综合(认知+人格+自驱力+自我概念)"""
|
|
|
+def extract_c1_data(text, page_texts=None, pdf_path=None):
|
|
|
+ """提取C1报告数据 - 校园版综合(认知+人格+自驱力+自我概念+情绪+依恋+健康+兴趣+智能+动机+策略)
|
|
|
+
|
|
|
+ 支持3种C1子格式:
|
|
|
+ - C1校园标准版(1-3年级): 认知+人格+自驱力+自我概念
|
|
|
+ - C1校园标准版(4-12年级): +学习动机+学习策略
|
|
|
+ - C1校园专业版(高中段): +职业兴趣(Holland)+多元智能+职业价值观
|
|
|
+ """
|
|
|
result = {}
|
|
|
result.update(extract_score_and_percentile(text))
|
|
|
|
|
|
# ===== 核心认知维度 (同A1, 6维度) =====
|
|
|
cognitive_dims = ['感知觉', '注意力', '记忆力', '推理能力', '空间能力', '加工速度']
|
|
|
|
|
|
- # 格式1: "我的感知觉得分 41 | 14%"
|
|
|
+ # 格式1: "我的感知觉得分 41 | 14%" 或 "195ح91%" (ح是PyMuPDF字体映射产物)
|
|
|
for dim in cognitive_dims:
|
|
|
detail_match = re.search(
|
|
|
- rf'我的{re.escape(dim)}得分\s*(\d+)\s*\|\s*(\d+)%',
|
|
|
+ rf'我的{re.escape(dim)}得分\s*(\d+)\s*[\|\u062D]\s*(\d+)%',
|
|
|
text
|
|
|
)
|
|
|
if detail_match:
|
|
|
result[f'{dim}_score'] = detail_match.group(1)
|
|
|
result[f'{dim}_pct'] = detail_match.group(2)
|
|
|
|
|
|
+ # 格式1b: 后备两段法处理更复杂的乱码格式 (同B4)
|
|
|
+ if len([k for k in result if k.endswith('_score')]) < 6:
|
|
|
+ for dim in cognitive_dims:
|
|
|
+ if f'{dim}_score' not in result:
|
|
|
+ detail_section = re.search(
|
|
|
+ rf'我的{re.escape(dim)}得分(.{{1,300}})',
|
|
|
+ text, re.DOTALL
|
|
|
+ )
|
|
|
+ if detail_section:
|
|
|
+ section_text = detail_section.group(1)
|
|
|
+ pct_m = re.search(r'(\d+)%', section_text)
|
|
|
+ if pct_m and f'{dim}_pct' not in result:
|
|
|
+ result[f'{dim}_pct'] = pct_m.group(1)
|
|
|
+ score_m = re.search(r'(\d+)', section_text)
|
|
|
+ if score_m:
|
|
|
+ result[f'{dim}_score'] = score_m.group(1)
|
|
|
+
|
|
|
# 格式2: summary页面 "感知觉 | Perception\n百分位(%)\n14"
|
|
|
for dim in cognitive_dims:
|
|
|
if f'{dim}_pct' not in result:
|
|
|
@@ -1103,16 +1333,95 @@ def extract_c1_data(text, page_texts=None):
|
|
|
if pct_match:
|
|
|
result[f'{dim}_pct'] = pct_match.group(1)
|
|
|
|
|
|
+ # ===== 情绪稳定性 (C1校园版只有总分,无单维分数) =====
|
|
|
+ # C1情绪稳定性报告中: "您在4个分测验中的总得分是XX分"
|
|
|
+ # 无独立的 "您在"自卑-自尊"的得分是X分" 格式(与A2不同)
|
|
|
+ total_em = re.search(r'4个分测验[^。]*?(?:总得分|得分|总分)\s*[是为]\s*(\d+(?:\.?\d+)?)\s*分', text, re.DOTALL)
|
|
|
+ if not total_em:
|
|
|
+ total_em = re.search(r'4个分测验[^。]*?(\d+(?:\.?\d+)?)\s*分', text, re.DOTALL)
|
|
|
+ if total_em:
|
|
|
+ result['情绪总分'] = total_em.group(1)
|
|
|
+
|
|
|
# ===== 大五人格 (同A2) =====
|
|
|
+ # PDF中引号可能是 \u201c (") \u201d (") 或 \u300c (「) \u300d (」)
|
|
|
big5_names = ['开放性', '宜人性', '责任心', '外倾性', '神经质']
|
|
|
if page_texts:
|
|
|
for pt in page_texts:
|
|
|
for dim in big5_names:
|
|
|
if dim not in result:
|
|
|
- b5_match = re.search(rf'您在[""「]{re.escape(dim)}[""」].*?得分是\s*(\d+(?:\.\d+)?)\s*分', pt)
|
|
|
+ b5_match = re.search(rf'您在["\u201c\u201d\u300c]{re.escape(dim)}["\u201c\u201d\u300d].*?得分是\s*(\d+(?:\.\d+)?)\s*分', pt)
|
|
|
if b5_match:
|
|
|
result[dim] = b5_match.group(1)
|
|
|
|
|
|
+ # ===== 社会依恋9维 (3关系×3维度) =====
|
|
|
+ # 格式: "您和母亲、父亲和同伴在信任方 面的得分分别\n是29分、49分、46分" (PDF断行可能插入\n或空格)
|
|
|
+ # 格式: "您和母亲、父亲和同伴在沟通上的得分分别是20分、39分、37分"
|
|
|
+ # 格式: "您和母亲、父亲和同伴在亲近上的得分分别是8分、18分、19分"
|
|
|
+ attachment_dims = ['信任', '沟通', '亲近']
|
|
|
+ for dim in attachment_dims:
|
|
|
+ # \s*容忍PDF断行空格和换行符 (亲近上的\n得分 可能断行)
|
|
|
+ att_match = re.search(
|
|
|
+ rf'您和母亲、父亲和同伴在{dim}\s*方\s*面\s*[的]*\s*得分分别\s*[是]?\s*(\d+)\s*分[、,]\s*(\d+)\s*分[、,]\s*(\d+)\s*分',
|
|
|
+ text
|
|
|
+ )
|
|
|
+ if not att_match:
|
|
|
+ # 后备: "在{dim}上的得分" (容忍 "上的\n得分" 断行)
|
|
|
+ att_match = re.search(
|
|
|
+ rf'您和母亲、父亲和同伴在{dim}上\s*[的]*\s*得分分别\s*[是]?\s*(\d+)\s*分[、,]\s*(\d+)\s*分[、,]\s*(\d+)\s*分',
|
|
|
+ text
|
|
|
+ )
|
|
|
+ if att_match:
|
|
|
+ result[f'与母亲{dim}'] = att_match.group(1)
|
|
|
+ result[f'与父亲{dim}'] = att_match.group(2)
|
|
|
+ result[f'与同伴{dim}'] = att_match.group(3)
|
|
|
+
|
|
|
+ # 后备: 逐页搜索
|
|
|
+ if len([k for k in result if k.startswith('与母亲')]) < 3 and page_texts:
|
|
|
+ for pt in page_texts:
|
|
|
+ for dim in attachment_dims:
|
|
|
+ if f'与母亲{dim}' not in result:
|
|
|
+ att_match2 = re.search(
|
|
|
+ rf'母亲、父亲和同伴在{dim}\s*方\s*面\s*[的]*\s*得分分别\s*[是]?\s*(\d+)\s*分[、,]\s*(\d+)\s*分[、,]\s*(\d+)\s*分',
|
|
|
+ pt
|
|
|
+ )
|
|
|
+ if not att_match2:
|
|
|
+ att_match2 = re.search(
|
|
|
+ rf'母亲、父亲和同伴在{dim}上\s*[的]*\s*得分分别\s*[是]?\s*(\d+)\s*分[、,]\s*(\d+)\s*分[、,]\s*(\d+)\s*分',
|
|
|
+ pt
|
|
|
+ )
|
|
|
+ if att_match2:
|
|
|
+ result[f'与母亲{dim}'] = att_match2.group(1)
|
|
|
+ result[f'与父亲{dim}'] = att_match2.group(2)
|
|
|
+ result[f'与同伴{dim}'] = att_match2.group(3)
|
|
|
+
|
|
|
+ # ===== 身体健康 (同A2) =====
|
|
|
+ # 格式: "身高:176CM" "体重:70KG" "BMI:23KG/M²"
|
|
|
+ if page_texts:
|
|
|
+ for pt in page_texts:
|
|
|
+ if 'BMI' in pt:
|
|
|
+ # 身高
|
|
|
+ h_match = re.search(r'身高[::]\s*(\d+)\s*[Cc][Mm]', pt)
|
|
|
+ if h_match and '身高' not in result:
|
|
|
+ result['身高'] = h_match.group(1)
|
|
|
+ # 体重
|
|
|
+ w_match = re.search(r'体重[::]\s*(\d+)\s*[Kk][Gg]', pt)
|
|
|
+ if w_match and '体重' not in result:
|
|
|
+ result['体重'] = w_match.group(1)
|
|
|
+ # BMI
|
|
|
+ bmi_match = re.search(r'BMI[::]\s*(\d+(?:\.\d+)?)\s*[Kk][Gg]/[Mm]²', pt)
|
|
|
+ if bmi_match and 'BMI' not in result:
|
|
|
+ result['BMI'] = bmi_match.group(1)
|
|
|
+
|
|
|
+ # 睡眠时长: "8.05小时/每天" 或 "5.1小时/每天"
|
|
|
+ sleep_match = re.search(r'(\d+\.?\d*)小时/每天', text)
|
|
|
+ if sleep_match and '睡眠时长' not in result:
|
|
|
+ result['睡眠时长'] = sleep_match.group(1)
|
|
|
+
|
|
|
+ # 运动时间: "9小时/周" 或 "4小时/周"
|
|
|
+ exercise_match = re.search(r'(\d+\.?\d*)小时/周', text)
|
|
|
+ if exercise_match and '运动时间' not in result:
|
|
|
+ result['运动时间'] = exercise_match.group(1)
|
|
|
+
|
|
|
# ===== 自驱力 (同B4) =====
|
|
|
all_scores = re.findall(r'我的得分[::](\d+\.?\d*)', text)
|
|
|
if len(all_scores) >= 3:
|
|
|
@@ -1140,6 +1449,98 @@ def extract_c1_data(text, page_texts=None):
|
|
|
if i < len(pct_matches):
|
|
|
result[f'自我概念_{dim}'] = pct_matches[-(6-i)]
|
|
|
|
|
|
+ # ===== 职业兴趣Holland 6型 (高中段) =====
|
|
|
+ # 格式: "现实型(实干家)\nRealistic\n7" 或 "现实型 Realistic 7分"
|
|
|
+ interest_dims = {
|
|
|
+ '现实型': '兴趣_现实型',
|
|
|
+ '研究型': '兴趣_研究型',
|
|
|
+ '艺术型': '兴趣_艺术型',
|
|
|
+ '社会型': '兴趣_社会型',
|
|
|
+ '事业型': '兴趣_事业型',
|
|
|
+ '常规型': '兴趣_常规型',
|
|
|
+ }
|
|
|
+ for cn_name, field_name in interest_dims.items():
|
|
|
+ # 方法1: "现实型(实干家)\nRealistic\n7"
|
|
|
+ h_match = re.search(
|
|
|
+ rf'{re.escape(cn_name)}[((][^))]+[))].*?\n.*?\n\s*(\d+)',
|
|
|
+ text
|
|
|
+ )
|
|
|
+ if h_match:
|
|
|
+ result[field_name] = h_match.group(1)
|
|
|
+ else:
|
|
|
+ # 方法2: "现实型 Realistic 7分"
|
|
|
+ h_match2 = re.search(
|
|
|
+ rf'{re.escape(cn_name)}\s+\w+\s+(\d+)\s*分',
|
|
|
+ text
|
|
|
+ )
|
|
|
+ if h_match2:
|
|
|
+ result[field_name] = h_match2.group(1)
|
|
|
+
|
|
|
+ # ===== 多元智能8维 (高中段) =====
|
|
|
+ # 格式: "逻辑数学能力\nLOGICAL-MATHEMATICAL\n10分" 或 "逻辑数学能力 10分"
|
|
|
+ ability_dims = {
|
|
|
+ '逻辑数学能力': '能力_逻辑数学',
|
|
|
+ '内省能力': '能力_内省',
|
|
|
+ '空间能力': '能力_空间',
|
|
|
+ '自然能力': '能力_自然',
|
|
|
+ '语言能力': '能力_语言',
|
|
|
+ '音乐能力': '能力_音乐',
|
|
|
+ '人际关系能力': '能力_人际关系',
|
|
|
+ '身体运动能力': '能力_身体运动',
|
|
|
+ }
|
|
|
+ for cn_name, field_name in ability_dims.items():
|
|
|
+ # 方法1: "逻辑数学能力\nLOGICAL-MATHEMATICAL\n10分"
|
|
|
+ ab_match = re.search(
|
|
|
+ rf'{re.escape(cn_name)}\s*\n\s*\w[\w-]*\w\s*\n\s*(\d+)\s*分',
|
|
|
+ text
|
|
|
+ )
|
|
|
+ if ab_match:
|
|
|
+ result[field_name] = ab_match.group(1)
|
|
|
+ else:
|
|
|
+ # 方法2: "逻辑数学能力 10分"
|
|
|
+ ab_match2 = re.search(
|
|
|
+ rf'{re.escape(cn_name)}\s*(\d+)\s*分',
|
|
|
+ text
|
|
|
+ )
|
|
|
+ if ab_match2:
|
|
|
+ score = ab_match2.group(1)
|
|
|
+ if 1 <= int(score) <= 15:
|
|
|
+ result[field_name] = score
|
|
|
+
|
|
|
+ # ===== 学习动机3维 (4-12年级) =====
|
|
|
+ # 格式: "深层动机\n我的得分:5.2分" 或 "深层动机\n5.2分" 或 "深层动机\nDeep Motivation\n我的得分:10分"
|
|
|
+ motivation_dims = ['深层动机', '表面动机', '自我效能感']
|
|
|
+ for dim in motivation_dims:
|
|
|
+ # 方法1: "深层动机\nDeep Motivation\n我的得分:10分"(紧邻匹配,禁止跨段取到前一个维度)
|
|
|
+ lm_match = re.search(
|
|
|
+ rf'{re.escape(dim)}\s*(?:[A-Za-z][\w\- ]*\s*)?\n\s*我的得分[::]\s*(\d+(?:\.\d+)?)',
|
|
|
+ text
|
|
|
+ )
|
|
|
+ if lm_match:
|
|
|
+ result[dim] = lm_match.group(1)
|
|
|
+ else:
|
|
|
+ # 方法2: "深层动机\n5.2分"
|
|
|
+ lm_match2 = re.search(rf'{re.escape(dim)}\s*\n?\s*(\d+(?:\.\d+)?)\s*分', text)
|
|
|
+ if lm_match2:
|
|
|
+ result[dim] = lm_match2.group(1)
|
|
|
+
|
|
|
+ # ===== 学习策略3维 (4-12年级) =====
|
|
|
+ # 格式: "学习深层方法与策略\n我的得分:5.6分" 或 "学习深层方法与策略\n5.6分" 或 "学习深层方法与策略\nDeep Methods and Strategies\n我的得分:9.6分"
|
|
|
+ strategy_dims = ['深层方法与策略', '表面方法与策略', '学习自我调节']
|
|
|
+ for dim in strategy_dims:
|
|
|
+ # 方法1: "学习深层方法与策略\nDeep Methods and Strategies\n我的得分:9.6分"(紧邻匹配)
|
|
|
+ ls_match = re.search(
|
|
|
+ rf'{re.escape(dim)}\s*(?:[A-Za-z][\w\- ]*\s*)?\n\s*我的得分[::]\s*(\d+(?:\.\d+)?)',
|
|
|
+ text
|
|
|
+ )
|
|
|
+ if ls_match:
|
|
|
+ result[dim] = ls_match.group(1)
|
|
|
+ else:
|
|
|
+ # 方法2: "学习深层方法与策略\n5.6分"
|
|
|
+ ls_match2 = re.search(rf'{re.escape(dim)}\s*\n?\s*(\d+(?:\.\d+)?)\s*分', text)
|
|
|
+ if ls_match2:
|
|
|
+ result[dim] = ls_match2.group(1)
|
|
|
+
|
|
|
return result
|
|
|
|
|
|
# ===== B5 人际关系指南针图子维度提取 =====
|
|
|
@@ -1402,7 +1803,7 @@ def extract_all_data(pdf_path, filename):
|
|
|
if report_type == 'A1':
|
|
|
data = extract_a1_data(text, page_texts)
|
|
|
elif report_type == 'A2':
|
|
|
- data = extract_a2_data(text, page_texts)
|
|
|
+ data = extract_a2_data(text, page_texts, pdf_path)
|
|
|
elif report_type == 'B2':
|
|
|
data = extract_b2_data(text, page_texts)
|
|
|
elif report_type == 'B3':
|
|
|
@@ -1414,7 +1815,7 @@ def extract_all_data(pdf_path, filename):
|
|
|
elif report_type == 'B6':
|
|
|
data = extract_b6_data(text, page_texts)
|
|
|
elif report_type == 'C1':
|
|
|
- data = extract_c1_data(text, page_texts)
|
|
|
+ data = extract_c1_data(text, page_texts, pdf_path)
|
|
|
else:
|
|
|
data = {}
|
|
|
|