wechat-robot-skills/skills/docx/scripts/compile_typst.py

89 lines
4.4 KiB
Python
Raw Permalink 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
"""Compile task-local Typst source to a checked PDF via a fixed command."""
from __future__ import annotations
import os
import json
import shutil
import tempfile
from contextlib import ExitStack
from pathlib import Path
from _docx_common import (SkillArgumentParser, WORD_OUTPUT_ROOT, find_program, input_file, load_json_argument,
output_file, publish_file, run_cli, run_program)
def resume_source(spec, directory):
if set(spec) - {"name", "contact", "summary", "sections"}:
raise ValueError("简历仅支持 name、contact、summary、sections")
if not isinstance(spec.get("name"), str) or not spec["name"].strip():
raise ValueError("name 必须是非空文本")
spec.setdefault("contact", [])
spec.setdefault("summary", "")
if not isinstance(spec["contact"], list) or not all(isinstance(x, str) for x in spec["contact"]):
raise ValueError("contact 必须是文本数组")
if not isinstance(spec["summary"], str) or not isinstance(spec.get("sections"), list):
raise ValueError("summary 必须是文本,sections 必须是数组")
for section in spec["sections"]:
if (not isinstance(section, dict) or set(section) != {"title", "items"}
or not isinstance(section["title"], str) or not isinstance(section["items"], list)
or not all(isinstance(item, str) for item in section["items"])):
raise ValueError("sections[] 必须包含文本 title 和文本数组 items")
(directory / "resume.json").write_text(json.dumps(spec, ensure_ascii=False), encoding="utf-8")
source = directory / "resume.typ"
shutil.copy2(Path(__file__).resolve().parent.parent / "assets/resume.typ", source)
return source
def main():
from pypdf import PdfReader
parser = SkillArgumentParser(description="把任务目录中的 Typst 文档编译为 PDF")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--input")
group.add_argument("--template", choices=["resume"])
parser.add_argument("--spec")
parser.add_argument("--spec-file")
parser.add_argument("--output", required=True)
parser.add_argument("--timeout", type=int, default=120)
parser.add_argument("--overwrite", action="store_true")
args = parser.parse_args()
with ExitStack() as stack:
if args.template:
task_root = WORD_OUTPUT_ROOT / "tmp"
task_root.mkdir(parents=True, exist_ok=True)
task_dir = Path(stack.enter_context(tempfile.TemporaryDirectory(prefix="typst-resume-", dir=task_root)))
source = resume_source(load_json_argument(args.spec, args.spec_file, label="简历说明"), task_dir)
else:
if args.spec is not None or args.spec_file is not None:
raise ValueError("spec/spec-file 仅用于 --template resume")
source = input_file(args.input, {".typ"})
try:
source.relative_to((WORD_OUTPUT_ROOT / "tmp").resolve())
except ValueError as exc:
raise ValueError("Typst 源文件及其素材必须放在 /usr/local/src/word/tmp/<任务名>/") from exc
if source.stat().st_size > 2 * 1024 * 1024:
raise ValueError("Typst 源文件不能超过 2 MiB")
destination = output_file(args.output, {".pdf"}, overwrite=args.overwrite)
descriptor, name = tempfile.mkstemp(prefix=".typst-", suffix=".pdf", dir=destination.parent)
os.close(descriptor)
temporary = Path(name)
try:
result = run_program([find_program("typst"), "compile", "--root", str(source.parent),
"--diagnostic-format", "short", str(source), str(temporary)],
timeout=args.timeout, cwd=source.parent)
reader = PdfReader(str(temporary))
page_count = len(reader.pages)
if reader.is_encrypted or page_count < 1:
raise ValueError("Typst 未生成可读取的 PDF 页面")
publish_file(temporary, destination, overwrite=args.overwrite)
finally:
temporary.unlink(missing_ok=True)
return {"path": str(destination), "source": args.input, "template": args.template, "page_count": page_count,
"warnings": result.stderr[-8000:], "requires_visual_review": True}
if __name__ == "__main__":
raise SystemExit(run_cli(main))