"""pptx_helpers.py — PPT skill 的共享版式工具箱(卡片式视觉系统)。

整 deck 在一个 `build_deck.py` 里构建,每页一个小函数,这些 helper 统一在
`P.` 命名空间下调用 —— 既省 token,又保证长 deck 里坐标/配色不漂移。

用法(在 build_deck.py 顶部):

    import sys; sys.path.insert(0, "<skill_dir>/scripts")  # <skill_dir> 用 system prompt 注入值
    import pptx_helpers as P

    prs = P.new_presentation("16:9")          # 默认 16:9,可传 4:3 / 9:16 / 3:4
    P.set_palette(spec_path="<task_dir>/...spec.md")   # 默认商务红;spec 覆盖了才需要
    slide = P.add_slide(prs)
    P.apply_brand(slide, "cover")
    P.add_textbox(slide, 0.9, 2.6, 11.9, 1.4, "标题", 44, bold=True, color=P.INK)
    prs.save("<task_dir>/<topic>.pptx")

视觉系统(相对老版"平矩形 + 圆点 bullet"的升级):
- **卡片**:`add_card` 圆角 + 柔和投影 + 可选底色/边线/强调条 —— 内容页主力容器
- **色阶**:`set_palette` 从主/辅/强调派生 wash/soft/dark 明暗阶,白底之外有层次
- **渐变**:`add_gradient_rect` 用于封面/章节大色块(原生可编辑,非图片)
- **组件**:`add_kpi`(数字卡) `add_pill`(胶囊标签) `add_icon_tile`(图标底块)
  `add_eyebrow`(小标签) `add_chevron`(流程箭头) `add_notes`(演讲者备注)

⚠️ 一律用 `P.xxx` 访问颜色常量与函数 —— set_palette 靠改模块属性生效,
   `from pptx_helpers import *` 会把旧绑定拷进页面命名空间,覆盖配色不生效。
"""
from __future__ import annotations

import re
from pathlib import Path

from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR, MSO_AUTO_SIZE
from pptx.enum.shapes import MSO_SHAPE
from pptx.oxml.ns import qn

# ============================================================
# 配色 (商务红 — 硬约束默认)
# ============================================================
# ⛔ 不允许擅自换色:除非用户明确点名其它配色 或 spec 已写其它 hex,否则就是这套红。
#    要换走 set_palette(),禁止以"这场景蓝色更专业"这类自我合理化做替换。
PRIMARY = RGBColor(0xC0, 0x00, 0x00)    # 深红 - 标题/强调/关键数据
SECONDARY = RGBColor(0xE1, 0x55, 0x54)  # 砖红 - 次要图形
ACCENT = RGBColor(0xFF, 0xC1, 0x07)     # 金黄 - 关键数据点/CTA
INK = RGBColor(0x1F, 0x1F, 0x1F)
GREY = RGBColor(0x59, 0x59, 0x59)
GREY_LIGHT = RGBColor(0x88, 0x88, 0x88)
HAIRLINE = RGBColor(0xDD, 0xDD, 0xDD)   # 细分隔线
BG = RGBColor(0xFA, 0xFA, 0xFA)         # 背景近白
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
# 语义状态色(升/降)—— 数据趋势标注用,绿=正/红=负,业界通用约定。
# 不计入"商务红三色制"(quality_check 把绿色当语义状态色豁免)。
GOOD = RGBColor(0x1E, 0x9E, 0x62)       # 增长 / 正向
BAD = RGBColor(0xD1, 0x34, 0x38)        # 下降 / 风险

# —— 从主/辅/强调派生的明暗色阶 (set_palette 里按当前三色重算) ——
# 卡片底色 / 章节渐变 / 标签底 都从这套阶取,避免"白底 + 纯红"两极、缺中间层次。
PRIMARY_WASH = RGBColor(0xF7, 0xE6, 0xE6)  # 主色 92% 兑白 —— 整页/大区域浅底
PRIMARY_SOFT = RGBColor(0xF0, 0xCC, 0xCC)  # 主色 80% 兑白 —— 卡片/标签浅底
PRIMARY_DARK = RGBColor(0x8A, 0x00, 0x00)  # 主色压暗 —— 渐变深端
ACCENT_SOFT = RGBColor(0xFF, 0xEC, 0xB8)   # 强调 80% 兑白 —— 高亮底
SURFACE = RGBColor(0xFF, 0xFF, 0xFF)       # 卡片面(白,衬在 BG 上靠投影浮起)

CN_FONT = "微软雅黑"   # 中文字形走 <a:ea> 槽位
EN_FONT = "Arial"      # 拉丁字形走 <a:latin> 槽位

# ============================================================
# 画布与安全区 (new_presentation / load 会按实际尺寸回填这些)
# ============================================================
SLIDE_W = 13.33
SLIDE_H = 7.5
MARGIN_X = 0.7
MARGIN_Y = 0.5
SAFE_LEFT = MARGIN_X
SAFE_TOP = MARGIN_Y
SAFE_RIGHT = SLIDE_W - MARGIN_X
SAFE_BOTTOM = SLIDE_H - MARGIN_Y
SAFE_W = SAFE_RIGHT - SAFE_LEFT
SAFE_H = SAFE_BOTTOM - SAFE_TOP

_CANVAS = {
    "16:9": (13.33, 7.5),
    "4:3": (10.0, 7.5),
    "9:16": (7.5, 13.33),
    "3:4": (7.5, 10.0),
}


def _recompute_safe() -> None:
    global SAFE_LEFT, SAFE_TOP, SAFE_RIGHT, SAFE_BOTTOM, SAFE_W, SAFE_H
    SAFE_LEFT = MARGIN_X
    SAFE_TOP = MARGIN_Y
    SAFE_RIGHT = SLIDE_W - MARGIN_X
    SAFE_BOTTOM = SLIDE_H - MARGIN_Y
    SAFE_W = SAFE_RIGHT - SAFE_LEFT
    SAFE_H = SAFE_BOTTOM - SAFE_TOP


def new_presentation(canvas: str = "16:9") -> Presentation:
    """建空白 deck 并设画布尺寸,同步回填模块的安全区常量。第一页用。"""
    global SLIDE_W, SLIDE_H
    if canvas not in _CANVAS:
        raise ValueError(f"未知画布 {canvas!r},支持 {list(_CANVAS)}")
    SLIDE_W, SLIDE_H = _CANVAS[canvas]
    _recompute_safe()
    prs = Presentation()
    prs.slide_width = Inches(SLIDE_W)
    prs.slide_height = Inches(SLIDE_H)
    return prs


def load(path) -> Presentation:
    """载入已有 deck,并按文件实际尺寸回填模块画布常量(逐页进程间自动同步)。"""
    global SLIDE_W, SLIDE_H
    prs = Presentation(str(path))
    SLIDE_W = prs.slide_width / 914400
    SLIDE_H = prs.slide_height / 914400
    _recompute_safe()
    return prs


def add_slide(prs: Presentation):
    """追加一张空白版式(layout 6)的 slide。"""
    return prs.slides.add_slide(prs.slide_layouts[6])


# ============================================================
# 配色覆盖 (默认商务红;spec 写了别的色才调) + 色阶派生
# ============================================================
def _to_rgb(h: str) -> RGBColor:
    return RGBColor.from_string(h.lstrip("#").upper())


def _mix(c1: RGBColor, c2: RGBColor, t: float) -> RGBColor:
    """线性混合:t=0 → c1,t=1 → c2。"""
    return RGBColor(
        round(c1[0] + (c2[0] - c1[0]) * t),
        round(c1[1] + (c2[1] - c1[1]) * t),
        round(c1[2] + (c2[2] - c1[2]) * t),
    )


def tint(c: RGBColor, pct: float) -> RGBColor:
    """提亮:pct=0.85 → 兑 85% 白(越大越浅)。"""
    return _mix(c, WHITE, pct)


def shade(c: RGBColor, pct: float) -> RGBColor:
    """压暗:pct=0.2 → 混 20% 黑(越大越深)。"""
    return _mix(c, RGBColor(0, 0, 0), pct)


def _recompute_ramp() -> None:
    """按当前 PRIMARY/ACCENT 重算明暗色阶。set_palette 末尾调。"""
    global PRIMARY_WASH, PRIMARY_SOFT, PRIMARY_DARK, ACCENT_SOFT
    PRIMARY_WASH = tint(PRIMARY, 0.92)
    PRIMARY_SOFT = tint(PRIMARY, 0.80)
    PRIMARY_DARK = shade(PRIMARY, 0.42)   # 加深:渐变要肉眼看得出深浅,别两端几乎同色
    ACCENT_SOFT = tint(ACCENT, 0.78)


def set_palette(primary: str | None = None, secondary: str | None = None,
                accent: str | None = None, cn_font: str | None = None,
                en_font: str | None = None, spec_path=None) -> None:
    """覆盖主题色 / 字体,并重算派生色阶。整 deck 设一次。

    - 显式传 primary/secondary/accent(hex,带不带 # 都行)即覆盖对应色。
    - 传 spec_path:从 spec.md 按文档顺序取前 3 个 #hex 作 主/辅/强调
      (spec 模板里配色行是 hex 唯一出现处)。找不到则保持商务红默认。
    - 都不传 = 维持商务红,无副作用。
    """
    global PRIMARY, SECONDARY, ACCENT, CN_FONT, EN_FONT
    if spec_path:
        p = Path(spec_path)
        if p.exists():
            hexes = re.findall(r"#([0-9A-Fa-f]{6})", p.read_text(encoding="utf-8"))
            if len(hexes) >= 1 and primary is None:
                primary = hexes[0]
            if len(hexes) >= 2 and secondary is None:
                secondary = hexes[1]
            if len(hexes) >= 3 and accent is None:
                accent = hexes[2]
    if primary:
        PRIMARY = _to_rgb(primary)
    if secondary:
        SECONDARY = _to_rgb(secondary)
    if accent:
        ACCENT = _to_rgb(accent)
    if cn_font:
        CN_FONT = cn_font
    if en_font:
        EN_FONT = en_font
    _recompute_ramp()


# ============================================================
# 安全区校验
# ============================================================
def assert_inside(left, top, width, height, name="") -> None:
    """放置前调一次。越界直接报错而不是悄悄超出。"""
    if left < 0 or top < 0:
        raise ValueError(f"[{name}] 左/上为负: ({left}, {top})")
    if left + width > SLIDE_W + 1e-3:
        raise ValueError(f"[{name}] 右越界: {left}+{width} > {SLIDE_W}")
    if top + height > SLIDE_H + 1e-3:
        raise ValueError(f"[{name}] 下越界: {top}+{height} > {SLIDE_H}")


# ============================================================
# 文本辅助
# ============================================================
def _apply_run_font(run, size, bold, color, latin_font, ea_font) -> None:
    """设字号/粗细/颜色 + 同时设 latin(拉丁)与 ea/cs(东亚)字体。

    关键:python-pptx 的 `run.font.name = x` 只写 <a:latin>。中文字形走 <a:ea>
    槽位,不设的话会落到主题默认字体 —— 这就是「指定了微软雅黑却没真生效」的根因。
    这里 latin=英文体、ea/cs=中文体,中英混排各自命中正确字体。
    """
    run.font.size = Pt(size)
    run.font.bold = bold
    run.font.color.rgb = color
    run.font.name = latin_font  # <a:latin>
    rPr = run._r.get_or_add_rPr()
    for tag in ("a:ea", "a:cs"):
        el = rPr.find(qn(tag))
        if el is None:
            el = rPr.makeelement(qn(tag), {})
            rPr.append(el)
        el.set("typeface", ea_font)


def set_text(tf, text, size, bold=False, color=INK, align=PP_ALIGN.LEFT,
             font=None) -> None:
    """写文本并设样式。**多行(含 \\n)时每一段都上色** —— 否则 `\\n` 产生的
    第 2 段会继承主题默认色(踩过:封面副标题第二行变暗色看不见)。
    font=None → 拉丁 EN_FONT + 东亚 CN_FONT;传 font 则两槽都用它(纯英文大字/数字)。"""
    latin = font or EN_FONT
    ea = font or CN_FONT
    tf.text = text
    for p in tf.paragraphs:
        p.alignment = align
        for r in p.runs:
            _apply_run_font(r, size, bold, color, latin, ea)


def add_textbox(slide, left, top, width, height, text, size,
                bold=False, color=INK, align=PP_ALIGN.LEFT,
                anchor=MSO_ANCHOR.TOP, font=None, shrink=True,
                name="textbox"):
    """加文本框。默认 word_wrap + shrink-to-fit 兜底(不替代字数预算)。"""
    assert_inside(left, top, width, height, name)
    tb = slide.shapes.add_textbox(Inches(left), Inches(top),
                                  Inches(width), Inches(height))
    tb.name = name  # 语义名写进 pptx —— quality_check 按名豁免标签 / 计 bullet 靠这个
    tf = tb.text_frame
    tf.vertical_anchor = anchor
    tf.word_wrap = True
    if shrink:
        tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
    set_text(tf, text, size, bold, color, align, font)
    return tb


# ============================================================
# 形状辅助 (无边线实心填充)
# ============================================================
def add_rect(slide, left, top, width, height, fill, name="rect"):
    assert_inside(left, top, width, height, name)
    s = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(left), Inches(top),
                               Inches(width), Inches(height))
    s.name = name
    s.fill.solid()
    s.fill.fore_color.rgb = fill
    s.line.fill.background()
    return s


def add_shape(slide, kind, left, top, width, height, fill, name="shape"):
    assert_inside(left, top, width, height, name)
    s = slide.shapes.add_shape(kind, Inches(left), Inches(top),
                               Inches(width), Inches(height))
    s.name = name
    s.fill.solid()
    s.fill.fore_color.rgb = fill
    s.line.fill.background()
    return s


def add_dot(slide, x, y, size=0.18, color=None):
    return add_shape(slide, MSO_SHAPE.OVAL, x, y, size, size,
                     ACCENT if color is None else color, "dot")


def add_accent_line(slide, x, y, length=1.0, thickness=0.05, color=None):
    """标题下面那条强调线,替代大色块。"""
    return add_rect(slide, x, y, length, thickness,
                    ACCENT if color is None else color, "accent_line")


def add_badge(slide, x, y, num, diameter=0.7, fill=None, fg=None):
    """编号徽章 (圆 + 数字)。"""
    c = add_shape(slide, MSO_SHAPE.OVAL, x, y, diameter, diameter,
                  PRIMARY if fill is None else fill, "badge")
    tf = c.text_frame
    tf.text = str(num)
    p = tf.paragraphs[0]
    p.alignment = PP_ALIGN.CENTER
    _apply_run_font(p.runs[0], int(diameter * 28), True,
                    WHITE if fg is None else fg, EN_FONT, EN_FONT)
    return c


# ============================================================
# 视觉质感:投影 / 圆角 / 渐变 / 描边
# ============================================================
def set_shadow(shape, blur=0.10, dist=0.045, dir_deg=90, alpha=0.26,
               color="000000") -> None:
    """给形状加柔和外投影(写 <a:effectLst><a:outerShdw>)。

    blur/dist 单位英寸;dir_deg 投影方向(90=正下,默认);alpha 不透明度(0-1)。
    卡片靠这个从背景"浮起",是平矩形与卡片观感的关键差。
    """
    spPr = shape._element.spPr
    for el in spPr.findall(qn("a:effectLst")):
        spPr.remove(el)
    eff = spPr.makeelement(qn("a:effectLst"), {})
    shd = eff.makeelement(qn("a:outerShdw"), {
        "blurRad": str(int(Inches(blur))),
        "dist": str(int(Inches(dist))),
        "dir": str(int(dir_deg * 60000)),
        "rotWithShape": "0",
    })
    clr = shd.makeelement(qn("a:srgbClr"), {"val": color})
    a = clr.makeelement(qn("a:alpha"), {"val": str(int(alpha * 100000))})
    clr.append(a)
    shd.append(clr)
    eff.append(shd)
    spPr.append(eff)


def set_line(shape, color, weight=0.75) -> None:
    """给形状描边(weight 单位 pt)。weight=0 / color=None 走无边线。"""
    if color is None:
        shape.line.fill.background()
        return
    shape.line.color.rgb = color
    shape.line.width = Pt(weight)


def _round_adj(shape, radius_in) -> None:
    """把圆角矩形的圆角设成约 radius_in 英寸(adjustments[0] 是相对短边的比例)。"""
    try:
        short = min(shape.width, shape.height) / 914400.0
        if short > 0:
            shape.adjustments[0] = max(0.0, min(0.5, radius_in / short))
    except (IndexError, ZeroDivisionError):
        pass


def add_round_rect(slide, left, top, width, height, fill, radius=0.10,
                   name="round_rect"):
    """无边线圆角矩形。radius 单位英寸(约 0.08-0.14 观感最稳)。"""
    assert_inside(left, top, width, height, name)
    s = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
                               Inches(left), Inches(top),
                               Inches(width), Inches(height))
    s.name = name
    s.fill.solid()
    s.fill.fore_color.rgb = fill
    s.line.fill.background()
    _round_adj(s, radius)
    return s


def add_gradient_rect(slide, left, top, width, height, c1, c2, angle=90,
                      rounded=False, radius=0.10, name="gradient"):
    """渐变矩形(原生可编辑,非图片)。封面/章节大色块用。

    angle:渐变方向(度,0=左→右,90=上→下)。rounded=True 走圆角。
    """
    assert_inside(left, top, width, height, name)
    kind = MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE
    s = slide.shapes.add_shape(kind, Inches(left), Inches(top),
                               Inches(width), Inches(height))
    s.name = name
    s.line.fill.background()
    if rounded:
        _round_adj(s, radius)
    s.fill.gradient()
    stops = s.fill.gradient_stops
    stops[0].color.rgb = c1
    stops[0].position = 0.0
    stops[1].color.rgb = c2
    stops[1].position = 1.0
    try:
        s.fill.gradient_angle = float(angle)
    except (ValueError, TypeError):
        pass
    return s


def add_bg(slide, color=None):
    """整页背景色块(每页第一笔铺,后续元素叠其上)。默认近白 BG。"""
    return add_rect(slide, 0, 0, SLIDE_W, SLIDE_H,
                    BG if color is None else color, "bg")


# ============================================================
# 卡片 (内容页主力容器) + 组件
# ============================================================
def add_card(slide, left, top, width, height, fill=None, radius=0.12,
             shadow=False, border=None, accent=None, accent_w=0.07,
             name="card"):
    """圆角卡片。**视觉手段单选**(投影 / 描边 / 底色 三选一,不叠加 = 模板味)。

    - 默认**平卡**:白底卡自动描发丝边定义边界(不投影)。平铺网格里的对等卡都该这样。
    - shadow=True:**只给真正"悬浮"的卡**(照片上的卡、被挑出的推荐项);
      pptmaster 铁律:每页 ≤2-3 个投影元素,对等网格卡一律平。
    - fill 传 PRIMARY_WASH/SOFT 等浅底时,底色即是手段,不再描边。
    - accent:左内缘细竖条(语义标记,标"这一张")—— 有它就不再自动描边。
    """
    is_white = fill is None
    fill = SURFACE if fill is None else fill
    card = add_round_rect(slide, left, top, width, height, fill, radius, name)
    if shadow:
        set_shadow(card)                       # 手段:投影(悬浮卡专用)
    else:
        if border is None:                     # 手段:描边(仅白底平卡且无 accent 时自动)
            border = is_white and accent is None
        if border:
            set_line(card, HAIRLINE, 1.0)
    if accent is not None:                      # 手段:左侧语义强调条
        add_round_rect(slide, left + 0.18, top + 0.22, accent_w,
                       max(0.4, height - 0.44), accent, radius=0.04,
                       name=name + "_accent")
    return card


def add_icon_tile(slide, x, y, size=0.9, png_path=None, fill=None,
                  radius=0.12, name="icon_tile"):
    """图标底块:圆角浅色方块 + 居中图标 PNG(没 PNG 就只出底块)。

    fill 默认 PRIMARY_SOFT(主色浅底)。图标按 ~58% 居中,留呼吸。
    业务概念页(战略/能力/模块)用它替代"光秃秃图标"或"只有圆点"。
    """
    tile = add_round_rect(slide, x, y, size, size,
                          PRIMARY_SOFT if fill is None else fill, radius,
                          name)
    if png_path and Path(str(png_path)).exists():
        ic = size * 0.56
        off = (size - ic) / 2
        slide.shapes.add_picture(str(png_path), Inches(x + off),
                                 Inches(y + off), width=Inches(ic))
    return tile


def add_icon(slide, png_path, x, y, size=0.6):
    """直接摆图标 PNG(方形源,只给 width 等比)。底块版用 add_icon_tile。"""
    if png_path and Path(str(png_path)).exists():
        return slide.shapes.add_picture(str(png_path), Inches(x), Inches(y),
                                        width=Inches(size))
    return None


def add_pill(slide, x, y, width, height, text, fill=None, fg=None, size=12,
             name="pill"):
    """胶囊标签 / chip:全圆角小块 + 居中文字。分类标签、状态、eyebrow 用。"""
    s = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
                               Inches(x), Inches(y),
                               Inches(width), Inches(height))
    s.name = name
    s.fill.solid()
    s.fill.fore_color.rgb = PRIMARY if fill is None else fill
    s.line.fill.background()
    s.adjustments[0] = 0.5  # 全圆角
    tf = s.text_frame
    tf.word_wrap = False
    tf.margin_top = tf.margin_bottom = 0
    set_text(tf, text, size, bold=True, color=WHITE if fg is None else fg,
             align=PP_ALIGN.CENTER)
    tf.paragraphs[0].alignment = PP_ALIGN.CENTER
    s.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
    return s


def add_eyebrow(slide, x, y, text, color=None, size=13, width=4.0):
    """小标签 / kicker:标题上方一行弱化前缀(如『核心结论 / 01』)。"""
    return add_textbox(slide, x, y, width, 0.35, text, size, bold=True,
                       color=PRIMARY if color is None else color,
                       shrink=False, name="eyebrow")


def add_kpi(slide, left, top, width, height, value, label, baseline=None,
            delta=None, delta_dir=None, value_color=None, card=True,
            value_size=40, name="kpi"):
    """KPI 数字卡:大号数字 + 标签 +(对比基准)+(升降趋势)。

    **数据语境化铁律**(pptmaster):数字不要孤立出现。尽量给:
    - baseline:对比基准,如 "行业均值 82%" / "上季 1.0M"(灰色小字)
    - delta:趋势标注,如 "12.3%" / "+11pt";delta_dir 'up'/'down'/'flat' 决定升降色
      (绿=正 / 红=负 / 灰=平);不传 delta_dir 则从 delta 开头的 +/-/↑/↓ 推断。
    数据页优先 2-4 张并排,比小柱图信息密度与质感都高。value 走 EN_FONT。
    """
    if card:
        add_card(slide, left, top, width, height, fill=SURFACE, name=name + "_card")
    pad = 0.28
    add_textbox(slide, left + pad, top + pad, width - 2 * pad, height * 0.42,
                str(value), value_size, bold=True,
                color=PRIMARY if value_color is None else value_color,
                font=EN_FONT, anchor=MSO_ANCHOR.BOTTOM, shrink=False,
                name=name + "_val")
    add_textbox(slide, left + pad, top + pad + height * 0.42, width - 2 * pad,
                0.36, label, 15, color=INK, anchor=MSO_ANCHOR.TOP,
                name=name + "_label")
    yb = top + height - 0.42
    if delta:
        if delta_dir is None:
            s = str(delta)
            delta_dir = ("up" if (s[:1] in "+↑" or "增" in s or "↑" in s)
                         else "down" if (s[:1] in "-↓" or "降" in s or "↓" in s)
                         else "flat")
        dcol = GOOD if delta_dir == "up" else BAD if delta_dir == "down" else GREY
        add_textbox(slide, left + pad, yb, width - 2 * pad, 0.34, str(delta),
                    13, bold=True, color=dcol, shrink=False, name=name + "_delta")
        yb -= 0.32
    if baseline:
        add_textbox(slide, left + pad, yb, width - 2 * pad, 0.32, str(baseline),
                    12, color=GREY_LIGHT, shrink=False, name=name + "_base")


def add_takeaway(slide, text, top=None, name="takeaway"):
    """Takeaway Box:标题下一句话**结论**(浅主色底 + 左主色短条)。

    咨询风内容页标配 —— 把"这页要讲什么"压成一句可带走的结论(pyramid 结论先行)。
    例:"Q4 同比增 158%,创历史新高" 而不是 "营收情况"。
    """
    y = (SAFE_TOP + 1.0) if top is None else top
    add_round_rect(slide, SAFE_LEFT, y, SAFE_W, 0.6, PRIMARY_WASH, radius=0.05,
                   name=name)
    add_round_rect(slide, SAFE_LEFT, y, 0.09, 0.6, PRIMARY, radius=0.02,
                   name=name + "_bar")
    add_textbox(slide, SAFE_LEFT + 0.32, y, SAFE_W - 0.6, 0.6, text, 16,
                bold=True, color=INK, anchor=MSO_ANCHOR.MIDDLE,
                name=name + "_txt")


def add_source(slide, text, name="source"):
    """数据来源标注(右下角弱化)。**含数据的页必标**(咨询风硬规则)。"""
    add_textbox(slide, SAFE_LEFT, SLIDE_H - 0.48, SAFE_W, 0.32,
                f"来源:{text}", 11, color=GREY_LIGHT, align=PP_ALIGN.RIGHT,
                shrink=False, name=name)


def add_chevron(slide, x, y, width=0.55, height=0.5, color=None):
    """流程箭头(步骤之间的指向)。"""
    return add_shape(slide, MSO_SHAPE.CHEVRON, x, y, width, height,
                     (GREY_LIGHT if color is None else color), "chevron")


def add_divider(slide, x, y, length, vertical=False, color=None):
    """细分隔线(横/竖)。"""
    c = HAIRLINE if color is None else color
    if vertical:
        return add_rect(slide, x, y, 0.02, length, c, "divider")
    return add_rect(slide, x, y, length, 0.02, c, "divider")


# ============================================================
# 组合版式件:均衡网格 / 时间轴 / 流程闭环 / 背景图
#   —— 模型直接调一个函数,别再手摆参差网格 / 用卡片硬凑时间线
# ============================================================
import math as _math
import os as _os

_GRID_COLS = {1: 1, 2: 2, 3: 3, 4: 2, 5: 3, 6: 3, 7: 4, 8: 4, 9: 3}


def _unpack(item, keys, defaults):
    if isinstance(item, dict):
        return [item.get(k, d) for k, d in zip(keys, defaults)]
    vals = list(item) + list(defaults)
    return vals[:len(keys)]


def add_card_grid(slide, items, top, height, cols=None, gap=0.35,
                  icon_dir=None, icon_color="C00000", accent=None,
                  title_size=18, body_size=14, name="grid"):
    """一次摆 N 张概念卡,**自动均衡行列**(2×2 / 2×3,不再手摆参差 3+2)。

    items: 每项 {icon, title, body} 或 (icon, title, body);icon 是图标名
           (去 tabler_ 前缀,如 'target'),配 icon_dir 找 PNG;None 则不放图标。
    top/height: 网格纵向区域(如 标题下 ~1.95 → 底部留页脚约 6.9)。
    布局自适应:**单行**(rows=1)图标顶置成高特征卡;**多行**图标左置成横向卡
    (正文拿到整卡高度,不会被顶置图标挤溢出)。正文请保持精炼(≤ ~18 字/卡)。
    """
    n = len(items)
    if cols is None:
        cols = _GRID_COLS.get(n, 4)
    rows = _math.ceil(n / cols)
    cw = (SAFE_W - gap * (cols - 1)) / cols
    ch = (height - gap * (rows - 1)) / rows
    pad = 0.32
    icon_top = rows == 1
    for i, it in enumerate(items):
        icon, title, body = _unpack(it, ("icon", "title", "body"), (None, "", ""))
        r, c = divmod(i, cols)
        x = SAFE_LEFT + c * (cw + gap)
        y = top + r * (ch + gap)
        add_card(slide, x, y, cw, ch, accent=accent, name=f"{name}_card_{i}")
        has_icon = bool(icon and icon_dir)
        png = (_os.path.join(str(icon_dir), f"tabler_{icon}_{icon_color}_128.png")
               if has_icon else None)
        if icon_top:
            tile = max(0.95, min(1.3, cw * 0.30))
            if has_icon:
                add_icon_tile(slide, x + pad, y + 0.4, tile, png_path=png,
                              name=f"{name}_tile_{i}")
                ty = y + 0.4 + tile + 0.2
            else:
                ty = y + pad
            tx, tw = x + pad, cw - 2 * pad
            add_textbox(slide, tx, ty, tw, 0.45, title, title_size, bold=True,
                        color=INK, name=f"{name}_t_{i}")
            add_textbox(slide, tx, ty + 0.5, tw, y + ch - 0.25 - (ty + 0.5),
                        body, body_size, color=GREY, name=f"{name}_b_{i}")
        else:
            # 多行:图标左置,文字竖直居中(整卡高度给文字,不会被挤)
            tile = max(0.72, min(0.95, ch * 0.46, cw * 0.26))
            if has_icon:
                add_icon_tile(slide, x + pad, y + (ch - tile) / 2, tile,
                              png_path=png, name=f"{name}_tile_{i}")
                tx = x + pad + tile + 0.26
            else:
                tx = x + pad
            tw = x + cw - pad - tx
            blk = 1.15                      # 文字块估高,用于竖直居中
            ty = y + max(pad, (ch - blk) / 2)
            add_textbox(slide, tx, ty, tw, 0.42, title, title_size, bold=True,
                        color=INK, name=f"{name}_t_{i}")
            add_textbox(slide, tx, ty + 0.46, tw, y + ch - pad - (ty + 0.46),
                        body, body_size, color=GREY, name=f"{name}_b_{i}")


def add_timeline(slide, nodes, y=3.2, name="tl"):
    """横向时间轴:主轴线 + 均布节点(年份 pill 在上,标题/说明在下)。

    nodes: list of {year, title, body} 或 (year, title, body)。3-6 个最佳。
    发展历程 / 路线图 / 里程碑类内容用它,**别塞进卡片网格**。
    """
    n = len(nodes)
    x0 = SAFE_LEFT + 0.4
    x1 = SAFE_RIGHT - 0.4
    span = x1 - x0
    add_rect(slide, x0, y, span, 0.035, PRIMARY, "tl_axis")
    step = span / (n - 1) if n > 1 else 0
    for i, nd in enumerate(nodes):
        year, title, body = _unpack(nd, ("year", "title", "body"), ("", "", ""))
        cx = x0 + i * step
        d = 0.26
        add_shape(slide, MSO_SHAPE.OVAL, cx - d / 2, y + 0.0175 - d / 2, d, d,
                  PRIMARY, f"tl_dot_{i}")
        pw = 1.15
        px = max(0.2, min(cx - pw / 2, SLIDE_W - pw - 0.2))
        add_pill(slide, px, y - 0.66, pw, 0.42, str(year), fill=ACCENT,
                 fg=INK, size=14, name=f"tl_year_{i}")
        bw = min(2.5, step * 0.96) if n > 1 else 3.0
        tx = max(0.2, min(cx - bw / 2, SLIDE_W - bw - 0.2))
        add_textbox(slide, tx, y + 0.42, bw, 0.45, title, 16, bold=True,
                    color=INK, align=PP_ALIGN.CENTER, name=f"tl_t_{i}")
        add_textbox(slide, tx, y + 0.92, bw, 1.4, body, 14, color=GREY,
                    align=PP_ALIGN.CENTER, name=f"tl_b_{i}")


def add_cycle(slide, steps, cx=None, cy=4.5, radius=1.55, center_label=None,
              name="cyc"):
    """流程闭环:节点沿圆环顺时针均布 + 可选中心词 + 浅环连线。

    steps: list of {title, body} 或 (title, body)。4-6 个最佳。
    "感知-规划-执行-反馈"这类**循环**用它,别做成平铺卡片(丢了闭环语义)。
    """
    n = len(steps)
    if cx is None:
        cx = SLIDE_W / 2
    ry = radius * 0.80  # 纵向压扁成椭圆,16:9 上更协调
    ring = add_shape(slide, MSO_SHAPE.OVAL, cx - radius, cy - ry,
                     2 * radius, 2 * ry, WHITE, name + "_ring")
    ring.fill.background()
    set_line(ring, HAIRLINE, 1.5)
    if center_label:
        cd = radius * 0.80
        add_shape(slide, MSO_SHAPE.OVAL, cx - cd / 2, cy - cd / 2, cd, cd,
                  PRIMARY_WASH, name + "_hub")
        add_textbox(slide, cx - cd / 2, cy - cd / 2, cd, cd, center_label, 17,
                    bold=True, color=PRIMARY, align=PP_ALIGN.CENTER,
                    anchor=MSO_ANCHOR.MIDDLE, name=name + "_hublabel")
    nd = 1.0
    for i, st in enumerate(steps):
        title, body = _unpack(st, ("title", "body"), ("", ""))
        ang = _math.radians(-90 + i * 360 / n)
        nx = cx + radius * _math.cos(ang)
        ny = cy + ry * _math.sin(ang)
        add_badge(slide, nx - nd / 2, ny - nd / 2, i + 1, diameter=nd)
        lw = 2.1
        lx = max(0.2, min(nx - lw / 2, SLIDE_W - lw - 0.2))
        ly = (ny - nd / 2 - 0.46) if ny <= cy else (ny + nd / 2 + 0.06)
        ly = max(0.2, min(ly, SLIDE_H - 0.4))
        add_textbox(slide, lx, ly, lw, 0.4, title, 15, bold=True, color=INK,
                    align=PP_ALIGN.CENTER, name=f"{name}_t_{i}")


def add_toc(slide, items, top=2.2, row_h=None, name="toc"):
    """贯通整宽的目录:每行 = 序号 + 标题 +(右侧副标)+ 发丝分隔线。

    items: 每项 (title, caption) 或 {title, caption} 或纯 title 字符串。
    比"左侧一列编号圆点"铺满版面、信息更足(副标给每章一句定位)。
    """
    n = len(items)
    if row_h is None:
        row_h = min(0.95, (SAFE_BOTTOM - 0.2 - top) / n)
    for i, it in enumerate(items):
        if isinstance(it, str):
            title, cap = it, ""
        else:
            title, cap = _unpack(it, ("title", "caption"), ("", ""))
        y = top + i * row_h
        add_textbox(slide, SAFE_LEFT, y, 1.05, row_h - 0.18, f"{i + 1:02d}", 34,
                    bold=True, color=PRIMARY, font=EN_FONT,
                    anchor=MSO_ANCHOR.MIDDLE, name=f"{name}_n_{i}")
        add_textbox(slide, SAFE_LEFT + 1.25, y, 6.3, row_h - 0.18, title, 21,
                    bold=True, color=INK, anchor=MSO_ANCHOR.MIDDLE,
                    name=f"{name}_t_{i}")
        if cap:
            add_textbox(slide, SAFE_LEFT + 8.0, y, SAFE_W - 8.0, row_h - 0.18,
                        cap, 15, color=GREY, align=PP_ALIGN.RIGHT,
                        anchor=MSO_ANCHOR.MIDDLE, name=f"{name}_c_{i}")
        add_divider(slide, SAFE_LEFT, y + row_h - 0.1, SAFE_W)


def add_picture_bg(slide, png):
    """整页铺一张渲染好的高清背景图(混合方案:背景图 + 其上原生可编辑文字)。

    封面/章节用:先 `add_picture_bg(slide, bg.png)`,再叠 `add_textbox` 文字。
    背景不可改但文字仍能在 PPT 里编辑 —— editable 前提下拿到的最佳观感。
    """
    if png and Path(str(png)).exists():
        return slide.shapes.add_picture(str(png), Inches(0), Inches(0),
                                        width=Inches(SLIDE_W),
                                        height=Inches(SLIDE_H))
    return None


# ============================================================
# 演讲者备注
# ============================================================
def add_notes(slide, text) -> None:
    """写演讲者备注(演示时可见,正式产物标配)。每页给 2-4 句口述要点。"""
    slide.notes_slide.notes_text_frame.text = text or ""


# ============================================================
# 标题套件 (内页通用)
# ============================================================
def page_title(slide, text, page_num=None, total=None, footer="项目汇报",
               eyebrow=None):
    """内页标题 + 强调线 (+ 可选 eyebrow 小标签 + 页脚页码)。

    eyebrow:标题上方一行弱化前缀(章节名 / 分类),给则标题整体下移。
    """
    ty = SAFE_TOP
    if eyebrow:
        add_eyebrow(slide, SAFE_LEFT, SAFE_TOP, eyebrow)
        ty = SAFE_TOP + 0.4
    add_textbox(slide, SAFE_LEFT, ty, SAFE_W, 0.7, text,
                32, bold=True, color=PRIMARY, name="title")
    add_accent_line(slide, SAFE_LEFT, ty + 0.85, length=0.8)
    if page_num is not None and total is not None:
        add_textbox(slide, SAFE_LEFT, SLIDE_H - 0.5, 6, 0.4, footer,
                    11, color=GREY_LIGHT, shrink=False, name="footer")
        add_textbox(slide, SLIDE_W - 1.33, SLIDE_H - 0.5, 1.2, 0.4,
                    f"{page_num} / {total}", 11, color=GREY_LIGHT,
                    align=PP_ALIGN.RIGHT, shrink=False, name="page_num")


# ============================================================
# 品牌条 (每页起手必调,确保不是裸白纸)
# ============================================================
def apply_brand(slide, kind="inner"):
    """统一品牌锚点。每个版式第一行调用。

    cover —— 右侧主色→深主色渐变大块 + 左侧细强调短线(现代封面)
    inner —— (默认) 近白底 + 左侧主色窄条 + 底部细灰线
    section —— 主色→深主色整页渐变 + 强调装饰条(章节大色块)
    end —— 浅底 + 顶/底强调短线
    """
    btm = SLIDE_H - 0.32
    if kind == "cover":
        add_bg(slide, WHITE)
        # 右侧约 38% 宽的渐变色块,封面从"白纸加条"升级成有视觉重量的构图
        bw = SLIDE_W * 0.40
        add_gradient_rect(slide, SLIDE_W - bw, 0, bw, SLIDE_H,
                          PRIMARY, PRIMARY_DARK, angle=60, name="cover_block")
        add_rect(slide, SAFE_LEFT, 0.7, 0.55, 0.07, ACCENT, "brand_top_line")
        add_rect(slide, SAFE_LEFT, btm, SLIDE_W - bw - SAFE_LEFT - 0.3, 0.02,
                 HAIRLINE, "brand_btm_hairline")
    elif kind == "section":
        add_gradient_rect(slide, 0, 0, SLIDE_W, SLIDE_H,
                          PRIMARY, PRIMARY_DARK, angle=55, name="section_bg")
        add_rect(slide, 0.7, SLIDE_H / 3, 0.09, SLIDE_H / 3, ACCENT,
                 "brand_section_bar")
    elif kind == "end":
        add_bg(slide, PRIMARY_WASH)
        add_rect(slide, SAFE_LEFT, 0.6, 0.8, 0.06, ACCENT, "brand_top_line")
        add_rect(slide, SAFE_RIGHT - 0.8, SLIDE_H - 0.65, 0.8, 0.06, ACCENT,
                 "brand_btm_line")
    else:  # inner
        add_bg(slide, BG)
        add_rect(slide, 0, 0, 0.10, SLIDE_H, PRIMARY, "brand_left_bar")
        add_rect(slide, SAFE_LEFT, btm, SAFE_W, 0.02, HAIRLINE,
                 "brand_btm_hairline")
