#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""科研点赞促销活动海报 · 生成模板 v1.0（技能 baoyu-promo-poster-template）

用途：一键生成「宝锐科研促销活动」朋友圈海报（主打产品版 + 数据 + 限时活动 + 落款三码）

固定设计语言（不要随意改）：
  · 结构（**只保留 3 个视觉重点**，其余中性陪衬）：
      Logo 顶栏
      ① 标题横幅 —— 品牌蓝渐变 + 白字        ← 重点①
      ③ 限时促销 —— 暖橙红，紧贴标题下方      ← 重点③
        产品图卡 —— 中性白卡内嵌浅渐变舞台
      ② 产品特点 —— 青色卡 + 三行            ← 重点②
        数据条   —— 中性白卡三格（品牌蓝数字）
        实测数据 —— 中性白卡图表
        扫码 CTA —— 自然底图 + 深色渐变罩
        底栏落款 —— slogan / 品牌 / 联系方式四行 / 三码
  · 配色：品牌蓝 #1852B6（标题）/ 青 #00A99E（产品特点）/ 暖橙红 #F2691E→#E8443C→#D6336C（促销）
          **三色各只出现一处**；其余一律中性白卡 + 品牌蓝，禁止在正文铺多色块
  · 栅格：全篇统一 30px；顶栏/底栏为圆角磨砂胶囊
  · **字号铁律：全图只用 3 种字号 —— 60 / 34 / 22 px**

换一个新活动只需改三处（见文件内 ═══ ① 内容配置 ═══ 段落）：
  1. RTQ / CLONE 两个 poster(...) 调用的文案、货号、图片、日期
  2. 顶栏促销标签文案（poster() 内）
  3. 输出目录 OUTPUT_DIR（或用环境变量 POSTER_DIR 覆盖）

跑法：env -u PYTHONPATH /usr/bin/python3 gen_promo_poster.py
      （该解释器同机同时具备 playwright + PIL；env -u PYTHONPATH 避开 3.14 venv 的 PIL 冲突）
"""
import base64
import io
import os
import re
import sys
from PIL import Image, ImageChops
from playwright.sync_api import sync_playwright

OUTPUT_DIR = os.environ.get(
    'POSTER_DIR',
    '/Users/liuxinyuan/Desktop/Hermes输出-工作类/对外物料/科研朋友圈_20261009')
DIR = OUTPUT_DIR
# ── 素材路径（在别处复用模板时用环境变量覆盖）──
IMG = os.environ.get('POSTER_IMG_DIR', os.path.join(DIR, 'images'))     # 产品图/图表
BG_DIR = os.environ.get('POSTER_BG_DIR', os.path.join(DIR, 'bg'))       # 底图
QRDIR = os.environ.get(
    'POSTER_QR_DIR',
    '/Users/liuxinyuan/Desktop/Hermes输出-工作类/科研2026秋季促销/华南农大海报_20260920/qr')
LOGO = '/Users/liuxinyuan/work/公司资料/logo_transparent.png'
SLOGAN = '守护生命健康，缔造美好生活'
BRAND = '宝锐生物 BIORI BIOTECH'
W = 1080
FS_ALLOWED = {'60px', '34px', '22px'}


def b64(p):
    return 'data:image/png;base64,' + base64.b64encode(io.open(p, 'rb').read()).decode()


def fit(p, maxw=1000, maxh=1000):
    im = Image.open(p).convert('RGB')
    bg = Image.new('RGB', im.size, (255, 255, 255))
    bb = ImageChops.difference(im, bg).getbbox()
    if bb:
        pad = 6
        im = im.crop((max(0, bb[0] - pad), max(0, bb[1] - pad),
                      min(im.width, bb[2] + pad), min(im.height, bb[3] + pad)))
    im.thumbnail((maxw, maxh), Image.LANCZOS)
    f = '/tmp/_p_%s.jpg' % os.path.basename(p)
    im.save(f, quality=92)
    return 'data:image/jpeg;base64,' + base64.b64encode(io.open(f, 'rb').read()).decode()


LOGO_U = b64(LOGO)
P = {k: fit(os.path.join(IMG, k + '.png')) for k in
     ['p1_i1_x19', 'p1_i2_x20', 'p1_i5_x24', 'p1_i7_x26', 'p2_i2_x30', 'p2_i3_x31',
      'p3_i2_x35', 'p3_i3_x46', 'p3_i4_x47', 'p4_i2_x50', 'p4_i3_x51']}

# ═══════════ 线性图标（内联 SVG，currentColor 随卡片变色）═══════════
def _svg(body):
    return ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" '
            'stroke-linecap="round" stroke-linejoin="round">%s</svg>' % body)


ICON = {
    'bolt': _svg('<path d="M13.5 2.5 5 13.2h5.6L10 21.5l8.6-10.9h-5.6z"/>'),
    'tube': _svg('<rect x="8.2" y="2.8" width="7.6" height="18.4" rx="3.8"/>'
                 '<path d="M8.2 12.5h7.6"/><path d="M10.6 6.2h2.8"/>'),
    'sliders': _svg('<path d="M4 7.5h16M4 12h16M4 16.5h16"/>'
                    '<circle cx="9" cy="7.5" r="2.1" fill="#fff"/>'
                    '<circle cx="15" cy="12" r="2.1" fill="#fff"/>'
                    '<circle cx="8" cy="16.5" r="2.1" fill="#fff"/>'),
    'thermo': _svg('<path d="M12 2.8a3.1 3.1 0 0 1 3.1 3.1v7.3a4.6 4.6 0 1 1-6.2 0V5.9A3.1 3.1 0 0 1 12 2.8z"/>'
                   '<path d="M12 9.6v6.2"/>'),
    'insert': _svg('<circle cx="12" cy="12" r="8.6"/><path d="M8.8 12h6.4M12 8.8v6.4"/>'),
    'sparkle': _svg('<path d="M12 2.6 14 8.6l6 2-6 2-2 6-2-6-6-2 6-2z"/>'),
    'chart': _svg('<path d="M5 20.2V11M12 20.2V3.8M19 20.2v-6.4"/><path d="M2.6 20.2h18.8"/>'),
    'shield': _svg('<path d="M12 2.8l7.2 3v6.1c0 4.6-3.1 7.7-7.2 9.3-4.1-1.6-7.2-4.7-7.2-9.3V5.8z"/>'
                   '<path d="M8.9 12.1l2.2 2.2 4-4.2"/>'),
    'clock': _svg('<circle cx="12" cy="12" r="8.6"/><path d="M12 7.2V12l3.4 2.1"/>'),
}

C = {'blue': '#1852B6', 'cyan': '#00A99E', 'orange': '#F2691E', 'purple': '#6C5CE7'}

CSS = """
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:#fff}
.poster{width:1080px;background:#fff;position:relative;overflow:hidden;display:flex;flex-direction:column;
  font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;color:#16202F;
  background-size:cover;background-position:center top;background-repeat:no-repeat}
.topbar{height:92px;flex:0 0 92px;display:flex;align-items:center;justify-content:space-between;
  padding:0 30px;margin:22px 30px 0;border-radius:22px;
  background:rgba(255,255,255,.84);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
  box-shadow:0 10px 30px rgba(16,40,90,.10);position:relative;z-index:3}
.topbar img{height:50px;display:block}
.tag{font-size:22px;font-weight:800;color:#fff;border-radius:999px;padding:9px 20px;white-space:nowrap;
  background:linear-gradient(96deg,#1852B6,#00A99E);box-shadow:0 8px 20px rgba(24,82,182,.30)}
/* ── 重点① 标题：品牌蓝渐变横幅（白字）── */
.hero{margin:22px 30px 0;border-radius:30px;padding:40px 36px;position:relative;overflow:hidden;
  background:linear-gradient(125deg,#0F3A8A 0%,#1852B6 52%,#2E7BE0 100%);
  box-shadow:0 18px 46px rgba(15,58,138,.28)}
.hero .wm{position:absolute;right:-90px;top:-120px;width:420px;height:420px;border-radius:50%;
  background:radial-gradient(circle at 40% 40%,rgba(255,255,255,.22),rgba(255,255,255,0) 62%)}
.kicker{font-size:34px;font-weight:800;letter-spacing:1px;margin-bottom:14px;
  color:rgba(255,255,255,.90);position:relative;z-index:2}
h1{font-size:60px;line-height:1.2;font-weight:900;color:#fff;letter-spacing:-.5px;position:relative;z-index:2}
.sub{font-size:34px;color:rgba(255,255,255,.92);line-height:1.5;margin-top:14px;position:relative;z-index:2}
/* ── 产品图卡（中性陪衬）── */
.stage-card{margin:22px 30px 0;border-radius:28px;padding:30px 24px 26px;background:rgba(255,255,255,.93);
  border:1px solid rgba(224,232,244,.9);box-shadow:0 8px 24px rgba(16,40,90,.06);
  backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
.stage{border-radius:24px;padding:30px 20px;display:flex;align-items:center;justify-content:center;gap:26px;
  background:linear-gradient(150deg,rgba(238,244,255,.92) 0%,rgba(243,251,249,.88) 52%,rgba(255,244,238,.88) 100%);
  border:1px solid rgba(228,236,248,.9)}
.stage img{display:block;max-height:400px;max-width:100%;object-fit:contain}
.pname{margin-top:20px;text-align:center;font-size:34px;font-weight:900;color:#0F2A5C}
.pen{font-size:22px;color:#7A8598;margin-top:6px;letter-spacing:.4px;text-align:center}
.band{padding:6px 30px 0}
/* ── 限时活动板块（紧贴封面下方）── */
.promo{margin:22px 30px 0;border-radius:26px;padding:26px 32px;display:flex;align-items:center;gap:26px;
  color:#fff;background:linear-gradient(115deg,#F2691E 0%,#E8443C 46%,#D6336C 100%);
  box-shadow:0 16px 36px rgba(214,51,108,.28);position:relative;overflow:hidden}
.promo .pg{position:absolute;right:-80px;top:-110px;width:320px;height:320px;border-radius:50%;
  background:radial-gradient(circle at 40% 40%,rgba(255,255,255,.26),rgba(255,255,255,0) 62%)}
.promo .pi{flex:0 0 78px;height:78px;border-radius:22px;background:rgba(255,255,255,.20);
  display:flex;align-items:center;justify-content:center;position:relative;z-index:2;
  border:1.5px solid rgba(255,255,255,.34)}
.promo .pi svg{width:46px;height:46px}
.promo .pt{font-size:34px;font-weight:900;letter-spacing:2px;position:relative;z-index:2}
.promo .pd{font-size:60px;font-weight:900;line-height:1.1;letter-spacing:-.5px;margin-top:6px;position:relative;z-index:2}
.promo .ps{font-size:22px;opacity:.95;margin-top:8px;position:relative;z-index:2}
/* ── 数据条：三格一卡（原来 3 个彩色药丸 → 1 张卡，减少色块与边界）── */
.stats{display:flex;background:rgba(255,255,255,.93);border:1px solid rgba(224,232,244,.9);
  border-radius:22px;padding:26px 6px;box-shadow:0 8px 24px rgba(16,40,90,.06);
  backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
.stats .st{flex:1;text-align:center;padding:0 8px;border-left:1px solid rgba(224,232,244,.95)}
.stats .st:first-child{border-left:none}
.stats .sv{font-size:60px;font-weight:900;color:#1852B6;line-height:1.05;letter-spacing:-.5px}
.stats .sv small{font-size:34px;font-weight:800;color:#8492A6;margin-left:2px}
.stats .sl{font-size:22px;color:#7A8598;margin-top:10px}
/* ── 重点② 产品特点：青色卡 ── */
.cards{padding:22px 30px 0}
.feats{background:linear-gradient(150deg,rgba(224,248,246,.97) 0%,rgba(244,253,252,.97) 100%);
  border:1px solid rgba(0,169,158,.24);border-radius:22px;padding:6px 28px;
  box-shadow:0 10px 30px rgba(0,169,158,.12);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
.feats .fh{font-size:34px;font-weight:900;color:#00786F;padding:24px 0 6px;
  display:flex;align-items:center;gap:12px}
.feats .fh i{width:10px;height:34px;border-radius:5px;background:#00A99E;display:block;flex:0 0 10px}
.feats .fr{display:flex;gap:20px;align-items:flex-start;padding:22px 0;border-bottom:1px solid rgba(0,169,158,.16)}
.feats .fr:last-child{border-bottom:none}
.feats .fi{flex:0 0 64px;height:64px;border-radius:18px;background:#D9F4F1;color:#00A99E;
  display:flex;align-items:center;justify-content:center}
.feats .fi svg{width:36px;height:36px}
.feats .ft{font-size:34px;font-weight:900;color:#0B3B38;line-height:1.2}
.feats .fd{font-size:22px;color:#4A5568;line-height:1.55;margin-top:8px}
.cta{margin:28px 30px 0;border-radius:28px;padding:32px 32px;display:flex;gap:28px;align-items:center;
  color:#fff;box-shadow:0 18px 44px rgba(16,24,40,.26);position:relative;overflow:hidden;
  background-size:cover;background-position:center}
.cta h3{font-size:34px;font-weight:900;line-height:1.25;position:relative;z-index:2;
  text-shadow:0 2px 12px rgba(4,18,44,.55)}
.cta .cs{font-size:22px;opacity:.96;margin-top:10px;line-height:1.55;position:relative;z-index:2;
  text-shadow:0 1px 10px rgba(4,18,44,.55)}
.cta .qr{position:relative;z-index:2}
.qr{width:196px;height:196px;border-radius:20px;border:7px solid #fff;background:#fff;display:block}
.foot{display:flex;gap:30px;align-items:center;padding:26px 30px;
  margin:26px 30px 22px;border-radius:24px;font-size:22px;color:#5A6475;
  background:rgba(255,255,255,.86);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);
  box-shadow:0 10px 30px rgba(16,40,90,.09)}
.fl{flex:1;min-width:0}
.sl{display:flex;align-items:center;gap:12px;font-size:22px;font-weight:800;color:#0F2A5C}
.sl i{width:8px;height:24px;border-radius:4px;background:linear-gradient(180deg,#1852B6,#00A99E);
  display:block;flex:0 0 8px}
.fb{font-size:22px;font-weight:900;color:#1852B6;margin:8px 0 14px 20px}
.fc>div{font-size:22px;color:#4A5568;line-height:1.5;white-space:nowrap}
.fc .k{color:#8A94A6;font-weight:700;display:inline-block;width:56px}
.fq{flex:0 0 auto;display:flex;gap:14px}
.qi{text-align:center}
.qi img{width:184px;height:184px;border-radius:12px;border:5px solid #fff;background:#fff;display:block;
  box-shadow:0 6px 18px rgba(16,24,40,.10)}
.qi span{display:block;font-size:22px;color:#4A5568;font-weight:700;margin-top:8px}
/* ── 实测数据区 ── */
.data{padding:30px 30px 0}
.sec-h{font-size:34px;font-weight:900;color:#0F2A5C;display:flex;align-items:center;gap:14px;margin-bottom:18px}
.sec-h i{width:10px;height:34px;border-radius:5px;background:#1852B6;display:block;flex:0 0 10px}
.dgrid{display:grid;grid-template-columns:1fr 1fr;gap:20px}
.dfig{background:rgba(255,255,255,.93);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
  border:1px solid rgba(224,232,244,.9);border-radius:22px;padding:18px 18px 16px;
  box-shadow:0 8px 24px rgba(16,40,90,.06)}
.dimg{height:258px;display:flex;align-items:center;justify-content:center}
.dimg img{max-width:100%;max-height:100%;object-fit:contain}
.dcap{font-size:22px;color:#5A6475;line-height:1.5;margin-top:12px;text-align:center}
.dwide{grid-column:1 / -1}
.dwide .dimg{height:300px}
.dnote{background:rgba(234,241,253,.97);border:1px solid rgba(201,219,245,.95);color:#0F2A5C;
  padding:26px 24px;display:flex;flex-direction:column;justify-content:center}
.dnote .nt{font-size:34px;font-weight:900;line-height:1.25}
.dnote .nd{font-size:22px;line-height:1.6;margin-top:12px;color:#4A5568}
"""


def stats_block(items):
    """数据条：三格一卡（原 3 个彩色药丸 → 1 张卡，减少色块与边界）"""
    return '<div class="stats">%s</div>' % ''.join(
        '<div class="st"><div class="sv">%s<small>%s</small></div><div class="sl">%s</div></div>' % t
        for t in items)


def feats_block(items):
    """重点② 产品特点：青色卡内三行 + 标题"""
    return ('<div class="feats"><div class="fh"><i></i>产品特点</div>%s</div>' % ''.join(
        '<div class="fr"><div class="fi">%s</div><div><div class="ft">%s</div>'
        '<div class="fd">%s</div></div></div>' % (ICON[ic], t, d)
        for ic, t, d in items))


def dfig(img, cap, wide=False):
    return ('<figure class="dfig%s"><div class="dimg"><img src="%s"></div>'
            '<figcaption class="dcap">%s</figcaption></figure>'
            % (' dwide' if wide else '', img, cap))


def dnote(title, text):
    """结论卡：统一浅蓝中性卡（原来与图标卡同色 → 色块分散、抢重点）"""
    return ('<div class="dfig dnote"><div class="nt">%s</div><div class="nd">%s</div></div>'
            % (title, text))


def bgimg(name, w=1500):
    """底图：等比缩放成 JPEG data URI（不做白边裁剪，保持原始构图）"""
    p = os.path.join(BG_DIR, name)
    if not os.path.exists(p):   # 兜底：技能自带底图
        p = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                         '..', 'assets', 'poster_bg_stitched.jpg')
    im = Image.open(p).convert('RGB')
    im.thumbnail((w, w), Image.LANCZOS)
    f = '/tmp/_bg_%s.jpg' % os.path.splitext(name)[0]
    im.save(f, quality=86)
    return 'data:image/jpeg;base64,' + base64.b64encode(io.open(f, 'rb').read()).decode()


def poster(kicker, h1, sub, stage_imgs, pname, pen, promo_date, pills, cards, data_html, cta_bg, cta_title, cta_sub):
    st = ''.join('<img src="%s">' % i for i in stage_imgs)
    return ('<div class="poster" style="background-image:linear-gradient(180deg,rgba(255,255,255,.62) 0%%,'
            'rgba(255,255,255,.52) 34%%,rgba(255,255,255,.44) 68%%,rgba(255,255,255,.60) 100%%),url(%s)">'
            '<div class="topbar"><img src="%s"><span class="tag">2026 秋季促销 · 实验加速季</span></div>'
            '<div class="hero"><div class="wm"></div>'
            '<div class="kicker">%s</div><h1>%s</h1><div class="sub">%s</div></div>'
            '<div class="promo"><div class="pg"></div><div class="pi">%s</div><div>'
            '<div class="pt">限时促销</div><div class="pd">%s</div>'
            '<div class="ps">活动期内下单，享受专属优惠，更可参加朋友圈点赞好礼活动，详询销售人员</div></div></div>'
            '<div class="stage-card"><div class="stage">%s</div>'
            '<div class="pname">%s</div><div class="pen" style="text-align:center">%s</div></div>'
            '<div class="cards">%s</div>'
            '<div class="band">%s</div>'
            '%s'
            '<div class="cta" style="background-image:linear-gradient(100deg,rgba(5,26,62,.62) 0%%,'
            'rgba(5,26,62,.36) 46%%,rgba(5,26,62,.08) 100%%),url(%s)"><div style="flex:1">'
            '<h3>%s</h3><div class="cs">%s</div></div>'
            '<img class="qr" src="%s"></div>'
            '<div class="foot"><div class="fl">'
            '<div class="sl"><i></i>%s</div><div class="fb">%s</div>'
            '<div class="fc">'
            '<div><span class="k">电话</span>0756-8699969</div>'
            '<div><span class="k">邮箱</span>marketing@biori.com</div>'
            '<div><span class="k">网址</span>www.biori.com</div>'
            '<div><span class="k">地址</span>珠海市香洲区水岸一路88号</div>'
            '</div></div>'
            '<div class="fq">'
            '<div class="qi"><img src="%s"><span>公众号</span></div>'
            '<div class="qi"><img src="%s"><span>小红书</span></div>'
            '<div class="qi"><img src="%s"><span>视频号</span></div>'
            '</div></div>'
            '</div>') % (BG_FULL, LOGO_U, kicker, h1, sub,
                         ICON['clock'], promo_date, st, pname, pen,
                         ''.join(cards), ''.join(pills), data_html, cta_bg,
                         cta_title, cta_sub, P['p2_i3_x31'], SLOGAN, BRAND,
                         QR_WX, QR_XHS, QR_SPH)


# 底部扫码板块的底层背景图（彩色大自然 · 无动物 · 无文字）
BG_TROPICAL = bgimg('bg_热带植物.png')
# 整张海报正文的底层背景图（3 张方图羽化拼接的竖版底图：1024×2672，铺满仅放大 1.08×）
BG_FULL = bgimg('海报底图_竖版拼接.png', w=1080)

# 底栏三码（顺序：公众号 · 小红书 · 视频号；已用 macOS Vision 反解验真，缩放后复验仍可识别）


def qr_img(name, px=184):
    """二维码：裁静默区 + 最近邻缩放到显示尺寸（LANCZOS 会糊掉细模块 → 扫不出）"""
    im = Image.open(os.path.join(QRDIR, name)).convert('L')
    bg = Image.new('L', im.size, 255)
    bb = ImageChops.difference(im, bg).getbbox()
    if bb:
        q = 6
        bb = (max(0, bb[0] - q), max(0, bb[1] - q),
              min(im.width, bb[2] + q), min(im.height, bb[3] + q))
        im = im.crop(bb)
    im = im.resize((px, px), Image.NEAREST)
    f = '/tmp/_qr_%s.png' % name[:-4]
    im.convert('RGB').save(f)
    return 'data:image/png;base64,' + base64.b64encode(io.open(f, 'rb').read()).decode()


QR_WX = qr_img('公众号.png')
QR_XHS = qr_img('小红书.png')
QR_SPH = qr_img('视频号.png')

# ═══════════════════════════════════════════════════════════════════════
#  ① 内容配置 —— 换活动只改这一段
#  每张海报 = 一次 poster(...) 调用，参数顺序：
#    小字主题 / 大字标题 / 副标题 / 产品图列表 / 品名 / 英文名
#    / 限时日期 / 数据药丸×3 / 特征卡×3 / 数据区 HTML / CTA底图
#    / CTA标题 / CTA副文案
#  合规：不得出现客户名、竞品品牌名、内部人员名、内部策略词；价格未获授权就不写
# ═══════════════════════════════════════════════════════════════════════
# ── 主打产品 A：RTQ（BR1D303 + BR1E102）· 活动① ──
RTQ = poster(
    'RNA 定量难题，交给 RTQ',
    '高效逆转搭配稳定 qPCR',
    '低丰度样本也能稳定出 Ct —— 5 min 逆转录 + 30 min 跑完 40 循环',
    [P['p1_i1_x19'], P['p1_i2_x20']],
    'BR1D303 ＋ BR1E102',
    'RT-qPCR 一站式解决方案 · 逆转录预混液 + 快速 qPCR 预混液',
    '10.12 – 10.18',
    stats_block([('5', ' min', '完成逆转录'), ('100.795', '%', '标准曲线扩增效率'), ('1.000', '', '线性度')]),
    feats_block([('bolt', '极速上手', '5 min 完成逆转录，快速程序 30 min 跑完 40 循环'),
                 ('tube', '一管到底', '单管完成 gDNA 去除与逆转录，告别两步操作与反复开盖'),
                 ('sliders', '平台通用', '通用型 ROX 免调浓度，标准程序与快速程序皆可定量')]),
    '<div class="data"><div class="sec-h"><i></i>实测数据</div><div class="dgrid">'
    + dfig(P['p1_i5_x24'], '标准曲线 · 模板 1:10 系列稀释<br>扩增效率 100.795%、R² = 1.000')
    + dfig(P['p1_i7_x26'], '熔解曲线 · 单峰无杂峰<br>特异性良好')
    + dfig(P['p2_i2_x30'], '耐抑制性 · 5 种常见抑制剂梯度对比')
    + dnote('抑制剂覆盖', '乙醇 · 异丙醇 · 苯酚 · 腐植酸 · SDS<br>宝锐 vs 供应商 A / B（匿名对照）')
    + '</div></div>',
    BG_TROPICAL,
    '扫码申请试用装',
    'BR1D303 ｜ BR1E102 ｜ BR1C103 三款均开放试用',
)

# ── 主打产品 B：BR1C103（VellixClon™ 室温无缝克隆）· 活动② ──
CLONE = poster(
    '载体构建，不用再守冰盒',
    'VellixClon™ 室温 5 min 无缝克隆',
    '同源重组一步到位 —— 打破温度限制，克隆随时随地',
    [P['p3_i2_x35']],
    'BR1C103',
    'VellixClon™ Ambient Seamless Cloning Mix · 室温同源重组预混液',
    '10.19 – 10.25',
    stats_block([('25', '℃', '通用反应温度'), ('5', ' min', '室温反应时长'), ('未纯化', '', 'PCR 产物可直接上样')]),
    feats_block([('thermo', '室温 25℃ 即可反应', '无需冰盒守温，桌面配完直接上机，实验节奏不再被温度绑住'),
                 ('insert', '单片段一步到位', '同源重组无缝克隆，单片段载体构建适用，模板兼容性广'),
                 ('sparkle', '未纯化也能用', '对未经纯化回收的 PCR 产物兼容良好，省掉胶回收那一步')]),
    '<div class="data"><div class="sec-h"><i></i>实测数据</div><div class="dgrid">'
    + dfig(P['p3_i3_x46'], '反应温度测定 · 25℃ 下克隆数（CFU）与校正效率均处高位', wide=True)
    + dfig(P['p4_i3_x51'], '未纯化 PCR 产物测定 · 无需胶回收纯化，可直接上样', wide=True)
    + '</div></div>',
    BG_TROPICAL,
    '扫码申请试用装',
    'BR1C103 ｜ BR1D303 ｜ BR1E102 三款均开放试用',
)

POSTERS = [('海报_2026年10月RTQ主推.png', 'RTQ 主推', RTQ),
           ('海报_2026年10月1C103主推.png', '1C103 主推', CLONE)]


def check_fs(html, label):
    sizes = set(re.findall(r'font-size:\s*([\d.]+px)', html))
    bad = sizes - FS_ALLOWED
    if bad:
        print('  ❌ 字号越界 [%s]：%s' % (label, sorted(bad)))
        return False
    # 渐变文字守卫：background-clip:text 会让文字边缘发虚（用户 2026-10-09 反馈「做渐变变模糊了」）
    flat = html.replace(' ', '')
    if 'background-clip:text' in flat or '-webkit-text-fill-color:transparent' in flat:
        print('  ❌ 出现渐变填充文字 [%s]：会发虚，必须用实色' % label)
        return False
    if re.search(r'(?<!backdrop-)filter\s*:\s*blur', html):
        print('  ❌ 出现 CSS 模糊滤镜 [%s]：会把文字一起糊掉（backdrop-filter 不受此限）' % label)
        return False
    print('  ✅ 字号守卫 [%s]：%s ｜ 文字全实色无模糊'
          % (label, ' / '.join(sorted(sizes, key=lambda x: float(x[:-2])))))
    return True


ok = True
with sync_playwright() as pw:
    br = pw.chromium.launch(channel='chrome', headless=True)
    pg = br.new_page(viewport={'width': W, 'height': 1400}, device_scale_factor=1)
    for fn, label, body in POSTERS:
        html = '<!DOCTYPE html><html><head><meta charset="utf-8"><style>%s</style></head><body>%s</body></html>' % (CSS, body)
        io.open('/tmp/_poster.html', 'w', encoding='utf-8').write(html)
        pg.goto('file:///tmp/_poster.html', wait_until='load')
        pg.wait_for_timeout(500)
        ok &= check_fs(html, label)
        d = pg.evaluate("() => {var t=document.querySelector('.poster');return [t.scrollWidth,t.scrollHeight];}")
        if d[0] > W + 2:
            print('  ⚠️ %s 超宽 %dx%d' % (label, d[0], d[1]))
            ok = False
        out = os.path.join(DIR, fn)
        pg.screenshot(path=out, full_page=True)
        # 文字可读性守卫：白字区要求底色够深；深字区要求底色够浅（防光晕/遮罩糊字）
        for sel, thr, mode, lab in (('.hero h1', 135, 'darkbg', '标题白字'),
                                    ('.promo .pd', 178, 'darkbg', '促销白字'),
                                    ('.cta h3', 135, 'darkbg', 'CTA白字'),
                                    ('.stats .sv', 115, 'darktext', '数据深字'),
                                    ('.feats .ft', 75, 'darktext', '特点深字')):
            r = pg.evaluate("(s)=>{var e=document.querySelector(s);if(!e)return null;"
                            "var b=e.getBoundingClientRect();return [b.x,b.y,b.width,b.height];}", sel)
            if not r or r[2] < 2:
                continue
            crop = Image.open(out).crop((int(r[0]), int(r[1]), int(r[0] + r[2]), int(r[1] + r[3]))).convert('L')
            hist = sorted(crop.getdata())
            if mode == 'darkbg':
                v = hist[int(len(hist) * 0.2)]
                good = v <= thr
                print('    %s %-8s 底色 20分位 %3d（≤%3d，越小越稳）' % ('✅' if good else '❌', lab, v, thr))
            else:
                v = hist[0]   # 深字：取最深像素（分位会被同格内的浅色单位词淹没）
                good = v <= thr
                print('    %s %-8s 最深像素 %3d（≤%3d）' % ('✅' if good else '❌', lab, v, thr))
            ok = ok and good
        print('  → %s  %dx%d（1:%.2f）' % (fn, d[0], d[1], d[1] / float(d[0])))
        # 促销说明行宽度检查（新文案较长，防止撑爆板块）
        pm = pg.evaluate("()=>{var e=document.querySelector('.ps'),b=e.getBoundingClientRect();"
                         "return [e.scrollWidth,e.clientWidth,b.height];}")
        print('    %s 促销说明行 %d/%d px（%s）'
              % ('✅' if pm[0] <= pm[1] + 2 else '⚠️', pm[0], pm[1],
                 '单行放下' if pm[0] <= pm[1] + 2 else '折成 2 行，行高 %.0fpx' % pm[2]))
        # 三码反解验证：从成品 PNG 里裁出底栏三码，供 Vision 复解（证明印出来的码可扫）
        rects = pg.evaluate("()=>Array.from(document.querySelectorAll('.qi img'))"
                            ".map(e=>{var b=e.getBoundingClientRect();return [b.x,b.y,b.width,b.height];})")
        for i, r in enumerate(rects):
            if r[2] < 4:
                continue
            Image.open(out).crop((int(r[0]), int(r[1]), int(r[0] + r[2]), int(r[1] + r[3]))).save(
                '/tmp/qr_crop_%d.png' % i)
        print('    底栏三码已裁出 /tmp/qr_crop_0~2.png')
    br.close()

print('\n%s' % ('✅ 全部通过（字号 3 种 / 无超宽）' if ok else '❌ 有失败项'))
sys.exit(0 if ok else 1)
