文档转PDF.ps1

<#PSScriptInfo
 
.VERSION 1.0.0
 
.GUID e3a7c5f8-9b21-4d6e-a0f3-8c1b2d4e5f67
 
.AUTHOR 埃博拉酱-机器人
 
.COMPANYNAME 一致行动党
 
.COPYRIGHT (c) 2026 埃博拉酱-机器人. MIT License.
 
.TAGS Markdown PDF Pandoc Mermaid MathML CJK 中文 转换
 
.LICENSEURI https://opensource.org/licenses/MIT
 
.RELEASENOTES
    1.0.0 - 初始版本。支持 MathML 数学公式、Mermaid 流程图预渲染、CJK 字体、
            依赖自动安装(Pandoc / Node.js / mermaid-cli)。
 
#>


<#
.SYNOPSIS
    将 Markdown 文件转换为 PDF(支持中文、数学公式、表格、Mermaid 流程图)
.DESCRIPTION
    使用 Pandoc 生成 HTML(MathML 渲染数学),mmdc 预渲染 Mermaid 流程图,
    再用 Edge / Chrome headless 打印为 PDF。缺失依赖时通过 winget / npm 自动安装。
 
    支持特性:
    - TeX 数学公式(通过 MathML,浏览器原生渲染)
    - Mermaid 流程图(通过 mermaid-cli 预渲染为 SVG)
    - 中日韩(CJK)字体
    - 表格、代码块、引用块
    - 依赖自动检测与安装
.PARAMETER 路径
    要转换的 Markdown 文件路径。支持通配符。默认为当前目录下的 *.md。
.PARAMETER 输出目录
    PDF 输出目录。默认与源文件同目录。
.EXAMPLE
    .\文档转PDF.ps1 -路径 "论文.md"
 
    将单个 Markdown 文件转换为同目录下的 PDF。
.EXAMPLE
    .\文档转PDF.ps1 -路径 *.md -输出目录 D:\Output
 
    批量转换当前目录所有 .md 文件,输出到 D:\Output。
.EXAMPLE
    Get-ChildItem -Recurse -Filter *.md | ForEach-Object { .\文档转PDF.ps1 -路径 $_.FullName }
 
    递归转换所有子目录中的 Markdown 文件。
.LINK
    https://pandoc.org/
.LINK
    https://mermaid.js.org/
#>

param(
    [Parameter(Position = 0)]
    [string[]]$路径,

    [string]$输出目录
)

$ErrorActionPreference = 'Stop'

# ── 检查并自动安装依赖 ────────────────────────────────────

# Pandoc
$Pandoc命令 = Get-Command pandoc -ErrorAction SilentlyContinue
if (-not $Pandoc命令) {
    Write-Host "未找到 pandoc,正在通过 winget 安装 ..." -ForegroundColor Yellow
    winget install --id JohnMacFarlane.Pandoc --accept-source-agreements --accept-package-agreements
    if ($LASTEXITCODE -ne 0) {
        Write-Error "自动安装 pandoc 失败。请手动安装: https://pandoc.org/installing.html"
        return
    }
    $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User')
    $Pandoc命令 = Get-Command pandoc -ErrorAction SilentlyContinue
    if (-not $Pandoc命令) {
        Write-Error "pandoc 已安装但未在 PATH 中找到,请重启终端后重试。"
        return
    }
    Write-Host "pandoc 安装成功: $(pandoc --version | Select-Object -First 1)" -ForegroundColor Green
}

# Node.js / npm / npx
$Npx命令 = Get-Command npx -ErrorAction SilentlyContinue
if (-not $Npx命令) {
    Write-Host "未找到 Node.js (npx),正在通过 winget 安装 ..." -ForegroundColor Yellow
    winget install --id OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements
    if ($LASTEXITCODE -ne 0) {
        Write-Error "自动安装 Node.js 失败。请手动安装: https://nodejs.org/"
        return
    }
    $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User')
    $Npx命令 = Get-Command npx -ErrorAction SilentlyContinue
    if (-not $Npx命令) {
        Write-Error "Node.js 已安装但 npx 未在 PATH 中找到,请重启终端后重试。"
        return
    }
    Write-Host "Node.js 安装成功: $(node --version)" -ForegroundColor Green
}

# mermaid-cli (mmdc)
$Mmdc命令 = Get-Command mmdc -ErrorAction SilentlyContinue
if (-not $Mmdc命令) {
    Write-Host "未找到 mermaid-cli (mmdc),正在全局安装 ..." -ForegroundColor Yellow
    $原错误策略 = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
    & npm install -g @mermaid-js/mermaid-cli 2>&1 | Where-Object { $_ -notmatch 'warn' } | Write-Host
    $ErrorActionPreference = $原错误策略
    $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User')
    $Mmdc命令 = Get-Command mmdc -ErrorAction SilentlyContinue
    if (-not $Mmdc命令) {
        Write-Host "全局安装 mmdc 未成功,将回退使用 npx 调用。" -ForegroundColor Yellow
    } else {
        Write-Host "mermaid-cli 安装成功: $(mmdc --version)" -ForegroundColor Green
    }
}

# Edge 浏览器
$Edge路径列表 = @(
    "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
    "C:\Program Files\Microsoft\Edge\Application\msedge.exe"
)
$Edge路径 = $Edge路径列表 | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $Edge路径) {
    Write-Error "未找到 Microsoft Edge。Edge 为 Windows 内置组件,无法自动安装。"
    return
}

# ── 解析输入文件 ──────────────────────────────────────────
if (-not $路径) {
    $路径 = @("*.md")
}
$文件列表 = @()
foreach ($路径项 in $路径) {
    $文件列表 += Get-Item $路径项 -ErrorAction SilentlyContinue
}
if ($文件列表.Count -eq 0) {
    Write-Error "未找到匹配的 Markdown 文件。"
    return
}

# ── 网页头尾(手动拼接,避免 Pandoc 模板转义问题) ────
$网页头部 = @'
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<style>
  @page { size: A4; margin: 20mm 18mm; }
  body {
    font-family: "Microsoft YaHei", "SimSun", "Noto Sans CJK SC", sans-serif;
    font-size: 11pt;
    line-height: 1.6;
    color: #222;
    max-width: 100%;
  }
  h1 { font-size: 18pt; margin-top: 1em; }
  h2 { font-size: 15pt; margin-top: 0.8em; }
  h3 { font-size: 13pt; margin-top: 0.6em; }
  table { border-collapse: collapse; width: 100%; margin: 0.6em 0; font-size: 10pt; }
  th, td { border: 1px solid #999; padding: 4px 8px; text-align: left; }
  th { background: #f0f0f0; }
  code { font-family: Consolas, "Courier New", monospace; font-size: 10pt; background: #f5f5f5; padding: 1px 4px; border-radius: 3px; }
  pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 9.5pt; }
  pre code { background: none; padding: 0; }
  blockquote { border-left: 3px solid #ccc; margin-left: 0; padding-left: 1em; color: #555; }
  img { max-width: 100%; }
  math { font-size: 1.05em; }
</style>
</head>
<body>
'@


$网页尾部 = @'
</body>
</html>
'@


# ── 逐文件转换 ────────────────────────────────────────────
foreach ($文件 in $文件列表) {
    $文件名 = [System.IO.Path]::GetFileNameWithoutExtension($文件.Name)
    $源目录 = $文件.DirectoryName
    $目标目录 = if ($输出目录) { $已解析 = Resolve-Path $输出目录 -ErrorAction SilentlyContinue; if ($已解析) { $已解析.Path } else { $输出目录 } } else { $源目录 }
    if (-not (Test-Path $目标目录)) { New-Item -ItemType Directory -Path $目标目录 -Force | Out-Null }

    $临时网页 = Join-Path $env:TEMP "文档转PDF_$PID`_$文件名.html"
    $PDF路径 = Join-Path $目标目录 "$文件名.pdf"

    try {
        # Pandoc: Markdown → HTML 片段
        Write-Host "[1/2] 转换 $($文件.Name) → HTML ..." -ForegroundColor Cyan
        $临时片段 = Join-Path $env:TEMP "文档转PDF_片段_$PID`_$文件名.html"
        & pandoc $文件.FullName `
            --from markdown+tex_math_dollars+pipe_tables+fenced_code_blocks `
            --to html5 `
            --mathml `
            --syntax-highlighting=none `
            -o $临时片段
        if ($LASTEXITCODE -ne 0) {
            Write-Error "Pandoc 转换失败: $($文件.Name)"
            continue
        }

        # 拼接完整网页
        $正文内容 = [System.IO.File]::ReadAllText($临时片段, [System.Text.Encoding]::UTF8)

        # 预渲染 Mermaid 代码块为内联 SVG
        $流程图正则 = [regex]'<pre class="mermaid"><code>([\s\S]*?)</code></pre>'
        $流程图计数 = 0
        $正文内容 = $流程图正则.Replace($正文内容, {
            param($匹配)
            $流程图计数++
            $流程图源码 = [System.Net.WebUtility]::HtmlDecode($匹配.Groups[1].Value)
            $流程图输入 = Join-Path $env:TEMP "文档转PDF_图_$PID`_$流程图计数.mmd"
            $流程图输出 = Join-Path $env:TEMP "文档转PDF_图_$PID`_$流程图计数.svg"
            [System.IO.File]::WriteAllText($流程图输入, $流程图源码, [System.Text.Encoding]::UTF8)
            $流程图命令 = Get-Command mmdc -ErrorAction SilentlyContinue
            $原错误策略2 = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
            if ($流程图命令) {
                & mmdc -i $流程图输入 -o $流程图输出 -b transparent --quiet 2>$null
            } else {
                & npx --yes @mermaid-js/mermaid-cli -i $流程图输入 -o $流程图输出 -b transparent --quiet 2>$null
            }
            $ErrorActionPreference = $原错误策略2
            if (Test-Path $流程图输出) {
                $矢量图 = [System.IO.File]::ReadAllText($流程图输出, [System.Text.Encoding]::UTF8)
                Remove-Item $流程图输入, $流程图输出 -ErrorAction SilentlyContinue
                return "<div style=`"text-align:center;margin:1em 0`">$矢量图</div>"
            } else {
                Remove-Item $流程图输入 -ErrorAction SilentlyContinue
                return $匹配.Value
            }
        })

        $完整网页 = $网页头部 + "`n" + $正文内容 + "`n" + $网页尾部
        [System.IO.File]::WriteAllText($临时网页, $完整网页, [System.Text.Encoding]::UTF8)

        # Edge headless: HTML → PDF
        Write-Host "[2/2] 打印 PDF → $PDF路径 ..." -ForegroundColor Cyan
        $Edge参数 = @(
            "--headless",
            "--disable-gpu",
            "--no-sandbox",
            "--run-all-compositor-stages-before-draw",
            "--virtual-time-budget=10000",
            "--print-to-pdf=$PDF路径",
            "--print-to-pdf-no-header",
            $临时网页
        )
        $进程 = Start-Process -FilePath $Edge路径 -ArgumentList $Edge参数 -Wait -PassThru -WindowStyle Hidden -RedirectStandardError (Join-Path $env:TEMP "文档转PDF_Edge日志.log")
        if ($进程.ExitCode -ne 0) {
            Write-Warning "Edge 退出码 $($进程.ExitCode),PDF 可能不完整。"
        }

        if (Test-Path $PDF路径) {
            $大小 = (Get-Item $PDF路径).Length / 1KB
            Write-Host "完成: $PDF路径 ($([math]::Round($大小, 1)) KB)" -ForegroundColor Green
        } else {
            Write-Error "PDF 生成失败: $PDF路径"
        }
    }
    finally {
        Remove-Item $临时片段 -ErrorAction SilentlyContinue
        Remove-Item $临时网页 -ErrorAction SilentlyContinue
    }
}