#!/usr/bin/env python3
"""
build_slides_html.py — Build a standalone presentation HTML from slides.json.
No external dependencies beyond Python stdlib. Open the output in any browser.

Usage:
  python3 build_slides_html.py --slides slides.json --output slides.html

Navigation: ← / → arrow keys, or click the prev/next buttons.
"""
import argparse
import base64
import io
import json
import re
import html as html_lib
from pathlib import Path

# ── Design tokens ─────────────────────────────────────────────────
# 2色（本文色＋アクセント1色）で組む設計。装飾で盛らず、余白・罫線・
# ウェイト差で階層を作る。slides.json の "theme" で配色だけを差し替える。
#
# テーマを足したいときは THEMES に1行足すだけでよい。ink は本文と面、
# accent はその資料で「1箇所だけ」主役になる色。tint はその淡い版。
THEMES = {
    "sumi":    {"name": "墨と朱赤",     "ink": "#222222", "accent": "#d7322d", "tint": "#fceaea"},
    "navy":    {"name": "紺と橙",       "ink": "#1e293b", "accent": "#ea6d1f", "tint": "#fdeee2"},
    "forest":  {"name": "深緑と芥子",   "ink": "#1f3d2f", "accent": "#c99700", "tint": "#faf1d8"},
    "plum":    {"name": "葡萄と桃",     "ink": "#3b2540", "accent": "#c2417a", "tint": "#fbe9f1"},
    "indigo":  {"name": "藍と水色",     "ink": "#20304d", "accent": "#2f7fbf", "tint": "#e6f1f9"},
    "charcoal":{"name": "炭と若草",     "ink": "#2b2b2b", "accent": "#5b8c3a", "tint": "#eef4e8"},
}
DEFAULT_THEME = "sumi"

# 実行中のテーマ（build() が slides.json を読んだ時点で差し替える）
THEME = dict(THEMES[DEFAULT_THEME])

ACCENT = {}      # 役割名 → 実際の色。set_theme() が組み立てる
DEFAULT_ACCENT = THEME["ink"]


def set_theme(key: str) -> dict:
    """slides.json の "theme" を反映する。未知のキーは既定テーマに落とす。"""
    global THEME, ACCENT, DEFAULT_ACCENT
    THEME = dict(THEMES.get(key, THEMES[DEFAULT_THEME]))
    ACCENT = {
        "ink": THEME["ink"], "accent": THEME["accent"], "mute": "#666666",
        "blue": THEME["ink"],      # 既定・中立   → 本文色
        "green": THEME["accent"],  # 推奨・強調   → アクセント
        "red": "#666666",          # 非推奨・現状 → グレー
    }
    DEFAULT_ACCENT = THEME["ink"]
    return THEME


set_theme(DEFAULT_THEME)

FONTS_DIR = Path(__file__).parent / "fonts" / "master"
FONT_WEIGHTS = [(400, "NotoSansJP-Regular.ttf"), (700, "NotoSansJP-Bold.ttf")]
# Always included regardless of slide content, so nav/UI chrome never falls
# back to a system font (which would break rendering consistency offline).
BASELINE_CHARS = (
    " !\"#$%&'()*+,-./0123456789:;<=>?@"
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`"
    "abcdefghijklmnopqrstuvwxyz{|}~"
    "←→↑⛶●▶▸⤬"
)

_BR_RE = re.compile(r"&lt;\s*br\s*/?\s*&gt;", re.IGNORECASE)


def esc(s: str) -> str:
    # Escape HTML but preserve <br> tags (any casing/spacing/self-closing variant)
    # written intentionally in content.
    escaped = html_lib.escape(str(s))
    return _BR_RE.sub("<br>", escaped)


def accent_color(key: str) -> str:
    return ACCENT.get(key, DEFAULT_ACCENT)


# ── Layout renderers → return inner HTML string ───────────────────

def render_cover(d: dict) -> str:
    title    = esc(d.get("title", ""))
    subtitle = esc(d.get("subtitle", ""))
    author   = esc(d.get("author", ""))
    sub_html = f'<p class="cover-subtitle">{subtitle}</p>' if subtitle else ""
    auth_html = f'<p class="cover-author">{author}</p>' if author else ""
    return f"""
<div class="layout-cover">
  <div class="cover-center">
    <h1 class="cover-title">{title}</h1>
    {sub_html}
    {auth_html}
  </div>
  <div class="cover-accent-line"></div>
</div>"""


def render_section(d: dict) -> str:
    num   = d.get("section_num", "")
    title = esc(d.get("title", ""))
    num_html = f'<p class="section-num">Section {esc(str(num))}</p>' if num else ""
    return f"""
<div class="layout-section">
  <div class="section-center">
    {num_html}
    <h2 class="section-title">{title}</h2>
    <div class="section-line"></div>
  </div>
</div>"""


def _item_title_body(item) -> tuple[str, str]:
    if isinstance(item, dict):
        return esc(item.get("title", "")), esc(item.get("body", ""))
    return esc(str(item)), ""


def _as_dict(item) -> dict:
    # big-number / bar-chart / donut / matrix require object items — a stray
    # string (schema violation, flagged by validate_slide) must not crash the
    # whole build, so fall back to an empty record instead.
    return item if isinstance(item, dict) else {}


def render_cards(d: dict) -> str:
    items  = d.get("items", [])
    accent = accent_color(d.get("accent", "blue"))
    headline  = esc(d.get("headline", ""))
    subhl     = esc(d.get("subheadline", ""))
    subhl_html = f'<p class="subheadline">{subhl}</p>' if subhl else ""

    cards_html = ""
    for i, item in enumerate(items):
        title, body = _item_title_body(item)
        body_html = f'<p class="card-body">{body}</p>' if body else ""
        cards_html += f"""
<div class="card">
  <div class="card-badge">{i+1}</div>
  <p class="card-title">{title}</p>
  {body_html}
</div>"""

    return f"""
<div class="layout-cards">
  <div class="headline-area">
    <h2 class="headline">{headline}</h2>
    {subhl_html}
  </div>
  <div class="cards-center">
    <div class="cards-row">{cards_html}</div>
  </div>
</div>"""


def render_bullets(d: dict) -> str:
    items  = d.get("items", [])
    accent = accent_color(d.get("accent", "blue"))
    headline = esc(d.get("headline", ""))
    subhl    = esc(d.get("subheadline", ""))
    subhl_html = f'<p class="subheadline">{subhl}</p>' if subhl else ""

    rows = ""
    for item in items:
        title, body = _item_title_body(item)
        body_html = f'<p class="bullet-body">{body}</p>' if body else ""
        rows += f"""
<div class="bullet-row">
  <div class="bullet-dot"></div>
  <div class="bullet-text">
    <p class="bullet-title">{title}</p>
    {body_html}
  </div>
</div>"""

    return f"""
<div class="layout-bullets">
  <div class="headline-area">
    <h2 class="headline">{headline}</h2>
    {subhl_html}
  </div>
  <div class="bullets-list">{rows}</div>
</div>"""


def render_comparison(d: dict) -> str:
    headline  = esc(d.get("headline", ""))
    ll  = esc(d.get("left_label",  "Before"))
    rl  = esc(d.get("right_label", "After"))
    li  = [esc(str(x)) for x in d.get("left_items",  [])]
    ri  = [esc(str(x)) for x in d.get("right_items", [])]
    lc  = accent_color(d.get("left_color",  "red"))
    rc  = accent_color(d.get("right_color", "green"))

    def col_html(label, items, color):
        rows = "".join(f'<li>{x}</li>' for x in items)
        return f"""
<div class="cmp-col">
  <div class="cmp-header" style="background:{color}">{label}</div>
  <div class="cmp-body" style="border-color:{color}">
    <ul>{rows}</ul>
  </div>
</div>"""

    return f"""
<div class="layout-comparison">
  <div class="headline-area">
    <h2 class="headline">{headline}</h2>
  </div>
  <div class="cmp-row">
    {col_html(ll, li, lc)}
    {col_html(rl, ri, rc)}
  </div>
</div>"""


def render_process(d: dict) -> str:
    steps  = d.get("steps", [])
    accent = accent_color(d.get("accent", "blue"))
    headline = esc(d.get("headline", ""))
    subhl    = esc(d.get("subheadline", ""))
    subhl_html = f'<p class="subheadline">{subhl}</p>' if subhl else ""

    steps_html = ""
    for i, step in enumerate(steps):
        cls = "process-step is-first" if i == 0 else "process-step"
        steps_html += f"""
<div class="{cls}">
  <div class="step-num">{i+1}</div>
  <div class="step-text">{esc(str(step))}</div>
</div>"""

    return f"""
<div class="layout-process">
  <div class="headline-area">
    <h2 class="headline">{headline}</h2>
    {subhl_html}
  </div>
  <div class="process-row">{steps_html}</div>
</div>"""


def render_closing(d: dict) -> str:
    msg = esc(d.get("message",     "ありがとうございました"))
    sub = esc(d.get("sub_message", ""))
    sub_html = f'<p class="closing-sub">{sub}</p>' if sub else ""
    return f"""
<div class="layout-cover">
  <div class="cover-center">
    <h1 class="cover-title">{msg}</h1>
    {sub_html}
  </div>
  <div class="cover-accent-line"></div>
</div>"""


def render_profile(d: dict) -> str:
    image  = esc(d.get("image", ""))
    name   = esc(d.get("name", d.get("headline", "")))
    role   = esc(d.get("role", d.get("subheadline", "")))
    accent = accent_color(d.get("accent", "blue"))
    items  = d.get("items", [])
    rows = ""
    for item in items:
        title, body = _item_title_body(item)
        body_html = f'<p class="prof-bul-body">{body}</p>' if body else ""
        rows += f"""
<div class="prof-bullet">
  <div class="prof-dot"></div>
  <div><p class="prof-bul-title">{title}</p>{body_html}</div>
</div>"""
    role_html = f'<p class="prof-role">{role}</p>' if role else ""
    return f"""
<div class="layout-profile">
  <div class="prof-photo-col"><img class="prof-photo" src="{image}" alt="{name}"></div>
  <div class="prof-info">
    <h2 class="prof-name">{name}</h2>
    {role_html}
    <div class="prof-bullets">{rows}</div>
  </div>
</div>"""


def _headline_block(d: dict) -> str:
    headline = esc(d.get("headline", ""))
    subhl    = esc(d.get("subheadline", ""))
    subhl_html = f'<p class="subheadline">{subhl}</p>' if subhl else ""
    return f"""
  <div class="headline-area">
    <h2 class="headline">{headline}</h2>
    {subhl_html}
  </div>"""


def render_big_number(d: dict) -> str:
    """items: [{value, label, note?}] — 数字を主役に見せる（1〜3個）"""
    items  = d.get("items", [])
    accent = accent_color(d.get("accent", "blue"))

    cells = ""
    for item in items:
        item  = _as_dict(item)
        value = esc(item.get("value", ""))
        label = esc(item.get("label", ""))
        note  = esc(item.get("note", ""))
        note_html = f'<p class="bignum-note">{note}</p>' if note else ""
        cells += f"""
<div class="bignum-cell">
  <p class="bignum-label">{label}</p>
  <p class="bignum-value" style="color:{accent}">{value}</p>
  {note_html}
</div>"""

    return f"""
<div class="layout-visual">
  {_headline_block(d)}
  <div class="visual-center">
    <div class="bignum-row">{cells}</div>
  </div>
</div>"""


def render_bar_chart(d: dict) -> str:
    """items: [{label, value, display?, highlight?}] — 横棒グラフで量を比較"""
    items  = [_as_dict(i) for i in d.get("items", [])]
    accent = accent_color(d.get("accent", "blue"))
    max_v  = max((float(i.get("value", 0)) for i in items), default=1) or 1

    rows = ""
    for item in items:
        label   = esc(item.get("label", ""))
        value   = float(item.get("value", 0))
        display = esc(str(item.get("display", item.get("value", ""))))
        pct     = max(value / max_v * 100, 3)
        hilite  = bool(item.get("highlight", value == max_v))
        color   = accent if hilite else "#cccccc"
        vcolor  = accent if hilite else "var(--sub)"
        rows += f"""
<div class="bar-row">
  <div class="bar-label">{label}</div>
  <div class="bar-track">
    <div class="bar-fill" style="width:{pct:.1f}%;background:{color}"></div>
  </div>
  <div class="bar-value" style="color:{vcolor}">{display}</div>
</div>"""

    return f"""
<div class="layout-visual">
  {_headline_block(d)}
  <div class="visual-center">
    <div class="bar-chart">{rows}</div>
  </div>
</div>"""


def render_donut(d: dict) -> str:
    """items: [{label, value}] — SVGドーナツで構成比を見せる"""
    items  = [_as_dict(i) for i in d.get("items", [])]
    total  = sum(float(i.get("value", 0)) for i in items) or 1
    colors = [THEME["accent"], THEME["ink"], "#666666", "#aaaaaa", "#cccccc"]

    cx = cy = 130
    r, sw = 100, 42
    circumference = 2 * 3.14159265 * r
    segs, legend = "", ""
    offset = 0.0
    for i, item in enumerate(items):
        v     = float(item.get("value", 0))
        frac  = v / total
        label = esc(item.get("label", ""))
        color = colors[i % len(colors)]
        dash  = frac * circumference
        segs += (f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="{color}" '
                 f'stroke-width="{sw}" stroke-dasharray="{dash:.2f} {circumference - dash:.2f}" '
                 f'stroke-dashoffset="{-offset:.2f}" transform="rotate(-90 {cx} {cy})"/>')
        offset += dash
        legend += f"""
<div class="donut-legend-row">
  <span class="donut-swatch" style="background:{color}"></span>
  <span class="donut-legend-label">{label}</span>
  <span class="donut-legend-value">{frac*100:.0f}%</span>
</div>"""

    return f"""
<div class="layout-visual">
  {_headline_block(d)}
  <div class="visual-center">
    <div class="donut-wrap">
      <svg width="260" height="260" viewBox="0 0 260 260">{segs}</svg>
      <div class="donut-legend">{legend}</div>
    </div>
  </div>
</div>"""


def render_flow(d: dict) -> str:
    """steps: [str | {title, body?}] — 矢印でつなぐ流れ図解"""
    steps  = d.get("steps", [])
    accent = accent_color(d.get("accent", "blue"))

    boxes = ""
    for i, step in enumerate(steps):
        title, body = _item_title_body(step)
        body_html = f'<p class="flow-body">{body}</p>' if body else ""
        arrow = '<div class="flow-arrow">→</div>' if i < len(steps) - 1 else ""
        boxes += f"""
<div class="flow-box" style="border-color:{accent}">
  <div class="flow-num">STEP {i+1}</div>
  <p class="flow-title">{title}</p>
  {body_html}
</div>{arrow}"""

    return f"""
<div class="layout-visual">
  {_headline_block(d)}
  <div class="visual-center">
    <div class="flow-row">{boxes}</div>
  </div>
</div>"""


def render_matrix(d: dict) -> str:
    """x_label / y_label / points: [{label, x, y, highlight?}] (x,y: 0-100) — 2軸マップ"""
    x_label = esc(d.get("x_label", ""))
    y_label = esc(d.get("y_label", ""))
    points  = [_as_dict(p) for p in d.get("points", [])]
    accent  = accent_color(d.get("accent", "blue"))

    W, H, PAD = 760, 420, 50
    pts = ""
    for p in points:
        label = esc(p.get("label", ""))
        x = max(0.0, min(100.0, float(p.get("x", 50))))
        y = max(0.0, min(100.0, float(p.get("y", 50))))
        px = PAD + x / 100 * (W - PAD * 2)
        py = H - PAD - y / 100 * (H - PAD * 2)
        color = accent if p.get("highlight") else "#aaaaaa"
        pts += (f'<circle cx="{px:.0f}" cy="{py:.0f}" r="11" fill="{color}"/>'
                f'<text x="{px:.0f}" y="{py - 20:.0f}" text-anchor="middle" '
                f'font-size="17" font-weight="700" fill="{THEME["ink"]}">{label}</text>')

    mid_x, mid_y = W / 2, H / 2
    return f"""
<div class="layout-visual">
  {_headline_block(d)}
  <div class="visual-center">
    <svg width="{W}" height="{H}" viewBox="0 0 {W} {H}" class="matrix-svg" font-family="'Noto Sans JP',sans-serif">
      <line x1="{PAD}" y1="{mid_y}" x2="{W-PAD}" y2="{mid_y}" stroke="#cccccc" stroke-width="1"/>
      <line x1="{mid_x}" y1="{PAD}" x2="{mid_x}" y2="{H-PAD}" stroke="#cccccc" stroke-width="1"/>
      <text x="{W-PAD}" y="{mid_y - 12}" text-anchor="end" font-size="15" fill="#666">{x_label} →</text>
      <text x="{mid_x + 12}" y="{PAD + 8}" font-size="15" fill="#666">↑ {y_label}</text>
      {pts}
    </svg>
  </div>
</div>"""


def render_icon_summary(d: dict) -> str:
    """items: [{icon, title, body?}] — 大きな絵文字アイコン＋ひとこと"""
    items  = d.get("items", [])
    accent = accent_color(d.get("accent", "blue"))

    cells = ""
    for item in items:
        icon = esc(item.get("icon", "●")) if isinstance(item, dict) else "●"
        title, body = _item_title_body(item)
        body_html = f'<p class="iconsum-body">{body}</p>' if body else ""
        cells += f"""
<div class="iconsum-cell">
  <div class="iconsum-icon">{icon}</div>
  <p class="iconsum-title" style="color:{accent}">{title}</p>
  {body_html}
</div>"""

    return f"""
<div class="layout-visual">
  {_headline_block(d)}
  <div class="visual-center">
    <div class="iconsum-row">{cells}</div>
  </div>
</div>"""


RENDERERS = {
    "cover":        render_cover,
    "section":      render_section,
    "cards":        render_cards,
    "bullets":      render_bullets,
    "comparison":   render_comparison,
    "process":      render_process,
    "closing":      render_closing,
    "profile":      render_profile,
    "big-number":   render_big_number,
    "bar-chart":    render_bar_chart,
    "donut":        render_donut,
    "flow":         render_flow,
    "matrix":       render_matrix,
    "icon-summary": render_icon_summary,
}


# ── CSS ───────────────────────────────────────────────────────────

CSS = """
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

/* ──────────────────────────────────────────────────────────────
   本文色＋アクセント1色の2色で組む。装飾で盛らず、余白・罫線・
   ウェイト差で階層を作る。非対称角丸 5px 0 / 20px 0。
   ウェイトは 400 / 700 の二極。行間は 1.7 を基本に一律。
   色は slides.json の "theme" で差し替わる（既定: 墨と朱赤）。
   ────────────────────────────────────────────────────────────── */
:root {
  --ink:      __INK__;      /* 本文・見出し・面。純黒は使わない */
  --accent:   __ACCENT__;   /* 唯一のアクセント */
  --mute:     #666666;   /* サブテキスト・非推奨側 */
  --white:    #ffffff;
  --surface:  #eeeeee;   /* 白地に段差をつける薄グレー */
  --border:   #cccccc;
  --divider:  #dddddd;
  --tint:     __TINT__;     /* アクセントの淡いティント */

  /* 後方互換（旧 slides.json の blue/green/red を役割にマップ） */
  --blue:    var(--ink);
  --green:   var(--accent);
  --red:     var(--mute);
  --grey-bg: var(--surface);
  --dark:    var(--ink);
  --sub:     var(--mute);

  --font: 'Noto Sans JP', 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', 'メイリオ', Meiryo, sans-serif;
  --mono: Roboto, 'Helvetica Neue', Arial, sans-serif;

  --r:    5px 0;    /* 署名的な非対称角丸（小） */
  --r-lg: 20px 0;   /* 同（大・カード） */

  --pad-x: 72px;
  --pad-t: 52px;
  --pad-b: 44px;
}

body { background: #111; margin: 0; font-family: var(--font); }

/* ── Stage ── */
.stage {
  background: #111;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
}
.stage:fullscreen, .stage:-webkit-full-screen { background: #111; }
.stage:fullscreen .slide-wrapper, .stage:-webkit-full-screen .slide-wrapper {
  position: absolute;
  top: 50%;
  left: 50%;
  transform-origin: center center;
}
.slide-wrapper { position: relative; }

.slide {
  display: none;
  width: 1280px;
  height: 720px;
  background: var(--white);
  color: var(--ink);
  overflow: hidden;
  line-height: 1.7;
  letter-spacing: normal;
  font-feature-settings: normal;
}
.slide.active { display: block; }

/* 全レイアウト共通の骨格。縦を必ず使い切る（空白を余らせない） */
.layout-bullets, .layout-cards, .layout-comparison,
.layout-process, .layout-visual, .layout-profile {
  display: flex;
  flex-direction: column;
  height: 100%;
  padding: var(--pad-t) var(--pad-x) var(--pad-b);
}

/* ── 見出し。下に朱赤の細い罫を敷くのが署名 ── */
.headline-area {
  padding: 0 0 18px;
  border-bottom: 1px solid var(--divider);
  position: relative;
  flex: none;
}
.headline-area::after {
  content: '';
  position: absolute;
  left: 0; bottom: -1px;
  width: 72px; height: 3px;
  background: var(--accent);
}
.headline {
  font-size: 44px;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.35;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.subheadline {
  font-size: 20px;
  font-weight: 400;
  color: var(--mute);
  margin-top: 10px;
  line-height: 1.7;
  line-break: strict;
}

/* 見出し下の本体: 枠は内容にフィットさせ、ブロックごと縦センターに置く。
   枠を引き伸ばして中を空洞にしない */
.cards-row, .bullets-list, .cmp-row, .process-row,
.visual-center, .cards-center {
  margin-top: auto;
  margin-bottom: auto;
}
.prof-info { flex: 1; min-height: 0; }

/* ── Cover ── */
.layout-cover {
  display: flex;
  flex-direction: column;
  justify-content: center;
  height: 100%;
  position: relative;
  padding: var(--pad-t) var(--pad-x);
}
.cover-center {
  text-align: left;
  max-width: 1090px;
  border-left: 6px solid var(--accent);
  padding-left: 44px;
}
.cover-title {
  font-size: 58px;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.3;
}
.cover-subtitle {
  font-size: 24px;
  font-weight: 400;
  color: var(--mute);
  margin-top: 22px;
  line-height: 1.7;
}
.cover-author {
  font-size: 18px;
  font-weight: 400;
  color: var(--ink);
  margin-top: 40px;
  padding-top: 18px;
  border-top: 1px solid var(--border);
  display: inline-block;
}
/* 縦罫は .cover-center の border-left に統合済み */
.cover-accent-line { display: none; }
.closing-sub {
  font-size: 22px;
  font-weight: 400;
  color: var(--mute);
  margin-top: 22px;
  line-height: 1.7;
}

/* ── Section（章扉）。墨ベタに白抜き ── */
.layout-section {
  display: flex;
  align-items: center;
  height: 100%;
  background: var(--ink);
  padding: 0 var(--pad-x);
}
.section-center { text-align: left; max-width: 1000px; }
.section-num {
  font-family: var(--mono);
  font-size: 17px;
  font-weight: 400;
  color: var(--white);
  opacity: .7;
  letter-spacing: .08em;
}
.section-title {
  font-size: 54px;
  font-weight: 700;
  color: var(--white);
  line-height: 1.35;
  margin-top: 14px;
}
.section-line {
  width: 72px;
  height: 3px;
  background: var(--accent);
  margin-top: 30px;
  border-radius: 0;
}

/* ── Cards ── */
.cards-row {
  display: flex;
  gap: 24px;
  align-items: stretch;
}
.cards-center { display: flex; align-items: stretch; }
.card {
  flex: 1;
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: 30px 32px;
  display: flex;
  flex-direction: column;
  background: var(--white);
}
.card-badge {
  font-family: var(--mono);
  font-size: 14px;
  font-weight: 700;
  color: var(--accent);
  background: transparent;
  border: 1px solid var(--accent);
  border-radius: var(--r);
  padding: 2px 10px;
  align-self: flex-start;
  margin-bottom: 16px;
}
.card-title {
  font-size: 26px;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.5;
}
.card-body {
  font-size: 21px;
  font-weight: 400;
  color: var(--mute);
  line-height: 1.7;
  margin-top: 12px;
}

/* ── Bullets ── */
.bullets-list {
  display: flex;
  flex-direction: column;
  gap: 0;
}
.bullet-row {
  display: flex;
  align-items: flex-start;
  gap: 20px;
  padding: 24px 0;
  border-bottom: 1px solid var(--divider);
}
.bullet-row:last-child { border-bottom: none; }
.bullet-dot {
  width: 10px;
  height: 10px;
  background: var(--accent);
  border-radius: 0;
  margin-top: 16px;
  flex: none;
}
.bullet-title {
  font-size: 28px;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.5;
}
.bullet-body {
  font-size: 21px;
  font-weight: 400;
  color: var(--mute);
  line-height: 1.7;
  margin-top: 4px;
}

/* ── Comparison ── */
.cmp-row {
  display: flex;
  gap: 24px;
  align-items: stretch;
}
.cmp-col {
  flex: 1;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  overflow: hidden;
}
.cmp-header {
  font-size: 22px;
  font-weight: 700;
  color: var(--white);
  text-align: left;
  padding: 15px 28px;
  line-height: 1.5;
}
.cmp-body {
  padding: 14px 28px;
  background: var(--white);
  border: none;
}
.cmp-body ul { list-style: none; }

.cmp-body li {
  position: relative;
  font-size: 22px;
  font-weight: 400;
  color: var(--ink);
  line-height: 1.7;
  padding: 16px 0 16px 26px;
  border-bottom: 1px solid var(--divider);
}
.cmp-body li:last-child { border-bottom: none; }
.cmp-body li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 31px;
  width: 8px;
  height: 8px;
  background: var(--accent);
}

/* ── Process ── */
.process-row {
  display: flex;
  flex-direction: column;
  gap: 0;
}
.process-step {
  display: flex;
  align-items: center;
  gap: 26px;
  padding: 24px 28px;
  border-bottom: 1px solid var(--divider);
}
.process-step:last-child { border-bottom: none; }
.process-step.is-first {
  background: var(--ink);
  border-radius: var(--r-lg);
  border-bottom: none;
}
.process-step.is-first .step-text { color: var(--white); }
.process-step.is-first .step-num {
  background: var(--white);
  color: var(--ink);
}
.step-num {
  font-family: var(--mono);
  font-size: 20px;
  font-weight: 700;
  color: var(--white);
  background: var(--ink);
  border-radius: var(--r);
  width: 42px;
  height: 42px;
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
}
.step-text {
  font-size: 25px;
  font-weight: 400;
  color: var(--ink);
  line-height: 1.7;
}
.process-arrow { display: none; }

/* ── Flow（番号ボックス＋矢印） ── */
.flow-row {
  display: flex;
  align-items: stretch;
  gap: 0;
  width: 100%;
}
.flow-box {
  flex: 1;
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: 24px 20px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  background: var(--white);
}
.flow-box:first-child {
  background: var(--ink);
  border-color: var(--ink);
}
.flow-box:first-child .flow-num,
.flow-box:first-child .flow-title,
.flow-box:first-child .flow-body { color: var(--white); }
.flow-num {
  font-family: var(--mono);
  font-size: 15px;
  font-weight: 700;
  color: var(--accent);
  letter-spacing: .08em;
}
.flow-title {
  font-size: 21px;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.5;
  margin-top: 10px;
}
.flow-body {
  font-size: 17px;
  font-weight: 400;
  color: var(--mute);
  line-height: 1.7;
  margin-top: 8px;
}
.flow-arrow {
  align-self: center;
  color: var(--accent);
  font-size: 22px;
  padding: 0 14px;
  flex: none;
}

/* ── Big number ── */
.bignum-row {
  display: flex;
  align-items: stretch;
  gap: 0;
  width: 100%;
}
.bignum-cell {
  flex: 1;
  padding: 0 34px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  border-left: 1px solid var(--divider);
}
.bignum-cell:first-child { border-left: none; padding-left: 0; }
.bignum-value {
  font-size: 104px;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.1;
}
.bignum-cell:first-child .bignum-value { color: var(--accent); }
.bignum-label {
  font-size: 22px;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.5;
  margin-top: 12px;
}
.bignum-note {
  font-size: 17px;
  font-weight: 400;
  color: var(--mute);
  line-height: 1.7;
  margin-top: 6px;
}

/* ── Bar chart ── */
.bar-chart { width: 100%; }
.bar-row {
  display: flex;
  align-items: center;
  gap: 20px;
  padding: 13px 0;
}
.bar-label {
  width: 240px;
  font-size: 20px;
  font-weight: 400;
  color: var(--ink);
  text-align: right;
  flex: none;
  line-height: 1.5;
}
.bar-track {
  flex: 1;
  height: 30px;
  background: var(--surface);
  border-radius: 0;
  overflow: hidden;
}
.bar-fill {
  height: 100%;
  background: var(--ink);
  border-radius: 0;
}
.bar-row:first-child .bar-fill { background: var(--accent); }
.bar-value {
  width: 132px;
  font-family: var(--mono);
  font-size: 20px;
  font-weight: 700;
  color: var(--ink);
  flex: none;
}

/* ── Donut ── */
.donut-wrap { display: flex; align-items: center; gap: 60px; }
.donut-legend { display: flex; flex-direction: column; gap: 4px; }
.donut-legend-row {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 10px 0;
  border-bottom: 1px solid var(--divider);
}
.donut-legend-row:last-child { border-bottom: none; }
.donut-swatch {
  width: 14px;
  height: 14px;
  border-radius: 0;
  flex: none;
}
.donut-legend-label {
  font-size: 20px;
  font-weight: 400;
  color: var(--ink);
  line-height: 1.5;
}
.donut-legend-value {
  font-family: var(--mono);
  font-size: 20px;
  font-weight: 700;
  color: var(--ink);
  margin-left: auto;
}

/* ── Icon summary ── */
.iconsum-row { display: flex; gap: 0; align-items: stretch; width: 100%; }
.iconsum-cell {
  flex: 1;
  padding: 0 32px;
  text-align: left;
  border-left: 1px solid var(--divider);
  display: flex;
  flex-direction: column;
  justify-content: center;
}
.iconsum-cell:first-child { border-left: none; padding-left: 0; }
.iconsum-icon { font-size: 60px; line-height: 1.2; }
.iconsum-title {
  font-size: 24px;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.5;
  margin-top: 18px;
}
.iconsum-body {
  font-size: 18px;
  font-weight: 400;
  color: var(--mute);
  line-height: 1.7;
  margin-top: 8px;
}

/* ── Visual（図解系レイアウトの共通器） ── */
.visual-center {
  display: flex;
  flex-direction: column;
}

/* ── Profile ── */
.layout-profile {
  flex-direction: row;
  align-items: center;
  gap: 56px;
}
.prof-info { flex: 1; min-height: 0; display: block; }
.prof-photo-col { flex: none; }
.prof-photo {
  width: 260px;
  height: 260px;
  object-fit: cover;
  border-radius: var(--r-lg);
  border: 1px solid var(--border);
  display: block;
}
.prof-name {
  font-size: 38px;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.35;
}
.prof-role {
  font-size: 20px;
  font-weight: 400;
  color: var(--mute);
  margin-top: 8px;
  padding-bottom: 18px;
  border-bottom: 1px solid var(--divider);
  line-height: 1.7;
}
.prof-bullets { margin-top: 18px; }
.prof-bullet { display: flex; align-items: flex-start; gap: 14px; padding: 8px 0; }
.prof-dot {
  width: 8px;
  height: 8px;
  background: var(--accent);
  border-radius: 0;
  margin-top: 12px;
  flex: none;
}
.prof-bul-title {
  font-size: 20px;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.6;
}
.prof-bul-body {
  font-size: 17px;
  font-weight: 400;
  color: var(--mute);
  line-height: 1.7;
}

/* ── Nav（画面下のUI。印刷時は消える） ── */
.nav {
  position: fixed;
  bottom: 18px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 14px;
  background: rgba(34,34,34,.86);
  border-radius: var(--r);
  padding: 8px 16px;
  font-family: var(--mono);
  font-size: 14px;
  color: #fff;
  z-index: 20;
}
.nav button {
  font-family: var(--mono);
  font-size: 14px;
  color: #fff;
  background: transparent;
  border: 1px solid rgba(255,255,255,.45);
  border-radius: var(--r);
  padding: 4px 12px;
  cursor: pointer;
}
.nav button:disabled { opacity: .35; cursor: default; }
"""


# ── JS ────────────────────────────────────────────────────────────

JS = """
const slides = document.querySelectorAll('.slide');
const counter = document.getElementById('counter');
const btnPrev = document.getElementById('btn-prev');
const btnNext = document.getElementById('btn-next');
let current = 0;

function show(i) {
  slides[current].classList.remove('active');
  current = Math.max(0, Math.min(i, slides.length - 1));
  slides[current].classList.add('active');
  counter.textContent = (current + 1) + ' / ' + slides.length;
  btnPrev.disabled = current === 0;
  btnNext.disabled = current === slides.length - 1;
}

btnPrev.addEventListener('click', () => show(current - 1));
btnNext.addEventListener('click', () => show(current + 1));
document.addEventListener('keydown', e => {
  if (e.key === 'ArrowRight' || e.key === 'ArrowDown') show(current + 1);
  if (e.key === 'ArrowLeft'  || e.key === 'ArrowUp')   show(current - 1);
  if (e.key === 'f' || e.key === 'F') toggleFullscreen();
});

// ── Fullscreen ──
const stage   = document.getElementById('stage');
const wrapper = document.querySelector('.slide-wrapper');
const btnFull = document.getElementById('btn-full');

function fsElement() { return document.fullscreenElement || document.webkitFullscreenElement; }

function fitScale() {
  if (fsElement()) {
    const s = Math.min(window.innerWidth / 1280, window.innerHeight / 720);
    wrapper.style.transformOrigin = 'center center';
    wrapper.style.transform = 'translate(-50%,-50%) scale(' + s + ')';
    wrapper.style.height = '';
    wrapper.style.width  = '';
    btnFull.textContent = '⤬ 終了';
  } else {
    // 通常表示。1280x720 のスライドがウィンドウに収まらないときは縮小する
    // （余白はナビゲーション分と左右のすき間）。拡大はしない。
    const s = Math.min((window.innerWidth - 40) / 1280,
                       (window.innerHeight - 96) / 720, 1);
    // 左上を基準に縮小し、占有領域も同じ倍率に詰める。基準を center にすると
    // 縮小後の描画と要素の矩形がずれて、左右がはみ出す
    wrapper.style.transformOrigin = 'top left';
    if (s < 1) {
      wrapper.style.transform = 'scale(' + s + ')';
      wrapper.style.height = (720 * s) + 'px';
      wrapper.style.width  = (1280 * s) + 'px';
    } else {
      wrapper.style.transform = '';
      wrapper.style.height = '';
      wrapper.style.width  = '';
    }
    btnFull.textContent = '⛶ 全画面';
  }
}

function toggleFullscreen() {
  if (!fsElement()) {
    const fn = stage.requestFullscreen || stage.webkitRequestFullscreen;
    if (fn) fn.call(stage);
  } else {
    const fn = document.exitFullscreen || document.webkitExitFullscreen;
    if (fn) fn.call(document);
  }
}

btnFull.addEventListener('click', toggleFullscreen);
document.addEventListener('fullscreenchange', fitScale);
document.addEventListener('webkitfullscreenchange', fitScale);
window.addEventListener('resize', fitScale);

show(0);
fitScale();   // 初回表示時から画面に合わせる（1280px未満のウィンドウ対策）
"""


# ── Font embedding (offline/any-environment safe) ─────────────────
#
# Google Fonts via @import requires network access at *view* time, and the
# 'Hiragino Sans' / 'Yu Gothic' fallbacks only exist on macOS / Windows
# respectively — Linux or a locked-down machine can render tofu boxes for
# Japanese text. To guarantee identical rendering everywhere, embed the
# actual font as a base64 data URI. Subset to the characters actually used
# (via fontTools, already a project dependency) to keep the HTML small;
# fall back to embedding the full master font if fontTools is unavailable.

def _collect_text(value, out: set) -> None:
    if isinstance(value, str):
        out.update(value)
    elif isinstance(value, dict):
        for v in value.values():
            _collect_text(v, out)
    elif isinstance(value, list):
        for v in value:
            _collect_text(v, out)


def _subset_font_woff(ttf_path: Path, text: str) -> tuple[bytes, str]:
    try:
        from fontTools import subset as ft_subset
        from fontTools.ttLib import TTFont
    except ImportError:
        return ttf_path.read_bytes(), "truetype"

    font = TTFont(str(ttf_path))
    options = ft_subset.Options()
    options.flavor = "woff"
    options.desubroutinize = False
    subsetter = ft_subset.Subsetter(options=options)
    subsetter.populate(text=text)
    subsetter.subset(font)
    buf = io.BytesIO()
    font.save(buf)
    return buf.getvalue(), "woff"


def build_font_faces_css(slides_data: list[dict]) -> str:
    chars = set(BASELINE_CHARS)
    _collect_text(slides_data, chars)
    text = "".join(sorted(chars))

    faces = []
    for weight, filename in FONT_WEIGHTS:
        src_path = FONTS_DIR / filename
        if not src_path.exists():
            print(f"  warning: font file missing ({src_path}); falling back to system fonts for weight {weight}")
            continue
        data, fmt = _subset_font_woff(src_path, text)
        b64 = base64.b64encode(data).decode("ascii")
        faces.append(f"""
@font-face {{
  font-family: 'Noto Sans JP';
  font-weight: {weight};
  font-style: normal;
  font-display: swap;
  src: url(data:font/{fmt};base64,{b64}) format('{fmt}');
}}""")
    return "\n".join(faces)


# ── Validation (build-time warnings, does not block output) ──────

HEADLINE_MAX_CHARS = 30
ITEMS_LIMITS = {
    "cards":        (3, 5, "items"),
    "bullets":      (3, 5, "items"),
    "icon-summary": (3, 3, "items"),
    "big-number":   (1, 3, "items"),
    "bar-chart":    (3, 6, "items"),
    "donut":        (2, 5, "items"),
    "flow":         (3, 4, "steps"),
    "matrix":       (3, 6, "points"),
    "process":      (1, 5, "steps"),
}
# Layouts whose items/steps must be dicts (renderers call .get() unconditionally)
DICT_ONLY_LAYOUTS = {"big-number", "bar-chart", "donut", "matrix"}


def validate_slide(sd: dict, i: int) -> list[str]:
    warnings = []
    layout = sd.get("layout", "bullets")
    n = i + 1

    headline = sd.get("headline", "")
    if headline:
        length = len(str(headline))
        if length > HEADLINE_MAX_CHARS:
            warnings.append(
                f"slide {n} ({layout}): headline が{length}文字で{HEADLINE_MAX_CHARS}文字を超過 "
                f"(nowrapで見えなくなるだけなので必ず短縮する): \"{headline}\""
            )
        if "<br" in str(headline).lower():
            warnings.append(f"slide {n} ({layout}): headline に <br> は使用不可（1行強制のため無効）")

    if layout in ITEMS_LIMITS:
        lo, hi, field = ITEMS_LIMITS[layout]
        values = sd.get(field, [])
        count = len(values)
        if count and (count < lo or count > hi):
            warnings.append(
                f"slide {n} ({layout}): {field} が{count}個 (上限{lo}〜{hi}個) — スライド分割か個数調整を検討"
            )
        if layout in DICT_ONLY_LAYOUTS:
            for v in values:
                if not isinstance(v, dict):
                    warnings.append(
                        f"slide {n} ({layout}): {field} は必ずオブジェクトで書く（文字列 \"{v}\" があるとビルドが落ちる）"
                    )
                    break

    if layout == "matrix":
        for p in sd.get("points", []):
            if not isinstance(p, dict):
                continue
            for axis in ("x", "y"):
                v = p.get(axis)
                if v is not None and not (0 <= float(v) <= 100):
                    warnings.append(
                        f"slide {n} (matrix): point \"{p.get('label', '')}\" の {axis}={v} が0〜100の範囲外 "
                        f"(自動でクランプされるが位置がずれるので修正推奨)"
                    )

    return warnings


# ── HTML builder ──────────────────────────────────────────────────

def build_html(slides_data: list[dict]) -> str:
    slides_html = ""
    all_warnings = []
    for i, sd in enumerate(slides_data):
        key      = sd.get("layout", "bullets")
        renderer = RENDERERS.get(key)
        if renderer is None:
            print(f"  warning: unknown layout '{key}' on slide {i+1}, using bullets")
            renderer = render_bullets
        all_warnings.extend(validate_slide(sd, i))
        inner = renderer(sd)
        slides_html += f'<div class="slide" id="slide-{i+1:02d}">{inner}</div>\n'

    if all_warnings:
        print(f"  {len(all_warnings)}件の警告:")
        for w in all_warnings:
            print(f"    - {w}")

    font_faces = build_font_faces_css(slides_data)
    # :root の色をテーマの値で確定させる（CSS本体はテンプレのまま持ち回る）
    css = (CSS.replace("__INK__", THEME["ink"])
              .replace("__ACCENT__", THEME["accent"])
              .replace("__TINT__", THEME["tint"]))

    n = len(slides_data)
    return f"""<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Slides</title>
<style>{font_faces}
{css}</style>
</head>
<body>
<div class="stage" id="stage">
<div class="slide-wrapper">
{slides_html}</div>
<nav class="nav">
  <button id="btn-prev">←</button>
  <span id="counter">1 / {n}</span>
  <button id="btn-next">→</button>
  <button id="btn-full" title="全画面表示 (F)">⛶ 全画面</button>
</nav>
</div>
<script>{JS}</script>
</body>
</html>"""


# ── Entry point ───────────────────────────────────────────────────

def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--slides", required=True, help="path to slides.json")
    ap.add_argument("--output", required=True, help="HTML output path")
    ap.add_argument("--theme", default=None,
                    help=f"配色テーマ（slides.json の \"theme\" より優先）: {', '.join(THEMES)}")
    args = ap.parse_args()

    src  = Path(args.slides).expanduser()
    out  = Path(args.output).expanduser()
    data = json.loads(src.read_text(encoding="utf-8"))

    requested = args.theme or data.get("theme", DEFAULT_THEME)
    if requested not in THEMES:
        print(f"  warning: 未知のテーマ '{requested}'。既定の {DEFAULT_THEME} で作ります"
              f"（使えるのは: {', '.join(THEMES)}）")
    theme = set_theme(requested)

    html = build_html(data.get("slides", []))

    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(html, encoding="utf-8")
    print(f"OK: {len(data.get('slides', []))} slides / テーマ「{theme['name']}」 -> {out}")


if __name__ == "__main__":
    main()
