Parcourir la source

docs: add PDF extraction scripts (v4 + v5 with JSON mode)

v5: fix antibiotic/SCFA/neurotransmitter parsing with known-name whitelists, add single-file JSON output mode
asus il y a 2 mois
Parent
commit
37390c7ec8

+ 366 - 0
docs/参考资料/extract_all.py

@@ -0,0 +1,366 @@
+"""
+PDF 健康报告 → 完整内容提取脚本
+================================
+提取"个体化食物推荐表"全部内容,输出:
+  1. 原始文本(debug用)
+  2. 结构化CSV(每行一个食物,10个字段)
+  3. 推荐指数汇总表(标准营养 + 每人推荐指数)
+
+用法:python extract_all.py [PDF目录]
+
+依赖:pip install PyPDF2
+"""
+
+import sys, os, csv, re, json
+sys.stdout.reconfigure(encoding='utf-8')
+sys.stderr.reconfigure(encoding='utf-8')
+from PyPDF2 import PdfReader
+
+# ── 字段定义 ──
+COLUMNS = ['名称', '分类', '推荐指数', '能量KJ', '蛋白g', '脂肪g',
+           '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']
+
+NUM_FIELDS = ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']
+
+KNOWN_CATS = [
+    '主食', '乳制品', '干果', '坚果', '快餐', '水产品',
+    '水果', '汤', '肉类', '蔬菜', '豆类及豆制品', '蛋类', '饮料'
+]
+
+# ── CJK 偏旁部首 → 标准汉字映射 ──
+RADICAL_MAP = {
+    '\u2f18': '卜', '\u2f1f': '土', '\u2f24': '大', '\u2f26': '子',
+    '\u2f29': '小', '\u2f2d': '山', '\u2f32': '干', '\u2f3c': '心',
+    '\u2f42': '文', '\u2f46': '无', '\u2f4a': '木', '\u2f50': '比',
+    '\u2f54': '水', '\u2f55': '火', '\u2f5c': '牛', '\u2f5f': '玉',
+    '\u2f60': '瓜', '\u2f62': '甘', '\u2f63': '生', '\u2f64': '用',
+    '\u2f69': '白', '\u2f6a': '皮', '\u2f6c': '目', '\u2f6f': '石',
+    '\u2f75': '竹', '\u2f76': '米', '\u2f7a': '羊', '\u2f7b': '羽',
+    '\u2f7c': '老', '\u2f7f': '耳', '\u2f81': '肉', '\u2f90': '衣',
+    '\u2f95': '谷', '\u2f96': '豆', '\u2f9d': '身', '\u2fa6': '金',
+    '\u2faf': '面', '\u2fb2': '韭', '\u2fb9': '香', '\u2fca': '黑',
+    '\u2ec9': '贝', '\u2edd': '食', '\u2ee2': '马', '\u2ee5': '鱼',
+    '\u2ee8': '麦', '\u2ee9': '黄', '\u2ef0': '龙',
+}
+
+SKIP_TEXTS = [
+    '根据您的肠道菌群', '分值从-100', '食物推荐考虑', '食物推荐是综合',
+    '需要注意的是', '本饮食推荐', '该饮食推荐根据', '后续表格中的营养',
+    '16S 高通量测序', '基于机器学习和', '肠道菌群健康检测报告说明',
+    '检测方法及局限性', '数据分析及模型', '结果解读及使用',
+    '影响因素说明', '建议将检测结果', '营养建议说明',
+    '重要提示', '推荐食物清单', '实际食用时需结合', '如有特殊疾病',
+    '免责声明', '本检测报告仅供', '以上模型预测', '正常范围的定义',
+]
+
+
+def normalize(text):
+    """替换偏旁部首为标准汉字"""
+    return ''.join(RADICAL_MAP.get(c, c) for c in text)
+
+
+def parse_vertical(lines):
+    """逐行格式:名称/分类/数值/... 每9行一组"""
+    rows = []
+    i = 0
+    while i + 9 < len(lines):
+        name = lines[i].strip()
+        cat = lines[i + 1].strip()
+        if cat not in KNOWN_CATS:
+            i += 1
+            continue
+        nums = []
+        ok = True
+        for j in range(2, 10):
+            v = lines[i + j].replace('\u2212', '-').replace('\u2014', '-').strip()
+            try:
+                int(v)
+                nums.append(v)
+            except ValueError:
+                ok = False
+                break
+        if ok and len(nums) == 8:
+            rows.append(dict(zip(COLUMNS, [name, cat] + nums)))
+        i += 1
+    return rows
+
+
+def split_7_fields(s):
+    """将7个连续数字串拆分为字段(回溯)"""
+    results = []
+    ranges = [(2, 4), (1, 2), (1, 2), (1, 2), (1, 2), (1, 2), (1, 4)]
+
+    def backtrack(pos, idx, nums):
+        if idx == 7:
+            if pos == len(s):
+                results.append(list(nums))
+            return
+        if pos >= len(s):
+            return
+        lo, hi = ranges[idx]
+        for w in range(lo, min(hi + 1, len(s) - pos + 1)):
+            chunk = s[pos:pos + w]
+            if chunk.isdigit():
+                backtrack(pos + w, idx + 1, nums + [int(chunk)])
+
+    backtrack(0, 0, [])
+    return results
+
+
+def decode_compressed(name, num_str, ref_vals=None):
+    """解码压缩格式数字串"""
+    raw = num_str.lstrip('-')
+    has_neg = num_str.startswith('-')
+    results = []
+
+    for rec_len in range(1, 3):
+        if rec_len > len(raw):
+            continue
+        rec = ('-' if has_neg else '') + raw[:rec_len]
+        try:
+            rec_val = int(rec)
+            if not (-100 <= rec_val <= 100):
+                continue
+        except ValueError:
+            continue
+        remain = raw[rec_len:]
+        candidates = split_7_fields(remain)
+        for nums in candidates:
+            if ref_vals:
+                matches = sum(1 for i in range(7) if ref_vals[i] == nums[i])
+                if matches >= 6:
+                    results.append([rec_val] + nums)
+            else:
+                results.append([rec_val] + nums)
+
+    if not results:
+        return None
+    if ref_vals:
+        results.sort(key=lambda r: (sum(1 for i in range(7) if ref_vals[i] == r[1:][i]),
+                                    -len(str(abs(r[0])))), reverse=True)
+        if sum(1 for i in range(7) if ref_vals[i] == results[0][1:][i]) < 6:
+            return None
+    return results[0]
+
+
+def parse_compressed(text, ref_lookup=None):
+    """压缩格式:所有字段在一行无分隔"""
+    header = '名称分类推荐指数能量KJ蛋白g脂肪g碳水化合物g淀粉g总膳食纤维g胆固醇mg'
+    rest = text
+    if header in rest:
+        rest = rest.replace(header, '')
+    for kw in SKIP_TEXTS:
+        rest = rest.replace(kw, '')
+
+    rows = []
+    while rest:
+        best_cat, best_idx = None, len(rest)
+        for cat in KNOWN_CATS:
+            idx = rest.find(cat)
+            if idx != -1 and idx < best_idx:
+                best_idx, best_cat = idx, cat
+        if best_cat is None:
+            break
+
+        name = rest[:best_idx]
+        rest = rest[best_idx + len(best_cat):]
+
+        num_str = ''
+        while rest and (rest[0].isdigit() or rest[0] in '-\u2212\u2014'):
+            c = '-' if rest[0] in '\u2212\u2014' else rest[0]
+            num_str += c
+            rest = rest[1:]
+
+        if not name or not num_str:
+            continue
+
+        ref_vals = ref_lookup.get(name) if ref_lookup else None
+        decoded = decode_compressed(name, num_str, ref_vals)
+        if decoded:
+            rows.append(dict(zip(COLUMNS, [name, best_cat] + [str(v) for v in decoded])))
+
+    return rows
+
+
+def detect_format(text):
+    """自动检测格式:vertical(逐行) / compressed(压缩)"""
+    lines = [l.strip() for l in text.split('\n') if l.strip() and not re.match(r'\d+/\d+', l)]
+    if not lines:
+        return 'unknown'
+    # 一行很长 + 无换行 = compressed
+    if len(lines) <= 3 and len(lines[0]) > 200:
+        return 'compressed'
+    long_count = sum(1 for l in lines[:10] if len(l) > 100)
+    if long_count >= 3:
+        return 'compressed'
+    cat_count = sum(1 for l in lines[:80] if l in KNOWN_CATS)
+    if cat_count >= 5:
+        return 'vertical'
+    return 'unknown'
+
+
+def extract_food_table(pdf_path, ref_lookup=None):
+    """从PDF提取食物推荐表,返回 (rows, fmt)"""
+    reader = PdfReader(pdf_path)
+    name = os.path.splitext(os.path.basename(pdf_path))[0]
+
+    # 找到表格起始页
+    food_start = None
+    for i, page in enumerate(reader.pages):
+        if '个体化食物推荐表' in page.extract_text():
+            food_start = i
+            break
+    if food_start is None:
+        return None, 'not_found'
+
+    # 检测格式
+    first_text = normalize(reader.pages[food_start + 1].extract_text())
+    fmt = detect_format(first_text)
+
+    rows = []
+    if fmt == 'compressed':
+        for i in range(food_start + 1, len(reader.pages)):
+            text = normalize(reader.pages[i].extract_text())
+            text = re.sub(r'\d+/\d+', '', text)
+            rows.extend(parse_compressed(text, ref_lookup))
+    else:
+        all_lines = []
+        for i in range(food_start + 1, len(reader.pages)):
+            for line in normalize(reader.pages[i].extract_text()).split('\n'):
+                lt = line.strip()
+                if not lt or re.match(r'\d+/\d+', lt) or lt in COLUMNS:
+                    continue
+                if len(lt) > 60 and any(k in lt for k in SKIP_TEXTS):
+                    continue
+                all_lines.append(lt)
+        rows = parse_vertical(all_lines)
+
+    return rows, fmt
+
+
+def main(pdf_dir):
+    pdf_dir = pdf_dir or r'D:\workspace\cfc\docs\参考资料'
+    outdir = pdf_dir
+    all_reports = {}
+
+    # ── Step 1: 提取每份PDF ──
+    print('=' * 60)
+    print('Step 1: 提取食物推荐表')
+    print('=' * 60)
+
+    pdf_files = sorted(f for f in os.listdir(pdf_dir) if f.lower().endswith('.pdf'))
+    for pdf_file in pdf_files:
+        pdf_path = os.path.join(pdf_dir, pdf_file)
+        label = pdf_file.replace('.pdf', '')
+        print(f'\n{label}...', end=' ')
+
+        try:
+            rows, fmt = extract_food_table(pdf_path)
+        except Exception as e:
+            print(f'ERROR: {e}')
+            continue
+
+        if rows is None:
+            print('SKIP (未找到表格)')
+            continue
+
+        print(f'[{fmt}] {len(rows)} 条')
+        all_reports[label] = rows
+
+        # 保存原始文本
+        raw_path = os.path.join(outdir, f'{label}-原始文本.txt')
+        reader = PdfReader(pdf_path)
+        with open(raw_path, 'w', encoding='utf-8') as f:
+            for page in reader.pages:
+                f.write(f'--- Page ---\n')
+                f.write(normalize(page.extract_text()))
+                f.write('\n')
+        print(f'  raw -> {os.path.basename(raw_path)}')
+
+        # 保存CSV
+        csv_name = f'{label}-个体化食物推荐表.csv'
+        csv_path = os.path.join(outdir, csv_name)
+        with open(csv_path, 'w', newline='', encoding='utf-8-sig') as f:
+            w = csv.DictWriter(f, fieldnames=COLUMNS)
+            w.writeheader()
+            w.writerows(rows)
+        print(f'  csv -> {csv_name}')
+
+    if not all_reports:
+        print('\n未找到任何有效PDF')
+        return
+
+    # ── Step 2: 生成推荐指数汇总表 ──
+    print('\n' + '=' * 60)
+    print('Step 2: 生成推荐指数汇总表')
+    print('=' * 60)
+
+    # 标准营养参考(优先用侯报告,224条最完整)
+    ref_report = None
+    std_label = None
+    for pref in ['530010234-侯', '侯']:
+        for label in all_reports:
+            if pref in label:
+                ref_report = all_reports[label]
+                std_label = label
+                break
+        if ref_report:
+            break
+    if not ref_report:
+        # 用条目最多的
+        std_label = max(all_reports, key=lambda k: len(all_reports[k]))
+        ref_report = all_reports[std_label]
+    print(f'标准参考: {std_label} ({len(ref_report)} 条)')
+
+    std_lookup = {}
+    for r in ref_report:
+        std_lookup[r['名称']] = {k: r[k] for k in ['分类'] + NUM_FIELDS}
+
+    # 收集推荐指数
+    report_labels = {}
+    for label in all_reports:
+        if '某人' in label:
+            report_labels[label] = '某人'
+        elif '侯' in label:
+            report_labels[label] = '侯'
+        elif '547982403' in label:
+            report_labels[label] = '547982403'
+        elif '儿童' in label:
+            report_labels[label] = '儿童示例'
+        elif '朱' in label:
+            report_labels[label] = '朱评估报告'
+        else:
+            report_labels[label] = label
+
+    rec_all = {}
+    for label, short in report_labels.items():
+        rec_all[short] = {}
+        for r in all_reports[label]:
+            rec_all[short][r['名称']] = r['推荐指数']
+
+    sum_cols = ['名称', '分类'] + NUM_FIELDS + list(report_labels.values())
+    sum_path = os.path.join(outdir, '推荐指数汇总.csv')
+    with open(sum_path, 'w', newline='', encoding='utf-8-sig') as f:
+        w = csv.DictWriter(f, fieldnames=sum_cols)
+        w.writeheader()
+        for r in ref_report:
+            name = r['名称']
+            s = std_lookup[name]
+            row = {'名称': name, '分类': s['分类']}
+            for k in NUM_FIELDS:
+                row[k] = s[k]
+            for short in report_labels.values():
+                row[short] = rec_all.get(short, {}).get(name, '')
+            w.writerow(row)
+
+    print(f'推荐指数汇总: {len(ref_report)} 行 -> {os.path.basename(sum_path)}')
+    for short in report_labels.values():
+        missing = sum(1 for r in ref_report if not rec_all.get(short, {}).get(r['名称'], ''))
+        print(f'  {short}: {"完整" if missing == 0 else f"缺失 {missing} 条"}')
+
+    print('\nDone!')
+
+
+if __name__ == '__main__':
+    pdf_dir = sys.argv[1] if len(sys.argv) > 1 else None
+    main(pdf_dir)

+ 636 - 0
docs/参考资料/extract_full_report.old.py

@@ -0,0 +1,636 @@
+"""
+肠道菌群健康检测报告 — 全指标提取脚本
+====================================
+提取5份PDF中所有结构化指标,输出:
+  - docs/参考资料/CSV/ 目录下各模块汇总CSV
+
+用法:python extract_full_report.py
+
+依赖:pip install PyPDF2
+"""
+
+import sys, os, csv, re
+sys.stdout.reconfigure(encoding='utf-8')
+sys.stderr.reconfigure(encoding='utf-8')
+from PyPDF2 import PdfReader
+
+BASE = r'D:\workspace\cfc\docs\参考资料'
+OUTDIR = os.path.join(BASE, 'CSV')
+os.makedirs(OUTDIR, exist_ok=True)
+
+# ── CJK Radical → 标准汉字 ──
+RADICAL_MAP = {
+    '\u2f18': '卜', '\u2f1f': '土', '\u2f24': '大', '\u2f26': '子',
+    '\u2f29': '小', '\u2f2d': '山', '\u2f32': '干', '\u2f3c': '心',
+    '\u2f42': '文', '\u2f46': '无', '\u2f4a': '木', '\u2f50': '比',
+    '\u2f54': '水', '\u2f55': '火', '\u2f5c': '牛', '\u2f5f': '玉',
+    '\u2f60': '瓜', '\u2f62': '甘', '\u2f63': '生', '\u2f64': '用',
+    '\u2f69': '白', '\u2f6a': '皮', '\u2f6c': '目', '\u2f6f': '石',
+    '\u2f75': '竹', '\u2f76': '米', '\u2f7a': '羊', '\u2f7b': '羽',
+    '\u2f7c': '老', '\u2f7f': '耳', '\u2f81': '肉', '\u2f90': '衣',
+    '\u2f95': '谷', '\u2f96': '豆', '\u2f9d': '身', '\u2fa6': '金',
+    '\u2faf': '面', '\u2fb2': '韭', '\u2fb9': '香', '\u2fca': '黑',
+    '\u2ec9': '贝', '\u2edd': '食', '\u2ee2': '马', '\u2ee5': '鱼',
+    '\u2ee8': '麦', '\u2ee9': '黄', '\u2ef0': '龙',
+}
+
+COLUMNS_FOOD = ['名称', '分类', '推荐指数', '能量KJ', '蛋白g', '脂肪g',
+                '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']
+KNOWN_CATS = [
+    '主食', '乳制品', '干果', '坚果', '快餐', '水产品',
+    '水果', '汤', '肉类', '蔬菜', '豆类及豆制品', '蛋类', '饮料'
+]
+
+def norm(s):
+    return ''.join(RADICAL_MAP.get(c, c) for c in s)
+
+# ============================
+# 模块1:基本信息 + 概要指标
+# ============================
+def extract_overview(label, pages_text):
+    """从p0提取基本信息 + 核心指标"""
+    t = norm(pages_text[0])
+    r = {'姓名': label}
+
+    m = re.search(r'编号\s*(\S+)', t)
+    if m: r['编号'] = m.group(1)
+    m = re.search(r'姓名\s*(\S+)', t)
+    if m: r['姓名'] = m.group(1)
+
+    for pat in ['年龄', '性别', '肠道预测年龄', '肠型']:
+        m = re.search(f'{pat}[:\s]*(\S+)', t)
+        if m: r[pat] = m.group(1)
+
+    # 核心指标(同一行或附近行)
+    for kw in ['肠道菌群平衡', '菌群多样性', '有益菌', '有害菌', '核心菌属']:
+        m = re.search(f'{kw}[:\s]*(\d+)', t)
+        if m: r[kw] = m.group(1)
+
+    # 健康总分可能不在p0
+    for pt in pages_text:
+        if '健康总分' in norm(pt):
+            m = re.search(r'健康总分.*?(\d+)', norm(pt), re.DOTALL)
+            if m:
+                r['健康总分'] = m.group(1)
+                break
+
+    # 疾病风险评估概览(第一行风险描述)
+    for pt in pages_text:
+        t2 = norm(pt)
+        if '个别疾病高风险' in t2 or '个别疾病高风' in t2:
+            m = re.search(r'(\S*高风险或\S*)', t2)
+            if m: r['风险评估结论'] = m.group(1)
+            break
+
+    return r
+
+
+# ============================
+# 模块2:表格类指标(通用)
+# ============================
+def extract_table(pages_text, header_marker, name_col=0, val_col=1, range_col=None):
+    """
+    通用表格提取器。
+    header_marker: 表格开始的标题关键词
+    返回 [{指标名称, 评估值, 评估结论?, 正常范围?}]
+    """
+    rows = []
+    found = False
+    for pt in pages_text:
+        t = norm(pt)
+        if header_marker in t:
+            found = True
+            lines = t.split('\n')
+            started = False
+            col_headers = [h.strip() for h in lines if h.strip() in ['指标范围', '指标', '名称', '评估值', '评估', '正常范围', '丰度']]
+            for line in lines:
+                ls = line.strip()
+                if not ls or ls in ['指标范围']:
+                    continue
+                # Detect data rows: 指标名 + 数字 + 可选范围/结论
+                parts = ls.split()
+                if len(parts) >= 2:
+                    # Try to parse as indicator + value + (optional status/range)
+                    name_part = parts[0]
+                    val_candidate = parts[1]
+                    # Also check if it's a new section header
+                    if any(name_part.startswith(h) for h in ['一、', '二、', '三、', '四、', '五、']):
+                        continue
+                    if re.match(r'^-?\d+\.?\d*$', val_candidate):
+                        row = {'指标名称': name_part, '评估值': val_candidate}
+                        if len(parts) >= 3:
+                            row['评估结论'] = parts[2]
+                        if len(parts) >= 4:
+                            row['正常范围'] = parts[3]
+                        rows.append(row)
+        elif found:
+            break  # Stop after first page containing the table
+    return rows
+
+
+def extract_nutrition_tables(pages_text):
+    """
+    从营养状况评估页面提取 主要营养评估/氨基酸/维生素/微量元素 表格。
+    返回 {模块名: [{指标, 评估值, 状态}]}
+    """
+    results = {}
+    current_section = None
+    # 找到包含营养状况评估的页面
+    for pt in pages_text:
+        t = norm(pt)
+        lines = [l.strip() for l in t.split('\n') if l.strip()]
+        
+        for line in lines:
+            # Section headers
+            if '主要营养评估' in line:
+                current_section = '主要营养评估'
+                results.setdefault(current_section, [])
+                continue
+            elif '氨基酸评估' in line or '氨基酸评' in line:
+                current_section = '氨基酸评估'
+                results.setdefault(current_section, [])
+                continue
+            elif '维生素评估' in line or '维生素评' in line:
+                current_section = '维生素评估'
+                results.setdefault(current_section, [])
+                continue
+            elif '微量元素评估' in line:
+                current_section = '微量元素评估'
+                results.setdefault(current_section, [])
+                continue
+            elif line in ['营养状况评估']:
+                current_section = '营养状况评估'
+                results.setdefault(current_section, [])
+                continue
+            
+            if not current_section:
+                continue
+            
+            # Parse data rows: 指标名 + 数值 + (状态)
+            parts = line.split()
+            if len(parts) >= 2:
+                name = parts[0]
+                if re.match(r'^-?\d+\.?\d*$', parts[1]):
+                    row = {'指标': name, '评估值': parts[1]}
+                    if len(parts) >= 3:
+                        row['状态'] = parts[2]
+                    if current_section:
+                        results[current_section].append(row)
+    
+    return results
+
+
+# ============================
+# 模块3:疾病风险评估
+# ============================
+def extract_disease_risk(pages_text):
+    """
+    提取疾病风险评估表格。
+    格式:疾病名 + 风险值 + 风险等级
+    """
+    found = False
+    rows = []
+    for pt in pages_text:
+        t = norm(pt)
+        if '疾病风险评估' in t:
+            found = True
+            continue
+        if found:
+            lines = [l.strip() for l in t.split('\n') if l.strip() and l.strip() not in ['指标范围']]
+            for line in lines:
+                if line in ['疾病风险评估']:
+                    continue
+                parts = line.split()
+                if len(parts) >= 2:
+                    name = parts[0]
+                    if re.match(r'^0\.\d+$', parts[1]):
+                        row = {'疾病': name, '风险值': parts[1]}
+                        if len(parts) >= 3:
+                            row['风险等级'] = parts[2]
+                        rows.append(row)
+            # Keep parsing until next major section
+            if any(k in t for k in ['营养状况评估', '主要消化道', '抗生素']):
+                break
+    return rows
+
+
+# ============================
+# 模块4:致病菌 + 肠道屏障 + 短链脂肪酸
+# ============================
+def extract_pathogens(pages_text):
+    rows = []
+    found = False
+    for pt in pages_text:
+        t = norm(pt)
+        if '主要消化道致病菌' in t:
+            found = True
+            continue
+        if found:
+            lines = [l.strip() for l in t.split('\n') if l.strip()]
+            for line in lines:
+                if '肠道屏障' in line or '抗生素' in line:
+                    return rows
+                parts = line.split()
+                if len(parts) >= 2:
+                    name = parts[0]
+                    if re.match(r'^\d+%?$', parts[1]):
+                        row = {'致病菌': name, '丰度': parts[1]}
+                        if len(parts) >= 3:
+                            row['评估'] = parts[2]
+                        rows.append(row)
+    return rows
+
+
+def extract_barrier(pages_text):
+    """肠道炎症水平"""
+    found = False
+    for pt in pages_text:
+        t = norm(pt)
+        if '肠道炎症水平' in t:
+            m = re.search(r'肠道炎症水平\s*(\d+)', t)
+            if m:
+                return {'肠道炎症水平': m.group(1)}
+    return {}
+
+
+def extract_scfa(pages_text):
+    """短链脂肪酸"""
+    rows = []
+    found = False
+    for pt in pages_text:
+        t = norm(pt)
+        if '短链脂肪' in t:
+            found = True
+            continue
+        if found:
+            lines = [l.strip() for l in t.split('\n') if l.strip()]
+            for line in lines:
+                if '抗生素' in line:
+                    return rows
+                parts = line.split()
+                if len(parts) >= 2 and '酸' in parts[0] and re.match(r'^\d+$', parts[1]):
+                    row = {'短链脂肪酸': parts[0], '评估值': parts[1]}
+                    if len(parts) >= 3:
+                        row['正常范围'] = parts[2]
+                    if len(parts) >= 4:
+                        row['症状'] = ' '.join(parts[3:])
+                    rows.append(row)
+    return rows
+
+
+def extract_antibiotic_risk(pages_text):
+    """抗生素风险评估"""
+    rows = []
+    found = False
+    for pt in pages_text:
+        t = norm(pt)
+        if '抗生素风险' in t:
+            found = True
+            continue
+        if found:
+            lines = [l.strip() for l in t.split('\n') if l.strip() and l.strip() != '指标范围']
+            for line in lines:
+                if '抗生素风险' in line:
+                    continue
+                parts = line.split()
+                if len(parts) >= 2:
+                    name = parts[0]
+                    if re.match(r'^\d+$', parts[1]):
+                        row = {'抗生素': name, '风险值': parts[1]}
+                        if len(parts) >= 3:
+                            row['评级'] = '正常' if parts[2] == '正常' else ('偏低' if '偏低' in parts[2] else parts[2])
+                        rows.append(row)
+            if '一、' in t or '二、' in t or len(rows) > 15:
+                break
+    return rows
+
+
+# ============================
+# 个体化食物推荐表(复用已有逻辑)
+# ============================
+FOOD_SKIP_TEXTS = [
+    '根据您的肠道菌群', '分值从-100', '食物推荐考虑', '食物推荐是综合',
+    '需要注意的是', '本饮食推荐', '该饮食推荐根据', '后续表格中的营养',
+    '16S 高通量测序', '基于机器学习和', '肠道菌群健康检测报告说明',
+    '检测方法及局限性', '数据分析及模型', '结果解读及使用',
+    '影响因素说明', '建议将检测结果', '营养建议说明',
+    '重要提示', '推荐食物清单', '实际食用时需结合', '如有特殊疾病',
+    '免责声明', '本检测报告仅供', '以上模型预测', '正常范围的定义',
+]
+
+def split_7_fields(s):
+    results = []
+    ranges = [(2, 4), (1, 2), (1, 2), (1, 2), (1, 2), (1, 2), (1, 4)]
+    def backtrack(pos, idx, nums):
+        if idx == 7:
+            if pos == len(s):
+                results.append(list(nums))
+            return
+        if pos >= len(s): return
+        lo, hi = ranges[idx]
+        for w in range(lo, min(hi + 1, len(s) - pos + 1)):
+            chunk = s[pos:pos + w]
+            if chunk.isdigit():
+                backtrack(pos + w, idx + 1, nums + [int(chunk)])
+    backtrack(0, 0, [])
+    return results
+
+def decode_compressed(name, num_str, ref_vals=None):
+    raw = num_str.lstrip('-')
+    has_neg = num_str.startswith('-')
+    results = []
+    for rec_len in range(1, 3):
+        if rec_len > len(raw): continue
+        rec = ('-' if has_neg else '') + raw[:rec_len]
+        try:
+            rec_val = int(rec)
+            if not (-100 <= rec_val <= 100): continue
+        except: continue
+        remain = raw[rec_len:]
+        candidates = split_7_fields(remain)
+        for nums in candidates:
+            if ref_vals:
+                matches = sum(1 for i in range(7) if ref_vals[i] == nums[i])
+                if matches >= 6:
+                    results.append([rec_val] + nums)
+            else:
+                results.append([rec_val] + nums)
+    if not results: return None
+    if ref_vals:
+        results.sort(key=lambda r: (sum(1 for i in range(7) if ref_vals[i] == r[1:][i]),
+                                    -len(str(abs(r[0])))), reverse=True)
+        if sum(1 for i in range(7) if ref_vals[i] == results[0][1:][i]) < 6: return None
+    return results[0]
+
+def extract_food_table(pdf_path, ref_lookup=None):
+    reader = PdfReader(pdf_path)
+    food_start = None
+    for i, page in enumerate(reader.pages):
+        if '个体化食物推荐表' in page.extract_text():
+            food_start = i
+            break
+    if food_start is None:
+        return [], 'not_found'
+
+    first_text = norm(reader.pages[food_start + 1].extract_text())
+    lines = [l.strip() for l in first_text.split('\n') if l.strip() and not re.match(r'\d+/\d+', l)]
+    is_compressed = (len(lines) <= 3 and len(lines[0]) > 200) or sum(1 for l in lines[:10] if len(l) > 100) >= 3
+
+    rows = []
+    if is_compressed:
+        fmt = 'compressed'
+        for i in range(food_start + 1, len(reader.pages)):
+            text = norm(reader.pages[i].extract_text())
+            text = re.sub(r'\d+/\d+', '', text)
+            # Remove header
+            header = '名称分类推荐指数能量KJ蛋白g脂肪g碳水化合物g淀粉g总膳食纤维g胆固醇mg'
+            text = text.replace(header, '')
+            for kw in FOOD_SKIP_TEXTS:
+                text = text.replace(kw, '')
+            while text:
+                best_cat, best_idx = None, len(text)
+                for cat in KNOWN_CATS:
+                    idx = text.find(cat)
+                    if idx != -1 and idx < best_idx:
+                        best_idx, best_cat = idx, cat
+                if best_cat is None: break
+                name = text[:best_idx]
+                text = text[best_idx + len(best_cat):]
+                num_str = ''
+                while text and (text[0].isdigit() or text[0] in '-\u2212\u2014'):
+                    c = '-' if text[0] in '\u2212\u2014' else text[0]
+                    num_str += c
+                    text = text[1:]
+                if not name or not num_str: continue
+                ref_vals = ref_lookup.get(name) if ref_lookup else None
+                decoded = decode_compressed(name, num_str, ref_vals)
+                if decoded:
+                    rows.append(dict(zip(COLUMNS_FOOD, [name, best_cat] + [str(v) for v in decoded])))
+    else:
+        fmt = 'vertical'
+        all_lines = []
+        for i in range(food_start + 1, len(reader.pages)):
+            for line in norm(reader.pages[i].extract_text()).split('\n'):
+                lt = line.strip()
+                if not lt or re.match(r'\d+/\d+', lt) or lt in COLUMNS_FOOD: continue
+                if len(lt) > 60 and any(k in lt for k in FOOD_SKIP_TEXTS): continue
+                all_lines.append(lt)
+        i = 0
+        while i + 9 < len(all_lines):
+            name = all_lines[i].strip()
+            cat = all_lines[i + 1].strip()
+            if cat not in KNOWN_CATS: i += 1; continue
+            nums = []
+            ok = True
+            for j in range(2, 10):
+                v = all_lines[i + j].replace('\u2212', '-').replace('\u2014', '-').strip()
+                try: int(v); nums.append(v)
+                except: ok = False; break
+            if ok and len(nums) == 8:
+                rows.append(dict(zip(COLUMNS_FOOD, [name, cat] + nums)))
+            i += 1
+
+    return rows, fmt
+
+
+# ============================
+# 主流程
+# ============================
+def main():
+    pdf_files = sorted(f for f in os.listdir(BASE) if f.lower().endswith('.pdf'))
+    if not pdf_files:
+        print('未找到PDF文件')
+        return
+
+    # ── 加载所有PDF文本(只做一次) ──
+    all_data = {}  # label -> {pages_text, overview, ...}
+    print('读取PDF文件...')
+    for pdf_file in pdf_files:
+        pdf_path = os.path.join(BASE, pdf_file)
+        label = pdf_file.replace('.pdf', '')
+        try:
+            reader = PdfReader(pdf_path)
+            pages_text = [p.extract_text() for p in reader.pages]
+            all_data[label] = {'reader': reader, 'pages_text': pages_text}
+            print(f'  {label}: {len(pages_text)} pages')
+        except Exception as e:
+            print(f'  {label}: ERROR - {e}')
+
+    if not all_data:
+        print('无可处理的PDF')
+        return
+
+    # ── 1. 报告概述 ──
+    print('\n[1/11] 报告概述...')
+    overviews = []
+    for label in all_data:
+        r = extract_overview(label, all_data[label]['pages_text'])
+        overviews.append(r)
+    overview_cols = set()
+    for r in overviews:
+        overview_cols.update(r.keys())
+    overview_cols = sorted(overview_cols)
+    with open(os.path.join(OUTDIR, '报告概述.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+        w = csv.DictWriter(f, fieldnames=overview_cols)
+        w.writeheader()
+        w.writerows(overviews)
+    print(f'  -> CSV/报告概述.csv ({len(overviews)} 行)')
+
+    # ── 2. 疾病风险评估 ──
+    print('[2/11] 疾病风险评估...')
+    disease_data = {}  # 疾病名 -> {label: 风险值}
+    for label, d in all_data.items():
+        rows = extract_disease_risk(d['pages_text'])
+        for r in rows:
+            disease_data.setdefault(r['疾病'], {})[label] = r['风险值']
+    if disease_data:
+        labels = list(all_data.keys())
+        with open(os.path.join(OUTDIR, '疾病风险评估.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+            w = csv.DictWriter(f, fieldnames=['疾病'] + labels)
+            w.writeheader()
+            for disease, vals in sorted(disease_data.items()):
+                row = {'疾病': disease}
+                row.update(vals)
+                w.writerow(row)
+        print(f'  -> CSV/疾病风险评估.csv ({len(disease_data)} 指标 × {len(labels)} 人)')
+
+    # ── 3-6. 营养相关评估 ──
+    nutrition_sections = ['营养状况评估', '主要营养评估', '氨基酸评估', '维生素评估', '微量元素评估']
+    for sec in nutrition_sections:
+        print(f'[3-6/11] {sec}...')
+        sec_data = {}
+        for label, d in all_data.items():
+            tables = extract_nutrition_tables(d['pages_text'])
+            for item in tables.get(sec, []):
+                sec_data.setdefault(item['指标'], {})[label] = item.get('状态', item.get('评估值', ''))
+        if sec_data:
+            labels = list(all_data.keys())
+            fname = f'{sec}.csv'
+            with open(os.path.join(OUTDIR, fname), 'w', newline='', encoding='utf-8-sig') as f:
+                w = csv.DictWriter(f, fieldnames=['指标'] + labels)
+                w.writeheader()
+                for name, vals in sorted(sec_data.items()):
+                    row = {'指标': name}
+                    row.update(vals)
+                    w.writerow(row)
+            print(f'  -> CSV/{fname} ({len(sec_data)} 指标)')
+
+    # ── 7. 主要消化道致病菌 ──
+    print('[7/11] 主要消化道致病菌...')
+    path_data = {}
+    for label, d in all_data.items():
+        rows = extract_pathogens(d['pages_text'])
+        for r in rows:
+            path_data.setdefault(r['致病菌'], {})[label] = r['丰度']
+    if path_data:
+        labels = list(all_data.keys())
+        with open(os.path.join(OUTDIR, '主要消化道致病菌.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+            w = csv.DictWriter(f, fieldnames=['致病菌'] + labels)
+            w.writeheader()
+            for name, vals in sorted(path_data.items()):
+                row = {'致病菌': name}
+                row.update(vals)
+                w.writerow(row)
+        print(f'  -> CSV/主要消化道致病菌.csv ({len(path_data)} 菌种)')
+
+    # ── 8. 肠道炎症水平 ──
+    print('[8/11] 肠道屏障及代谢物...')
+    barrier_rows = []
+    for label, d in all_data.items():
+        b = extract_barrier(d['pages_text'])
+        if b:
+            barrier_rows.append({'指标': '肠道炎症水平', '姓名': label, '评估值': b['肠道炎症水平']})
+    # 短链脂肪酸
+    for label, d in all_data.items():
+        rows = extract_scfa(d['pages_text'])
+        for r in rows:
+            barrier_rows.append({'指标': r['短链脂肪酸'], '姓名': label, '评估值': r['评估值'],
+                                 '正常范围': r.get('正常范围', ''), '症状': r.get('症状', '')})
+    if barrier_rows:
+        with open(os.path.join(OUTDIR, '肠道屏障及代谢物.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+            w = csv.DictWriter(f, fieldnames=['指标', '姓名', '评估值', '正常范围', '症状'])
+            w.writeheader()
+            w.writerows(barrier_rows)
+        print(f'  -> CSV/肠道屏障及代谢物.csv ({len(barrier_rows)} 条)')
+
+    # ── 9. 抗生素风险评估 ──
+    print('[9/11] 抗生素风险评估...')
+    abx_data = {}
+    for label, d in all_data.items():
+        rows = extract_antibiotic_risk(d['pages_text'])
+        for r in rows:
+            abx_data.setdefault(r['抗生素'], {})[label] = r['风险值']
+    if abx_data:
+        labels = list(all_data.keys())
+        with open(os.path.join(OUTDIR, '抗生素风险评估.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+            w = csv.DictWriter(f, fieldnames=['抗生素'] + labels)
+            w.writeheader()
+            for name, vals in sorted(abx_data.items()):
+                row = {'抗生素': name}
+                row.update(vals)
+                w.writerow(row)
+        print(f'  -> CSV/抗生素风险评估.csv ({len(abx_data)} 种抗生素)')
+
+    # ── 10. 个体化食物推荐表 ──
+    print('[10/11] 个体化食物推荐表...')
+
+    # 先用侯报告建立标准营养参考(用于压缩格式解码)
+    ref_nutrition = {}
+    for label in all_data:
+        if '侯' in label:
+            pdf_path = os.path.join(BASE, label + '.pdf')
+            ref_rows, _ = extract_food_table(pdf_path)
+            for r in ref_rows:
+                ref_nutrition[r['名称']] = [int(r[k]) for k in
+                    ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']]
+            break
+
+    for label, d in all_data.items():
+        pdf_path = os.path.join(BASE, label + '.pdf')
+        rows, fmt = extract_food_table(pdf_path, ref_nutrition)
+        fname = f'{label}-个体化食物推荐表.csv'
+        with open(os.path.join(OUTDIR, fname), 'w', newline='', encoding='utf-8-sig') as f:
+            w = csv.DictWriter(f, fieldnames=COLUMNS_FOOD)
+            w.writeheader()
+            w.writerows(rows)
+        print(f'  -> CSV/{fname} ({len(rows)} 条, {fmt})')
+
+    # ── 11. 推荐指数汇总 ──
+    print('[11/11] 推荐指数汇总...')
+    # 收集各报告的推荐指数
+    rec_all = {}
+    for label in all_data:
+        pdf_path = os.path.join(BASE, label + '.pdf')
+        rows, _ = extract_food_table(pdf_path, ref_nutrition)
+        rec_all[label] = {}
+        for r in rows:
+            rec_all[label][r['名称']] = r['推荐指数']
+
+    # 标准营养参考(优先侯)
+    std_label = next((l for l in all_data if '侯' in l), list(all_data.keys())[0])
+    pdf_path = os.path.join(BASE, std_label + '.pdf')
+    std_rows, _ = extract_food_table(pdf_path)
+
+    labels = list(all_data.keys())
+    sum_cols = ['名称', '分类'] + ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg'] + labels
+    with open(os.path.join(OUTDIR, '推荐指数汇总.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+        w = csv.DictWriter(f, fieldnames=sum_cols)
+        w.writeheader()
+        for r in std_rows:
+            name = r['名称']
+            row = {'名称': name, '分类': r['分类']}
+            for k in ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']:
+                row[k] = r[k]
+            for la in labels:
+                row[la] = rec_all.get(la, {}).get(name, '')
+            w.writerow(row)
+    print(f'  -> CSV/推荐指数汇总.csv ({len(std_rows)} 行)')
+    for la in labels:
+        missing = sum(1 for r in std_rows if not rec_all.get(la, {}).get(r['名称'], ''))
+        print(f'    {la}: {"完整" if missing == 0 else f"缺失 {missing} 条"}')
+
+    print(f'\n完成!所有CSV已输出到 {OUTDIR}/')
+
+
+if __name__ == '__main__':
+    main()

+ 668 - 0
docs/参考资料/extract_full_report.py

@@ -0,0 +1,668 @@
+"""
+肠道菌群健康检测报告 — 全指标提取脚本(v3)
+========================================
+支持两种PDF文本格式:
+  - 三元组逐行格式(某人、侯、547982403、儿童示例)
+  - 压缩同行格式(朱评估报告)
+
+输出到 docs/参考资料/CSV/ 目录
+
+用法:python extract_full_report.py
+"""
+
+import sys, os, csv, re
+sys.stdout.reconfigure(encoding='utf-8')
+sys.stderr.reconfigure(encoding='utf-8')
+from PyPDF2 import PdfReader
+
+BASE = r'D:\workspace\cfc\docs\参考资料'
+OUTDIR = os.path.join(BASE, 'CSV')
+os.makedirs(OUTDIR, exist_ok=True)
+
+RADICAL_MAP = {
+    '\u2f18': '卜', '\u2f1f': '土', '\u2f24': '大', '\u2f26': '子',
+    '\u2f29': '小', '\u2f2d': '山', '\u2f32': '干', '\u2f3c': '心',
+    '\u2f42': '文', '\u2f46': '无', '\u2f4a': '木', '\u2f50': '比',
+    '\u2f54': '水', '\u2f55': '火', '\u2f5c': '牛', '\u2f5f': '玉',
+    '\u2f60': '瓜', '\u2f62': '甘', '\u2f63': '生', '\u2f64': '用',
+    '\u2f69': '白', '\u2f6a': '皮', '\u2f6c': '目', '\u2f6f': '石',
+    '\u2f75': '竹', '\u2f76': '米', '\u2f7a': '羊', '\u2f7b': '羽',
+    '\u2f7c': '老', '\u2f7f': '耳', '\u2f81': '肉', '\u2f90': '衣',
+    '\u2f95': '谷', '\u2f96': '豆', '\u2f9d': '身', '\u2fa6': '金',
+    '\u2faf': '面', '\u2fb2': '韭', '\u2fb9': '香', '\u2fca': '黑',
+    '\u2ec9': '贝', '\u2edd': '食', '\u2ee2': '马', '\u2ee5': '鱼',
+    '\u2ee8': '麦', '\u2ee9': '黄', '\u2ef0': '龙',
+}
+
+COLUMNS_FOOD = ['名称', '分类', '推荐指数', '能量KJ', '蛋白g', '脂肪g',
+                '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']
+KNOWN_CATS = ['主食', '乳制品', '干果', '坚果', '快餐', '水产品',
+              '水果', '汤', '肉类', '蔬菜', '豆类及豆制品', '蛋类', '饮料']
+FOOD_SKIP_TEXTS = [
+    '根据您的肠道菌群', '分值从-100', '食物推荐考虑', '食物推荐是综合',
+    '需要注意的是', '本饮食推荐', '该饮食推荐根据', '后续表格中的营养',
+    '16S 高通量测序', '基于机器学习和', '肠道菌群健康检测报告说明',
+    '检测方法及局限性', '数据分析及模型', '结果解读及使用',
+    '影响因素说明', '建议将检测结果', '营养建议说明',
+    '重要提示', '推荐食物清单', '实际食用时需结合', '如有特殊疾病',
+    '免责声明', '本检测报告仅供', '以上模型预测', '正常范围的定义',
+    '募极生物',
+]
+
+def norm(s):
+    return ''.join(RADICAL_MAP.get(c, c) for c in s)
+
+
+def clean_lines(text):
+    lines = []
+    for line in norm(text).split('\n'):
+        ls = line.strip()
+        if not ls or re.match(r'^\d+/\d+$', ls):
+            continue
+        lines.append(ls)
+    return lines
+
+
+def detect_format(pages_text):
+    """检测报告格式:'triplet'(逐行) 或 'inline'(压缩同行)"""
+    for pt in pages_text:
+        t = norm(pt)
+        if '疾病风险评估' in t and '指标范围' in t:
+            lines = t.split('\n')
+            for line in lines:
+                ls = line.strip()
+                if not ls: continue
+                if re.search(r'[\u4e00-\u9fff]+\d+\.\d+[\u4e00-\u9fff]+', ls):
+                    return 'inline'
+            return 'triplet'
+    return 'triplet'
+
+
+# =====================================================
+# 格式1:三元组逐行
+# =====================================================
+def extract_triplet_module(lines, end_markers=None):
+    results = []
+    i = 0
+    while i < len(lines):
+        if end_markers:
+            if any(lines[i] == em or lines[i].startswith(em) for em in end_markers):
+                break
+        if lines[i] == '指标范围':
+            i += 1
+            continue
+        name = lines[i]
+        if i + 2 >= len(lines):
+            break
+        val = lines[i + 1]
+        status = lines[i + 2]
+        if re.match(r'^-?\d+\.?\d*$', val):
+            results.append({'名称': name, '数值': val, '状态': status})
+            i += 3
+        else:
+            i += 1
+    return results
+
+
+def extract_pathogens_triplet(lines):
+    results = []
+    i = 0
+    found = False
+    while i < len(lines):
+        if lines[i] == '主要消化道致病菌':
+            found = True
+            i += 1
+            continue
+        if not found:
+            i += 1
+            continue
+        if lines[i] in ['肠道屏障及菌群代谢物', '肠道屏障', '抗生素风险', '短链脂肪酸']:
+            break
+        if lines[i] in ['致病菌', '丰度', '评估']:
+            i += 1
+            continue
+        name = lines[i]
+        if i + 2 < len(lines) and re.match(r'^\d+%$', lines[i+1]):
+            results.append({'致病菌': name, '丰度': lines[i+1], '评估': lines[i+2]})
+            i += 3
+        else:
+            i += 1
+    return results
+
+
+# =====================================================
+# 格式2:压缩同行
+# =====================================================
+def extract_inline_module(text):
+    results = []
+    pattern = r'([\u4e00-\u9fff()\u2014-]+?)(\d+(?:\.\d+)?)([\u4e00-\u9fff/]+)'
+    for m in re.finditer(pattern, text):
+        name = m.group(1).strip()
+        val = m.group(2)
+        status = m.group(3).strip()
+        if any(k in name for k in ['指标范围', '疾病风险', '营养状况', '抗生素风险',
+                                      '注:', '注:', '页']):
+            continue
+        if any(k in status for k in ['指标范围', '疾病风险']):
+            continue
+        try:
+            fv = float(val)
+            if fv >= 0 and fv <= 1000:
+                results.append({'名称': name, '数值': val, '状态': status})
+        except:
+            pass
+    return results
+
+
+def extract_pathogens_inline(text):
+    results = []
+    pattern = r'([\u4e00-\u9fff]+?)(\d+%)([\u4e00-\u9fff]+)'
+    for m in re.finditer(pattern, text):
+        name = m.group(1).strip()
+        val = m.group(2)
+        status = m.group(3).strip()
+        if name in ['主要消化道', '肠道屏障']:
+            continue
+        results.append({'致病菌': name, '丰度': val, '评估': status})
+    return results
+
+
+def extract_barrier_inline(text):
+    results = {}
+    pattern = r'([\u4e00-\u9fff()\u2014-]+?)(\d+)\s*(正常|轻度产气|过多|过低)\s*(\d+-\d+)\s*([\u4e00-\u9fff、,。/\s]+?)(?=[\u4e00-\u9fff()\u2014-]+\d+\s*|$)'
+    for m in re.finditer(pattern, text):
+        name = m.group(1).strip()
+        val = m.group(2)
+        status = m.group(3)
+        rng = m.group(4)
+        symptoms = m.group(5).strip()
+        if '肠道屏障' in name or '菌群代谢物' in name:
+            continue
+        if name not in results:
+            results[name] = {
+                '名称': name, '评估值': val, '健康状况': status,
+                '正常范围': rng, '症状': symptoms
+            }
+    return results
+
+
+# =====================================================
+# 报告概述
+# =====================================================
+def extract_overview(lines_all, label):
+    r = {'姓名': label}
+    t = '\n'.join(lines_all)
+
+    m = re.search(r'编号\s*(\S+)', t)
+    if m: r['编号'] = m.group(1)
+    m = re.search(r'姓名\s*(\S+)', t)
+    if m: r['姓名'] = m.group(1)
+    for pat in ['年龄', '性别']:
+        m = re.search(rf'{pat}\s*(\S+)', t)
+        if m: r[pat] = m.group(1)
+    m = re.search(r'肠道预测年龄[:\s]*([\d.]+岁?)', t)
+    if m: r['肠道预测年龄'] = m.group(1)
+    m = re.search(r'肠型[:\s]*(\S+)', t)
+    if m: r['肠型'] = m.group(1)
+    for kw in ['肠道菌群平衡', '菌群多样性', '有益菌', '有害菌', '核心菌属']:
+        m = re.search(rf'{kw}\s*(\d+)', t)
+        if m: r[kw] = m.group(1)
+    m = re.search(r'健康总分\s*(\d+)', t)
+    if m: r['健康总分'] = m.group(1)
+    for kw in ['菌群健康', '慢病控制', '营养均衡']:
+        m = re.search(rf'{kw}\s*(\d+)', t)
+        if m: r[kw] = m.group(1)
+    return r
+
+
+# =====================================================
+# 食物推荐表
+# =====================================================
+def split_7_fields(s):
+    results = []
+    ranges = [(2, 4), (1, 2), (1, 2), (1, 2), (1, 2), (1, 2), (1, 4)]
+    def backtrack(pos, idx, nums):
+        if idx == 7:
+            if pos == len(s):
+                results.append(list(nums))
+            return
+        if pos >= len(s): return
+        lo, hi = ranges[idx]
+        for w in range(lo, min(hi + 1, len(s) - pos + 1)):
+            chunk = s[pos:pos + w]
+            if chunk.isdigit():
+                backtrack(pos + w, idx + 1, nums + [int(chunk)])
+    backtrack(0, 0, [])
+    return results
+
+def decode_compressed(name, num_str, ref_vals=None):
+    raw = num_str.lstrip('-')
+    has_neg = num_str.startswith('-')
+    candidates = []
+    for rec_len in range(1, 3):
+        if rec_len > len(raw): continue
+        rec = ('-' if has_neg else '') + raw[:rec_len]
+        try:
+            rec_val = int(rec)
+            if not (-100 <= rec_val <= 100): continue
+        except: continue
+        remain = raw[rec_len:]
+        for nums in split_7_fields(remain):
+            if ref_vals:
+                matches = sum(1 for i in range(7) if ref_vals[i] == nums[i])
+                if matches >= 6:
+                    candidates.append([rec_val] + nums)
+            else:
+                candidates.append([rec_val] + nums)
+    if not candidates: return None
+    if ref_vals:
+        candidates.sort(key=lambda r: (sum(1 for i in range(7) if ref_vals[i] == r[1:][i]),
+                                    -len(str(abs(r[0])))), reverse=True)
+        if sum(1 for i in range(7) if ref_vals[i] == candidates[0][1:][i]) < 6:
+            return None
+    return candidates[0]
+
+def extract_food_table(pdf_path, ref_lookup=None):
+    reader = PdfReader(pdf_path)
+    food_start = None
+    for i, page in enumerate(reader.pages):
+        if '个体化食物推荐表' in page.extract_text():
+            food_start = i
+            break
+    if food_start is None:
+        return [], 'not_found'
+
+    first_text = norm(reader.pages[food_start + 1].extract_text())
+    lines = [l.strip() for l in first_text.split('\n') if l.strip() and not re.match(r'\d+/\d+', l)]
+    is_compressed = sum(1 for l in lines[:10] if len(l) > 100) >= 3
+
+    rows = []
+    if is_compressed:
+        fmt = 'compressed'
+        for i in range(food_start + 1, len(reader.pages)):
+            text = norm(reader.pages[i].extract_text())
+            text = re.sub(r'\d+/\d+', '', text)
+            header = '名称分类推荐指数能量KJ蛋白g脂肪g碳水化合物g淀粉g总膳食纤维g胆固醇mg'
+            text = text.replace(header, '')
+            for kw in FOOD_SKIP_TEXTS:
+                text = text.replace(kw, '')
+            while text:
+                best_cat, best_idx = None, len(text)
+                for cat in KNOWN_CATS:
+                    idx = text.find(cat)
+                    if idx != -1 and idx < best_idx:
+                        best_idx, best_cat = idx, cat
+                if best_cat is None: break
+                name = text[:best_idx]
+                text = text[best_idx + len(best_cat):]
+                num_str = ''
+                while text and (text[0].isdigit() or text[0] in '-\u2212\u2014'):
+                    c = '-' if text[0] in '\u2212\u2014' else text[0]
+                    num_str += c
+                    text = text[1:]
+                if not name or not num_str: continue
+                ref_vals = ref_lookup.get(name) if ref_lookup else None
+                decoded = decode_compressed(name, num_str, ref_vals)
+                if decoded:
+                    rows.append(dict(zip(COLUMNS_FOOD, [name, best_cat] + [str(v) for v in decoded])))
+    else:
+        fmt = 'vertical'
+        all_lines = []
+        for i in range(food_start + 1, len(reader.pages)):
+            for line in norm(reader.pages[i].extract_text()).split('\n'):
+                lt = line.strip()
+                if not lt or re.match(r'\d+/\d+', lt) or lt in COLUMNS_FOOD:
+                    continue
+                if len(lt) > 60 and any(k in lt for k in FOOD_SKIP_TEXTS):
+                    continue
+                all_lines.append(lt)
+        i = 0
+        while i + 9 < len(all_lines):
+            name = all_lines[i].strip()
+            cat = all_lines[i + 1].strip()
+            if cat not in KNOWN_CATS:
+                i += 1
+                continue
+            nums = []
+            ok = True
+            for j in range(2, 10):
+                v = all_lines[i + j].replace('\u2212', '-').replace('\u2014', '-').strip()
+                try: int(v); nums.append(v)
+                except: ok = False; break
+            if ok and len(nums) == 8:
+                rows.append(dict(zip(COLUMNS_FOOD, [name, cat] + nums)))
+            i += 1
+    return rows, fmt
+
+
+# =====================================================
+# 肠道屏障 - 三元组格式解析
+# =====================================================
+def extract_barrier_triplet(lines, section_keyword):
+    results = {}
+    found = False
+    i = 0
+    while i < len(lines):
+        if section_keyword in lines[i]:
+            found = True
+            i += 1
+            continue
+        if not found:
+            i += 1
+            continue
+        if lines[i] in ['短链脂肪酸', '抗生素风险', '抗生素耐药风险',
+                         '神经递质', '神经递质及激素指标', '神经递质及激素',
+                         '个体化食物推荐表']:
+            break
+        if lines[i] in ['名称', '评估值', '正常范围', '过量', '缺乏']:
+            i += 1
+            continue
+        if '过量' in lines[i] or '缺乏' in lines[i] or '相关症状' in lines[i]:
+            i += 1
+            continue
+
+        # Try inline: "肠道炎症水平 36 正常 0-80 症状..."
+        parts = lines[i].split()
+        val_idx = None
+        for vi, p in enumerate(parts):
+            if re.match(r'^\d+$', p) and vi >= 1:
+                val_idx = vi
+                break
+        if val_idx:
+            name = ' '.join(parts[:val_idx])
+            val = parts[val_idx]
+            status = ''
+            normal_range = ''
+            symptoms = ''
+            for rp in parts[val_idx+1:]:
+                if rp in ['正常', '过多', '轻度产气', '过低', '不足']:
+                    status = rp
+                elif re.match(r'^\d+-\d+$', rp):
+                    normal_range = rp
+                elif not re.match(r'^[\d.]+$', rp):
+                    symptoms += rp + ' '
+            # 读取后续行作为症状(有些症状分行显示)
+            j = i + 1
+            while j < len(lines) and len(lines[j]) > 5 and not re.match(r'^\d+[-/,]', lines[j]):
+                if any(lines[j].startswith(sk) for sk in ['短链脂肪酸', '抗生素风险', '神经递质']):
+                    break
+                symptoms += lines[j] + ' '
+                j += 1
+            i = j
+            results[name] = {
+                '名称': name, '评估值': val, '健康状况': status,
+                '正常范围': normal_range, '症状': symptoms.strip()
+            }
+        elif i + 1 < len(lines) and re.match(r'^\d+$', lines[i+1]):
+            name = lines[i]
+            val = lines[i+1]
+            status = ''
+            normal_range = ''
+            symptoms = ''
+            for j in range(2, min(8, len(lines)-i)):
+                if lines[i+j] in ['正常', '过多', '轻度产气', '过低', '不足']:
+                    status = lines[i+j]
+                elif re.match(r'^\d+-\d+$', lines[i+j]):
+                    normal_range = lines[i+j]
+                    if i+j+1 < len(lines) and not re.match(r'^\d+[-/,]', lines[i+j+1]) and lines[i+j+1] not in ['短链脂肪酸', '抗生素风险', '神经递质', '神经递质及激素指标']:
+                        symptoms = lines[i+j+1]
+                    i += (j + 1 + (1 if symptoms else 0))
+                    break
+            else:
+                i += 3
+            if name not in ['名称', '评估值', '正常范围', '过量', '缺乏']:
+                results[name] = {
+                    '名称': name, '评估值': val, '健康状况': status,
+                    '正常范围': normal_range, '症状': symptoms.strip()
+                }
+        else:
+            i += 1
+    return results
+
+
+# =====================================================
+# 主流程
+# =====================================================
+def main():
+    pdf_files = sorted(f for f in os.listdir(BASE) if f.lower().endswith('.pdf'))
+    if not pdf_files:
+        print('未找到PDF文件')
+        return
+
+    all_data = {}
+    print('读取PDF文件...')
+    for pdf_file in pdf_files:
+        pdf_path = os.path.join(BASE, pdf_file)
+        label = pdf_file.replace('.pdf', '')
+        try:
+            reader = PdfReader(pdf_path)
+            pages_text = [p.extract_text() for p in reader.pages]
+            fmt = detect_format(pages_text)
+            all_data[label] = {'reader': reader, 'pages_text': pages_text, 'fmt': fmt}
+            print(f'  {label}: {len(pages_text)} pages, format={fmt}')
+        except Exception as e:
+            print(f'  {label}: ERROR - {e}')
+
+    if not all_data:
+        print('无可处理的PDF')
+        return
+
+    # ── 1. 报告概述 ──
+    print('\n[1/11] 报告概述...')
+    overviews = []
+    for label, d in all_data.items():
+        lines = clean_lines(d['pages_text'][0])
+        # 如果第一页没有关键信息,找有"基本信息"和"肠道预测年龄"的页
+        found_info = any(k in '\n'.join(lines) for k in ['肠道预测年龄', '核心菌属'])
+        if not found_info:
+            for pt in d['pages_text']:
+                if '基本信息' in norm(pt) and '肠道预测年龄' in norm(pt):
+                    lines = clean_lines(pt)
+                    break
+        r = extract_overview(lines, label)
+        overviews.append(r)
+
+    overview_cols = ['姓名', '编号', '年龄', '性别', '肠道预测年龄', '肠型',
+                     '肠道菌群平衡', '菌群多样性', '有益菌', '有害菌', '核心菌属',
+                     '健康总分', '菌群健康', '慢病控制', '营养均衡']
+    with open(os.path.join(OUTDIR, '报告概述.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+        w = csv.DictWriter(f, fieldnames=overview_cols, extrasaction='ignore')
+        w.writeheader()
+        w.writerows(overviews)
+    print(f'  -> CSV/报告概述.csv ({len(overviews)} 行)')
+
+    all_full_text = {}
+    all_full_lines = {}
+    for label, d in all_data.items():
+        all_full_text[label] = '\n\n---PAGEBREAK---\n\n'.join(norm(p.extract_text()) for p in d['reader'].pages)
+        lines = []
+        for pt in d['pages_text']:
+            lines.extend(clean_lines(pt))
+        all_full_lines[label] = lines
+
+    # ── 2-6, 9. 模块 ──
+    module_configs = {
+        '疾病风险评估': {'end': ['主要营养评估', '氨基酸评估', '维生素评估', '微量元素评估',
+                              '主要消化道致病菌'], 'name_col': '疾病', 'file': '疾病风险评估.csv'},
+        '主要营养评估': {'end': ['氨基酸评估', '维生素评估', '微量元素评估', '主要消化道致病菌'],
+                      'name_col': '指标', 'file': '主要营养评估.csv'},
+        '氨基酸评估': {'end': ['维生素评估', '微量元素评估', '主要消化道致病菌'],
+                    'name_col': '氨基酸', 'file': '氨基酸评估.csv'},
+        '维生素评估': {'end': ['微量元素评估', '主要消化道致病菌'],
+                    'name_col': '维生素', 'file': '维生素评估.csv'},
+        '微量元素评估': {'end': ['主要消化道致病菌'],
+                      'name_col': '微量元素', 'file': '微量元素评估.csv'},
+        '抗生素风险评估': {'end': ['抗生素耐药风险', '个体化食物推荐表'],
+                        'name_col': '抗生素', 'file': '抗生素风险评估.csv'},
+    }
+
+    for section_name, cfg in module_configs.items():
+        n = list(module_configs.keys()).index(section_name) + 2
+        print(f'[{n}/11] {section_name}...')
+        name_col = cfg['name_col']
+        data = {}
+
+        for label, d in all_data.items():
+            if d['fmt'] == 'triplet':
+                lines = all_full_lines[label]
+                sidx = -1
+                for i, line in enumerate(lines):
+                    if line == section_name:
+                        sidx = i
+                        break
+                if sidx == -1:
+                    continue
+                results = extract_triplet_module(lines[sidx+1:], end_markers=cfg['end'])
+            else:
+                text = all_full_text[label]
+                sidx = text.find(section_name)
+                if sidx == -1:
+                    continue
+                end_pos = len(text)
+                for em in cfg['end']:
+                    ei = text.find(em, sidx)
+                    if ei != -1 and ei < end_pos:
+                        end_pos = ei
+                section_text = text[sidx:end_pos]
+                results = extract_inline_module(section_text)
+
+            for r in results:
+                data.setdefault(r['名称'], {})[label] = r['数值']
+
+        if data:
+            labels = list(all_data.keys())
+            with open(os.path.join(OUTDIR, cfg['file']), 'w', newline='', encoding='utf-8-sig') as f:
+                w = csv.DictWriter(f, fieldnames=[name_col] + labels)
+                w.writeheader()
+                for name, vals in sorted(data.items()):
+                    row = {name_col: name}
+                    row.update(vals)
+                    w.writerow(row)
+            print(f'  -> CSV/{cfg["file"]} ({len(data)} 指标)')
+        else:
+            print(f'  (无数据)')
+
+    # ── 7. 主要消化道致病菌 ──
+    print('[7/11] 主要消化道致病菌...')
+    path_data = {}
+    for label, d in all_data.items():
+        if d['fmt'] == 'triplet':
+            rows = extract_pathogens_triplet(all_full_lines[label])
+        else:
+            text = all_full_text[label]
+            sidx = text.find('主要消化道致病菌')
+            eidx = text.find('肠道屏障', sidx)
+            section_text = text[sidx:eidx]
+            rows = extract_pathogens_inline(section_text)
+        for r in rows:
+            path_data.setdefault(r['致病菌'], {})[label] = r['丰度']
+
+    if path_data:
+        labels = list(all_data.keys())
+        with open(os.path.join(OUTDIR, '主要消化道致病菌.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+            w = csv.DictWriter(f, fieldnames=['致病菌'] + labels)
+            w.writeheader()
+            for name, vals in sorted(path_data.items()):
+                row = {'致病菌': name}
+                row.update(vals)
+                w.writerow(row)
+        print(f'  -> CSV/主要消化道致病菌.csv ({len(path_data)} 菌种)')
+
+    # ── 8. 肠道屏障及代谢物 + 短链脂肪酸 ──
+    print('[8/11] 肠道屏障及代谢物...')
+    with open(os.path.join(OUTDIR, '肠道屏障及代谢物.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+        w = csv.writer(f)
+        w.writerow(['指标', '姓名', '评估值', '健康状况', '正常范围', '症状'])
+        total = 0
+
+        for label, d in all_data.items():
+            if d['fmt'] == 'triplet':
+                results = extract_barrier_triplet(all_full_lines[label], '肠道屏障')
+            else:
+                text = all_full_text[label]
+                m = re.search(r'肠道屏障及菌群代谢物(.+?)(?:短链脂肪酸|$)', text, re.DOTALL)
+                results = extract_barrier_inline(m.group(1)) if m else {}
+
+            for nm, info in results.items():
+                w.writerow([nm, label, info.get('评估值',''), info.get('健康状况',''),
+                           info.get('正常范围',''), info.get('症状','')])
+                total += 1
+    print(f'  -> CSV/肠道屏障及代谢物.csv ({total} 条)')
+
+    # 短链脂肪酸
+    print('  [短链脂肪酸]...')
+    scfa_total = 0
+    with open(os.path.join(OUTDIR, '肠道屏障及代谢物.csv'), 'a', newline='', encoding='utf-8-sig') as f:
+        w = csv.writer(f)
+        for label, d in all_data.items():
+            if d['fmt'] == 'triplet':
+                results = extract_barrier_triplet(all_full_lines[label], '短链脂肪酸')
+            else:
+                text = all_full_text[label]
+                m = re.search(r'短链脂肪酸(.+?)(?:神经递质|抗生素风险|$)', text, re.DOTALL)
+                results = extract_barrier_inline(m.group(1)) if m else {}
+
+            for nm, info in results.items():
+                w.writerow([nm, label, info.get('评估值',''), info.get('健康状况',''),
+                           info.get('正常范围',''), info.get('症状','')])
+                scfa_total += 1
+    print(f'    (短链脂肪酸 {scfa_total} 条, 已追加)')
+
+    # ── 10. 个体化食物推荐表 ──
+    print('[10/11] 个体化食物推荐表...')
+    ref_nutrition = {}
+    for label in all_data:
+        if '侯' in label:
+            pdf_path = os.path.join(BASE, label + '.pdf')
+            ref_rows, _ = extract_food_table(pdf_path)
+            for r in ref_rows:
+                ref_nutrition[r['名称']] = [int(r[k]) for k in
+                    ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']]
+            break
+
+    for label, d in all_data.items():
+        pdf_path = os.path.join(BASE, label + '.pdf')
+        rows, fmt = extract_food_table(pdf_path, ref_nutrition)
+        fname = f'{label}-个体化食物推荐表.csv'
+        with open(os.path.join(OUTDIR, fname), 'w', newline='', encoding='utf-8-sig') as f:
+            w = csv.DictWriter(f, fieldnames=COLUMNS_FOOD)
+            w.writeheader()
+            w.writerows(rows)
+        print(f'  -> CSV/{fname} ({len(rows)} 条, {fmt})')
+
+    # ── 11. 推荐指数汇总 ──
+    print('[11/11] 推荐指数汇总...')
+    rec_all = {}
+    for label, d in all_data.items():
+        pdf_path = os.path.join(BASE, label + '.pdf')
+        rows, _ = extract_food_table(pdf_path, ref_nutrition)
+        rec_all[label] = {r['名称']: r['推荐指数'] for r in rows}
+
+    std_label = next((l for l in all_data if '侯' in l), list(all_data.keys())[0])
+    pdf_path = os.path.join(BASE, std_label + '.pdf')
+    std_rows, _ = extract_food_table(pdf_path)
+
+    labels_all = list(all_data.keys())
+    sum_cols = ['名称', '分类'] + ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g',
+                                    '总膳食纤维g', '胆固醇mg'] + labels_all
+    with open(os.path.join(OUTDIR, '推荐指数汇总.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+        w = csv.DictWriter(f, fieldnames=sum_cols)
+        w.writeheader()
+        for r in std_rows:
+            name = r['名称']
+            row = {'名称': name, '分类': r['分类']}
+            for k in ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']:
+                row[k] = r[k]
+            for la in labels_all:
+                row[la] = rec_all.get(la, {}).get(name, '')
+            w.writerow(row)
+    print(f'  -> CSV/推荐指数汇总.csv ({len(std_rows)} 行)')
+    for la in labels_all:
+        missing = sum(1 for r in std_rows if not rec_all.get(la, {}).get(r['名称'], ''))
+        print(f'    {la}: {"完整" if missing == 0 else f"缺失 {missing} 条"}')
+
+    print(f'\n完成!所有CSV已输出到 {OUTDIR}/')
+
+
+if __name__ == '__main__':
+    main()

+ 740 - 0
docs/参考资料/extract_full_report_v4.py

@@ -0,0 +1,740 @@
+"""
+肠道菌群健康检测报告 — 全指标提取脚本(v4)
+========================================
+基于PDF实际文本格式精确解析。
+
+三元组格式结构:
+  - p0: 基本信息(姓名/编号/年龄/性别/肠道预测年龄/肠型/核心菌属等)
+  - p1: 指标范围 + 疾病风险评估(三元组)
+  - p2: 指标范围 + 营养状况评估(三元组,含碳水/蛋白/脂肪/纤维/乳制品 以及 氨基酸)
+     末尾:主要营养评估 / 氨基酸评估
+  - p3: 指标范围 + 剩余氨基酸评估
+  - p4: 指标范围 + 维生素评估(含铁/锌也在其中)
+     末尾:维生素评估 / 微量元素评估
+  - p5: 主要消化道致病菌(三元组) + 肠道屏障及菌群代谢物(五元组)
+  - p6: 短链脂肪酸 + 神经递质及激素
+  - p7: 指标范围 + 抗生素风险评估
+  ...
+
+用法:python extract_full_report_v4.py
+"""
+
+import sys, os, csv, re
+sys.stdout.reconfigure(encoding='utf-8')
+sys.stderr.reconfigure(encoding='utf-8')
+from PyPDF2 import PdfReader
+
+BASE = r'D:\workspace\cfc\docs\参考资料'
+OUTDIR = os.path.join(BASE, 'CSV')
+os.makedirs(OUTDIR, exist_ok=True)
+
+RADICAL_MAP = {
+    '\u2f18': '卜', '\u2f1f': '土', '\u2f24': '大', '\u2f26': '子',
+    '\u2f29': '小', '\u2f2d': '山', '\u2f32': '干', '\u2f3c': '心',
+    '\u2f42': '文', '\u2f46': '无', '\u2f4a': '木', '\u2f50': '比',
+    '\u2f54': '水', '\u2f55': '火', '\u2f5c': '牛', '\u2f5f': '玉',
+    '\u2f60': '瓜', '\u2f62': '甘', '\u2f63': '生', '\u2f64': '用',
+    '\u2f69': '白', '\u2f6a': '皮', '\u2f6c': '目', '\u2f6f': '石',
+    '\u2f75': '竹', '\u2f76': '米', '\u2f7a': '羊', '\u2f7b': '羽',
+    '\u2f7c': '老', '\u2f7f': '耳', '\u2f81': '肉', '\u2f90': '衣',
+    '\u2f95': '谷', '\u2f96': '豆', '\u2f9d': '身', '\u2fa6': '金',
+    '\u2faf': '面', '\u2fb2': '韭', '\u2fb9': '香', '\u2fca': '黑',
+    '\u2ec9': '贝', '\u2edd': '食', '\u2ee2': '马', '\u2ee5': '鱼',
+    '\u2ee8': '麦', '\u2ee9': '黄', '\u2ef0': '龙',
+}
+
+COLUMNS_FOOD = ['名称', '分类', '推荐指数', '能量KJ', '蛋白g', '脂肪g',
+                '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']
+KNOWN_CATS = ['主食', '乳制品', '干果', '坚果', '快餐', '水产品',
+              '水果', '汤', '肉类', '蔬菜', '豆类及豆制品', '蛋类', '饮料']
+FOOD_SKIP_TEXTS = [
+    '根据您的肠道菌群', '分值从-100', '食物推荐考虑', '食物推荐是综合',
+    '需要注意的是', '本饮食推荐', '该饮食推荐根据', '后续表格中的营养',
+    '16S 高通量测序', '基于机器学习和', '肠道菌群健康检测报告说明',
+    '检测方法及局限性', '数据分析及模型', '结果解读及使用',
+    '影响因素说明', '建议将检测结果', '营养建议说明',
+    '重要提示', '推荐食物清单', '实际食用时需结合', '如有特殊疾病',
+    '免责声明', '本检测报告仅供', '以上模型预测', '正常范围的定义',
+    '募极生物',
+]
+
+def norm(s):
+    return ''.join(RADICAL_MAP.get(c, c) for c in s)
+
+
+def clean_lines(text):
+    lines = []
+    for line in norm(text).split('\n'):
+        ls = line.strip()
+        if not ls or re.match(r'^\d+/\d+$', ls):
+            continue
+        lines.append(ls)
+    return lines
+
+
+def detect_format(pages_text):
+    for pt in pages_text:
+        t = norm(pt)
+        if '疾病风险评估' in t and '指标范围' in t:
+            for line in t.split('\n'):
+                ls = line.strip()
+                if not ls: continue
+                if re.search(r'[\u4e00-\u9fff]+\d+\.\d+[\u4e00-\u9fff]+', ls):
+                    return 'inline'
+            return 'triplet'
+    return 'triplet'
+
+
+# ── 三元组解析 ──
+def parse_triplet(lines):
+    """从行列表中解析三元组(名称/数值/状态)。返回 [{名称, 数值, 状态}] 和剩余行"""
+    results = []
+    i = 0
+    while i < len(lines):
+        name = lines[i]
+        if i + 2 >= len(lines):
+            break
+        val = lines[i + 1]
+        status = lines[i + 2]
+        if re.match(r'^-?\d+\.?\d*$', val):
+            results.append({'名称': name, '数值': val, '状态': status})
+            i += 3
+        else:
+            i += 1
+    return results
+
+def parse_triplet_until(lines, stop_markers):
+    """解析三元组直到遇到stop_markers"""
+    results = []
+    i = 0
+    while i < len(lines):
+        if any(lines[i] == sm or lines[i].startswith(sm) for sm in stop_markers):
+            break
+        if lines[i] == '指标范围':
+            i += 1
+            continue
+        name = lines[i]
+        if i + 2 >= len(lines):
+            break
+        val = lines[i + 1]
+        status = lines[i + 2]
+        if re.match(r'^-?\d+\.?\d*$', val):
+            results.append({'名称': name, '数值': val, '状态': status})
+            i += 3
+        else:
+            i += 1
+    return results, lines[i:]
+
+# ── inline解析 ──
+def parse_inline(text, stop_patterns=None):
+    """压缩同行格式解析"""
+    results = []
+    pattern = r'([\u4e00-\u9fff()\u2014-]+?)(\d+(?:\.\d+)?)([\u4e00-\u9fff/]+)'
+    for m in re.finditer(pattern, text):
+        name = m.group(1).strip()
+        val = m.group(2)
+        status = m.group(3).strip()
+        # 严格过滤
+        if len(name) <= 1: continue
+        if any(k in name for k in ['指标范围', '疾病风险', '营养状况', '抗生素风险',
+                                      '注:', '注:', '页']):
+            continue
+        if any(k in status for k in ['指标范围', '疾病风险', '养分', '范例']):
+            continue
+        try:
+            fv = float(val)
+            if fv >= 0 and fv <= 1000:
+                results.append({'名称': name, '数值': val, '状态': status})
+        except:
+            pass
+    return results
+
+def parse_inline_region(text, start_marker, end_markers):
+    """从文本中找开始标记到结束标记之间的区域,用inline解析"""
+    sidx = text.find(start_marker)
+    if sidx == -1:
+        return [], text
+    end_pos = len(text)
+    for em in end_markers:
+        ei = text.find(em, sidx + len(start_marker))
+        if ei != -1 and ei < end_pos:
+            end_pos = ei
+    region = text[sidx:end_pos]
+    results = parse_inline(region)
+    return results, region
+
+
+# ==========================================
+# 报告概述
+# ==========================================
+def extract_overview(lines_all, label):
+    r = {'姓名': label}
+    t = '\n'.join(lines_all)
+
+    m = re.search(r'编号\s*(\S+)', t)
+    if m: r['编号'] = m.group(1)
+    m = re.search(r'姓名\s*(\S+)', t)
+    if m: r['姓名'] = m.group(1)
+    for pat in ['年龄', '性别']:
+        m = re.search(rf'{pat}\s*(\S+)', t)
+        if m: r[pat] = m.group(1)
+    m = re.search(r'肠道预测年龄[:\s]*([\d.]+岁?)', t)
+    if m: r['肠道预测年龄'] = m.group(1)
+    m = re.search(r'肠型[:\s]*(\S+)', t)
+    if m: r['肠型'] = m.group(1)
+    for kw in ['肠道菌群平衡', '菌群多样性', '有益菌', '有害菌', '核心菌属']:
+        m = re.search(rf'{kw}\s*(\d+)', t)
+        if m: r[kw] = m.group(1)
+    m = re.search(r'健康总分\s*(\d+)', t)
+    if m: r['健康总分'] = m.group(1)
+    for kw in ['菌群健康', '慢病控制', '营养均衡']:
+        m = re.search(rf'{kw}\s*(\d+)', t)
+        if m: r[kw] = m.group(1)
+    return r
+
+
+# ==========================================
+# 三元组格式 - 按页提取各模块
+# ==========================================
+def extract_all_triplet(all_lines):
+    """
+    从三元组格式的全量行列表中按页提取各模块。
+    all_lines: 所有页的clean_lines合并
+    
+    返回 {模块名: [{名称, 数值, 状态}]}
+    """
+    # 找到所有"指标范围"的位置(标记数据页面开始)
+    data_pages = []
+    for i, line in enumerate(all_lines):
+        if line == '指标范围':
+            data_pages.append(i)
+    
+    result = {}
+    
+    # 数据页1: 疾病风险评估(指标范围 → 主要营养评估/氨基酸评估/致病菌)
+    if len(data_pages) >= 1:
+        i = data_pages[0] + 1
+        # 跳过指标范围后的第一行(模块标题:疾病风险评估)
+        if i < len(all_lines) and all_lines[i] == '疾病风险评估':
+            i += 1
+        rows, _ = parse_triplet_until(all_lines[i:], ['主要营养评估', '氨基酸评估', '维生素评估',
+                                                          '微量元素评估', '主要消化道致病菌',
+                                                          '抗生素风险评估', '抗生素耐药风险'])
+        result['疾病风险评估'] = [r for r in rows if '注:' not in r['名称'] and '注:' not in r['名称']]
+    
+    # 数据页2: 营养状况评估(指标范围 → 氨基酸评估/主要营养评估)
+    if len(data_pages) >= 2:
+        i = data_pages[1] + 1
+        if i < len(all_lines) and all_lines[i] == '营养状况评估':
+            i += 1
+        
+        # 碳水/蛋白/脂肪/纤维/乳制品 → 主要营养评估(5个指标)
+        rows, remainder = parse_triplet_until(all_lines[i:], ['主要营养评估', '氨基酸评估'])
+        result['主要营养评估'] = rows[:5]
+        
+        # 氨基酸部分(从苏氨酸开始,到氨基酸评估标记)
+        ama_rows, _ = parse_triplet_until(remainder, ['氨基酸评估'])
+        result['氨基酸评估_p2'] = ama_rows
+    
+    # 数据页3: 剩余氨基酸(指标范围 → 氨基酸评估)
+    if len(data_pages) >= 3:
+        i = data_pages[2] + 1
+        if i < len(all_lines) and all_lines[i] == '氨基酸评估'[:3]:  # 可能包含"氨基酸评估"
+            while i < len(all_lines) and '氨基酸' in all_lines[i]:
+                i += 1
+        remaining_amino, _ = parse_triplet_until(all_lines[i:], ['氨基酸评估'])
+        result['氨基酸评估_p3'] = remaining_amino
+    
+    # 数据页4: 维生素评估(指标范围 → 微量元素评估/主要消化道致病菌)
+    if len(data_pages) >= 4:
+        i = data_pages[3] + 1
+        if i < len(all_lines) and all_lines[i] == '维生素评估':
+            i += 1
+        
+        # 这里包含了维生素 + 微量元素(铁、锌)
+        vit_rows, remainder = parse_triplet_until(all_lines[i:], ['微量元素评估', '主要消化道致病菌'])
+        
+        # 前9个是维生素(维生素A到维生素D),后面是微量元素
+        vit_names = set(['维生素A', '维生素B1', '维生素B2', '维生素B5', '维生素B6',
+                        '叶酸', '维生素B12', '维生素C', '维生素D'])
+        vit = []
+        trace = []
+        for r in vit_rows:
+            if r['名称'] in vit_names or '维生素' in r['名称']:
+                vit.append(r)
+            else:
+                trace.append(r)
+        result['维生素评估'] = vit
+        result['微量元素评估'] = trace
+    
+    # 数据页5: 主要消化道致病菌(已在页面中)
+    # 使用5行一组:名称/丰度%/评估
+    for i in range(len(all_lines)):
+        if all_lines[i] == '主要消化道致病菌':
+            j = i + 1
+            # 跳过头
+            while j < len(all_lines) and all_lines[j] in ['致病菌', '丰度', '评估']:
+                j += 1
+            path_rows = []
+            while j < len(all_lines) and not all_lines[j].startswith('肠道屏障'):
+                name = all_lines[j]
+                if j + 2 < len(all_lines) and re.match(r'^\d+%$', all_lines[j+1]):
+                    path_rows.append({'致病菌': name, '丰度': all_lines[j+1], '评估': all_lines[j+2]})
+                    j += 3
+                else:
+                    j += 1
+            result['主要消化道致病菌'] = path_rows
+            break
+    
+    # 抗生素风险评估
+    for i in range(len(all_lines)):
+        if all_lines[i] == '抗生素风险评估':
+            j = i + 1
+            abx_rows, _ = parse_triplet_until(all_lines[j:], ['抗生素耐药风险', '个体化食物推荐表'])
+            result['抗生素风险评估'] = abx_rows
+            break
+    
+    return result
+
+
+# ==========================================
+# 肠道屏障及代谢物 - 三元组格式
+# ==========================================
+def extract_barrier_and_scfa_triplet(all_lines):
+    """
+    解析肠道屏障 + 短链脂肪酸数据
+    格式:名称/数值/状态/范围/症状(行模式或跨行)
+    """
+    results = {'barrier': [], 'scfa': [], 'neurotransmitter': []}
+    
+    current_section = None
+    i = 0
+    while i < len(all_lines):
+        if all_lines[i] == '肠道屏障及菌群代谢物':
+            current_section = 'barrier'
+            i += 1
+            continue
+        if '短链脂肪酸' in all_lines[i]:
+            current_section = 'scfa'
+            i += 1
+            continue
+        if '神经递质' in all_lines[i]:
+            current_section = 'neurotransmitter'
+            i += 1
+            continue
+        if current_section is None:
+            i += 1
+            continue
+        
+        if all_lines[i] in ['名称', '评估值', '正常范围', '过量', '缺乏', '相关症状'] or \
+           '过量 /' in all_lines[i] or '缺乏' in all_lines[i]:
+            i += 1
+            continue
+        if current_section == 'barrier' and all_lines[i] in ['短链脂肪酸', '抗生素风险', '抗生素耐药风险']:
+            break
+        if current_section == 'scfa' and all_lines[i] in ['神经递质', '神经递质及激素指标', '神经递质及激素', '抗生素风险']:
+            break
+        if current_section == 'neurotransmitter' and all_lines[i] in ['抗生素风险', '个体化食物推荐表']:
+            break
+        
+        # 模式1:一行内包含名称+数值+状态
+        parts = all_lines[i].split()
+        if len(parts) >= 3:
+            vi = None
+            for pi, p in enumerate(parts):
+                if re.match(r'^\d+$', p) and pi >= 1:
+                    vi = pi
+                    break
+            if vi:
+                name = ' '.join(parts[:vi])
+                val = parts[vi]
+                status = ''
+                range_ = ''
+                for rp in parts[vi+1:]:
+                    if rp in ['正常', '过多', '轻度产气', '过低', '不足']:
+                        status = rp
+                    elif re.match(r'^\d+-\d+$', rp):
+                        range_ = rp
+                results[current_section].append({
+                    '名称': name, '评估值': val, '健康状况': status, '正常范围': range_
+                })
+                i += 1
+                continue
+        
+        # 模式2:逐行(名称/数值/状态/范围/症状)
+        if i + 3 < len(all_lines) and re.match(r'^\d+$', all_lines[i+1]):
+            name = all_lines[i]
+            val = all_lines[i+1]
+            status = all_lines[i+2]
+            range_ = ''
+            # 找范围
+            for j in range(3, min(8, len(all_lines)-i)):
+                if re.match(r'^\d+-\d+$', all_lines[i+j]):
+                    range_ = all_lines[i+j]
+                    break
+            results[current_section].append({
+                '名称': name, '评估值': val, '健康状况': status, '正常范围': range_
+            })
+            i += 5 if range_ else 4
+            continue
+        
+        i += 1
+    
+    return results
+
+
+# ==========================================
+# 食物推荐表
+# ==========================================
+def split_7_fields(s):
+    results = []
+    ranges = [(2, 4), (1, 2), (1, 2), (1, 2), (1, 2), (1, 2), (1, 4)]
+    def backtrack(pos, idx, nums):
+        if idx == 7:
+            if pos == len(s):
+                results.append(list(nums))
+            return
+        if pos >= len(s): return
+        lo, hi = ranges[idx]
+        for w in range(lo, min(hi + 1, len(s) - pos + 1)):
+            chunk = s[pos:pos + w]
+            if chunk.isdigit():
+                backtrack(pos + w, idx + 1, nums + [int(chunk)])
+    backtrack(0, 0, [])
+    return results
+
+def decode_compressed(name, num_str, ref_vals=None):
+    raw = num_str.lstrip('-')
+    has_neg = num_str.startswith('-')
+    candidates = []
+    for rec_len in range(1, 3):
+        if rec_len > len(raw): continue
+        rec = ('-' if has_neg else '') + raw[:rec_len]
+        try:
+            rec_val = int(rec)
+            if not (-100 <= rec_val <= 100): continue
+        except: continue
+        remain = raw[rec_len:]
+        for nums in split_7_fields(remain):
+            if ref_vals:
+                matches = sum(1 for i in range(7) if ref_vals[i] == nums[i])
+                if matches >= 6:
+                    candidates.append([rec_val] + nums)
+            else:
+                candidates.append([rec_val] + nums)
+    if not candidates: return None
+    if ref_vals:
+        candidates.sort(key=lambda r: (sum(1 for i in range(7) if ref_vals[i] == r[1:][i]),
+                                    -len(str(abs(r[0])))), reverse=True)
+        if sum(1 for i in range(7) if ref_vals[i] == candidates[0][1:][i]) < 6:
+            return None
+    return candidates[0]
+
+def extract_food_table(pdf_path, ref_lookup=None):
+    reader = PdfReader(pdf_path)
+    food_start = None
+    for i, page in enumerate(reader.pages):
+        if '个体化食物推荐表' in page.extract_text():
+            food_start = i
+            break
+    if food_start is None:
+        return [], 'not_found'
+
+    first_text = norm(reader.pages[food_start + 1].extract_text())
+    lines = [l.strip() for l in first_text.split('\n') if l.strip() and not re.match(r'\d+/\d+', l)]
+    is_compressed = sum(1 for l in lines[:10] if len(l) > 100) >= 3
+
+    rows = []
+    if is_compressed:
+        fmt = 'compressed'
+        for i in range(food_start + 1, len(reader.pages)):
+            text = norm(reader.pages[i].extract_text())
+            text = re.sub(r'\d+/\d+', '', text)
+            header = '名称分类推荐指数能量KJ蛋白g脂肪g碳水化合物g淀粉g总膳食纤维g胆固醇mg'
+            text = text.replace(header, '')
+            for kw in FOOD_SKIP_TEXTS:
+                text = text.replace(kw, '')
+            while text:
+                best_cat, best_idx = None, len(text)
+                for cat in KNOWN_CATS:
+                    idx = text.find(cat)
+                    if idx != -1 and idx < best_idx:
+                        best_idx, best_cat = idx, cat
+                if best_cat is None: break
+                name = text[:best_idx]
+                text = text[best_idx + len(best_cat):]
+                num_str = ''
+                while text and (text[0].isdigit() or text[0] in '-\u2212\u2014'):
+                    c = '-' if text[0] in '\u2212\u2014' else text[0]
+                    num_str += c
+                    text = text[1:]
+                if not name or not num_str: continue
+                ref_vals = ref_lookup.get(name) if ref_lookup else None
+                decoded = decode_compressed(name, num_str, ref_vals)
+                if decoded:
+                    rows.append(dict(zip(COLUMNS_FOOD, [name, best_cat] + [str(v) for v in decoded])))
+    else:
+        fmt = 'vertical'
+        all_lines = []
+        for i in range(food_start + 1, len(reader.pages)):
+            for line in norm(reader.pages[i].extract_text()).split('\n'):
+                lt = line.strip()
+                if not lt or re.match(r'\d+/\d+', lt) or lt in COLUMNS_FOOD:
+                    continue
+                if len(lt) > 60 and any(k in lt for k in FOOD_SKIP_TEXTS):
+                    continue
+                all_lines.append(lt)
+        i = 0
+        while i + 9 < len(all_lines):
+            name = all_lines[i].strip()
+            cat = all_lines[i + 1].strip()
+            if cat not in KNOWN_CATS:
+                i += 1
+                continue
+            nums = []
+            ok = True
+            for j in range(2, 10):
+                v = all_lines[i + j].replace('\u2212', '-').replace('\u2014', '-').strip()
+                try: int(v); nums.append(v)
+                except: ok = False; break
+            if ok and len(nums) == 8:
+                rows.append(dict(zip(COLUMNS_FOOD, [name, cat] + nums)))
+            i += 1
+    return rows, fmt
+
+
+# ==========================================
+# 主流程
+# ==========================================
+def main():
+    pdf_files = sorted(f for f in os.listdir(BASE) if f.lower().endswith('.pdf'))
+    if not pdf_files:
+        print('未找到PDF文件')
+        return
+
+    all_data = {}
+    print('读取PDF文件...')
+    for pdf_file in pdf_files:
+        pdf_path = os.path.join(BASE, pdf_file)
+        label = pdf_file.replace('.pdf', '')
+        try:
+            reader = PdfReader(pdf_path)
+            pages_text = [p.extract_text() for p in reader.pages]
+            fmt = detect_format(pages_text)
+            all_data[label] = {'reader': reader, 'pages_text': pages_text, 'fmt': fmt}
+            print(f'  {label}: {len(pages_text)} pages, format={fmt}')
+        except Exception as e:
+            print(f'  {label}: ERROR - {e}')
+
+    if not all_data:
+        print('无可处理的PDF')
+        return
+
+    # 分离
+    triplet_labels = [l for l, d in all_data.items() if d['fmt'] == 'triplet']
+    inline_labels = [l for l, d in all_data.items() if d['fmt'] == 'inline']
+    all_labels = list(all_data.keys())
+
+    print(f'\ntriplet: {triplet_labels}, inline: {inline_labels}')
+
+    all_full_text = {}
+    all_full_lines = {}
+    for label, d in all_data.items():
+        all_full_text[label] = '\n\n---PAGEBREAK---\n\n'.join(norm(p.extract_text()) for p in d['reader'].pages)
+        lines = []
+        for pt in d['pages_text']:
+            lines.extend(clean_lines(pt))
+        all_full_lines[label] = lines
+
+    # ── 1. 报告概述 ──
+    print('\n[1/11] 报告概述...')
+    overviews = []
+    for label, d in all_data.items():
+        lines = clean_lines(d['pages_text'][0])
+        found_info = any(k in '\n'.join(lines) for k in ['肠道预测年龄', '核心菌属'])
+        if not found_info:
+            for pt in d['pages_text']:
+                if '基本信息' in norm(pt) and '肠道预测年龄' in norm(pt):
+                    lines = clean_lines(pt)
+                    break
+        r = extract_overview(lines, label)
+        overviews.append(r)
+
+    overview_cols = ['姓名', '编号', '年龄', '性别', '肠道预测年龄', '肠型',
+                     '肠道菌群平衡', '菌群多样性', '有益菌', '有害菌', '核心菌属',
+                     '健康总分', '菌群健康', '慢病控制', '营养均衡']
+    with open(os.path.join(OUTDIR, '报告概述.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+        w = csv.DictWriter(f, fieldnames=overview_cols, extrasaction='ignore')
+        w.writeheader()
+        w.writerows(overviews)
+    print(f'  -> CSV/报告概述.csv ({len(overviews)} 行)')
+
+    # ── 2-9. 模块数据 ──
+    # 定义模块:名称 -> (name_col, 文件, 在triplet中提取key, inline的start_marker, inline的end_markers)
+    modules = [
+        ('疾病风险评估', '疾病', '疾病风险评估.csv', '疾病风险评估', ['主要营养评估', '主要消化道致病菌']),
+        ('主要营养评估', '指标', '主要营养评估.csv', '营养状况评估', ['主要营养评估', '氨基酸评估']),
+        ('氨基酸评估', '氨基酸', '氨基酸评估.csv', '氨基酸评估', ['维生素评估', '主要消化道致病菌']),
+        ('维生素评估', '维生素', '维生素评估.csv', '维生素评估', ['微量元素评估', '主要消化道致病菌']),
+        ('微量元素评估', '微量元素', '微量元素评估.csv', '微量元素评估', ['主要消化道致病菌']),
+        ('抗生素风险评估', '抗生素', '抗生素风险评估.csv', '抗生素风险评估', ['抗生素耐药风险', '个体化食物推荐表']),
+    ]
+
+    for mod_name, name_col, fname, start_mk, end_mks in modules:
+        print(f'  [{mod_name}]...')
+        data = {}
+
+        for label in triplet_labels:
+            # triplet: 从所有行解析
+            parsed = extract_all_triplet(all_full_lines[label])
+            rows = parsed.get(mod_name, [])
+            if not rows:
+                # fallback: 找模块在行中的位置
+                if mod_name == '氨基酸评估':
+                    # 合并p2和p3
+                    rows = parsed.get('氨基酸评估_p2', []) + parsed.get('氨基酸评估_p3', [])
+            for r in rows:
+                data.setdefault(r['名称'], {})[label] = r['数值']
+
+        for label in inline_labels:
+            rows, _ = parse_inline_region(all_full_text[label], start_mk, end_mks)
+            for r in rows:
+                data.setdefault(r['名称'], {})[label] = r['数值']
+
+        if data:
+            labels = all_labels
+            with open(os.path.join(OUTDIR, fname), 'w', newline='', encoding='utf-8-sig') as f:
+                w = csv.DictWriter(f, fieldnames=[name_col] + labels)
+                w.writeheader()
+                for name, vals in sorted(data.items()):
+                    row = {name_col: name}
+                    row.update(vals)
+                    w.writerow(row)
+            print(f'    -> CSV/{fname} ({len(data)} 指标)')
+        else:
+            print(f'    (无数据)')
+
+    # ── 7. 主要消化道致病菌 ──
+    print('[7/11] 主要消化道致病菌...')
+    path_data = {}
+    for label in triplet_labels:
+        parsed = extract_all_triplet(all_full_lines[label])
+        for r in parsed.get('主要消化道致病菌', []):
+            path_data.setdefault(r['致病菌'], {})[label] = r['丰度']
+
+    for label in inline_labels:
+        rows, _ = parse_inline_region(all_full_text[label], '主要消化道致病菌', ['肠道屏障'])
+        for r in rows:
+            path_data.setdefault(r['名称'], {})[label] = r['数值'] if '%' in r['数值'] else r['数值'] + '%' if '未' not in r.get('状态','') else '0%'
+
+    if path_data:
+        labels = all_labels
+        with open(os.path.join(OUTDIR, '主要消化道致病菌.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+            w = csv.DictWriter(f, fieldnames=['致病菌'] + labels)
+            w.writeheader()
+            for name, vals in sorted(path_data.items()):
+                row = {'致病菌': name}
+                row.update(vals)
+                w.writerow(row)
+        print(f'  -> CSV/主要消化道致病菌.csv ({len(path_data)} 菌种)')
+
+    # ── 8. 肠道屏障及代谢物 ──
+    print('[8/11] 肠道屏障及代谢物...')
+    with open(os.path.join(OUTDIR, '肠道屏障及代谢物.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+        w = csv.writer(f)
+        w.writerow(['指标', '姓名', '评估值', '健康状况', '正常范围', '症状'])
+        total = 0
+
+        for label in triplet_labels:
+            bs = extract_barrier_and_scfa_triplet(all_full_lines[label])
+            for item in bs.get('barrier', []):
+                w.writerow([item['名称'], label, item.get('评估值',''), item.get('健康状况',''),
+                          item.get('正常范围',''), item.get('症状','')])
+                total += 1
+
+        for label in inline_labels:
+            # Not implemented for inline format yet
+            pass
+
+    print(f'  -> CSV/肠道屏障及代谢物.csv ({total} 条)')
+
+    # 短链脂肪酸
+    print('  [短链脂肪酸]...')
+    scfa_total = 0
+    with open(os.path.join(OUTDIR, '肠道屏障及代谢物.csv'), 'a', newline='', encoding='utf-8-sig') as f:
+        w = csv.writer(f)
+        for label in triplet_labels:
+            bs = extract_barrier_and_scfa_triplet(all_full_lines[label])
+            for item in bs.get('scfa', []):
+                w.writerow([item['名称'], label, item.get('评估值',''), item.get('健康状况',''),
+                          item.get('正常范围',''), item.get('症状','')])
+                scfa_total += 1
+    print(f'    (短链脂肪酸 {scfa_total} 条, 已追加)')
+
+    # 神经递质
+    print('  [神经递质]...')
+    nt_total = 0
+    with open(os.path.join(OUTDIR, '肠道屏障及代谢物.csv'), 'a', newline='', encoding='utf-8-sig') as f:
+        w = csv.writer(f)
+        for label in triplet_labels:
+            bs = extract_barrier_and_scfa_triplet(all_full_lines[label])
+            for item in bs.get('neurotransmitter', []):
+                w.writerow([item['名称'], label, item.get('评估值',''), item.get('健康状况',''),
+                          item.get('正常范围',''), item.get('症状','')])
+                nt_total += 1
+    print(f'    (神经递质 {nt_total} 条, 已追加)')
+
+    # ── 10. 个体化食物推荐表 ──
+    print('[10/11] 个体化食物推荐表...')
+    ref_nutrition = {}
+    for label in triplet_labels:
+        if '侯' in label:
+            pdf_path = os.path.join(BASE, label + '.pdf')
+            ref_rows, _ = extract_food_table(pdf_path)
+            for r in ref_rows:
+                ref_nutrition[r['名称']] = [int(r[k]) for k in
+                    ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']]
+            break
+
+    for label, d in all_data.items():
+        pdf_path = os.path.join(BASE, label + '.pdf')
+        rows, fmt = extract_food_table(pdf_path, ref_nutrition)
+        fname = f'{label}-个体化食物推荐表.csv'
+        with open(os.path.join(OUTDIR, fname), 'w', newline='', encoding='utf-8-sig') as f:
+            w = csv.DictWriter(f, fieldnames=COLUMNS_FOOD)
+            w.writeheader()
+            w.writerows(rows)
+        print(f'  -> CSV/{fname} ({len(rows)} 条, {fmt})')
+
+    # ── 11. 推荐指数汇总 ──
+    print('[11/11] 推荐指数汇总...')
+    rec_all = {}
+    for label, d in all_data.items():
+        pdf_path = os.path.join(BASE, label + '.pdf')
+        rows, _ = extract_food_table(pdf_path, ref_nutrition)
+        rec_all[label] = {r['名称']: r['推荐指数'] for r in rows}
+
+    std_label = next((l for l in triplet_labels if '侯' in l), triplet_labels[0])
+    pdf_path = os.path.join(BASE, std_label + '.pdf')
+    std_rows, _ = extract_food_table(pdf_path)
+
+    sum_cols = ['名称', '分类'] + ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g',
+                                    '总膳食纤维g', '胆固醇mg'] + all_labels
+    with open(os.path.join(OUTDIR, '推荐指数汇总.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+        w = csv.DictWriter(f, fieldnames=sum_cols)
+        w.writeheader()
+        for r in std_rows:
+            name = r['名称']
+            row = {'名称': name, '分类': r['分类']}
+            for k in ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']:
+                row[k] = r[k]
+            for la in all_labels:
+                row[la] = rec_all.get(la, {}).get(name, '')
+            w.writerow(row)
+    print(f'  -> CSV/推荐指数汇总.csv ({len(std_rows)} 行)')
+
+    print(f'\n完成!所有CSV已输出到 {OUTDIR}/')
+
+
+if __name__ == '__main__':
+    main()

+ 942 - 0
docs/参考资料/extract_full_report_v5.py

@@ -0,0 +1,942 @@
+"""
+肠道菌群健康检测报告 — 全指标提取脚本(v5)
+========================================
+基于PDF实际文本格式精确解析 + JSON输出模式。
+修复:
+1) 抗生素风险评估 inline 区域使用已知名称词表过滤
+2) 肠道屏障 SCFA/神经递质使用已知指标名精确匹配
+3) Inline 氨基酸/维生素/微量元素使用已知名称过滤
+
+用法:
+    python extract_full_report_v5.py              # CSV 批量输出(原有模式)
+    python extract_full_report_v5.py -j <PDF路径> # JSON 单文件输出
+"""
+
+import sys, os, csv, re, json
+sys.stdout.reconfigure(encoding='utf-8')
+sys.stderr.reconfigure(encoding='utf-8')
+from PyPDF2 import PdfReader
+
+BASE = r'D:\workspace\cfc\docs\参考资料'
+OUTDIR = os.path.join(BASE, 'CSV')
+os.makedirs(OUTDIR, exist_ok=True)
+
+RADICAL_MAP = {
+    '\u2f18': '卜', '\u2f1f': '土', '\u2f24': '大', '\u2f26': '子',
+    '\u2f29': '小', '\u2f2d': '山', '\u2f32': '干', '\u2f3c': '心',
+    '\u2f42': '文', '\u2f46': '无', '\u2f4a': '木', '\u2f50': '比',
+    '\u2f54': '水', '\u2f55': '火', '\u2f5c': '牛', '\u2f5f': '玉',
+    '\u2f60': '瓜', '\u2f62': '甘', '\u2f63': '生', '\u2f64': '用',
+    '\u2f69': '白', '\u2f6a': '皮', '\u2f6c': '目', '\u2f6f': '石',
+    '\u2f75': '竹', '\u2f76': '米', '\u2f7a': '羊', '\u2f7b': '羽',
+    '\u2f7c': '老', '\u2f7f': '耳', '\u2f81': '肉', '\u2f90': '衣',
+    '\u2f95': '谷', '\u2f96': '豆', '\u2f9d': '身', '\u2fa6': '金',
+    '\u2faf': '面', '\u2fb2': '韭', '\u2fb9': '香', '\u2fca': '黑',
+    '\u2ec9': '贝', '\u2edd': '食', '\u2ee2': '马', '\u2ee5': '鱼',
+    '\u2ee8': '麦', '\u2ee9': '黄', '\u2ef0': '龙',
+}
+
+# ── 已知指标名词表 ──
+KNOWN_MACRO_NUTRIENTS = ['碳水化合物', '蛋白质', '脂肪', '纤维素', '乳制品']
+KNOWN_AMINO_ACIDS = sorted(['苏氨酸', '异亮氨酸', '亮氨酸', '赖氨酸', '蛋氨酸',
+    '胱氨酸', '苯丙氨酸', '酪氨酸', '缬氨酸', '组氨酸', '丙氨酸', '丝氨酸',
+    '甘氨酸', '脯氨酸', '谷氨酸', '天门冬氨酸', '天冬氨酸', '天冬酰胺',
+    '谷氨酰胺', '精氨酸', '色氨酸', '丙氨酸'])
+KNOWN_VITAMINS = ['维生素A', '维生素B1', '维生素B2', '维生素B5', '维生素B6',
+    '叶酸', '维生素B12', '维生素C', '维生素D', '维生素K2', '维生素E']
+KNOWN_TRACE = ['铁', '锌']
+KNOWN_ANTIBIOTICS = ['β-内酰胺酶类', '氨基糖苷类', '大环内酯类', '呋喃类',
+    '喹诺酮类', '磺胺类', '甲氧苄啶类', '氯霉素类', '四环素类']
+
+# 已知肠道屏障指标
+KNOWN_BARRIER = ['肠道炎症水平', '肠道产气', '肠道屏障', '脂多糖LPS',
+    '次级胆汁酸', '对甲酚(p-Cresol)', '吲哚', '苯酚', '腐胺',
+    '硫化氢', '尸胺']
+# 已知短链脂肪酸
+KNOWN_SCFA = ['丁酸盐(Butyrate)', '丙酸盐(Propionate)', '乙酸盐(Acetate)',
+    '异戊酸盐(Isovaleric)']
+# 已知神经递质及激素
+KNOWN_NEUROTRANSMITTER = ['血清素(5-HT)', 'γ-氨基丁酸(GABA)',
+    '谷氨酸(Glutamate)', '色氨酸(Tryptophan)', 'DOPAC',
+    '多巴胺', '组胺(Histamine)', '一氧化氮', '喹啉(Quinolinic)',
+    '维生素K2', '肌醇(Inositol)', '肾上腺素', '去甲肾上腺素',
+    '乙酰胆碱', '皮质醇']
+
+COLUMNS_FOOD = ['名称', '分类', '推荐指数', '能量KJ', '蛋白g', '脂肪g',
+    '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']
+KNOWN_CATS = ['主食', '乳制品', '干果', '坚果', '快餐', '水产品',
+    '水果', '汤', '肉类', '蔬菜', '豆类及豆制品', '蛋类', '饮料']
+FOOD_SKIP_TEXTS = [
+    '根据您的肠道菌群', '分值从-100', '食物推荐考虑', '食物推荐是综合',
+    '需要注意的是', '本饮食推荐', '该饮食推荐根据', '后续表格中的营养',
+    '16S 高通量测序', '基于机器学习和', '肠道菌群健康检测报告说明',
+    '检测方法及局限性', '数据分析及模型', '结果解读及使用',
+    '影响因素说明', '建议将检测结果', '营养建议说明',
+    '重要提示', '推荐食物清单', '实际食用时需结合', '如有特殊疾病',
+    '免责声明', '本检测报告仅供', '以上模型预测', '正常范围的定义',
+    '募极生物',
+]
+
+
+def norm(s):
+    return ''.join(RADICAL_MAP.get(c, c) for c in s)
+
+
+def clean_lines(text):
+    lines = []
+    for line in norm(text).split('\n'):
+        ls = line.strip()
+        if not ls or re.match(r'^\d+/\d+$', ls):
+            continue
+        lines.append(ls)
+    return lines
+
+
+def detect_format(pages_text):
+    for pt in pages_text:
+        t = norm(pt)
+        if '疾病风险评估' in t and '指标范围' in t:
+            for line in t.split('\n'):
+                ls = line.strip()
+                if not ls: continue
+                if re.search(r'[\u4e00-\u9fff]+\d+\.?\d*[\u4e00-\u9fff]+', ls):
+                    return 'inline'
+            return 'triplet'
+    return 'triplet'
+
+
+# ── 三元组解析 ──
+def parse_triplet_until(lines, stop_markers):
+    results = []
+    i = 0
+    while i < len(lines):
+        if any(lines[i] == sm or lines[i].startswith(sm) for sm in stop_markers):
+            break
+        if lines[i] == '指标范围':
+            i += 1
+            continue
+        name = lines[i]
+        if i + 2 >= len(lines):
+            break
+        val = lines[i + 1]
+        status = lines[i + 2]
+        if re.match(r'^-?\d+\.?\d*$', val):
+            results.append({'名称': name, '数值': val, '状态': status})
+            i += 3
+        else:
+            i += 1
+    return results, lines[i:]
+
+
+# ── inline解析(v5:支持名称词表过滤) ──
+def parse_inline(text, name_whitelist=None):
+    """压缩同行格式解析。如果提供 name_whitelist,只返回在词表中的条目。"""
+    results = []
+    # inline 格式:中文名+数字+状态(中文或/)
+    pattern = r'([\u4e00-\u9fff()\u2014\-\u2212]+?)(\d+(?:\.\d+)?)([\u4e00-\u9fff/]+)'
+    for m in re.finditer(pattern, text):
+        name = m.group(1).strip()
+        val = m.group(2)
+        status = m.group(3).strip()
+        if len(name) <= 1:
+            continue
+        # 始终过滤掉非指标标记
+        if any(k in name for k in ['指标范围', '疾病风险', '营养状况', '抗生素风险',
+                                      '注:', '注:', '页', '分值', '正常',
+                                      '本次', '根据', '用户', '共检测',
+                                      '三项', '核心', '您的厚壁', '注意',
+                                      '异常', '健康', '肠道菌群',
+                                      '水平', '肠道炎症']):
+            continue
+        if any(k in status for k in ['指标范围', '疾病风险', '养分', '范例']):
+            continue
+        # 如果提供词表,只返回词表内的
+        if name_whitelist is not None:
+            if name not in name_whitelist:
+                continue
+        try:
+            fv = float(val)
+            if 0 <= fv <= 1000:
+                results.append({'名称': name, '数值': val, '状态': status})
+        except:
+            pass
+    return results
+
+
+def parse_inline_region(text, start_marker, end_markers, name_whitelist=None):
+    sidx = text.find(start_marker)
+    if sidx == -1:
+        return [], text
+    end_pos = len(text)
+    for em in end_markers:
+        ei = text.find(em, sidx + len(start_marker))
+        if ei != -1 and ei < end_pos:
+            end_pos = ei
+    region = text[sidx:end_pos]
+    results = parse_inline(region, name_whitelist)
+    return results, region
+
+
+# ==========================================
+# 报告概述
+# ==========================================
+def extract_overview(lines_all, label):
+    r = {'姓名': label}
+    t = '\n'.join(lines_all)
+
+    m = re.search(r'编号\s*(\S+)', t)
+    if m: r['编号'] = m.group(1)
+    m = re.search(r'姓名\s*(\S+)', t)
+    if m: r['姓名'] = m.group(1)
+    for pat in ['年龄', '性别']:
+        m = re.search(rf'{pat}\s*(\S+)', t)
+        if m: r[pat] = m.group(1)
+    m = re.search(r'肠道预测年龄[:\s]*([\d.]+岁?)', t)
+    if m: r['肠道预测年龄'] = m.group(1)
+    m = re.search(r'肠型[:\s]*(\S+)', t)
+    if m: r['肠型'] = m.group(1)
+    for kw in ['肠道菌群平衡', '菌群多样性', '有益菌', '有害菌', '核心菌属']:
+        m = re.search(rf'{kw}\s*(\d+)', t)
+        if m: r[kw] = m.group(1)
+    m = re.search(r'健康总分\s*(\d+)', t)
+    if m: r['健康总分'] = m.group(1)
+    for kw in ['菌群健康', '慢病控制', '营养均衡']:
+        m = re.search(rf'{kw}\s*(\d+)', t)
+        if m: r[kw] = m.group(1)
+    return r
+
+
+# ==========================================
+# 三元组格式 - 按页提取各模块
+# ==========================================
+def extract_all_triplet(all_lines):
+    data_pages = []
+    for i, line in enumerate(all_lines):
+        if line == '指标范围':
+            data_pages.append(i)
+
+    result = {}
+
+    # 数据页1: 疾病风险评估
+    if len(data_pages) >= 1:
+        i = data_pages[0] + 1
+        if i < len(all_lines) and all_lines[i] == '疾病风险评估':
+            i += 1
+        rows, _ = parse_triplet_until(all_lines[i:], ['主要营养评估', '氨基酸评估', '维生素评估',
+                                                          '微量元素评估', '主要消化道致病菌',
+                                                          '抗生素风险评估', '抗生素耐药风险'])
+        result['疾病风险评估'] = [r for r in rows if '注:' not in r['名称'] and '注:' not in r['名称']]
+
+    # 数据页2: 营养状况评估
+    if len(data_pages) >= 2:
+        i = data_pages[1] + 1
+        if i < len(all_lines) and all_lines[i] == '营养状况评估':
+            i += 1
+        rows, remainder = parse_triplet_until(all_lines[i:], ['主要营养评估', '氨基酸评估'])
+        result['主要营养评估'] = rows[:5]
+        ama_rows, _ = parse_triplet_until(remainder, ['氨基酸评估'])
+        result['氨基酸评估_p2'] = ama_rows
+
+    # 数据页3: 剩余氨基酸
+    if len(data_pages) >= 3:
+        i = data_pages[2] + 1
+        if i < len(all_lines) and '氨基酸' in all_lines[i]:
+            while i < len(all_lines) and '氨基酸' in all_lines[i]:
+                i += 1
+        remaining_amino, _ = parse_triplet_until(all_lines[i:], ['氨基酸评估'])
+        result['氨基酸评估_p3'] = remaining_amino
+
+    # 数据页4: 维生素评估
+    if len(data_pages) >= 4:
+        i = data_pages[3] + 1
+        if i < len(all_lines) and all_lines[i] == '维生素评估':
+            i += 1
+        vit_rows, remainder = parse_triplet_until(all_lines[i:], ['微量元素评估', '主要消化道致病菌'])
+        vit_names = set(['维生素A', '维生素B1', '维生素B2', '维生素B5', '维生素B6',
+                         '叶酸', '维生素B12', '维生素C', '维生素D'])
+        vit = []
+        trace = []
+        for r in vit_rows:
+            if r['名称'] in vit_names or '维生素' in r['名称']:
+                vit.append(r)
+            else:
+                trace.append(r)
+        result['维生素评估'] = vit
+        result['微量元素评估'] = trace
+
+    # 数据页5: 主要消化道致病菌
+    for i in range(len(all_lines)):
+        if all_lines[i] == '主要消化道致病菌':
+            j = i + 1
+            while j < len(all_lines) and all_lines[j] in ['致病菌', '丰度', '评估']:
+                j += 1
+            path_rows = []
+            while j < len(all_lines) and not all_lines[j].startswith('肠道屏障'):
+                name = all_lines[j]
+                if j + 2 < len(all_lines) and re.match(r'^\d+%$', all_lines[j+1]):
+                    path_rows.append({'致病菌': name, '丰度': all_lines[j+1], '评估': all_lines[j+2]})
+                    j += 3
+                else:
+                    j += 1
+            result['主要消化道致病菌'] = path_rows
+            break
+
+    # 抗生素风险评估
+    for i in range(len(all_lines)):
+        if all_lines[i] == '抗生素风险评估':
+            j = i + 1
+            abx_rows, _ = parse_triplet_until(all_lines[j:], ['抗生素耐药风险', '个体化食物推荐表'])
+            result['抗生素风险评估'] = abx_rows
+            break
+
+    return result
+
+
+# ==========================================
+# 肠道屏障及代谢物 - 三元组格式(v5:使用已知指标名精确匹配)
+# ==========================================
+def extract_barrier_and_scfa_triplet(all_lines):
+    results = {'barrier': [], 'scfa': [], 'neurotransmitter': []}
+
+    current_section = None
+    i = 0
+    while i < len(all_lines):
+        line = all_lines[i]
+        if line == '肠道屏障及菌群代谢物':
+            current_section = 'barrier'
+            i += 1
+            continue
+        if '短链脂肪酸' in line:
+            current_section = 'scfa'
+            i += 1
+            continue
+        if '神经递质' in line:
+            current_section = 'neurotransmitter'
+            i += 1
+            continue
+        if current_section is None:
+            i += 1
+            continue
+
+        # 跳过头行
+        if line in ['名称', '评估值', '正常范围', '过量', '缺乏', '相关症状'] or \
+           '过量 /' in line or '缺乏' in line:
+            i += 1
+            continue
+
+        # 提前终止
+        if current_section == 'barrier' and line in ['短链脂肪酸', '抗生素风险', '抗生素耐药风险']:
+            break
+        if current_section == 'scfa' and line in ['神经递质', '神经递质及激素指标', '神经递质及激素', '抗生素风险']:
+            break
+        if current_section == 'neurotransmitter' and line in ['抗生素风险', '个体化食物推荐表']:
+            break
+
+        # 只处理已知指标名
+        known = {'barrier': KNOWN_BARRIER, 'scfa': KNOWN_SCFA, 'neurotransmitter': KNOWN_NEUROTRANSMITTER}
+        known_list = known[current_section]
+
+        # 检查当前行是否以已知指标名开头
+        matched_name = None
+        for kn in sorted(known_list, key=len, reverse=True):
+            if line.startswith(kn) or line == kn:
+                matched_name = kn
+                break
+        if matched_name is None:
+            i += 1
+            continue
+
+        # 尝试整行解析:名称+空格+数值+状态+范围+...
+        parts = line.split()
+        if len(parts) >= 2:
+            # 找数值
+            vi = None
+            for pi, p in enumerate(parts):
+                if re.match(r'^\d+$', p) and pi >= 1:
+                    vi = pi
+                    break
+            if vi:
+                name = matched_name
+                val = parts[vi]
+                status = ''
+                range_ = ''
+                for rp in parts[vi+1:]:
+                    if rp in ['正常', '过多', '轻度产气', '过低', '不足', '缺乏', '不⾜']:
+                        status = rp
+                    elif re.match(r'^\d+-\d+$', rp):
+                        range_ = rp
+                results[current_section].append({
+                    '名称': name, '评估值': val, '健康状况': status, '正常范围': range_
+                })
+                i += 1
+                continue
+
+        # 模式2:下一行是数值
+        if i + 1 < len(all_lines) and re.match(r'^\d+$', all_lines[i+1]):
+            name = matched_name
+            val = all_lines[i+1]
+            status = ''
+            range_ = ''
+            for j in range(2, min(6, len(all_lines)-i)):
+                if all_lines[i+j] in ['正常', '过多', '轻度产气', '过低', '不足', '缺乏', '不⾜']:
+                    status = all_lines[i+j]
+                elif re.match(r'^\d+-\d+$', all_lines[i+j]):
+                    range_ = all_lines[i+j]
+            results[current_section].append({
+                '名称': name, '评估值': val, '健康状况': status, '正常范围': range_
+            })
+            i += 2
+            continue
+
+        i += 1
+
+    return results
+
+
+# ==========================================
+# inline 格式中各模块提取(v5:使用已知名称词表)
+# ==========================================
+def extract_inline_module(text, start_marker, end_markers, known_names):
+    """从 inline 文本中提取已知名称的指标(密集同行格式专用)
+
+    inline 格式示例:
+        维生素A54正常维生素B183正常维生素B253正常
+        或:β-内酰胺酶类76正常氨基糖苷类97注意
+
+    已知名称在文本中是紧挨着的,没有空格分隔。
+    找到名称后,紧跟着的数字就是数值,之后到下一个名称之间的文本就是状态。
+    """
+    results = []
+    sidx = text.find(start_marker)
+    if sidx == -1:
+        return results
+    end_pos = len(text)
+    for em in end_markers:
+        ei = text.find(em, sidx + len(start_marker))
+        if ei != -1 and ei < end_pos:
+            end_pos = ei
+    region = text[sidx:end_pos]
+
+    # 按名称在区域中的位置排序
+    name_positions = []
+    for kn in known_names:
+        idx = region.find(kn)
+        if idx != -1:
+            name_positions.append((idx, kn))
+    name_positions.sort()
+
+    for i, (idx, kn) in enumerate(name_positions):
+        start_after = idx + len(kn)
+        # 取数字
+        val_match = re.match(r'(\d+(?:\.\d+)?)', region[start_after:])
+        if not val_match:
+            continue
+        val = val_match.group(1)
+        val_end = start_after + len(val)
+        # 状态:从数值结束到下一个已知名称开始
+        if i + 1 < len(name_positions):
+            next_idx = name_positions[i + 1][0]
+            status = region[val_end:next_idx].strip()
+        else:
+            status = region[val_end:end_pos].strip()
+        # 剪掉状态中的后续标记(如"正常氨基酸评估"→"正常")
+        status = re.sub(r'注意大环内酯类|注意呋喃类|注意氯霉素类|注意喹诺酮类|注意磺胺类|注意甲氧苄啶类|注意四环素类|正常氨基酸评估|正常维生素评估|正常微量元素评估', '', status).strip()
+        results.append({'名称': kn, '数值': val, '状态': status})
+
+    return results
+
+
+# ==========================================
+# 食物推荐表
+# ==========================================
+def split_7_fields(s):
+    results = []
+    ranges = [(2, 4), (1, 2), (1, 2), (1, 2), (1, 2), (1, 2), (1, 4)]
+    def backtrack(pos, idx, nums):
+        if idx == 7:
+            if pos == len(s):
+                results.append(list(nums))
+            return
+        if pos >= len(s): return
+        lo, hi = ranges[idx]
+        for w in range(lo, min(hi + 1, len(s) - pos + 1)):
+            chunk = s[pos:pos + w]
+            if chunk.isdigit():
+                backtrack(pos + w, idx + 1, nums + [int(chunk)])
+    backtrack(0, 0, [])
+    return results
+
+
+def decode_compressed(name, num_str, ref_vals=None):
+    raw = num_str.lstrip('-')
+    has_neg = num_str.startswith('-')
+    candidates = []
+    for rec_len in range(1, 3):
+        if rec_len > len(raw): continue
+        rec = ('-' if has_neg else '') + raw[:rec_len]
+        try:
+            rec_val = int(rec)
+            if not (-100 <= rec_val <= 100): continue
+        except: continue
+        remain = raw[rec_len:]
+        for nums in split_7_fields(remain):
+            if ref_vals:
+                matches = sum(1 for i in range(7) if ref_vals[i] == nums[i])
+                if matches >= 6:
+                    candidates.append([rec_val] + nums)
+            else:
+                candidates.append([rec_val] + nums)
+    if not candidates: return None
+    if ref_vals:
+        candidates.sort(key=lambda r: (sum(1 for i in range(7) if ref_vals[i] == r[1:][i]),
+                                    -len(str(abs(r[0])))), reverse=True)
+        if sum(1 for i in range(7) if ref_vals[i] == candidates[0][1:][i]) < 6:
+            return None
+    return candidates[0]
+
+
+def extract_food_table(pdf_path, ref_lookup=None):
+    reader = PdfReader(pdf_path)
+    food_start = None
+    for i, page in enumerate(reader.pages):
+        if '个体化食物推荐表' in page.extract_text():
+            food_start = i
+            break
+    if food_start is None:
+        return [], 'not_found'
+
+    first_text = norm(reader.pages[food_start + 1].extract_text())
+    lines = [l.strip() for l in first_text.split('\n') if l.strip() and not re.match(r'\d+/\d+', l)]
+    is_compressed = sum(1 for l in lines[:10] if len(l) > 100) >= 3
+
+    rows = []
+    if is_compressed:
+        fmt = 'compressed'
+        for i in range(food_start + 1, len(reader.pages)):
+            text = norm(reader.pages[i].extract_text())
+            text = re.sub(r'\d+/\d+', '', text)
+            header = '名称分类推荐指数能量KJ蛋白g脂肪g碳水化合物g淀粉g总膳食纤维g胆固醇mg'
+            text = text.replace(header, '')
+            for kw in FOOD_SKIP_TEXTS:
+                text = text.replace(kw, '')
+            while text:
+                best_cat, best_idx = None, len(text)
+                for cat in KNOWN_CATS:
+                    idx = text.find(cat)
+                    if idx != -1 and idx < best_idx:
+                        best_idx, best_cat = idx, cat
+                if best_cat is None: break
+                name = text[:best_idx]
+                text = text[best_idx + len(best_cat):]
+                num_str = ''
+                while text and (text[0].isdigit() or text[0] in '-\u2212\u2014'):
+                    c = '-' if text[0] in '\u2212\u2014' else text[0]
+                    num_str += c
+                    text = text[1:]
+                if not name or not num_str: continue
+                ref_vals = ref_lookup.get(name) if ref_lookup else None
+                decoded = decode_compressed(name, num_str, ref_vals)
+                if decoded:
+                    rows.append(dict(zip(COLUMNS_FOOD, [name, best_cat] + [str(v) for v in decoded])))
+    else:
+        fmt = 'vertical'
+        all_lines = []
+        for i in range(food_start + 1, len(reader.pages)):
+            for line in norm(reader.pages[i].extract_text()).split('\n'):
+                lt = line.strip()
+                if not lt or re.match(r'\d+/\d+', lt) or lt in COLUMNS_FOOD:
+                    continue
+                if len(lt) > 60 and any(k in lt for k in FOOD_SKIP_TEXTS):
+                    continue
+                all_lines.append(lt)
+        i = 0
+        while i + 9 < len(all_lines):
+            name = all_lines[i].strip()
+            cat = all_lines[i + 1].strip()
+            if cat not in KNOWN_CATS:
+                i += 1
+                continue
+            nums = []
+            ok = True
+            for j in range(2, 10):
+                v = all_lines[i + j].replace('\u2212', '-').replace('\u2014', '-').strip()
+                try: int(v); nums.append(v)
+                except: ok = False; break
+            if ok and len(nums) == 8:
+                rows.append(dict(zip(COLUMNS_FOOD, [name, cat] + nums)))
+            i += 1
+    return rows, fmt
+
+
+# ==========================================
+# 单 PDF ➔ JSON 输出(v5 新增)
+# ==========================================
+def extract_pdf_to_json(pdf_path):
+    """输入单个PDF文件路径,输出JSON格式的全量指标内容"""
+    reader = PdfReader(pdf_path)
+    pages_text = [p.extract_text() for p in reader.pages]
+    fmt = detect_format(pages_text)
+    label = os.path.splitext(os.path.basename(pdf_path))[0]
+
+    all_full_text = '\n\n---PAGEBREAK---\n\n'.join(norm(p.extract_text()) for p in reader.pages)
+    all_lines = []
+    for pt in pages_text:
+        all_lines.extend(clean_lines(pt))
+
+    result = {
+        '文件名': label,
+        '格式': fmt,
+        '总页数': len(reader.pages),
+    }
+
+    # 1. 报告概述
+    overview = extract_overview(clean_lines(pages_text[0]), label)
+    # 如果第一页没找到关键信息,搜其他页
+    found_info = any(k in str(overview) for k in ['肠道预测年龄', '核心菌属'])
+    if not found_info:
+        for pt in pages_text:
+            if '基本信息' in norm(pt) and '肠道预测年龄' in norm(pt):
+                overview = extract_overview(clean_lines(pt), label)
+                break
+    result['报告概述'] = overview
+
+    if fmt == 'triplet':
+        parsed = extract_all_triplet(all_lines)
+
+        # 疾病风险评估
+        result['疾病风险评估'] = parsed.get('疾病风险评估', [])
+        # 主要营养评估
+        result['主要营养评估'] = parsed.get('主要营养评估', [])
+        # 氨基酸
+        amino = parsed.get('氨基酸评估_p2', []) + parsed.get('氨基酸评估_p3', [])
+        result['氨基酸评估'] = amino
+        # 维生素
+        result['维生素评估'] = parsed.get('维生素评估', [])
+        # 微量元素
+        result['微量元素评估'] = parsed.get('微量元素评估', [])
+        # 主要消化道致病菌
+        result['主要消化道致病菌'] = parsed.get('主要消化道致病菌', [])
+        # 抗生素风险评估
+        result['抗生素风险评估'] = parsed.get('抗生素风险评估', [])
+
+        # 肠道屏障 + 短链脂肪酸 + 神经递质
+        bs = extract_barrier_and_scfa_triplet(all_lines)
+        result['肠道屏障及代谢物'] = bs['barrier']
+        result['短链脂肪酸'] = bs['scfa']
+        result['神经递质及激素'] = bs['neurotransmitter']
+
+    else:
+        # inline 格式
+        result['疾病风险评估'] = parse_inline_region(all_full_text, '疾病风险评估',
+            ['主要营养评估', '主要消化道致病菌'])[0]
+
+        # 主要营养评估:使用精确区域+已知5项
+        result['主要营养评估'] = extract_inline_module(all_full_text, '营养状况评估',
+            ['主要营养评估', '氨基酸评估'], KNOWN_MACRO_NUTRIENTS)
+
+        # 氨基酸评估
+        result['氨基酸评估'] = extract_inline_module(all_full_text, '氨基酸评估',
+            ['维生素评估', '主要消化道致病菌'], KNOWN_AMINO_ACIDS)
+
+        # 维生素评估
+        result['维生素评估'] = extract_inline_module(all_full_text, '维生素评估',
+            ['微量元素评估', '主要消化道致病菌'], KNOWN_VITAMINS)
+
+        # 微量元素评估
+        result['微量元素评估'] = extract_inline_module(all_full_text, '微量元素评估',
+            ['主要消化道致病菌'], KNOWN_TRACE)
+
+        # 主要消化道致病菌
+        path_rows, _ = parse_inline_region(all_full_text, '主要消化道致病菌', ['肠道屏障'])
+        result['主要消化道致病菌'] = path_rows
+
+        # 抗生素风险评估(使用已知抗生素词表)
+        result['抗生素风险评估'] = extract_inline_module(all_full_text, '抗生素风险评估',
+            ['抗生素耐药风险', '个体化食物推荐表'], KNOWN_ANTIBIOTICS)
+
+        # 肠道屏障 - inline 使用精确词表
+        result['肠道屏障及代谢物'] = extract_inline_module(all_full_text, '肠道屏障及菌群代谢物',
+            ['短链脂肪酸', '神经递质', '抗生素风险'], KNOWN_BARRIER)
+
+        # 短链脂肪酸
+        result['短链脂肪酸'] = extract_inline_module(all_full_text, '短链脂肪酸',
+            ['神经递质', '抗生素风险'], KNOWN_SCFA)
+
+        # 神经递质
+        result['神经递质及激素'] = extract_inline_module(all_full_text, '神经递质',
+            ['抗生素风险', '个体化食物推荐表'], KNOWN_NEUROTRANSMITTER)
+
+    # 食物推荐表
+    food_rows, food_fmt = extract_food_table(pdf_path)
+    result['个体化食物推荐表'] = {
+        '格式': food_fmt,
+        '条目数': len(food_rows),
+        '数据': food_rows
+    }
+
+    return result
+
+
+# ==========================================
+# 主流程 - 批量 CSV 模式
+# ==========================================
+def main_csv():
+    pdf_files = sorted(f for f in os.listdir(BASE) if f.lower().endswith('.pdf'))
+    if not pdf_files:
+        print('未找到PDF文件')
+        return
+
+    all_data = {}
+    print('读取PDF文件...')
+    for pdf_file in pdf_files:
+        pdf_path = os.path.join(BASE, pdf_file)
+        label = pdf_file.replace('.pdf', '')
+        try:
+            reader = PdfReader(pdf_path)
+            pages_text = [p.extract_text() for p in reader.pages]
+            fmt = detect_format(pages_text)
+            all_data[label] = {'reader': reader, 'pages_text': pages_text, 'fmt': fmt}
+            print(f'  {label}: {len(pages_text)} pages, format={fmt}')
+        except Exception as e:
+            print(f'  {label}: ERROR - {e}')
+
+    if not all_data:
+        print('无可处理的PDF')
+        return
+
+    triplet_labels = [l for l, d in all_data.items() if d['fmt'] == 'triplet']
+    inline_labels = [l for l, d in all_data.items() if d['fmt'] == 'inline']
+    all_labels = list(all_data.keys())
+
+    print(f'\ntriplet: {triplet_labels}, inline: {inline_labels}')
+
+    all_full_text = {}
+    all_full_lines = {}
+    for label, d in all_data.items():
+        all_full_text[label] = '\n\n---PAGEBREAK---\n\n'.join(norm(p.extract_text()) for p in d['reader'].pages)
+        lines = []
+        for pt in d['pages_text']:
+            lines.extend(clean_lines(pt))
+        all_full_lines[label] = lines
+
+    # ── 1. 报告概述 ──
+    print('\n[1/11] 报告概述...')
+    overviews = []
+    for label, d in all_data.items():
+        lines = clean_lines(d['pages_text'][0])
+        found_info = any(k in '\n'.join(lines) for k in ['肠道预测年龄', '核心菌属'])
+        if not found_info:
+            for pt in d['pages_text']:
+                if '基本信息' in norm(pt) and '肠道预测年龄' in norm(pt):
+                    lines = clean_lines(pt)
+                    break
+        r = extract_overview(lines, label)
+        overviews.append(r)
+
+    overview_cols = ['姓名', '编号', '年龄', '性别', '肠道预测年龄', '肠型',
+                     '肠道菌群平衡', '菌群多样性', '有益菌', '有害菌', '核心菌属',
+                     '健康总分', '菌群健康', '慢病控制', '营养均衡']
+    with open(os.path.join(OUTDIR, '报告概述.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+        w = csv.DictWriter(f, fieldnames=overview_cols, extrasaction='ignore')
+        w.writeheader()
+        w.writerows(overviews)
+    print(f'  -> CSV/报告概述.csv ({len(overviews)} 行)')
+
+    # ── 2-9. 模块数据(v5:使用精确词表过滤 inline) ──
+    modules = [
+        ('疾病风险评估', '疾病', '疾病风险评估.csv', '疾病风险评估', ['主要营养评估', '主要消化道致病菌'], None),
+        ('主要营养评估', '指标', '主要营养评估.csv', '营养状况评估', ['主要营养评估', '氨基酸评估'], KNOWN_MACRO_NUTRIENTS),
+        ('氨基酸评估', '氨基酸', '氨基酸评估.csv', '氨基酸评估', ['维生素评估', '主要消化道致病菌'], KNOWN_AMINO_ACIDS),
+        ('维生素评估', '维生素', '维生素评估.csv', '维生素评估', ['微量元素评估', '主要消化道致病菌'], KNOWN_VITAMINS),
+        ('微量元素评估', '微量元素', '微量元素评估.csv', '微量元素评估', ['主要消化道致病菌'], KNOWN_TRACE),
+        ('抗生素风险评估', '抗生素', '抗生素风险评估.csv', '抗生素风险评估', ['抗生素耐药风险', '个体化食物推荐表'], KNOWN_ANTIBIOTICS),
+    ]
+
+    for mod_name, name_col, fname, start_mk, end_mks, whitelist in modules:
+        print(f'  [{mod_name}]...')
+        data = {}
+
+        for label in triplet_labels:
+            parsed = extract_all_triplet(all_full_lines[label])
+            rows = parsed.get(mod_name, [])
+            if not rows and mod_name == '氨基酸评估':
+                rows = parsed.get('氨基酸评估_p2', []) + parsed.get('氨基酸评估_p3', [])
+            for r in rows:
+                data.setdefault(r['名称'], {})[label] = r['数值']
+
+        for label in inline_labels:
+            if whitelist:
+                rows = extract_inline_module(all_full_text[label], start_mk, end_mks, whitelist)
+            else:
+                rows, _ = parse_inline_region(all_full_text[label], start_mk, end_mks)
+            for r in rows:
+                data.setdefault(r['名称'], {})[label] = r['数值']
+
+        if data:
+            labels = all_labels
+            with open(os.path.join(OUTDIR, fname), 'w', newline='', encoding='utf-8-sig') as f:
+                w = csv.DictWriter(f, fieldnames=[name_col] + labels)
+                w.writeheader()
+                for name, vals in sorted(data.items()):
+                    row = {name_col: name}
+                    row.update(vals)
+                    w.writerow(row)
+            print(f'    -> CSV/{fname} ({len(data)} 指标)')
+        else:
+            print(f'    (无数据)')
+
+    # ── 7. 主要消化道致病菌 ──
+    print('[7/11] 主要消化道致病菌...')
+    path_data = {}
+    for label in triplet_labels:
+        parsed = extract_all_triplet(all_full_lines[label])
+        for r in parsed.get('主要消化道致病菌', []):
+            path_data.setdefault(r['致病菌'], {})[label] = r['丰度']
+
+    for label in inline_labels:
+        rows, _ = parse_inline_region(all_full_text[label], '主要消化道致病菌', ['肠道屏障'])
+        for r in rows:
+            path_data.setdefault(r['名称'], {})[label] = r['数值'] if '%' in r['数值'] else r['数值'] + '%'
+
+    if path_data:
+        labels = all_labels
+        with open(os.path.join(OUTDIR, '主要消化道致病菌.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+            w = csv.DictWriter(f, fieldnames=['致病菌'] + labels)
+            w.writeheader()
+            for name, vals in sorted(path_data.items()):
+                row = {'致病菌': name}
+                row.update(vals)
+                w.writerow(row)
+        print(f'  -> CSV/主要消化道致病菌.csv ({len(path_data)} 菌种)')
+
+    # ── 8. 肠道屏障及代谢物 ──
+    print('[8/11] 肠道屏障及代谢物...')
+    with open(os.path.join(OUTDIR, '肠道屏障及代谢物.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+        w = csv.writer(f)
+        w.writerow(['指标', '姓名', '评估值', '健康状况', '正常范围', '症状'])
+        total = 0
+
+        for label in triplet_labels:
+            bs = extract_barrier_and_scfa_triplet(all_full_lines[label])
+            for item in bs.get('barrier', []):
+                w.writerow([item['名称'], label, item.get('评估值',''), item.get('健康状况',''),
+                          item.get('正常范围',''), item.get('症状','')])
+                total += 1
+
+        for label in inline_labels:
+            rows = extract_inline_module(all_full_text[label], '肠道屏障及菌群代谢物',
+                ['短链脂肪酸', '神经递质', '抗生素风险'], KNOWN_BARRIER)
+            for item in rows:
+                w.writerow([item['名称'], label, item.get('数值',''), item.get('状态',''), '', ''])
+                total += 1
+
+    print(f'  -> CSV/肠道屏障及代谢物.csv ({total} 条)')
+
+    # 短链脂肪酸
+    print('  [短链脂肪酸]...')
+    scfa_total = 0
+    with open(os.path.join(OUTDIR, '肠道屏障及代谢物.csv'), 'a', newline='', encoding='utf-8-sig') as f:
+        w = csv.writer(f)
+        for label in triplet_labels:
+            bs = extract_barrier_and_scfa_triplet(all_full_lines[label])
+            for item in bs.get('scfa', []):
+                w.writerow([item['名称'], label, item.get('评估值',''), item.get('健康状况',''),
+                          item.get('正常范围',''), item.get('症状','')])
+                scfa_total += 1
+        for label in inline_labels:
+            rows = extract_inline_module(all_full_text[label], '短链脂肪酸',
+                ['神经递质', '抗生素风险'], KNOWN_SCFA)
+            for item in rows:
+                w.writerow([item['名称'], label, item.get('数值',''), item.get('状态',''), '', ''])
+                scfa_total += 1
+    print(f'    (短链脂肪酸 {scfa_total} 条, 已追加)')
+
+    # 神经递质
+    print('  [神经递质]...')
+    nt_total = 0
+    with open(os.path.join(OUTDIR, '肠道屏障及代谢物.csv'), 'a', newline='', encoding='utf-8-sig') as f:
+        w = csv.writer(f)
+        for label in triplet_labels:
+            bs = extract_barrier_and_scfa_triplet(all_full_lines[label])
+            for item in bs.get('neurotransmitter', []):
+                w.writerow([item['名称'], label, item.get('评估值',''), item.get('健康状况',''),
+                          item.get('正常范围',''), item.get('症状','')])
+                nt_total += 1
+        for label in inline_labels:
+            rows = extract_inline_module(all_full_text[label], '神经递质',
+                ['抗生素风险', '个体化食物推荐表'], KNOWN_NEUROTRANSMITTER)
+            for item in rows:
+                w.writerow([item['名称'], label, item.get('数值',''), item.get('状态',''), '', ''])
+                nt_total += 1
+    print(f'    (神经递质 {nt_total} 条, 已追加)')
+
+    # ── 10. 个体化食物推荐表 ──
+    print('[10/11] 个体化食物推荐表...')
+    ref_nutrition = {}
+    for label in triplet_labels:
+        if '侯' in label:
+            pdf_path = os.path.join(BASE, label + '.pdf')
+            ref_rows, _ = extract_food_table(pdf_path)
+            for r in ref_rows:
+                ref_nutrition[r['名称']] = [int(r[k]) for k in
+                    ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']]
+            break
+
+    for label, d in all_data.items():
+        pdf_path = os.path.join(BASE, label + '.pdf')
+        rows, fmt = extract_food_table(pdf_path, ref_nutrition)
+        fname = f'{label}-个体化食物推荐表.csv'
+        with open(os.path.join(OUTDIR, fname), 'w', newline='', encoding='utf-8-sig') as f:
+            w = csv.DictWriter(f, fieldnames=COLUMNS_FOOD)
+            w.writeheader()
+            w.writerows(rows)
+        print(f'  -> CSV/{fname} ({len(rows)} 条, {fmt})')
+
+    # ── 11. 推荐指数汇总 ──
+    print('[11/11] 推荐指数汇总...')
+    rec_all = {}
+    for label, d in all_data.items():
+        pdf_path = os.path.join(BASE, label + '.pdf')
+        rows, _ = extract_food_table(pdf_path, ref_nutrition)
+        rec_all[label] = {r['名称']: r['推荐指数'] for r in rows}
+
+    std_label = next((l for l in triplet_labels if '侯' in l), triplet_labels[0])
+    pdf_path = os.path.join(BASE, std_label + '.pdf')
+    std_rows, _ = extract_food_table(pdf_path)
+
+    sum_cols = ['名称', '分类'] + ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g',
+                                    '总膳食纤维g', '胆固醇mg'] + all_labels
+    with open(os.path.join(OUTDIR, '推荐指数汇总.csv'), 'w', newline='', encoding='utf-8-sig') as f:
+        w = csv.DictWriter(f, fieldnames=sum_cols)
+        w.writeheader()
+        for r in std_rows:
+            name = r['名称']
+            row = {'名称': name, '分类': r['分类']}
+            for k in ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']:
+                row[k] = r[k]
+            for la in all_labels:
+                row[la] = rec_all.get(la, {}).get(name, '')
+            w.writerow(row)
+    print(f'  -> CSV/推荐指数汇总.csv ({len(std_rows)} 行)')
+
+    print(f'\n完成!所有CSV已输出到 {OUTDIR}/')
+
+
+# ==========================================
+# 主入口
+# ==========================================
+def main():
+    if len(sys.argv) >= 3 and sys.argv[1] == '-j':
+        # JSON 单文件输出模式
+        pdf_path = sys.argv[2]
+        if not os.path.isfile(pdf_path):
+            print(f'错误:找不到文件 {pdf_path}')
+            sys.exit(1)
+        result = extract_pdf_to_json(pdf_path)
+        print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
+    else:
+        main_csv()
+
+
+if __name__ == '__main__':
+    main()