192 lines
5.8 KiB
Python
192 lines
5.8 KiB
Python
#!/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())
|