# -*- coding: utf-8 -*-
"""汇总 D1-D4 技术方向深调草稿 -> 自包含 HTML 精读页（宝锐蓝白版式）v2"""
import base64, html, os, re
import markdown

BASE = '/Users/liuxinyuan/Desktop/Hermes输出-工作类'
KD = os.path.join(BASE, '知识沉淀')
OUT = os.path.join(BASE, '销售分析', '技术方向深度调研_A档4条_等温扩增_冻干预混_MRD_酶平台_20260912.html')
LOGO = os.path.expanduser('~/work/公司资料/logo_transparent.png')

DOCS = [
    ('D1', '① 等温扩增整体方案 + 冻干', '等温扩增（LAMP/RPA/RCA/SDA·tHDA·NEAR）', 86.4, '#1F4E79', '_draft_D1_等温扩增冻干.md'),
    ('D2', '② 冻干全预混 OEM + 稳定性数据包', '冻干全预混试剂 OEM（服务化）', 85.0, '#0F766E', '_draft_D2_冻干预混OEM.md'),
    ('D3', '③ MRD / ctDNA 超高灵敏检测原料包', 'MRD / ctDNA 超高灵敏原料', 84.0, '#7C3AED', '_draft_D3_MRD_ctDNA原料.md'),
    ('D4', '④ 酶定向进化 + 冻干耐受/抗抑制改造平台', '酶定向进化改造平台', 83.4, '#B45309', '_draft_D4_酶定向进化平台.md'),
]

HERO = {
 'D1': ('宝锐已有 Bst 类恒温酶 + RT 酶 + 冻干线，迁移距离最短；LAMP 全预混冻干珠是最快可卖的样板',
        'RPA 卡在重组酶 UvsX/UvsY/gp32（需外购或自建表达）；冻干后 12 个月常温稳定性 + 批间 CV',
        '12 个月内出样：LAMP 全预混 + 冻干珠'),
 'D2': ('去冷链、即溶即用是确定性路线；上游普遍以「无甘油预混 + 保护剂 + 参考冻干工艺」交付，而非成品盒',
        'Tc / Tg′ / 共晶点测定 + 卡尔费休残留水分 + ICH 口径稳定性报告能力',
        '1 个季度起：现役货号冻干基线 → 服务包报价'),
 'D3': ('0.01% VAF 是分水岭，差的不是 panel 大小而是纠错机制 + 输入分子数；机会集中在「酶 + 建库 + 防污染」三件套',
        '把背景错误率压到目标 VAF 以下：错配率硬数据 + 热敏 UDG + dUTP 耐受高保真酶',
        '6–12 个月：补测错配率 → dUTP 耐受酶 → 建库套装'),
 'D4': ('能力底座：护城河不在单品，而在酶改造平台 + 稳定性数据 + 专利池；建议先借外部平台再自建',
        '抗抑制 + 冻干耐受的指标化改造（Tm/t½、冻干回收率、血红素/腐殖酸/EDTA 耐受）',
        '先借平台（CRO/液滴微流控）出首批变体，再建最小自有平台'),
}

def esc(s):
    return html.escape(str(s), quote=False)

def read(p):
    with open(p, encoding='utf-8') as f:
        return f.read()

def md2html(t):
    # 先剥离状态标记外层的加粗（避免 markdown 转换后残留 ** ）
    t = re.sub(r'\*\*\s*(✓已读全文|⚠仅摘要\+部分方法|⚠仅摘要|SI\s*已读|正文未读)\s*\*\*', lambda m: m.group(1), t)
    h = markdown.markdown(t, extensions=['tables', 'fenced_code', 'sane_lists', 'attr_list'])
    for a, b in [('⚠仅摘要+部分方法', '<span class="tag warn">⚠ 仅摘要</span>'),
                 ('✓已读全文', '<span class="tag ok">✓ 已读全文</span>'),
                 ('SI 已读', '<span class="tag ok">SI 已读</span>'),
                 ('正文未读', '<span class="tag warn">正文未读</span>'),
                 ('⚠仅摘要', '<span class="tag warn">⚠ 仅摘要</span>')]:
        h = h.replace(a, b)
    return h

def inline_md(s):
    """单元格级行内渲染：**粗体** / *斜体* / `代码` + 状态标记着色"""
    x = html.escape(str(s), quote=False)
    for a, b in [('⚠仅摘要+部分方法', '<span class="tag warn">⚠ 仅摘要</span>'),
                 ('✓已读全文', '<span class="tag ok">✓ 已读全文</span>'),
                 ('SI 已读', '<span class="tag ok">SI 已读</span>'),
                 ('正文未读', '<span class="tag warn">正文未读</span>'),
                 ('⚠仅摘要', '<span class="tag warn">⚠ 仅摘要</span>')]:
        x = x.replace(a, b)
    x = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', x)
    x = re.sub(r'(?<![\w*])\*(?!\s)([^*]+?)\*(?![\w*])', r'<em>\1</em>', x)
    x = re.sub(r'`([^`]+?)`', r'<code>\1</code>', x)
    return x

def parse_tables(body):
    """返回 [(headers, rows)]，正确跳过表头与分隔行"""
    out, cur = [], []
    for ln in body.splitlines():
        s = ln.strip()
        if s.startswith('|'):
            cur.append(s)
        else:
            if cur:
                out.append(cur); cur = []
    if cur:
        out.append(cur)
    res = []
    for blk in out:
        cells = [[c.strip() for c in l.strip('|').split('|')] for l in blk]
        if len(cells) < 1:
            continue
        sep_idx = None
        for i, r in enumerate(cells):
            if r and all(re.fullmatch(r':?-{2,}:?', c) for c in r if c):
                sep_idx = i
                break
        if sep_idx is None:
            continue
        headers = cells[sep_idx - 1] if sep_idx >= 1 else []
        rows = [r for r in cells[sep_idx + 1:] if any(c for c in r)]
        if rows:
            res.append((headers, rows))
    return res

def split_sections(md):
    lines = md.splitlines()
    i = 0
    while i < len(lines) and not lines[i].startswith('# '):
        i += 1
    h1 = lines[i][2:].strip() if i < len(lines) else ''
    body = '\n'.join(lines[i+1:]) if i < len(lines) else md
    parts = re.split(r'(?m)^##\s+', body)
    secs = []
    for p in parts[1:]:
        head, _, rest = p.partition('\n')
        secs.append((head.strip(), rest))
    return h1, secs

def collect(md):
    h1, sections = split_sections(md)
    ev = []      # [(headers, rows, nread, nabs)]
    gaps = []    # [{'type':'table'|'list','headers':[...],'rows':[...],'items':[...]}]
    actions = [] # [(headers, rows)]
    nread = nabs = 0
    for head, body in sections:
        tabs = parse_tables(body)
        if re.search(r'文献证据', head):
            for headers, rows in tabs:
                r_ = sum(1 for r in rows if '✓' in ' '.join(r))
                a_ = sum(1 for r in rows if '✓' not in ' '.join(r) and '⚠' in ' '.join(r))
                nread += r_; nabs += a_
                ev.append((headers, rows, r_, a_))
        if re.search(r'文献缺口', head):
            if tabs:
                for headers, rows in tabs:
                    gaps.append({'type': 'table', 'headers': headers, 'rows': rows})
            else:
                items = [re.sub(r'^\s*\d+\.\s*', '', l).strip() for l in body.splitlines()
                         if re.match(r'^\s*\d+\.\s+\S', l)]
                if items:
                    gaps.append({'type': 'list', 'items': items})
        if re.search(r'落地建议|行动步骤|优先级|路线', head):
            for headers, rows in tabs:
                rs = [r for r in rows if re.match(r'^\**\s*P[0-9]', (r[0] or ''))]
                if rs:
                    actions.append((headers, rs))
    ngap = sum((len(g['rows']) if g['type'] == 'table' else len(g['items'])) for g in gaps)
    return dict(h1=h1, sections=sections, ev=ev, gaps=gaps, actions=actions,
                nread=nread, nabs=nabs, ngap=ngap, bytes=os.path.getsize(os.path.join(KD, 'x')) if False else 0)

def hrow(cells):
    return '<tr>' + ''.join('<th>%s</th>' % esc(c) for c in cells) + '</tr>'

def table_html(headers, rows, cls='', raw=False):
    o = ['<div class="tablewrap"><table class="%s">' % cls]
    if headers:
        o.append('<thead>' + hrow(headers) + '</thead>')
    o.append('<tbody>')
    for r in rows:
        o.append('<tr>' + ''.join('<td>%s</td>' % (c if raw else inline_md(c)) for c in r) + '</tr>')
    o.append('</tbody></table></div>')
    return ''.join(o)

data = {}
for docid, label, short, score, color, fn in DOCS:
    p = os.path.join(KD, fn)
    md = read(p)
    d = collect(md)
    d.update(short=short, score=score, color=color, fn=fn, label=label,
             bytes=os.path.getsize(p), nlines=len(md.splitlines()))
    data[docid] = d

tot = {k: sum(d[k] for d in data.values()) for k in ('nread', 'nabs', 'ngap')}
tot_actions = sum(len(r) for d in data.values() for _, r in d['actions'])

logo_b64 = base64.b64encode(open(LOGO, 'rb').read()).decode() if os.path.exists(LOGO) else ''
logo_tag = ('<img class="logo" src="data:image/png;base64,%s" alt="BIORI BIOTECH">' % logo_b64) if logo_b64 else '<span class="logo-txt">BIORI BIOTECH</span>'

CSS = """
*{box-sizing:border-box}
:root{--blue:#1F4E79;--blue2:#2C6BA8;--bg:#f4f6f9;--card:#fff;--line:#e3e9f0;--txt:#1f2937;--muted:#64748b;--ok:#0f766e;--warn:#b45309;--gap:#9f1239}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif}
.topbar{position:sticky;top:0;z-index:60;display:flex;align-items:center;gap:14px;padding:9px 20px;background:#fff;border-bottom:1px solid var(--line);box-shadow:0 1px 4px rgba(15,23,42,.06)}
.topbar .logo{height:30px;display:block}
.topbar .logo-txt{font-weight:800;color:var(--blue);letter-spacing:.5px}
.topbar .tt{font-weight:700;color:var(--blue);font-size:15px}
.topbar .tt small{display:block;font-weight:500;color:var(--muted);font-size:11.5px}
.topbar .sp{flex:1}
.topbar .meta{font-size:11.5px;color:var(--muted);text-align:right;line-height:1.35}
.tabs{position:sticky;top:49px;z-index:55;display:flex;gap:6px;flex-wrap:wrap;padding:9px 20px;background:#fff;border-bottom:1px solid var(--line)}
.tab{padding:7px 14px;border-radius:999px;background:#eef2f7;color:var(--blue);font-weight:600;font-size:13px;cursor:pointer;white-space:nowrap;user-select:none}
.tab:hover{background:#e2ebf5}
.tab.on{background:linear-gradient(135deg,#1F4E79,#2C6BA8);color:#fff}
.wrap{max-width:1320px;margin:0 auto;padding:20px}
.panel{display:none}.panel.on{display:block}
.hero{background:linear-gradient(135deg,#1F4E79 0%,#2C6BA8 55%,#3E8FC7 100%);color:#fff;border-radius:16px;padding:22px 24px;box-shadow:0 6px 18px rgba(31,78,121,.18)}
.hero h1{margin:0 0 8px;font-size:22px}
.hero p{margin:0;opacity:.95;font-size:13.5px;max-width:1050px}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:14px 0 0}
.kpi{background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.28);border-radius:12px;padding:11px 14px}
.kpi b{display:block;font-size:22px;line-height:1.2}
.kpi span{font-size:11.5px;opacity:.93}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(285px,1fr));gap:14px;margin:16px 0}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:15px 16px;box-shadow:0 1px 3px rgba(16,24,40,.05)}
.card h3{margin:0 0 6px;font-size:15px;color:var(--blue);display:flex;align-items:center;gap:8px}
.card .score{margin-left:auto;font-size:12px;font-weight:700;color:#fff;background:linear-gradient(135deg,#1F4E79,#2C6BA8);border-radius:999px;padding:3px 10px;white-space:nowrap}
.card p{margin:6px 0;font-size:13px;color:#334155}
.card .kv{font-size:12.5px;color:var(--muted);border-top:1px dashed var(--line);padding-top:7px;margin-top:8px}
.split{display:grid;grid-template-columns:235px 1fr;gap:18px;align-items:start}
aside.toc{position:sticky;top:105px;background:#fff;border:1px solid var(--line);border-radius:12px;padding:12px;font-size:12.5px;max-height:calc(100vh - 130px);overflow:auto}
aside.toc b{display:block;color:var(--blue);margin-bottom:7px}
aside.toc a{display:block;color:#475569;text-decoration:none;padding:4px 6px;border-radius:6px;border-left:2px solid transparent}
aside.toc a:hover{background:#f1f5f9;color:var(--blue);border-left-color:var(--blue2)}
main.body{min-width:0}
.sec{background:#fff;border:1px solid var(--line);border-radius:12px;padding:4px 18px 14px;margin:0 0 16px;box-shadow:0 1px 3px rgba(16,24,40,.04);scroll-margin-top:105px}
.sec>h2{font-size:16.5px;color:var(--blue);margin:16px 0 10px;padding-left:10px;border-left:4px solid var(--blue2)}
.sec h3{font-size:14.5px;color:#0f3f66;margin:16px 0 8px}
.sec h4{font-size:13.5px;color:#334155;margin:12px 0 6px}
.sec p{margin:8px 0}.sec li{margin:4px 0}
.sec blockquote{margin:10px 0;padding:9px 12px;background:#f8fafc;border-left:3px solid var(--blue2);color:#475569;border-radius:0 8px 8px 0;font-size:13px}
.tablewrap{overflow-x:auto;margin:12px 0;border:1px solid var(--line);border-radius:10px}
table{border-collapse:collapse;width:100%;font-size:13px;min-width:560px}
th{background:#eef2f7;color:#0f3f66;text-align:left;font-weight:700;padding:8px 10px;border-bottom:1px solid var(--line)}
td{padding:8px 10px;border-bottom:1px solid #eef2f7;vertical-align:top}
tbody tr:nth-child(even){background:#fbfcfe}
tbody tr:hover{background:#f4f8fc}
code{background:#eef2f7;border-radius:4px;padding:1px 5px;font-size:12.5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
a{color:#1d4ed8;word-break:break-all}
.tag{display:inline-block;border-radius:999px;padding:1px 8px;font-size:11.5px;font-weight:700;white-space:nowrap}
.tag.ok{background:#e6f5f2;color:var(--ok)}
.tag.warn{background:#fdf3e3;color:var(--warn)}
.tag.gap{background:#fdeaf0;color:var(--gap)}
hr{border:0;border-top:1px dashed var(--line);margin:16px 0}
.note{background:#fff;border:1px solid var(--line);border-left:4px solid var(--warn);border-radius:0 10px 10px 0;padding:12px 15px;margin:14px 0;font-size:13px;color:#475569}
.h2s{color:var(--blue);font-size:16.5px;margin:20px 0 8px;padding-left:10px;border-left:4px solid var(--blue2)}
ol.gl{margin:10px 0 10px 18px;padding:0}ol.gl li{margin:8px 0;font-size:13px;line-height:1.6}
footer{color:var(--muted);font-size:12px;text-align:center;padding:22px 10px 34px}
@media (max-width:920px){.split{grid-template-columns:1fr}aside.toc{position:static;max-height:none}}
@media print{.tabs,.topbar{position:static}.panel{display:block!important}table{min-width:0;font-size:11px}}
"""

JS = """
function showTab(id){
  document.querySelectorAll('.panel').forEach(function(p){p.classList.toggle('on', p.id==='panel-'+id);});
  document.querySelectorAll('.tab').forEach(function(t){t.classList.toggle('on', t.dataset.id===id);});
  window.scrollTo({top:0,behavior:'smooth'});
}
document.addEventListener('DOMContentLoaded',function(){
  document.querySelectorAll('.tab').forEach(function(t){t.addEventListener('click',function(){showTab(t.dataset.id);});});
  showTab('sum');
});
"""

P = []
P.append('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">')
P.append('<meta name="viewport" content="width=device-width,initial-scale=1">')
P.append('<title>结合宝锐能力的技术方向深度调研（A 档 4 条）| 宝锐生物</title>')
P.append('<style>%s</style></head><body>' % CSS)
P.append('<div class="topbar">%s<div class="tt">技术方向深度调研（A 档 4 条）'
         '<small>等温扩增+冻干 · 冻干预混 OEM · MRD/ctDNA 原料 · 酶定向进化平台</small></div><div class="sp"></div>'
         '<div class="meta">宝锐生物 BIORI BIOTECH · 销售管理<br>依据：Kalorama IVD 19 版 + 宝锐 854 货号能力底盘<br>2026-09-12</div></div>' % logo_tag)
tabs = [('sum', '汇总对比'), ('D1', '① 等温扩增 + 冻干'), ('D2', '② 冻干预混 OEM'), ('D3', '③ MRD / ctDNA'),
        ('D4', '④ 酶改造平台'), ('evi', '文献总表'), ('road', '落地路线图')]
P.append('<div class="tabs">' + ''.join('<div class="tab" data-id="%s">%s</div>' % (i, esc(t)) for i, t in tabs) + '</div>')
P.append('<div class="wrap">')

# ======== 汇总 ========
s = ['<div class="hero"><h1>结合宝锐能力的技术方向深度调研 · A 档 4 条</h1>',
     '<p>四条方向均由「不拥挤 / 门槛高 / 增量大 / 顺应市场趋势 / 能力可迁移」加权评分（满分 100）筛出。每条给出：原理与反应式 · 酶体系与 SOP · 文献证据（可核实 DOI/PMCID/专利号）· 商业化对标 · 文献缺口清单 · 对宝锐的落地建议。状态口径：✓已读全文＝抓取并阅读了正文关键数据；⚠仅摘要＝仅取到标题/摘要/检索片段；缺口＝付费墙未获全文，已给题录与阅读理由。凡未获来源支持的数字均标注「未核实」。</p>',
     '<div class="kpis">',
     '<div class="kpi"><b>4</b><span>技术方向（A 档）</span></div>',
     '<div class="kpi"><b>%d</b><span>✓ 已读全文文献</span></div>' % tot['nread'],
     '<div class="kpi"><b>%d</b><span>⚠ 仅摘要/检索级</span></div>' % tot['nabs'],
     '<div class="kpi"><b>%d</b><span>缺口文献（付费墙）</span></div>' % tot['ngap'],
     '<div class="kpi"><b>%d</b><span>落地行动项 P0–P4</span></div>' % tot_actions,
     '<div class="kpi"><b>%.0f KB</b><span>调研稿总量（4 篇）</span></div>' % (sum(d['bytes'] for d in data.values())/1024.0),
     '</div></div>']
s.append('<div class="grid">')
for docid, label, short, score, color, fn in DOCS:
    d = data[docid]
    pos, gate, speed = HERO[docid]
    s.append('<div class="card" style="border-top:3px solid %s"><h3>%s<span class="score">%.1f 分</span></h3>'
             '<p><b>为什么是它</b>：%s</p><p><b>核心门槛</b>：%s</p>'
             '<div class="kv">变现节奏：%s<br>文献：✓%d · ⚠%d · 缺口 %d　行动项 %d 条　调研稿 %d 行</div></div>'
             % (color, esc(label), score, esc(pos), esc(gate), esc(speed), d['nread'], d['nabs'], d['ngap'],
                len(d['actions'] and [1]) and sum(len(r) for _, r in d['actions']), d['nlines']))
s.append('</div>')
s.append('<div class="note"><b>阅读口径</b>：① 本页为文献与公开产品页实证，不替代实验室验证；② 标「未核实」的数字在任何对外文件中不得直接引用；③ 表格可横向滚动，DOI/专利号给出可点击来源；④ 评分口径与 B/C 档清单见《技术方向候选清单》（知识沉淀/技术方向候选清单_20260912.md）。</div>')
s.append('<h2 class="h2s">四条方向一览</h2>')
rows = []
for docid, label, short, score, color, fn in DOCS:
    d = data[docid]
    pos, gate, speed = HERO[docid]
    rows.append(['<b style="color:%s">%s</b>' % (color, esc(short)), '%.1f' % score, esc(gate), esc(speed),
                 '✓%d / ⚠%d / 缺口 %d' % (d['nread'], d['nabs'], d['ngap'])])
s.append(table_html(['方向', '评分', '门槛（要补的能力）', '变现节奏', '文献'], rows, raw=True))
P.append('<div class="panel" id="panel-sum">%s</div>' % ''.join(s))

# ======== 各方向 ========
for docid, label, short, score, color, fn in DOCS:
    d = data[docid]
    o = ['<div class="hero" style="background:linear-gradient(135deg,%s 0%%,#2C6BA8 100%%)"><h1>%s</h1>'
         '<p>评分 <b>%.1f</b>/100　·　调研稿 <code>%s</code>（%d 行 / %.1f KB）　·　文献 ✓%d、⚠%d、缺口 %d　·　行动项 %d 条</p></div>'
         % (color, esc(label), score, esc(fn), d['nlines'], d['bytes']/1024.0, d['nread'], d['nabs'], d['ngap'],
            sum(len(r) for _, r in d['actions']))]
    o.append('<div class="split"><aside class="toc"><b>目录</b>')
    for i, (head, _) in enumerate(d['sections'], 1):
        o.append('<a href="#s%s%02d">%s</a>' % (docid, i, esc(head)))
    o.append('</aside><main class="body">')
    for i, (head, body) in enumerate(d['sections'], 1):
        o.append('<section class="sec" id="s%s%02d"><h2>%s</h2>%s</section>' % (docid, i, esc(head), md2html(body)))
    o.append('</main></div>')
    P.append('<div class="panel" id="panel-%s">%s</div>' % (docid, ''.join(o)))

# ======== 文献总表 ========
e = ['<div class="hero"><h1>文献总表（合并 4 篇调研稿）</h1>'
     '<p>✓ 已读全文 %d 篇 · ⚠ 仅摘要/检索级 %d 篇 · 付费墙缺口 %d 篇。缺口已给题录、DOI/平台与阅读理由，可按图索骥补读。</p>'
     '<div class="kpis"><div class="kpi"><b>%d</b><span>✓ 已读全文</span></div>'
     '<div class="kpi"><b>%d</b><span>⚠ 仅摘要</span></div>'
     '<div class="kpi"><b>%d</b><span>缺口清单</span></div></div></div>' % (tot['nread'], tot['nabs'], tot['ngap'], tot['nread'], tot['nabs'], tot['ngap'])]
e.append('<h2 class="h2s">一、已核实文献（证据表）</h2>')
for docid, label, short, score, color, fn in DOCS:
    d = data[docid]
    if not d['ev']:
        continue
    e.append('<h3 style="color:#0f3f66;font-size:14.5px;margin:14px 0 6px">%s　<span class="tag ok">✓ %d</span> <span class="tag warn">⚠ %d</span></h3>' % (esc(label), d['nread'], d['nabs']))
    for headers, rows, r_, a_ in d['ev']:
        e.append(table_html(headers, rows, 'evi'))
e.append('<h2 class="h2s">二、文献缺口清单（付费墙 / 未获全文）</h2>')
for docid, label, short, score, color, fn in DOCS:
    d = data[docid]
    if not d['gaps']:
        continue
    n = sum((len(g['rows']) if g['type'] == 'table' else len(g['items'])) for g in d['gaps'])
    e.append('<h3 style="color:#0f3f66;font-size:14.5px;margin:14px 0 6px">%s　<span class="tag gap">缺口 %d</span></h3>' % (esc(label), n))
    for g in d['gaps']:
        if g['type'] == 'table':
            e.append(table_html(g['headers'], g['rows'], 'gap'))
        else:
            e.append('<ol class="gl">' + ''.join('<li>%s</li>' % md2html(it) for it in g['items']) + '</ol>')
P.append('<div class="panel" id="panel-evi">%s</div>' % ''.join(e))

# ======== 路线图 ========
r = ['<div class="hero"><h1>落地路线图（P0→P4 合并视图）</h1>'
     '<p>四篇调研稿的落地建议按优先级合并，共 %d 项。先决条件＝启动该步骤前必须先满足的条件；主责部门为调研稿的建议分工，最终以宝锐现行组织架构为准。</p></div>' % tot_actions]
for phase in ['P0', 'P1', 'P2', 'P3', 'P4']:
    items = []
    for docid, label, short, score, color, fn in DOCS:
        for headers, rows in data[docid]['actions']:
            for row in rows:
                if re.match(r'^\**\s*' + phase + r'\b', row[0]):
                    items.append((label, headers, row))
    if not items:
        continue
    r.append('<h2 class="h2s">%s（%d 项）</h2>' % (phase, len(items)))
    for label, headers, row in items:
        rest = row[1:]
        hd = headers[1:] if len(headers) > len(rest) else headers
        kv = '　'.join('<b>%s</b>：%s' % (esc(hd[i]) if i < len(hd) else '', md2html(c)) for i, c in enumerate(rest))
        r.append('<div class="card" style="margin:8px 0"><div class="kv" style="border-top:0;padding-top:0;margin:0">'
                 '<b style="color:var(--blue)">%s</b>　%s</div></div>' % (esc(label), kv))
r.append('<div class="note"><b>一句话主线</b>：<b>① 等温扩增</b>先出可卖样板（LAMP 全预混冻干珠）→ <b>② 冻干服务包</b>把工艺变成可报价产品 → <b>③ MRD 原料</b>补「错配率硬数据 + dUTP 耐受酶 + 热敏 UDG」三件套 → <b>④ 酶改造平台</b>先借外部平台出首批变体、再建最小自有平台。四条共用同一套稳定性数据与专利资产——这才是护城河；只卖单品会被竞品用价格打掉。</div>')
P.append('<div class="panel" id="panel-road">%s</div>' % ''.join(r))

P.append('</div><footer>宝锐生物 BIORI BIOTECH · 销售管理 · 技术方向深度调研（A 档 4 条） · 2026-09-12<br>'
         '数据来源：Kalorama《The Worldwide Market for IVD Tests, 19th Edition》(2026-05) + 宝锐 854 货号能力底盘 + 各篇可核实文献/公开产品页<br>'
         '本页自包含（内联样式与 Logo，无外部依赖），可离线打开、可打印为 PDF</footer>')
P.append('<script>%s</script></body></html>' % JS)

htm = ''.join(P)
with open(OUT, 'w', encoding='utf-8') as f:
    f.write(htm)

print('OUT:', OUT)
print('bytes:', os.path.getsize(OUT))
print('tabs/panels:', htm.count('class="tab"'), htm.count('class="panel'))
print('read/abs/gap:', tot['nread'], tot['nabs'], tot['ngap'], 'actions:', tot_actions)
print('logo inlined:', 'data:image/png;base64,' in htm, '| external refs:', len(re.findall(r'(?:src|href)="https?://', htm)))
for k, v in data.items():
    print(k, 'ev_tables:', len(v['ev']), 'gap_groups:', len(v['gaps']), 'read/abs/gap:', v['nread'], v['nabs'], v['ngap'],
          'action_tables:', len(v['actions']), 'lines:', v['nlines'])
