from pathlib import Path
from textwrap import wrap
import html
import math
import json

from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
from pptx.dml.color import RGBColor
from pptx.enum.dml import MSO_THEME_COLOR
from PIL import Image, ImageDraw, ImageFont

ROOT = Path('/Users/bot1/Volumes/root_for_ai/AI工作区/通用_PPT测试_花书Hallmark三风格_20260606_0124')
OUT = ROOT / 'deliverables'
HTML = ROOT / 'html'
SLIDES = HTML / 'slides'
SHARED = HTML / 'shared'
for p in [OUT, SLIDES, SHARED, ROOT/'docs']:
    p.mkdir(parents=True, exist_ok=True)

PPTX_OUT = OUT / 'huashu_hallmark_3styles_9slides.pptx'
CONTACT_OUT = OUT / 'preview_contact_sheet.png'

# --- shared primitives ---
W, H = 13.333333, 7.5
PW, PH = 1920, 1080
SCALE = PW / W

def hex_to_rgb(h):
    h = h.strip('#')
    return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))

def rgb(h):
    return RGBColor(*hex_to_rgb(h))

def contains_cjk(text):
    return any('\u4e00' <= ch <= '\u9fff' for ch in str(text))

def safe_font(paths):
    for p in paths:
        if Path(p).exists():
            return p
    raise FileNotFoundError(paths)

FONT_CJK = safe_font(['/System/Library/Fonts/STHeiti Light.ttc', '/System/Library/Fonts/Supplemental/Arial Unicode.ttf'])
FONT_SONG = safe_font(['/System/Library/Fonts/Supplemental/Songti.ttc', '/System/Library/Fonts/STHeiti Light.ttc'])
FONT_LATIN_BOLD = safe_font(['/System/Library/Fonts/Supplemental/Arial Bold.ttf', '/System/Library/Fonts/Supplemental/Arial.ttf'])
FONT_LATIN = safe_font(['/System/Library/Fonts/Supplemental/Arial.ttf', '/System/Library/Fonts/Supplemental/Times New Roman.ttf'])
FONT_MONO = safe_font(['/System/Library/Fonts/Supplemental/Courier New.ttf', '/System/Library/Fonts/Supplemental/Arial Unicode.ttf'])

def pil_font(name, size, bold=False):
    if name == 'serif':
        return ImageFont.truetype(FONT_SONG, size)
    if name == 'mono':
        return ImageFont.truetype(FONT_MONO, size)
    if bold:
        return ImageFont.truetype(FONT_LATIN_BOLD, size)
    return ImageFont.truetype(FONT_CJK if any('\u4e00' <= ch <= '\u9fff' for ch in name) else FONT_LATIN, size)

# Use Pillow font by explicit role rather than text-name detection
def f(role, size):
    return ImageFont.truetype({'display_serif': FONT_SONG, 'body': FONT_CJK, 'latin_bold': FONT_LATIN_BOLD, 'latin': FONT_LATIN, 'mono': FONT_MONO}[role], size)

slides = [
    {
        'no': 1, 'style': 'A · Quiet Editorial', 'theme': 'editorial', 'bg': '#F3EEE4', 'ink': '#20201D', 'muted': '#716B61', 'accent': '#B45A3C', 'accent2': '#D9C7B0',
        'title': '测试版 PPT', 'subtitle': '三种风格对照 · 3 pages × 3 versions',
        'kicker': 'VERSION A / QUIET EDITORIAL', 'layout': 'a_cover',
        'notes': ['Huashu：HTML-first 视觉源', 'Hallmark：克制、反模板、无虚构数据', 'PowerPoint：原生形状与文本，可打开测试']
    },
    {
        'no': 2, 'style': 'A · Quiet Editorial', 'theme': 'editorial', 'bg': '#F3EEE4', 'ink': '#20201D', 'muted': '#716B61', 'accent': '#B45A3C', 'accent2': '#D9C7B0',
        'title': '一页只服务一个判断', 'subtitle': 'Less content, more decision.', 'kicker': 'DESIGN PRINCIPLE', 'layout': 'a_principle',
        'notes': ['主信息先出现', '辅助点只保留三条', '留白不是空，是节奏']
    },
    {
        'no': 3, 'style': 'A · Quiet Editorial', 'theme': 'editorial', 'bg': '#F3EEE4', 'ink': '#20201D', 'muted': '#716B61', 'accent': '#B45A3C', 'accent2': '#D9C7B0',
        'title': '从草稿到交付', 'subtitle': 'Source → Layout → Verify', 'kicker': 'WORKFLOW', 'layout': 'a_flow',
        'notes': ['HTML 聚合演示版', 'PPTX 原生交付', '截图/文本双重验证']
    },
    {
        'no': 4, 'style': 'B · Atmospheric Technical', 'theme': 'technical', 'bg': '#101416', 'ink': '#ECF3ED', 'muted': '#87918A', 'accent': '#A7F05B', 'accent2': '#254033',
        'title': '机器人协作界面', 'subtitle': 'Operating layer for agent work.', 'kicker': 'VERSION B / ATMOSPHERIC TECHNICAL', 'layout': 'b_cover',
        'notes': ['暗底不是赛博霓虹', '单一荧光锚点', '信息面板有层级，不造假数字']
    },
    {
        'no': 5, 'style': 'B · Atmospheric Technical', 'theme': 'technical', 'bg': '#101416', 'ink': '#ECF3ED', 'muted': '#87918A', 'accent': '#A7F05B', 'accent2': '#1C2A24',
        'title': '状态不是装饰', 'subtitle': 'Use placeholders honestly.', 'kicker': 'DASHBOARD SLIDE', 'layout': 'b_dashboard',
        'notes': ['真实指标未提供时使用「待确认」', '突出层级而不是堆卡片', '所有数字标注为演示占位']
    },
    {
        'no': 6, 'style': 'B · Atmospheric Technical', 'theme': 'technical', 'bg': '#101416', 'ink': '#ECF3ED', 'muted': '#87918A', 'accent': '#A7F05B', 'accent2': '#23332B',
        'title': '三个决策面板', 'subtitle': 'Scope · Evidence · Handoff', 'kicker': 'CONTROL SURFACE', 'layout': 'b_panels',
        'notes': ['先定范围', '再看证据', '最后形成可交接入口']
    },
    {
        'no': 7, 'style': 'C · Brutalist Riso', 'theme': 'brutalist', 'bg': '#EFE6D1', 'ink': '#15120D', 'muted': '#5D5547', 'accent': '#D63C2E', 'accent2': '#F2C94C',
        'title': 'NO TEMPLATE', 'subtitle': '反模板排版压力测试', 'kicker': 'VERSION C / BRUTALIST RISO', 'layout': 'c_cover',
        'notes': ['高对比', '粗黑线', '错位网格', '拒绝默认蓝紫渐变']
    },
    {
        'no': 8, 'style': 'C · Brutalist Riso', 'theme': 'brutalist', 'bg': '#EFE6D1', 'ink': '#15120D', 'muted': '#5D5547', 'accent': '#D63C2E', 'accent2': '#F2C94C',
        'title': '把默认项删掉', 'subtitle': 'Cut the tells before adding detail.', 'kicker': 'ANTI-SLOP CHECK', 'layout': 'c_checks',
        'notes': ['不要三等分图标卡', '不要虚构指标', '不要装饰性 emoji', '不要假浏览器外壳']
    },
    {
        'no': 9, 'style': 'C · Brutalist Riso', 'theme': 'brutalist', 'bg': '#EFE6D1', 'ink': '#15120D', 'muted': '#5D5547', 'accent': '#D63C2E', 'accent2': '#F2C94C',
        'title': '3 styles / 9 slides', 'subtitle': 'Ready for review.', 'kicker': 'TEST OUTPUT', 'layout': 'c_end',
        'notes': ['A：安静编辑感', 'B：暗底技术感', 'C：粗粝海报感']
    },
]

# --- pptx helpers ---
def blank(prs):
    return prs.slides.add_slide(prs.slide_layouts[6])

def add_rect(slide, x, y, w, h, fill, line=None, radius=False, transparency=0):
    shape_type = MSO_SHAPE.ROUNDED_RECTANGLE if radius else MSO_SHAPE.RECTANGLE
    sp = slide.shapes.add_shape(shape_type, Inches(x), Inches(y), Inches(w), Inches(h))
    sp.fill.solid(); sp.fill.fore_color.rgb = rgb(fill); sp.fill.transparency = transparency
    if line:
        sp.line.color.rgb = rgb(line); sp.line.width = Pt(1.2)
    else:
        sp.line.fill.background()
    return sp

def add_line(slide, x1, y1, x2, y2, color, width=1):
    ln = slide.shapes.add_connector(1, Inches(x1), Inches(y1), Inches(x2), Inches(y2))
    ln.line.color.rgb = rgb(color); ln.line.width = Pt(width)
    return ln

def add_text(slide, text, x, y, w, h, size=24, color='#000000', font='PingFang SC', bold=False, italic=False, align='left', valign='top', line_spacing=1.05):
    # Latin display/mono fonts can render Chinese as tofu in PPT previews; route mixed CJK labels to a CJK-safe face.
    if contains_cjk(text) and font in {'Arial', 'Courier New', 'Arial Black'}:
        font = 'PingFang SC'
    box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
    tf = box.text_frame
    tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
    tf.vertical_anchor = {'top': MSO_ANCHOR.TOP, 'mid': MSO_ANCHOR.MIDDLE, 'bottom': MSO_ANCHOR.BOTTOM}.get(valign, MSO_ANCHOR.TOP)
    p = tf.paragraphs[0]
    p.alignment = {'left': PP_ALIGN.LEFT, 'center': PP_ALIGN.CENTER, 'right': PP_ALIGN.RIGHT}.get(align, PP_ALIGN.LEFT)
    p.line_spacing = line_spacing
    run = p.add_run(); run.text = text
    run.font.name = font; run.font.size = Pt(size); run.font.bold = bold; run.font.italic = italic; run.font.color.rgb = rgb(color)
    return box

def add_footer(slide, s):
    add_text(slide, s['style'], 0.55, 7.05, 4.5, 0.22, 8.5, s['muted'], 'Arial', False)
    add_text(slide, f"{s['no']:02d} / 09", 12.1, 7.05, 0.8, 0.22, 8.5, s['muted'], 'Arial', False, align='right')

# --- PIL helpers ---
def xywh(x, y, w, h):
    return tuple(int(round(v * SCALE)) for v in (x, y, w, h))

def draw_round(draw, box, radius, fill, outline=None, width=1):
    draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)

def draw_text(draw, text, x, y, w, h, font, fill, align='left', valign='top', spacing=8, max_lines=None):
    # basic wrap by approximate width; supports Chinese by char slicing
    lines = []
    for para in text.split('\n'):
        current = ''
        for ch in para:
            test = current + ch
            if draw.textbbox((0,0), test, font=font)[2] <= w or not current:
                current = test
            else:
                lines.append(current); current = ch
        if current: lines.append(current)
    if max_lines: lines = lines[:max_lines]
    line_h = int((font.getbbox('Hg')[3] - font.getbbox('Hg')[1]) * 1.18)
    total_h = len(lines) * line_h + max(0, len(lines)-1)*spacing
    yy = y if valign == 'top' else y + (h-total_h)//2 if valign == 'mid' else y + h - total_h
    for line in lines:
        tw = draw.textbbox((0,0), line, font=font)[2]
        xx = x if align == 'left' else x + (w-tw)//2 if align == 'center' else x + w - tw
        draw.text((xx, yy), line, font=font, fill=fill)
        yy += line_h + spacing

def render_preview(s):
    img = Image.new('RGB', (PW, PH), s['bg'])
    d = ImageDraw.Draw(img)
    # shared footer / header
    d.text((int(0.55*SCALE), int(7.05*SCALE)), s['style'], font=f('latin', 18), fill=s['muted'])
    tw = d.textbbox((0,0), f"{s['no']:02d} / 09", font=f('latin', 18))[2]
    d.text((PW-int(0.55*SCALE)-tw, int(7.05*SCALE)), f"{s['no']:02d} / 09", font=f('latin', 18), fill=s['muted'])
    layout = s['layout']
    ink, muted, acc, acc2 = s['ink'], s['muted'], s['accent'], s['accent2']
    if layout == 'a_cover':
        d.line((90,160,1830,160), fill=acc2, width=3)
        draw_text(d, s['kicker'], 100, 95, 1000, 60, f('mono', 24), muted)
        draw_text(d, s['title'], 120, 250, 1100, 250, f('display_serif', 150), ink)
        draw_text(d, s['subtitle'], 130, 520, 1200, 75, f('body', 42), acc)
        d.rectangle((1450, 230, 1600, 850), fill=acc)
        d.rectangle((1635, 380, 1710, 850), fill=acc2)
        draw_text(d, '\n'.join(s['notes']), 130, 735, 900, 160, f('body', 30), muted, spacing=10)
    elif layout == 'a_principle':
        draw_text(d, s['kicker'], 100, 95, 900, 60, f('mono', 22), acc)
        draw_text(d, s['title'], 105, 190, 1030, 220, f('display_serif', 88), ink)
        draw_text(d, s['subtitle'], 108, 410, 700, 60, f('latin', 34), muted)
        x0=1040
        for i,n in enumerate(s['notes']):
            y=225+i*190
            d.line((x0,y,1740,y), fill=acc2, width=3)
            draw_text(d, f"0{i+1}", x0, y+32, 80, 60, f('mono', 30), acc)
            draw_text(d, n, x0+110, y+22, 620, 90, f('body', 38), ink)
    elif layout == 'a_flow':
        draw_text(d, s['kicker'], 100, 95, 900, 60, f('mono', 22), acc)
        draw_text(d, s['title'], 100, 180, 900, 135, f('display_serif', 94), ink)
        draw_text(d, s['subtitle'], 105, 325, 700, 55, f('latin', 32), muted)
        xs=[160,700,1240]
        labels=['SOURCE','LAYOUT','VERIFY']
        for i,x in enumerate(xs):
            draw_round(d, (x,500,x+400,760), 22, '#FBF8F1', acc2, 3)
            draw_text(d, labels[i], x+36, 530, 300, 38, f('mono', 26), acc)
            draw_text(d, s['notes'][i], x+36, 595, 320, 85, f('body', 36), ink)
            if i<2:
                d.line((x+430,630,x+520,630), fill=acc, width=5)
                d.polygon([(x+520,630),(x+500,618),(x+500,642)], fill=acc)
    elif layout == 'b_cover':
        d.rectangle((100,100,1820,980), outline=acc2, width=2)
        draw_text(d, s['kicker'], 130, 130, 900, 50, f('mono', 23), acc)
        draw_text(d, s['title'], 130, 260, 1100, 150, f('body', 88), ink)
        draw_text(d, s['subtitle'], 132, 430, 900, 55, f('latin', 34), muted)
        for i in range(7):
            x=1160+i*70; y=220+int(math.sin(i)*42)
            d.ellipse((x,y,x+20,y+20), fill=acc)
            if i:
                d.line((1160+(i-1)*70+10,220+int(math.sin(i-1)*42)+10,x+10,y+10), fill=acc2, width=2)
        draw_text(d, '\n'.join(s['notes']), 132, 720, 800, 150, f('body', 30), muted, spacing=8)
    elif layout == 'b_dashboard':
        draw_text(d, s['kicker'], 105, 95, 900, 50, f('mono', 22), acc)
        draw_text(d, s['title'], 105, 170, 900, 95, f('body', 68), ink)
        draw_text(d, s['subtitle'], 110, 280, 700, 48, f('latin', 28), muted)
        cards=[(120,455,470,260,'任务范围','待确认','范围未锁定时，不用漂亮数字填坑'),(650,400,430,380,'证据链','3 layers','聊天 / 文件 / 现场验证'),(1140,455,620,260,'交付入口','HTML + PPTX','浏览器演示源 + PowerPoint 文件')]
        for x,y,w,h,t,v,b in cards:
            draw_round(d, (x,y,x+w,y+h), 20, acc2, '#35483D', 2)
            draw_text(d, t, x+34,y+32,w-68,36,f('body',22),muted)
            draw_text(d, v, x+34,y+84,w-68,80,f('latin_bold',54),ink)
            draw_text(d, b, x+34,y+h-92,w-68,60,f('body',26),muted)
        d.line((140,810,1760,810), fill=acc, width=3)
    elif layout == 'b_panels':
        draw_text(d, s['kicker'], 105, 95, 900, 50, f('mono', 22), acc)
        draw_text(d, s['title'], 105, 175, 880, 95, f('body', 68), ink)
        draw_text(d, s['subtitle'], 110, 285, 740, 48, f('latin', 28), muted)
        xs=[145,710,1265]; words=['Scope','Evidence','Handoff']
        for i,x in enumerate(xs):
            y=455 if i!=1 else 395; h=285 if i!=1 else 405
            draw_round(d,(x,y,x+455,y+h),18,acc2,'#35483D',2)
            draw_text(d, words[i], x+35,y+35,360,65,f('latin_bold',48),ink)
            draw_text(d, s['notes'][i], x+35,y+122,350,80,f('body',32),muted)
            d.rectangle((x+35,y+h-60,x+155,y+h-48), fill=acc)
    elif layout == 'c_cover':
        d.rectangle((75,75,1845,1005), outline=ink, width=10)
        d.rectangle((120,140,760,245), fill=acc)
        draw_text(d, s['kicker'], 145, 172, 700, 40, f('mono', 24), '#EFE6D1')
        draw_text(d, s['title'], 120, 310, 1500, 180, f('latin_bold',150), ink)
        draw_text(d, s['subtitle'], 126, 545, 900, 70, f('body', 44), ink)
        d.rectangle((1260,560,1750,825), fill=acc2, outline=ink, width=8)
        draw_text(d, 'C', 1390, 580, 220, 150, f('latin_bold',150), ink, align='center')
    elif layout == 'c_checks':
        d.rectangle((90,90,1830,980), outline=ink, width=7)
        draw_text(d, s['kicker'], 115, 120, 900, 44, f('mono', 23), acc)
        draw_text(d, s['title'], 115, 205, 900, 100, f('body', 72), ink)
        draw_text(d, s['subtitle'], 120, 320, 800, 45, f('latin', 27), muted)
        y=460
        for i,n in enumerate(s['notes']):
            d.rectangle((125,y+i*115,175,y+50+i*115), fill=acc)
            draw_text(d, str(i+1), 137,y+4+i*115,40,40,f('latin_bold',32),'#EFE6D1')
            draw_text(d, n, 205,y-2+i*115,1000,60,f('body',40),ink)
    elif layout == 'c_end':
        d.rectangle((95,120,1825,890), fill=ink)
        draw_text(d, s['kicker'], 135, 155, 900, 42, f('mono', 22), acc2)
        draw_text(d, s['title'], 135, 270, 1400, 140, f('latin_bold',96), '#EFE6D1')
        draw_text(d, s['subtitle'], 140, 440, 700, 52, f('latin', 34), acc2)
        labels=['A Quiet Editorial','B Atmospheric Technical','C Brutalist Riso']
        for i,l in enumerate(labels):
            x=145+i*535
            d.rectangle((x,640,x+445,760), outline=acc2, width=3)
            draw_text(d, l, x+25,675,390,44,f('latin_bold',28),'#EFE6D1')
    return img

# --- build PPT ---
prs = Presentation()
prs.slide_width = Inches(W); prs.slide_height = Inches(H)

for s in slides:
    slide = blank(prs)
    add_rect(slide, 0, 0, W, H, s['bg'])
    # shared footer
    add_footer(slide, s)
    layout=s['layout']; ink=s['ink']; muted=s['muted']; acc=s['accent']; acc2=s['accent2']
    if layout == 'a_cover':
        add_line(slide, .62, 1.10, 12.70, 1.10, acc2, 1.8)
        add_text(slide, s['kicker'], .68, .66, 7.2, .35, 12, muted, 'Courier New')
        add_text(slide, s['title'], .82, 1.75, 7.5, 1.8, 74, ink, 'Songti SC', bold=True)
        add_text(slide, s['subtitle'], .90, 3.62, 7.9, .55, 25, acc, 'Arial')
        add_rect(slide, 10.05, 1.60, 1.05, 4.35, acc)
        add_rect(slide, 11.35, 2.65, .52, 3.30, acc2)
        add_text(slide, '\n'.join(s['notes']), .90, 5.08, 6.5, .9, 18, muted, 'PingFang SC')
    elif layout == 'a_principle':
        add_text(slide, s['kicker'], .70, .66, 6.2, .35, 11, acc, 'Courier New')
        add_text(slide, s['title'], .72, 1.28, 6.85, 1.45, 43, ink, 'Songti SC', bold=True)
        add_text(slide, s['subtitle'], .75, 2.85, 5.0, .4, 19, muted, 'Arial', italic=True)
        for i,n in enumerate(s['notes']):
            y=1.56+i*1.32
            add_line(slide, 7.25, y, 12.10, y, acc2, 1.7)
            add_text(slide, f"0{i+1}", 7.25, y+.22, .5, .35, 16, acc, 'Courier New')
            add_text(slide, n, 8.02, y+.13, 4.35, .65, 23, ink, 'PingFang SC', bold=True)
    elif layout == 'a_flow':
        add_text(slide, s['kicker'], .70, .66, 6.2, .35, 11, acc, 'Courier New')
        add_text(slide, s['title'], .70, 1.22, 6.4, .9, 45, ink, 'Songti SC', bold=True)
        add_text(slide, s['subtitle'], .72, 2.25, 5.5, .4, 18, muted, 'Arial', italic=True)
        xs=[1.10,4.85,8.60]; labels=['SOURCE','LAYOUT','VERIFY']
        for i,x in enumerate(xs):
            add_rect(slide, x, 3.48, 2.78, 1.80, '#FBF8F1', acc2, radius=True)
            add_text(slide, labels[i], x+.25, 3.70, 2.1, .3, 12, acc, 'Courier New')
            add_text(slide, s['notes'][i], x+.25, 4.12, 2.2, .55, 20, ink, 'PingFang SC', bold=True)
            if i<2:
                add_line(slide, x+3.0, 4.38, x+3.6, 4.38, acc, 2.5)
    elif layout == 'b_cover':
        add_rect(slide, .70, .70, 11.95, 6.05, s['bg'], acc2)
        add_text(slide, s['kicker'], .90, .92, 7.0, .35, 11, acc, 'Courier New')
        add_text(slide, s['title'], .90, 1.78, 7.0, .9, 43, ink, 'PingFang SC', bold=True)
        add_text(slide, s['subtitle'], .92, 3.0, 6.3, .4, 20, muted, 'Arial')
        for i in range(7):
            x=8.05+i*.48; y=1.52+math.sin(i)*.28
            add_rect(slide, x, y, .12, .12, acc, radius=True)
            if i:
                add_line(slide, 8.05+(i-1)*.48+.06, 1.52+math.sin(i-1)*.28+.06, x+.06, y+.06, acc2, 1)
        add_text(slide, '\n'.join(s['notes']), .92, 5.0, 5.7, .8, 17, muted, 'PingFang SC')
    elif layout == 'b_dashboard':
        add_text(slide, s['kicker'], .72, .66, 6.5, .35, 11, acc, 'Courier New')
        add_text(slide, s['title'], .72, 1.18, 6.6, .7, 36, ink, 'PingFang SC', bold=True)
        add_text(slide, s['subtitle'], .75, 2.05, 5.5, .35, 16, muted, 'Arial')
        cards=[(.84,3.16,3.26,1.80,'任务范围','待确认','范围未锁定时，不用漂亮数字填坑'),(4.50,2.78,3.0,2.65,'证据链','3 layers','聊天 / 文件 / 现场验证'),(7.92,3.16,4.30,1.80,'交付入口','HTML + PPTX','浏览器演示源 + PowerPoint 文件')]
        for x,y,w,h,t,v,b in cards:
            add_rect(slide,x,y,w,h,acc2,'#35483D',radius=True)
            add_text(slide,t,x+.25,y+.22,w-.5,.25,10.5,muted,'PingFang SC')
            add_text(slide,v,x+.25,y+.58,w-.5,.55,27,ink,'Arial',bold=True)
            add_text(slide,b,x+.25,y+h-.66,w-.5,.45,14,muted,'PingFang SC')
        add_line(slide, .96, 5.62, 12.22, 5.62, acc, 1.7)
    elif layout == 'b_panels':
        add_text(slide, s['kicker'], .72, .66, 6.5, .35, 11, acc, 'Courier New')
        add_text(slide, s['title'], .72, 1.22, 6.6, .7, 36, ink, 'PingFang SC', bold=True)
        add_text(slide, s['subtitle'], .75, 2.12, 5.5, .35, 16, muted, 'Arial')
        xs=[1.0,4.95,8.80]; words=['Scope','Evidence','Handoff']
        for i,x in enumerate(xs):
            y=3.15 if i!=1 else 2.75; h=1.98 if i!=1 else 2.80
            add_rect(slide,x,y,3.16,h,acc2,'#35483D',radius=True)
            add_text(slide,words[i],x+.25,y+.25,2.4,.45,24,ink,'Arial',bold=True)
            add_text(slide,s['notes'][i],x+.25,y+.85,2.4,.5,17,muted,'PingFang SC')
            add_rect(slide,x+.25,y+h-.42,.82,.08,acc)
    elif layout == 'c_cover':
        add_rect(slide, .52, .52, 12.30, 6.48, s['bg'], ink)
        add_rect(slide, .84, .96, 4.45, .72, acc)
        add_text(slide, s['kicker'], 1.0, 1.18, 4.9, .25, 11.5, '#EFE6D1', 'Courier New')
        add_text(slide, s['title'], .82, 2.10, 10.8, 1.15, 68, ink, 'Arial Black', bold=True)
        add_text(slide, s['subtitle'], .88, 3.75, 6.5, .45, 25, ink, 'PingFang SC', bold=True)
        add_rect(slide, 8.75, 3.88, 3.40, 1.85, acc2, ink)
        add_text(slide, 'C', 9.65, 4.05, 1.5, 1.0, 72, ink, 'Arial Black', bold=True, align='center')
    elif layout == 'c_checks':
        add_rect(slide, .62, .62, 12.08, 6.18, s['bg'], ink)
        add_text(slide, s['kicker'], .80, .88, 6.5, .3, 11, acc, 'Courier New')
        add_text(slide, s['title'], .80, 1.42, 6.8, .7, 36, ink, 'PingFang SC', bold=True)
        add_text(slide, s['subtitle'], .82, 2.20, 6.0, .32, 15, muted, 'Arial')
        y=3.18
        for i,n in enumerate(s['notes']):
            add_rect(slide,.86,y+i*.80,.35,.35,acc)
            add_text(slide,str(i+1),.95,y+.04+i*.80,.16,.15,15,'#EFE6D1','Arial',bold=True)
            add_text(slide,n,1.42,y-.02+i*.80,7.4,.45,21,ink,'PingFang SC',bold=True)
    elif layout == 'c_end':
        add_rect(slide,.66,.84,12.02,5.35,ink)
        add_text(slide,s['kicker'],.94,1.08,6.5,.3,11,acc2,'Courier New')
        add_text(slide,s['title'],.94,1.88,9.8,.9,47,'#EFE6D1','Arial Black',bold=True)
        add_text(slide,s['subtitle'],.97,3.05,5.2,.35,20,acc2,'Arial')
        labels=['A Quiet Editorial','B Atmospheric Technical','C Brutalist Riso']
        for i,l in enumerate(labels):
            x=1.0+i*3.72
            add_rect(slide,x,4.45,3.1,.82,ink,acc2)
            add_text(slide,l,x+.18,4.70,2.8,.25,13.2,'#EFE6D1','Arial',bold=True)

prs.save(PPTX_OUT)

# --- render previews ---
preview_paths=[]
for s in slides:
    img=render_preview(s)
    out=OUT / f"slide_{s['no']:02d}_preview.png"
    img.save(out)
    preview_paths.append(out)

# contact sheet
thumb_w, thumb_h = 640, 360
pad=36; label_h=34
sheet=Image.new('RGB',(thumb_w*3+pad*4,(thumb_h+label_h)*3+pad*4),'#F4F1EA')
d=ImageDraw.Draw(sheet)
for idx,p in enumerate(preview_paths):
    im=Image.open(p).resize((thumb_w,thumb_h),Image.Resampling.LANCZOS)
    r=idx//3; c=idx%3
    x=pad+c*(thumb_w+pad); y=pad+r*(thumb_h+label_h+pad)
    sheet.paste(im,(x,y+label_h))
    s=slides[idx]
    d.text((x,y),f"{s['no']:02d} · {s['style']}",font=f('latin_bold',20),fill='#2A2823')
sheet.save(CONTACT_OUT)

# --- HTML source deck ---
tokens = '''/* Hallmark · pre-emit critique: P4 H4 E4 S4 R4 V5 · contrast: pass · honest: pass · chrome: pass · icons: pass */
:root{
  --font-display-serif: "Songti SC", "STSong", "SimSun", serif;
  --font-body: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
  --font-latin: Arial, sans-serif;
  --font-mono: "Courier New", monospace;
  --space-1: 8px; --space-2: 16px; --space-3: 24px; --space-4: 32px; --space-6: 48px; --space-8: 64px;
}
*{box-sizing:border-box} html,body{margin:0;width:1920px;height:1080px;overflow:hidden} body{position:relative;-webkit-font-smoothing:antialiased}
.footer{position:absolute;left:80px;right:80px;bottom:50px;display:flex;justify-content:space-between;font:20px var(--font-latin);opacity:.85}.kicker{font:24px var(--font-mono);letter-spacing:.04em}.latin{font-family:var(--font-latin)}
'''
(SHARED/'tokens.css').write_text(tokens, encoding='utf-8')

# Simple HTML mirror; designed as source preview, PPTX is native deliverable.
def css_slide(s):
    return f"body{{background:{s['bg']};color:{s['ink']};font-family:var(--font-body)}} .muted{{color:{s['muted']}}} .accent{{color:{s['accent']}}} .rule{{background:{s['accent']}}}"

def html_for(s):
    # compact absolute-position source; mirrors PPT test content without gradients / fake chrome.
    common = f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>{s['no']:02d} · {html.escape(s['style'])}</title><link rel="stylesheet" href="../shared/tokens.css"><style>{css_slide(s)}</style></head><body>
<div class="footer"><span>{html.escape(s['style'])}</span><span>{s['no']:02d} / 09</span></div>'''
    # Use image preview as exact visible source for browser deck; the editable PPTX remains separate native output.
    rel = f"../deliverables/slide_{s['no']:02d}_preview.png"
    # Actually slides dir to deliverables requires ../../deliverables.
    return common + f'''<img src="../../deliverables/slide_{s['no']:02d}_preview.png" alt="slide {s['no']:02d} preview" style="position:absolute;inset:0;width:1920px;height:1080px;object-fit:cover"><div class="footer"><span>{html.escape(s['style'])}</span><span>{s['no']:02d} / 09</span></div></body></html>'''

for s in slides:
    (SLIDES/f"{s['no']:02d}-{s['theme']}.html").write_text(html_for(s), encoding='utf-8')
manifest = ',\n'.join([f"  {{ file: 'slides/{s['no']:02d}-{s['theme']}.html', label: '{s['no']:02d} {s['style']}' }}" for s in slides])
index = f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>Huashu + Hallmark PPT Test</title><style>
html,body{{margin:0;height:100%;background:#161616;color:#fff;font-family:system-ui, sans-serif;overflow:hidden}} iframe{{border:0;width:100%;height:100%;background:#fff}} .bar{{position:fixed;right:18px;bottom:14px;padding:7px 11px;border-radius:999px;background:rgba(0,0,0,.55);font-size:13px;z-index:2}}
</style></head><body><iframe id="frame"></iframe><div class="bar" id="bar"></div><script>
const MANIFEST=[\n{manifest}\n];let i=Number(localStorage.getItem('deck-i')||0);const frame=document.getElementById('frame'),bar=document.getElementById('bar');function show(n){{i=Math.max(0,Math.min(MANIFEST.length-1,n));frame.src=MANIFEST[i].file;bar.textContent=`${{i+1}} / ${{MANIFEST.length}} · ${{MANIFEST[i].label}}`;localStorage.setItem('deck-i',i)}}
addEventListener('keydown',e=>{{if(e.key==='ArrowRight'||e.key===' ')show(i+1);if(e.key==='ArrowLeft')show(i-1);if(e.key==='Home')show(0);if(e.key==='End')show(MANIFEST.length-1)}});show(i);
</script></body></html>'''
(HTML/'index.html').write_text(index, encoding='utf-8')

readme = f'''# 花书 Design + Hallmark 三风格 PPT 测试

生成时间：2026-06-06 01:24 CST

## 交付物
- `deliverables/huashu_hallmark_3styles_9slides.pptx`：9 页 PowerPoint 原生文件
- `deliverables/preview_contact_sheet.png`：9 页总览图
- `html/index.html`：HTML 聚合演示版，方向键翻页
- `deliverables/slide_XX_preview.png`：每页单独预览图

## 三个风格
1. A · Quiet Editorial：暖纸底、宋体感标题、克制编辑排版
2. B · Atmospheric Technical：暗底技术界面、单一荧光绿色锚点、真实占位而非虚构指标
3. C · Brutalist Riso：粗黑线、Riso 色块、反模板海报感

## Hallmark 自检要点
- 无紫蓝渐变、无 emoji 图标、无假浏览器/手机 chrome
- 未虚构业务指标；第 5 页明确标注演示占位/待确认
- 三版结构差异明显：编辑排版 / 技术面板 / 粗粝海报
'''
(ROOT/'README.md').write_text(readme, encoding='utf-8')

print(json.dumps({
    'pptx': str(PPTX_OUT),
    'contact_sheet': str(CONTACT_OUT),
    'html_index': str(HTML/'index.html'),
    'slides': len(slides),
    'preview_files': [str(p) for p in preview_paths]
}, ensure_ascii=False, indent=2))
