Explorar o código

docs: 更新参考资料/dan_reports 提取脚本(extract_all_types +468行重构,merge 与 type_detector 小修)

asus hai 1 mes
pai
achega
5d66ef6fcb

+ 464 - 63
docs/参考资料/dan_reports/extract_all_types.py

@@ -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 = {}
     

+ 2 - 2
docs/参考资料/dan_reports/merge_all_types.py

@@ -169,7 +169,7 @@ for cluster_date, name, recs_in_cluster in clusters:
         if rt == 'A2':
             for dim in ['开放性', '宜人性', '外倾性', '神经质', '责任心',
                        '情绪调节', '抗挫折', '内驱力', '目标管理', '人际交往',
-                       '自卑-自信', '抑郁-安详', '焦虑-安详', '无力感-掌控感',
+                       '自卑-自尊', '抑郁-愉快', '焦虑-安详', '无力感-掌控感',
                        '情绪总分',
                        '与父亲信任', '与父亲沟通', '与父亲亲近',
                        '与母亲信任', '与母亲沟通', '与母亲亲近',
@@ -250,7 +250,7 @@ for dim in cognitive_dims:
 # A2 特有
 columns.extend(['开放性', '宜人性', '外倾性', '神经质', '责任心',
                '情绪调节', '抗挫折', '内驱力', '目标管理', '人际交往',
-               '自卑-自信', '抑郁-安详', '焦虑-安详', '无力感-掌控感',
+               '自卑-自尊', '抑郁-愉快', '焦虑-安详', '无力感-掌控感',
                '情绪总分',
                '与父亲信任', '与父亲沟通', '与父亲亲近',
                '与母亲信任', '与母亲沟通', '与母亲亲近',

+ 2 - 0
docs/参考资料/dan_reports/type_detector.py

@@ -60,6 +60,7 @@ FINGERPRINTS = {
     ],
     'C1': [
         ('校园版', 35),                  # 独占标识(提高权重以胜出共享指纹)
+        ('Holland', 12),                # 高中段C1含Holland,区别于纯B6(B6有CAREER+职业发展加成仍胜出)
         ('认知能力测评报告', 5),         # 共享,低权重
         ('COGNITIVE ABILITY REPORT', 5), # 共享,低权重
     ],
@@ -71,6 +72,7 @@ EXCLUSIONS = {
     'A1': [('GROWTH MINDSET', -20)],        # A1 不含成长型思维(轻扣分,避免抵消文件名加成)
     'C1': [('GROWTH MINDSET', -50)],        # C1 不含成长型思维
     'B4': [('校园版', -50), ('PARENTAL EDUCATION', -40)],  # B4 非校园版,无教养内容
+    'B6': [('校园版', -60)],                # B6 非校园版(高中段C1含Holland/多元智能但有"校园版"标识)
     'A2': [('Conduct problems', -40)],      # A2 无儿童行为
 }