"""md(sections 目录或单 .md)→ PDF,沙盒自带 chromium 渲染。 渲染链(全程沙盒内,不进 weasyprint、不装额外包): md --(python `markdown` 库)--> HTML --(chromium --headless --print-to-pdf)--> PDF chromium 是镜像里已装的(给 mermaid 用),fonts-noto-cjk 也已装;chromium 是完整浏览器 内核,CSS 保真度比 weasyprint 高。冒烟见 deploy/sandbox/probe_chromium_pdf.sh。 视觉与 docx 一致:复用 common.CHEM_RE(化学式下标白名单,单一事实源)+ 商务红配色 + DOI/URL 超链。引文 [n] 上标回链这版按字面渲染(后续与 docx 一起 DRY 再补)。 ASCII-only stdout(Windows GBK 控制台安全)。 """ from __future__ import annotations import os import re import shutil import subprocess import tempfile from pathlib import Path from .common import CHEM_RE # ───────────────────────── 主题色(与 docx 商务红一致)───────────────────────── PRIMARY = "#C00000" TLDR_FILL = "#FBE9E9" LINK_BLUE = "#1155CC" TABLE_HEAD_FILL = "#C00000" TABLE_ZEBRA = "#F8F0F0" # 行内 DOI 子串(HTML-safe 边界) _DOI_INLINE_RE = re.compile(r"10\.\d{4,9}/[^\s<>\"]+") # 裸 URL / 域名 token _URL_TOKEN_RE = re.compile( r"(?\"]*)?)", re.IGNORECASE, ) # 切分 HTML 成 [文本, 标签, ...];只对文本 token 做下标/超链替换 _TAG_SPLIT = re.compile(r"(<[^>]+>)") _SKIP_TAGS = {"a", "code", "pre", "script", "style", "head"} _TAG_NAME_RE = re.compile(r"<\s*(/?)\s*([a-zA-Z0-9]+)") def _log(msg: str) -> None: print(f"[render_pdf] {msg}") def _emit_chem(text: str) -> str: def repl(m: re.Match) -> str: return re.sub(r"(\d+)", r"\1", m.group(0)) return CHEM_RE.sub(repl, text) def _emit_links(text: str) -> str: def doi_repl(m: re.Match) -> str: doi = m.group(0) return f'{doi}' text = _DOI_INLINE_RE.sub(doi_repl, text) out_parts = [] for piece in _TAG_SPLIT.split(text): if piece.startswith("<"): out_parts.append(piece) continue def url_repl(m: re.Match) -> str: raw = m.group(1) href = raw if raw.lower().startswith("http") else f"https://{raw}" return f'{raw}' out_parts.append(_URL_TOKEN_RE.sub(url_repl, piece)) return "".join(out_parts) def _enrich_html(html: str) -> str: """对 HTML 纯文本片段做化学式下标 + DOI/URL 超链;//
 内不动。"""
    out = []
    skip_depth = 0
    for token in _TAG_SPLIT.split(html):
        if not token:
            continue
        if token.startswith("<"):
            m = _TAG_NAME_RE.match(token)
            if m:
                closing, name = m.group(1), m.group(2).lower()
                if name in _SKIP_TAGS and not token.rstrip().endswith("/>"):
                    skip_depth += -1 if closing else 1
                    skip_depth = max(0, skip_depth)
            out.append(token)
        else:
            out.append(token if skip_depth else _emit_links(_emit_chem(token)))
    return "".join(out)


def _read_sections(src: Path) -> str:
    if src.is_dir():
        parts = [md.read_text(encoding="utf-8") for md in sorted(src.glob("*.md"))]
        if not parts:
            raise SystemExit(f"[render_pdf] no *.md under {src}")
        return "\n\n".join(parts)
    return src.read_text(encoding="utf-8")


def _css(color: bool) -> str:
    primary = PRIMARY if color else "#000000"
    head_fill = TABLE_HEAD_FILL if color else "#000000"
    zebra = TABLE_ZEBRA if color else "#FFFFFF"
    tldr = TLDR_FILL if color else "#FFFFFF"
    link = LINK_BLUE if color else "#000000"
    return f"""
@page {{ size: A4; margin: 2.2cm 2cm; }}
* {{ -webkit-print-color-adjust: exact; print-color-adjust: exact; }}
body {{ font-family: 'Times New Roman','Noto Serif CJK SC','Noto Sans CJK SC',serif;
        font-size: 12pt; line-height: 1.6; color: #000; }}
h1 {{ font-family: 'Noto Sans CJK SC',sans-serif; font-size: 19pt; color: {primary};
      border-bottom: 2px solid {primary}; padding-bottom: 4pt; margin: 22pt 0 12pt; }}
h2 {{ font-family: 'Noto Sans CJK SC',sans-serif; font-size: 15pt; color: {primary}; margin: 20pt 0 8pt; }}
h3 {{ font-family: 'Noto Sans CJK SC',sans-serif; font-size: 13pt; color: {primary}; margin: 16pt 0 6pt; }}
p {{ text-align: justify; margin: 6pt 0; }}
a {{ color: {link}; text-decoration: underline; word-break: break-all; }}
sub {{ font-size: 0.72em; }}
table {{ border-collapse: collapse; width: 100%; margin: 12pt 0; font-size: 10.5pt; }}
th {{ background: {head_fill}; color: #fff; padding: 6pt 8pt; border: 1px solid #999; text-align: center; }}
td {{ padding: 5pt 8pt; border: 1px solid #999; }}
tr:nth-child(even) td {{ background: {zebra}; }}
blockquote {{ border-left: 4px solid {primary}; background: {tldr}; margin: 12pt 0;
              padding: 8pt 12pt; font-size: 11pt; }}
blockquote p {{ margin: 3pt 0; }}
code {{ font-family: Consolas,monospace; font-size: 10pt; background: #f5f5f5; padding: 1pt 3pt; }}
ul,ol {{ margin: 6pt 0; padding-left: 22pt; }}
li {{ margin: 3pt 0; }}
"""


def _find_chromium() -> str:
    env = os.environ.get("CHROMIUM") or os.environ.get("CHROME")
    cands = [env] if env else []
    cands += ["chromium", "chromium-browser", "google-chrome",
              "/usr/bin/chromium", "/usr/bin/chromium-browser"]
    for c in cands:
        if c and (shutil.which(c) or Path(c).exists()):
            return shutil.which(c) or c
    raise SystemExit("[render_pdf] chromium 不在沙盒里(镜像应已装,给 mermaid 用)。"
                     "确认 `which chromium` 或设 CHROMIUM 环境变量。")


def md_to_pdf(src: Path, out: Path, *, color: bool = True, profile: str = "") -> Path:
    try:
        import markdown
    except ImportError:
        raise SystemExit("[render_pdf] 缺 `markdown` 包。基础镜像应已装(requirements.txt);"
                         "本地补:.venv/Scripts/python.exe -m pip install markdown")

    md_text = _read_sections(src)
    body = markdown.markdown(
        md_text, extensions=["tables", "fenced_code", "sane_lists", "attr_list"]
    )
    body = _enrich_html(body)
    html = (f''
            f"{body}")

    chromium = _find_chromium()
    out.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory(prefix="render-pdf-") as tmp:
        html_path = Path(tmp) / "doc.html"
        html_path.write_text(html, encoding="utf-8")
        cmd = [
            chromium, "--headless", "--disable-gpu", "--no-sandbox",
            "--disable-dev-shm-usage", f"--user-data-dir={tmp}/cr",
            "--no-pdf-header-footer",
            f"--print-to-pdf={out}", html_path.as_uri(),
        ]
        proc = subprocess.run(cmd, capture_output=True, timeout=120, check=False)
        if proc.returncode != 0 or not out.exists() or out.stat().st_size == 0:
            tail = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace")[-600:]
            raise SystemExit(f"[render_pdf] chromium 转 PDF 失败(rc={proc.returncode}):\n{tail}")
    return out