# -*- coding: utf-8 -*-
"""把课件内容模块（_contentN.py 的 SLIDES：导航短标题/页大标题/正文HTML）渲染为 16:9 PPTX。

用法：
    python _build_pptx.py _content1.py out.pptx "第 1 章 引论" [assets_dir]
要点：
  - 全角引号原样保留；正文按块解析（标题/段落/列表/表格/卡片/提示框/判断互动/图片）。
  - 内容超出一页时自动分页，续页标题加“（续）”。
  - 判断题互动渲染为“题目页 + 参考答案页”，答案同时写入演讲者备注。
  - 图片按文件名匹配 assets_dir 下的实际文件，居中满宽展示。
"""
import importlib.util
import math
import os
import sys

import lxml.html
from PIL import Image
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Emu, Inches, Pt

NAVY = RGBColor(0x1A, 0x36, 0x5D)
BLUE = RGBColor(0x2B, 0x6C, 0xB0)
INK = RGBColor(0x2D, 0x37, 0x48)
GRAY = RGBColor(0x71, 0x80, 0x96)
LIGHT = RGBColor(0xEB, 0xF4, 0xFF)
BOXBG = RGBColor(0xF7, 0xFA, 0xFC)
BORDER = RGBColor(0xCB, 0xD5, 0xE0)
RED = RGBColor(0xC5, 0x30, 0x30)
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
GREEN = RGBColor(0x2F, 0x85, 0x5B)

FONT = '微软雅黑'
SLIDE_W, SLIDE_H = 13.333, 7.5
MARGIN_L, BODY_TOP, BODY_W = 0.72, 1.42, 11.9
BODY_BOTTOM = 6.85
CHARS_PER_LINE = 60.0


def norm(s):
    return ' '.join(s.split())


def flat(el):
    """取元素可见文本（保留全角引号，压缩空白）。"""
    return norm(''.join(el.itertext()))


def size_of(text, pt):
    return len(text) * pt / 72.0 * 0.98


def lines_of(text, chars=CHARS_PER_LINE):
    return max(1, math.ceil(len(text) / chars))


# ---------------------------------------------------------------- 解析
def parse_body(html):
    root = lxml.html.fromstring('<div>%s</div>' % html)
    blocks = []
    walk_children(root, blocks)      # 注意：root 自身就是包裹 div，直接遍历其子元素
    return blocks


def walk_children(node, out):
    for el in node:
        tag = el.tag if isinstance(el.tag, str) else ''
        cls = (el.get('class') or '').split()
        if tag in ('h1', 'h2', 'h3', 'h4'):
            t = flat(el)
            if t:
                out.append(('head', t, 1 if tag == 'h1' else 2))
        elif tag == 'p':
            t = flat(el)
            if t:
                out.append(('para', t))
        elif tag == 'table':
            rows = []
            for tr in el.iter('tr'):
                cells = [flat(td) for td in tr if td.tag in ('td', 'th')]
                if any(cells):
                    rows.append(cells)
            if rows:
                out.append(('table', rows))
        elif tag in ('ul', 'ol'):
            items = [flat(li) for li in el if li.tag == 'li']
            items = [x for x in items if x]
            if items:
                out.append(('bullets', items))
        elif tag == 'img':
            out.append(('image', el.get('src') or '', flat(el) or el.get('alt') or ''))
        elif tag == 'button':
            continue
        elif 'quiz' in cls:
            out.append(parse_quiz(el))
        elif 'card-mini' in cls:
            h = el.find('.//h1'); h = h if h is not None else el.find('.//h3')
            body = ' '.join(flat(p) for p in el.findall('.//p'))
            out.append(('card', norm(h.text_content() if h is not None else ''), norm(body)))
        elif 'quote-block' in cls:
            t = flat(el)
            if t:
                out.append(('callout', t))
        elif 'info-box' in cls:
            t = flat(el)
            if t:
                out.append(('info', t))
        else:
            walk_children(el, out)


def parse_quiz(el):
    q = []
    opts = []
    feedback = ''
    for child in el.iter():
        if not isinstance(child.tag, str):
            continue
        cls = (child.get('class') or '').split()
        if child.tag == 'button' and 'quiz-opt' in cls:
            opts.append((flat(child), child.get('data-correct') == 'true'))
        elif child.tag == 'div' and 'quiz-feedback' in cls:
            feedback = flat(child)
        elif child.tag in ('p', 'h3', 'h4') and 'quiz-opt' not in cls:
            t = flat(child)
            if t and t not in q:
                q.append(t)
    return ('quiz', q, opts, feedback)


# ---------------------------------------------------------------- 渲染
def add_logo_to_master(prs, logo_path, left=0.34, top=0.26, height=0.44):
    """把校徽写进幻灯片母版左上角（母版形状会被所有版式的页面继承）。"""
    from pptx.oxml.ns import qn
    from lxml import etree
    master = prs.slide_masters[0]
    image_part, rId = master.part.get_or_add_image_part(logo_path)
    with Image.open(logo_path) as im:
        ratio = im.width / float(im.height)
    cx = Emu(int(Inches(height) * ratio))
    cy = Emu(int(Inches(height)))
    xml = (
        '<p:pic xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
        'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
        'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
        '<p:nvPicPr><p:cNvPr id="900" name="校徽"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/>'
        '</p:cNvPicPr><p:nvPr/></p:nvPicPr>'
        '<p:blipFill><a:blip r:embed="%s"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>'
        '<p:spPr><a:xfrm><a:off x="%d" y="%d"/><a:ext cx="%d" cy="%d"/></a:xfrm>'
        '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>'
        % (rId, int(Inches(left)), int(Inches(top)), int(cx), int(cy))
    )
    master.shapes._spTree.append(etree.fromstring(xml))


class Deck:
    def __init__(self, title_line, subtitle, logo=None):
        self.prs = Presentation()
        if logo and os.path.exists(logo):
            add_logo_to_master(self.prs, logo)
            self.logo = logo
        else:
            self.logo = None
        self.prs.slide_width = Inches(SLIDE_W)
        self.prs.slide_height = Inches(SLIDE_H)
        self.title_line = title_line
        self.subtitle = subtitle
        self.page = 0
        self.blank = self.prs.slide_layouts[6]

    # ---- 基础绘制
    def new_slide(self, page_title=None, nav=None):
        slide = self.prs.slides.add_slide(self.blank)
        self.page += 1
        if page_title is not None:
            bar = slide.shapes.add_shape(1, 0, 0, Inches(SLIDE_W), Inches(0.13))
            bar.fill.solid(); bar.fill.fore_color.rgb = NAVY; bar.line.fill.background()
            title_x = 2.25 if self.logo else MARGIN_L
            tb = slide.shapes.add_textbox(Inches(title_x), Inches(0.34), Inches(8.3), Inches(0.9))
            tf = tb.text_frame; tf.word_wrap = True
            p = tf.paragraphs[0]; r = p.add_run(); r.text = page_title
            r.font.size = Pt(27); r.font.bold = True; r.font.name = FONT; r.font.color.rgb = NAVY
            if nav:
                nb = slide.shapes.add_textbox(Inches(10.35), Inches(0.42), Inches(2.3), Inches(0.4))
                np_ = nb.text_frame.paragraphs[0]; nr = np_.add_run(); nr.text = nav
                nr.font.size = Pt(12); nr.font.name = FONT; nr.font.color.rgb = GRAY
                np_.alignment = PP_ALIGN.RIGHT
            self.footer(slide)
        return slide

    def footer(self, slide):
        fb = slide.shapes.add_textbox(Inches(MARGIN_L), Inches(6.95), Inches(11.9), Inches(0.35))
        p = fb.text_frame.paragraphs[0]
        r = p.add_run(); r.text = '%s ｜ %s' % (self.title_line, self.subtitle)
        r.font.size = Pt(10); r.font.name = FONT; r.font.color.rgb = GRAY
        pb = slide.shapes.add_textbox(Inches(12.3), Inches(6.95), Inches(0.7), Inches(0.35))
        pp = pb.text_frame.paragraphs[0]; pp.alignment = PP_ALIGN.RIGHT
        pr = pp.add_run(); pr.text = str(self.page)
        pr.font.size = Pt(10); pr.font.name = FONT; pr.font.color.rgb = GRAY

    def textbox(self, slide, x, y, w, h):
        tb = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
        tf = tb.text_frame; tf.word_wrap = True; tf.margin_left = 0; tf.margin_right = 0
        tf.margin_top = 0; tf.margin_bottom = 0
        return tf

    def style(self, para, text, size=16, bold=False, color=INK, italic=False, space_after=6,
              first=False, align=PP_ALIGN.LEFT, font=FONT):
        p = para if first else para
        r = p.add_run(); r.text = text
        r.font.size = Pt(size); r.font.bold = bold; r.font.italic = italic
        r.font.name = font; r.font.color.rgb = color
        p.space_after = Pt(space_after)
        p.alignment = PP_ALIGN.LEFT if align is None else align   # 一律显式指定，避免渲染器默认值差异
        return p

    def draw_block(self, slide, x, y, w, block, height):
        """按块类型绘制，返回实际占用高度（英寸）。"""
        kind = block[0]
        if kind == 'head':
            _, text, level = block
            tf = self.textbox(slide, x, y + 0.06, w, height)
            self.style(tf.paragraphs[0], text, size=20 if level == 1 else 18, bold=True,
                       color=BLUE if level > 1 else NAVY, space_after=2, first=True)
        elif kind == 'para':
            tf = self.textbox(slide, x, y, w, height)
            self.style(tf.paragraphs[0], block[1], size=16, first=True)
        elif kind == 'bullets':
            tf = self.textbox(slide, x, y, w, height)
            for i, item in enumerate(block[1]):
                self.style(tf.paragraphs[0] if i == 0 else tf.add_paragraph(),
                           '• ' + item, size=16, first=(i == 0), space_after=4)
        elif kind == 'card':
            _, title, body = block
            box = slide.shapes.add_shape(1, Inches(x), Inches(y), Inches(w), Inches(height))
            box.fill.solid(); box.fill.fore_color.rgb = LIGHT
            box.line.color.rgb = BORDER; box.line.width = Pt(0.75)
            box.shadow.inherit = False
            tf = box.text_frame; tf.word_wrap = True
            tf.margin_left = Inches(0.14); tf.margin_right = Inches(0.14)
            tf.margin_top = Inches(0.08); tf.margin_bottom = Inches(0.06)
            tf.vertical_anchor = MSO_ANCHOR.TOP
            if title:
                self.style(tf.paragraphs[0], title, size=15, bold=True, color=NAVY, first=True,
                           space_after=3, align=PP_ALIGN.LEFT)
                para = tf.add_paragraph()
            else:
                para = tf.paragraphs[0]
            self.style(para, body, size=13, color=INK, first=True, space_after=0,
                       align=PP_ALIGN.LEFT)
        elif kind == 'callout':
            _, text = block
            box = slide.shapes.add_shape(1, Inches(x), Inches(y), Inches(w), Inches(height))
            box.fill.solid(); box.fill.fore_color.rgb = RGBColor(0xF0, 0xF7, 0xFF)
            box.line.color.rgb = BLUE; box.line.width = Pt(1)
            box.shadow.inherit = False
            tf = box.text_frame; tf.word_wrap = True
            tf.margin_left = Inches(0.16); tf.margin_right = Inches(0.16)
            tf.margin_top = Inches(0.1); tf.margin_bottom = Inches(0.08)
            tf.vertical_anchor = MSO_ANCHOR.MIDDLE
            self.style(tf.paragraphs[0], text, size=15, bold=False, color=NAVY, first=True,
                       space_after=0, align=PP_ALIGN.LEFT)
        elif kind == 'info':
            _, text = block
            box = slide.shapes.add_shape(1, Inches(x), Inches(y), Inches(w), Inches(height))
            box.fill.solid(); box.fill.fore_color.rgb = BOXBG
            box.line.color.rgb = BORDER; box.line.width = Pt(0.75)
            box.shadow.inherit = False
            tf = box.text_frame; tf.word_wrap = True
            tf.margin_left = Inches(0.16); tf.margin_right = Inches(0.16)
            tf.margin_top = Inches(0.1); tf.margin_bottom = Inches(0.08)
            tf.vertical_anchor = MSO_ANCHOR.MIDDLE
            self.style(tf.paragraphs[0], text, size=14.5, color=INK, first=True, space_after=0,
                       align=PP_ALIGN.LEFT)
        elif kind == 'table':
            rows = block[1]
            nrow, ncol = len(rows), max(len(r) for r in rows)
            shape = slide.shapes.add_table(nrow, ncol, Inches(x), Inches(y), Inches(w), Inches(height))
            tbl = shape.table
            for ri, row in enumerate(rows):
                for ci in range(ncol):
                    cell = tbl.cell(ri, ci)
                    cell.text = row[ci] if ci < len(row) else ''
                    cell.margin_left = Inches(0.08); cell.margin_right = Inches(0.06)
                    cell.margin_top = Inches(0.03); cell.margin_bottom = Inches(0.03)
                    cell.vertical_anchor = MSO_ANCHOR.MIDDLE
                    p = cell.text_frame.paragraphs[0]
                    p.alignment = PP_ALIGN.LEFT
                    for r in p.runs:
                        r.font.name = FONT
                        r.font.size = Pt(13 if ri else 14)
                        r.font.bold = (ri == 0)
                        r.font.color.rgb = WHITE if ri == 0 else INK
                    cell.fill.solid()
                    cell.fill.fore_color.rgb = NAVY if ri == 0 else (WHITE if ri % 2 else RGBColor(0xF2, 0xF6, 0xFA))
            return height
        elif kind == 'image':
            pass
        return height

    # ---- 尺寸估算
    def block_height(self, block, w=BODY_W):
        kind = block[0]
        if kind == 'head':
            return 0.46 if block[2] == 1 else 0.42
        if kind == 'para':
            return 0.30 * lines_of(block[1], CHARS_PER_LINE) + 0.12
        if kind == 'bullets':
            return 0.30 * len(block[1]) + 0.16
        if kind == 'card':
            body_lines = lines_of(block[2], CHARS_PER_LINE * 1.05)
            return 0.34 + 0.24 * body_lines + 0.24
        if kind == 'callout':
            return 0.30 * lines_of(block[1], CHARS_PER_LINE * 0.95) + 0.34
        if kind == 'info':
            return 0.28 * lines_of(block[1], CHARS_PER_LINE * 0.98) + 0.30
        if kind == 'table':
            rows = block[1]
            per = [0.34 * lines_of(' '.join(r), 26) for r in rows]
            return sum(per) + 0.12
        return 0.3

    # ---- 页面组装
    def content_slides(self, nav, title, blocks):
        pending = list(blocks)
        first = True
        while pending or first:
            head = title if first else title + '（续）'
            slide = self.new_slide(head, nav)
            # 先挑出本页能放下的块，再按剩余空间均摊间距
            take, used = [], 0.0
            for blk in pending:
                h = self.block_height(blk)
                if used + h > BODY_BOTTOM - BODY_TOP and take:
                    break
                take.append((blk, h)); used += h
            avail0 = BODY_BOTTOM - BODY_TOP
            rest = pending[len(take):]
            if rest:
                rest_h = sum(self.block_height(b) for b in rest)
                if len(rest) <= 2 and used + rest_h + 0.10 * len(rest) <= avail0 + 0.45:
                    for blk in rest:
                        h = self.block_height(blk)
                        take.append((blk, h)); used += h
            n = len(take)
            gap = 0.12
            if n > 1:
                gap = min(0.55, 0.12 + max(0.0, (BODY_BOTTOM - BODY_TOP - used)) / (n - 1) * 0.55)
            avail = BODY_BOTTOM - BODY_TOP
            # 整页只有一句提示时，做成“金句页”：大字号居中，避免空旷
            single = take[0][0] if take else None
            short_single = (len(take) == 1 and not pending[len(take):] and single and
                            single[0] in ('callout', 'info', 'para') and len(single[1]) <= 140)
            if short_single:
                text = single[1]
                bh = 2.4
                top = BODY_TOP + (avail - bh) / 2.0
                box = slide.shapes.add_shape(1, Inches(MARGIN_L), Inches(top), Inches(BODY_W), Inches(bh))
                box.fill.solid(); box.fill.fore_color.rgb = RGBColor(0xF0, 0xF7, 0xFF)
                box.line.color.rgb = BLUE; box.line.width = Pt(1.25); box.shadow.inherit = False
                tf = box.text_frame; tf.word_wrap = True
                tf.margin_left = Inches(0.3); tf.margin_right = Inches(0.3)
                tf.vertical_anchor = MSO_ANCHOR.MIDDLE
                self.style(tf.paragraphs[0], text, size=25, bold=True, color=NAVY, first=True,
                           space_after=0, align=PP_ALIGN.LEFT)
                pending.clear()
                first = False
                continue
            # 内容过少时整体垂直居中，避免头重脚轻的大片空白
            y = BODY_TOP + max(0.0, (avail - used) / 2.0) if used < avail * 0.55 else BODY_TOP
            while pending:
                blk = pending[0]
                h = self.block_height(blk)
                if y + h > BODY_BOTTOM and y > BODY_TOP + 0.2:
                    break
                if h > BODY_BOTTOM - BODY_TOP:          # 单块过高：表格拆行
                    if blk[0] == 'table' and len(blk[1]) > 3:
                        head_rows = blk[1][:3]
                        rest = blk[1][3:]
                        pending[0] = ('table', rest)
                        self.draw_block(slide, MARGIN_L, y, BODY_W, ('table', head_rows),
                                        self.block_height(('table', head_rows)))
                        y = BODY_BOTTOM
                        break
                    h = BODY_BOTTOM - BODY_TOP - 0.1
                self.draw_block(slide, MARGIN_L, y, BODY_W, blk, h)
                y += h + gap
                pending.pop(0)
            first = False

    def image_slide(self, nav, title, path, caption):
        slide = self.new_slide(title, nav)
        with Image.open(path) as im:
            ratio = im.height / float(im.width)
        w = 10.6
        h = w * ratio
        if h > 5.0:
            h = 5.0; w = h / ratio
        left = (SLIDE_W - w) / 2.0
        top = BODY_TOP + max(0.0, (BODY_BOTTOM - BODY_TOP - h - 0.5) / 2.0)
        pic = slide.shapes.add_picture(path, Inches(left), Inches(top), Inches(w), Inches(h))
        pic.line.color.rgb = BORDER; pic.line.width = Pt(1)
        if caption:
            tf = self.textbox(slide, MARGIN_L, min(top + h + 0.12, BODY_BOTTOM), BODY_W, 0.4)
            self.style(tf.paragraphs[0], caption, size=13, color=GRAY, first=True,
                       align=PP_ALIGN.CENTER)

    def quiz_slides(self, nav, title, quiz, intro=None):
        _, questions, opts, feedback = quiz
        intro = intro or []
        slide = self.new_slide(title, nav)
        # 内容整体垂直居中，避免题目少时下半页大片空白
        intro_h = sum(self.block_height(b) + 0.14 for b in intro)
        q_lines = sum(lines_of(q, CHARS_PER_LINE * 1.05) for q in questions)
        box_h = min(0.52 * len(opts) + 0.34, 4.6)
        total = intro_h + 0.34 * q_lines + 0.35 + box_h
        y = BODY_TOP + max(0.0, (BODY_BOTTOM - BODY_TOP - total) / 2.0)
        for blk in intro:                      # 导语/小标题等先画
            h = self.block_height(blk)
            self.draw_block(slide, MARGIN_L, y, BODY_W, blk, h)
            y += h + 0.14
        tf = self.textbox(slide, MARGIN_L, y, BODY_W, 4.6)
        first = True
        for q in questions:
            self.style(tf.paragraphs[0] if first else tf.add_paragraph(), q, size=17,
                       bold=first, color=NAVY if first else INK, first=first, space_after=8)
            first = False
        y += 0.34 * q_lines + 0.35
        box = slide.shapes.add_shape(1, Inches(MARGIN_L), Inches(y), Inches(BODY_W), Inches(box_h))
        box.fill.solid(); box.fill.fore_color.rgb = BOXBG
        box.line.color.rgb = BORDER; box.line.width = Pt(1); box.shadow.inherit = False
        btf = box.text_frame; btf.word_wrap = True
        btf.margin_left = Inches(0.2); btf.margin_top = Inches(0.12)
        for i, (text, _c) in enumerate(opts):
            self.style(btf.paragraphs[0] if i == 0 else btf.add_paragraph(), text,
                       size=15, first=(i == 0), space_after=6, align=PP_ALIGN.LEFT)
        if not opts and feedback:
            fh = self.block_height(('callout', feedback))
            self.draw_block(slide, MARGIN_L, min(y + 0.25, BODY_BOTTOM - fh), BODY_W,
                            ('callout', feedback), fh)
            slide.notes_slide.notes_text_frame.text = feedback
            return
        nb = slide.shapes.add_textbox(Inches(MARGIN_L), Inches(min(y + 0.2 + box_h, 6.5)),
                                      Inches(BODY_W), Inches(0.4))
        self.style(nb.text_frame.paragraphs[0], '（先自己判断，再看下一页参考答案）', size=12,
                   color=GRAY, italic=True, first=True)
        # 答案页
        ans = self.new_slide(title + ' · 参考答案', nav)
        fb_h = self.block_height(('callout', feedback)) if feedback else 0.0
        a_total = 0.46 * len(opts) + (fb_h + 0.2 if feedback else 0.0)
        y = BODY_TOP + max(0.0, (BODY_BOTTOM - BODY_TOP - a_total) / 2.0)
        for text, correct in opts:
            mark = '✓ 是' if correct else '✗ 不是'
            color = GREEN if correct else GRAY
            tf = self.textbox(ans, MARGIN_L, y, BODY_W, 0.5)
            tf.paragraphs[0].alignment = PP_ALIGN.LEFT
            r = tf.paragraphs[0].add_run(); r.text = mark + '　'
            r.font.size = Pt(15); r.font.bold = True; r.font.color.rgb = color; r.font.name = FONT
            r2 = tf.paragraphs[0].add_run(); r2.text = text
            r2.font.size = Pt(15); r2.font.color.rgb = INK; r2.font.name = FONT
            y += 0.46
        if feedback:
            h = self.block_height(('callout', feedback))
            self.draw_block(ans, MARGIN_L, min(y + 0.1, BODY_BOTTOM - h), BODY_W,
                            ('callout', feedback), h)
        ans.notes_slide.notes_text_frame.text = '参考答案：' + '；'.join(
            t for t, c in opts if c) + ('\n' + feedback if feedback else '')

    def cover_slide(self, nav, title, blocks, image=None):
        slide = self.new_slide(None)
        band = slide.shapes.add_shape(1, 0, 0, Inches(SLIDE_W), Inches(2.5))
        band.fill.solid(); band.fill.fore_color.rgb = NAVY; band.line.fill.background()
        if self.logo:
            plate = slide.shapes.add_shape(5, Inches(0.26), Inches(0.24), Inches(1.98), Inches(0.62))
            plate.fill.solid(); plate.fill.fore_color.rgb = WHITE
            plate.line.fill.background(); plate.shadow.inherit = False
            with Image.open(self.logo) as im:
                ratio = im.width / float(im.height)
            lh = 0.34
            slide.shapes.add_picture(self.logo, Inches(0.26 + (1.98 - lh * ratio) / 2.0),
                                     Inches(0.24 + (0.62 - lh) / 2.0),
                                     Inches(lh * ratio), Inches(lh))
        tf = self.textbox(slide, 0.85, 0.85, 7.1, 1.6)
        self.style(tf.paragraphs[0], title, size=31, bold=True, color=WHITE, first=True, space_after=6)
        if image and os.path.exists(image):
            with Image.open(image) as im:
                ratio = im.height / float(im.width)
            iw = 4.55
            ih = iw * ratio
            if ih > 2.2:
                ih = 2.2; iw = ih / ratio
            slide.shapes.add_picture(image, Inches(8.35), Inches(0.15 + (2.5 - ih) / 2.0),
                                     Inches(iw), Inches(ih))
        y = 2.85
        for blk in blocks:
            h = self.block_height(blk)
            self.draw_block(slide, MARGIN_L, y, BODY_W, blk, h)
            y += h + 0.12

    def end_slide(self, nav, title, blocks):
        self.content_slides(nav, title, blocks)


def load_slides(module_path):
    spec = importlib.util.spec_from_file_location('deck_content', module_path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod.SLIDES


def asset_for(assets_dir, src):
    """src 可能是 assets/ch1/xxx.jpg；在 assets_dir 及其子目录里找同名文件。"""
    if not src:
        return None
    name = os.path.basename(src.split('?')[0])
    for root, _dirs, files in os.walk(assets_dir):
        if name in files:
            return os.path.join(root, name)
    return None


def build(content_path, out_path, title_line, subtitle, assets_dir, imgmap=None, logo=None):
    slides = load_slides(content_path)
    imgmap = imgmap or {}
    deck = Deck(title_line, subtitle, logo)
    for idx, item in enumerate(slides):
        nav, title, body = item[0], item[1], item[2]
        blocks = parse_body(body)
        images = [b for b in blocks if b[0] == 'image']
        blocks = [b for b in blocks if b[0] != 'image']
        quizzes = [b for b in blocks if b[0] == 'quiz']
        if quizzes:
            lead = [b for b in blocks if b[0] not in ('quiz', 'para')] + [b for b in blocks if b[0] == 'para']
            for k, q in enumerate(quizzes):
                deck.quiz_slides(nav, title, q, lead if k == 0 else [])
        elif idx == 0:
            cover = asset_for(assets_dir, 'cover.png') or (
                asset_for(assets_dir, images[0][1]) if images else None)
            deck.cover_slide(nav, title, blocks, cover)
        else:
            deck.content_slides(nav, title, blocks)
        for img in images:
            path = asset_for(assets_dir, img[1])
            if path:
                caption = img[2] if img[2] and not img[2].startswith('assets/') else ''
                deck.image_slide(nav, title, path, caption)
        extra = imgmap.get(nav)
        if extra:
            path = asset_for(assets_dir, extra)
            if path:
                deck.image_slide(nav, title, path, '')
    deck.prs.save(out_path)
    return deck.page


if __name__ == '__main__':
    content = sys.argv[1]
    out = sys.argv[2]
    title_line = sys.argv[3] if len(sys.argv) > 3 else '软件测试课程设计'
    assets = sys.argv[4] if len(sys.argv) > 4 else os.path.join(os.path.dirname(out), 'assets')
    imgmap = {}
    if len(sys.argv) > 5 and os.path.exists(sys.argv[5]):
        import json
        imgmap = json.load(open(sys.argv[5], encoding='utf-8'))
    logo = None
    for cand in ('assets/brand/logo-master.png', 'assets/brand/logo-local-6x.png', 'assets/brand/logo-original.png'):
        cp = os.path.join(os.path.dirname(os.path.abspath(out)), cand)
        if os.path.exists(cp):
            logo = cp; break
    n = build(content, out, title_line, '专业选修 · 154442008 · 周宇文', assets, imgmap, logo)
    print('生成 %d 页 -> %s' % (n, out))
