"""svg_preview.py: 把 SVG 页渲成 PNG 的可选预览工具(chromium 优先,cairosvg 兜底)。

SVG-first 管线里 SVG 就是视觉真相(导出的 pptx 与之 1:1),想直观看一眼版面时
渲 SVG 最忠实 —— 比渲最终 pptx 更早、更准地暴露观感问题。

注意:本脚本是**可选工具**,不再是交付管线的强制环节。页面正确性(越界 /
压字 / 错位 / 网格漂移)由 svg_quality_checker.py 的几何检测保证,并在导出
边界自动复跑 —— 交付不依赖渲图,渲图只服务"人想看效果"。

用法:
    python svg_preview.py <project_dir>                  # 渲 <project_dir> 全部页
    python svg_preview.py <project_dir> --pages 1,3,5    # 只渲第 1/3/5 页(按文件排序)
    python svg_preview.py <project_dir> -o <out_dir>     # 指定 PNG 输出目录(默认 .build/preview)
    python svg_preview.py <svg_dir_or_file>              # 直接渲某个目录/单文件

约定:优先渲 <project_dir>/.build/svg_final(图标/配图已内嵌);没有则渲
svg_output(图标占位符会由 cairosvg 路径就地展开)。
渲染器:找得到 Chrome/Edge/Chromium 就用无头浏览器(保真度最高);找不到、
或浏览器对某页渲染失败,自动回退 cairosvg(装了的话)。产物默认 2x 超采样。
"""
from __future__ import annotations

import argparse
import re
import subprocess
import sys
try:  # zcbot: Windows GBK 控制台兼容，避免 emoji/© 等触发 UnicodeEncodeError
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
    pass
import tempfile
import os
import shutil
from pathlib import Path

_CHROME_CANDIDATES = [
    r"C:\Program Files\Google\Chrome\Application\chrome.exe",
    r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
    r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
    r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
]
# Linux / zcbot sandbox: the image ships chromium at /usr/bin/chromium (installed
# for mermaid-cli). The original ppt-master discovery only knew Windows
# chrome/edge, so SVG preview always failed inside the container. Mirror the
# discovery zcbot already uses in rendering/pdf.py so it resolves the bundled
# chromium instead.
_LINUX_CANDIDATES = [
    "/usr/bin/chromium", "/usr/bin/chromium-browser",
    "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable",
]
_WHICH_NAMES = (
    "chrome", "chrome.exe", "msedge", "msedge.exe",
    "chromium", "chromium-browser", "google-chrome", "google-chrome-stable",
)


def find_browser() -> str | None:
    """Return a chromium/chrome/edge executable path, or None if none found.

    Returns None (not raise) so the caller can fall back to the cairosvg
    renderer when no browser exists (e.g. a sandbox image built without the
    chromium layer).
    """
    # Explicit override first (matches rendering/pdf.py's CHROMIUM/CHROME env).
    env = os.environ.get("CHROMIUM") or os.environ.get("CHROME")
    if env and (shutil.which(env) or Path(env).exists()):
        return shutil.which(env) or env
    for c in _CHROME_CANDIDATES + _LINUX_CANDIDATES:
        if Path(c).exists():
            return c
    for name in _WHICH_NAMES:
        p = shutil.which(name)
        if p:
            return p
    return None


def find_cairosvg():
    """Return the cairosvg module, or None if not importable."""
    try:
        import cairosvg
        return cairosvg
    except Exception:
        return None


_VIEWBOX_RE = re.compile(r'viewBox\s*=\s*["\']\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*["\']')
_WH_RE = re.compile(r'\b(width|height)\s*=\s*["\']\s*([\d.]+)')


def _dims(svg_text: str) -> tuple[float, float]:
    m = _VIEWBOX_RE.search(svg_text)
    if m:
        return float(m.group(3)), float(m.group(4))
    w = h = None
    for name, val in _WH_RE.findall(svg_text):
        if name == "width":
            w = float(val)
        elif name == "height":
            h = float(val)
    return (w or 1280.0), (h or 720.0)


def _wrap_html(svg_path: Path, w: float, h: float) -> str:
    # 内联引用本地 svg,固定画布尺寸、去边距,Chrome 截图即得整页
    uri = svg_path.resolve().as_uri()
    return (
        "<!doctype html><html><head><meta charset='utf-8'><style>"
        "html,body{margin:0;padding:0;background:#fff}"
        f"img{{display:block;width:{w}px;height:{h}px}}"
        "</style></head><body>"
        f"<img src='{uri}'>"
        "</body></html>"
    )


def render(browser: str, svg_path: Path, out_png: Path, scale: float = 2.0) -> bool:
    """Render one SVG to PNG via headless chromium. Returns True on success.

    On failure prints a concise warning with the chromium stderr tail (the
    original code silenced stderr, so a broken render looked identical to a
    missing browser — impossible to diagnose). The caller keeps going so one
    bad page doesn't abort the whole batch.
    """
    svg_text = svg_path.read_text(encoding="utf-8", errors="ignore")
    w, h = _dims(svg_text)
    html = _wrap_html(svg_path, w, h)
    # chromium resolves --screenshot against its own cwd, not ours; a relative
    # path silently fails to write ("系统找不到指定的路径"). Always pass absolute.
    out_png = out_png.resolve()
    out_png.parent.mkdir(parents=True, exist_ok=True)
    # TemporaryDirectory holds both the wrapper HTML and a throwaway
    # --user-data-dir; auto-cleaned on exit.
    with tempfile.TemporaryDirectory(prefix="svgprev-") as tmp:
        html_path = Path(tmp) / "page.html"
        html_path.write_text(html, encoding="utf-8")
        proc = subprocess.run(
            [
                browser, "--headless", "--disable-gpu", "--no-sandbox",
                # Required in the cap-dropped sandbox: chromium's own setuid
                # sandbox can't start (--no-sandbox), and the container's 64MB
                # /dev/shm is too small (--disable-dev-shm-usage), else chromium
                # crashes mid-render. Same flags rendering/pdf.py uses.
                "--disable-dev-shm-usage", "--user-data-dir=%s" % (Path(tmp) / "cr"),
                "--hide-scrollbars", "--force-device-scale-factor=%s" % scale,
                "--window-size=%d,%d" % (round(w), round(h)),
                "--default-background-color=FFFFFFFF",
                "--screenshot=%s" % str(out_png),
                html_path.as_uri(),
            ],
            stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=120, check=False,
        )
    if out_png.exists() and out_png.stat().st_size > 0:
        return True
    tail = (proc.stderr or b"").decode("utf-8", "replace").strip()[-400:]
    print(f"  [warn] chromium 渲染失败 {svg_path.name}(rc={proc.returncode})"
          + (f": {tail}" if tail else ""))
    return False


def _collect(target: Path) -> tuple[list[Path], Path]:
    """返回 (svg 文件列表, 默认输出目录)。"""
    from project_utils import svg_final_dir, preview_dir
    if target.is_file() and target.suffix.lower() == ".svg":
        return [target], preview_dir(target.parent)
    # 目录:优先 .build/svg_final(finalize 后图标/配图已内嵌,渲出来最忠实);
    # 没有就退而渲 svg_output(cairosvg 兜底会就地展开图标,chromium 直接渲则
    # 图标仍是占位符不显示)。
    sf = svg_final_dir(target)
    if sf.is_dir() and any(sf.glob("*.svg")):
        svg_dir = sf
    elif (target / "svg_output").is_dir():
        svg_dir = target / "svg_output"
    else:
        svg_dir = target
    return sorted(svg_dir.glob("*.svg")), preview_dir(target)


def _select(files: list[Path], pages: str | None) -> list[Path]:
    if not pages:
        return files
    idxs = []
    for tok in pages.split(","):
        tok = tok.strip()
        if tok.isdigit():
            idxs.append(int(tok) - 1)
    return [files[i] for i in idxs if 0 <= i < len(files)]


def _expand_icons_for_cairo(svg_text: str) -> str:
    """Expand `<use data-icon>` placeholders to real `<g><path>` in-memory.

    cairosvg does not understand the `data-icon` placeholder and errors on the
    href-less `<use>` (observed: CAIRO_STATUS_INVALID_MATRIX on every icon-bearing
    page). Reuse finalize's exact regex embedder so the fallback rasterizes the
    same icons the native exporter draws. No-op when the SVG carries no
    placeholders (e.g. already-finalized svg_final/).
    """
    if 'data-icon' not in svg_text:
        return svg_text
    try:
        from svg_finalize.embed_icons import process_svg_file, DEFAULT_ICONS_DIR
    except Exception:
        return svg_text  # can't expand — let cairosvg try as-is
    try:
        import contextlib
        import io as _io
        with tempfile.TemporaryDirectory(prefix="svgprev-ico-") as tmp:
            f = Path(tmp) / "x.svg"
            f.write_text(svg_text, encoding="utf-8")
            # Silence the embedder's per-file "[OK] x.svg (N icons)" chatter —
            # the temp name is meaningless in preview output.
            with contextlib.redirect_stdout(_io.StringIO()):
                process_svg_file(f, DEFAULT_ICONS_DIR, dry_run=False, verbose=False,
                                 fallback_dir=DEFAULT_ICONS_DIR)
            return f.read_text(encoding="utf-8")
    except Exception:
        return svg_text


def render_cairosvg(cairosvg, svg_path: Path, out_png: Path, scale: float = 2.0) -> bool:
    """Rasterize one SVG via cairosvg (browser-free fallback). Returns True on success.

    Icons are pre-expanded so cairosvg doesn't choke on `<use data-icon>`.
    Fidelity is slightly below chromium (no JS, limited filter support), but it
    lets preview run in a sandbox image built without chromium — or one whose
    chromium crashes at runtime.
    """
    svg_text = _expand_icons_for_cairo(
        svg_path.read_text(encoding="utf-8", errors="ignore"))
    out_png = out_png.resolve()
    out_png.parent.mkdir(parents=True, exist_ok=True)
    try:
        cairosvg.svg2png(bytestring=svg_text.encode("utf-8"),
                         write_to=str(out_png), scale=scale)
    except Exception as e:
        print(f"  [warn] cairosvg 渲染失败 {svg_path.name}: {e}")
        return False
    return out_png.exists() and out_png.stat().st_size > 0


def main() -> None:
    ap = argparse.ArgumentParser(description="把 SVG 页渲成 PNG(可选预览工具)")
    ap.add_argument("target", type=Path, help="project_dir / svg 目录 / 单个 .svg 文件")
    ap.add_argument("--pages", default=None, help="只渲指定页,如 1,3,5(按文件排序)")
    ap.add_argument("-o", "--out", type=Path, default=None, help="PNG 输出目录")
    ap.add_argument("--scale", type=float, default=2.0, help="超采样倍数,默认 2")
    args = ap.parse_args()

    files, default_out = _collect(args.target)
    if not files:
        raise SystemExit(f"[fatal] 没找到 SVG:{args.target}")
    all_count = len(files)
    files = _select(files, args.pages)
    if not files:
        raise SystemExit(f"[fatal] --pages {args.pages} 没选中任何页(共 {all_count} 页)")

    out_dir = args.out or default_out

    # Renderer: prefer a real browser (chromium — highest fidelity, matches the
    # native pptx); cairosvg covers both "no browser at all" and "browser found
    # but crashes at render time" (seen with the sandbox chromium) — a found
    # browser that can't render must not be a dead end.
    browser = find_browser()
    cairo = find_cairosvg()
    if browser:
        print(f"[svg_preview] browser={browser}"
              + ("" if cairo else " (无 cairosvg 兜底)"))
    elif cairo:
        print("[svg_preview] 未找到 chromium → 用 cairosvg 渲染(图标已预展开;"
              "保真度略低于 chromium。装 chromium 或设 CHROMIUM 可用浏览器渲染)")
    else:
        raise SystemExit(
            "[fatal] 未找到 Chrome / Edge / Chromium,也无 cairosvg。这是环境/镜像"
            "问题:沙箱镜像应自带 /usr/bin/chromium(需 rebuild sandbox 镜像),"
            "本机可设 CHROMIUM 环境变量指向浏览器。渲图只是可选预览,不阻塞"
            "质检与导出 —— 请把本报错如实告知用户,不要在会话内安装渲染依赖。")

    ok = 0
    fell_back = 0
    for svg in files:
        png = out_dir / (svg.stem + ".png")
        done = render(browser, svg, png, scale=args.scale) if browser else False
        if not done and cairo:
            if browser:
                fell_back += 1
            done = render_cairosvg(cairo, svg, png, scale=args.scale)
        if done:
            ok += 1
            print(f"  [ok] {svg.name} -> {png}")
        else:
            print(f"  [FAIL] {svg.name} 未生成 PNG")
    if fell_back:
        print(f"[svg_preview] {fell_back} 页浏览器渲染失败,已回退 cairosvg")
    print(f"[svg_preview] {ok}/{len(files)} 页渲好,输出目录:{out_dir}")
    if ok < len(files):
        raise SystemExit(1)


if __name__ == "__main__":
    main()
