wechat-robot-skills/skills/pdf/scripts/render_pdf.py

192 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
from _pdf_common import (
SkillArgumentParser,
input_pdf,
output_directory,
publish_temp_file,
run_cli,
selected_page_window,
)
DEFAULT_DPI = 150
DEFAULT_MAX_PAGES = 10
DEFAULT_TIMEOUT_SECONDS = 180
def _parse_args(argv: list[str]):
parser = SkillArgumentParser(description="使用 Poppler 将 PDF 页面渲染为 PNG")
parser.add_argument("--input", required=True, help="本地 PDF 路径")
parser.add_argument("--output-dir", required=True, help="PNG 输出目录")
parser.add_argument("--start-page", type=int, default=1, help="起始页1-based")
parser.add_argument("--end-page", type=int, help="结束页1-based默认到末页")
parser.add_argument(
"--max-pages",
type=int,
default=DEFAULT_MAX_PAGES,
help=f"单次最多渲染页数,默认 {DEFAULT_MAX_PAGES}",
)
parser.add_argument(
"--dpi",
type=int,
default=DEFAULT_DPI,
help=f"渲染分辨率,默认 {DEFAULT_DPI} DPI",
)
parser.add_argument(
"--timeout",
type=int,
default=DEFAULT_TIMEOUT_SECONDS,
help=f"渲染超时秒数,默认 {DEFAULT_TIMEOUT_SECONDS}",
)
parser.add_argument("--overwrite", action="store_true", help="覆盖已有页面 PNG")
args = parser.parse_args(argv)
if args.dpi < 36 or args.dpi > 600:
raise ValueError("dpi 必须在 36 到 600 之间")
if args.timeout < 1:
raise ValueError("timeout 必须大于 0")
return args
def _page_count(path: Path) -> int:
from pypdf import PdfReader
with path.open("rb") as stream:
reader = PdfReader(stream, strict=False)
if reader.is_encrypted:
raise ValueError("PDF 已加密,无法渲染")
return len(reader.pages)
def _fontconfig_file(pdftoppm: Path) -> Path | None:
for ancestor in pdftoppm.parents:
candidate = (
ancestor
/ "native"
/ "poppler"
/ "poppler"
/ "etc"
/ "fonts"
/ "fonts.conf"
)
if candidate.is_file():
return candidate
for candidate in (
Path("/etc/fonts/fonts.conf"),
Path("/opt/homebrew/etc/fonts/fonts.conf"),
Path("/usr/local/etc/fonts/fonts.conf"),
):
if candidate.is_file():
return candidate
return None
def _render(args) -> dict[str, Any]:
path = input_pdf(args.input)
destination = output_directory(args.output_dir)
page_count = _page_count(path)
start_page, end_page, next_page = selected_page_window(
page_count,
args.start_page,
args.end_page,
args.max_pages,
)
outputs = [
destination / f"page-{page_number:04d}.png"
for page_number in range(start_page, end_page + 1)
]
existing = [path for path in outputs if path.exists()]
if existing and not args.overwrite:
raise FileExistsError(f"页面图片已存在:{existing[0]}")
executable_name = shutil.which("pdftoppm")
if not executable_name:
raise RuntimeError("环境预置的 pdftoppm 不可用")
executable = Path(executable_name).resolve()
with tempfile.TemporaryDirectory(
prefix="pdf-render-",
dir=str(destination),
) as temp_dir_name:
temp_dir = Path(temp_dir_name)
prefix = temp_dir / "page"
font_cache = destination / ".fontconfig-cache"
font_cache.mkdir(parents=True, exist_ok=True)
environment = os.environ.copy()
environment["XDG_CACHE_HOME"] = str(font_cache)
if not environment.get("FONTCONFIG_FILE"):
fontconfig = _fontconfig_file(executable)
if fontconfig is not None:
environment["FONTCONFIG_FILE"] = str(fontconfig)
command = [
str(executable),
"-f",
str(start_page),
"-l",
str(end_page),
"-png",
"-r",
str(args.dpi),
str(path),
str(prefix),
]
try:
completed = subprocess.run(
command,
env=environment,
text=True,
capture_output=True,
timeout=args.timeout,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"PDF 渲染超过 {args.timeout}") from exc
if completed.returncode != 0:
detail = (completed.stderr or completed.stdout or "").strip()[-2000:]
raise RuntimeError(f"PDF 渲染失败:{detail or 'pdftoppm 返回错误'}")
rendered = sorted(
temp_dir.glob("page-*.png"),
key=lambda item: int(item.stem.rsplit("-", 1)[-1]),
)
if len(rendered) != len(outputs):
raise RuntimeError(
f"PDF 渲染页数不正确:预期 {len(outputs)},实际 {len(rendered)}"
)
for temporary, output in zip(rendered, outputs):
publish_temp_file(temporary, output, args.overwrite)
return {
"input": str(path),
"output_dir": str(destination),
"page_count": page_count,
"start_page": start_page,
"end_page": end_page,
"dpi": args.dpi,
"rendered_count": len(outputs),
"files": [str(path) for path in outputs],
"has_more": next_page is not None,
"next_page": next_page,
}
def main(argv: list[str] | None = None) -> int:
arguments = sys.argv[1:] if argv is None else argv
return run_cli(lambda: _render(_parse_args(arguments)))
if __name__ == "__main__":
raise SystemExit(main())